From 24130fa3c01cc7267bf6244f2d6b0f358726fd17 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:00:47 +0000 Subject: [PATCH 01/19] gpu-browser: the server for rendering on your GPU through an agent-socket link A Chromium on the owner's machine that an agent anywhere uses through a link: uploads (served from memory at https://uploads.gpu-browser.internal), pages, JavaScript, screenshots (in parts when one reply can't hold them), agent-defined tools for the session, and /request to ask the owner for more hosts. Cloud sandboxes render WebGL in software or not at all; the Chrome extension needs the tab in front. The boundary is fixed in app/policy.mjs and app/server.mjs: every Chromium request goes through a per-run, password-protected filtering proxy (allowlisted public hosts only, ports 80/443, names only; loopback, private, link-local, CGNAT and IPv4-embedding IPv6 addresses refused on the address it connects to), Chromium's sandbox is on, owner-supplied Chromium flags are limited to GPU settings, and widening needs the owner's answer on stdin. Events to the CLI carry a per-run key. Tests: unit tests for the checks (npm run gpu:test:unit, new CI job) and harness scenario 63, which runs the server against the local relay. The SDK is vendored into app/lib/sdk like the extension's, with the same freshness check. The installer and CLI that run it under its own OS account are the next PR. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- .github/workflows/ci.yml | 20 +- CHANGELOG.md | 4 + CONTRIBUTING.md | 4 +- README.md | 1 + SECURITY.md | 4 + gpu-browser/README.md | 56 +++ gpu-browser/app/README.md | 12 + gpu-browser/app/lib/README.md | 3 + gpu-browser/app/lib/sdk/VENDORED.md | 16 + gpu-browser/app/lib/sdk/agents-md.js | 39 ++ gpu-browser/app/lib/sdk/backoff.js | 35 ++ gpu-browser/app/lib/sdk/heartbeat.js | 9 + gpu-browser/app/lib/sdk/index.js | 4 + gpu-browser/app/lib/sdk/preamble.js | 74 +++ gpu-browser/app/lib/sdk/session.js | 727 +++++++++++++++++++++++++++ gpu-browser/app/lib/sdk/transport.js | 48 ++ gpu-browser/app/lib/sdk/types.js | 2 + gpu-browser/app/package.json | 14 + gpu-browser/app/policy.mjs | 91 ++++ gpu-browser/app/server.mjs | 671 ++++++++++++++++++++++++ gpu-browser/scripts/README.md | 3 + gpu-browser/scripts/vendor-sdk.sh | 52 ++ gpu-browser/test/README.md | 5 + gpu-browser/test/policy.unit.mjs | 78 +++ harness/README.md | 1 + harness/scenarios/63-gpu-browser.mjs | 200 ++++++++ package-lock.json | 30 +- package.json | 10 +- 28 files changed, 2202 insertions(+), 11 deletions(-) create mode 100644 gpu-browser/README.md create mode 100644 gpu-browser/app/README.md create mode 100644 gpu-browser/app/lib/README.md create mode 100644 gpu-browser/app/lib/sdk/VENDORED.md create mode 100644 gpu-browser/app/lib/sdk/agents-md.js create mode 100644 gpu-browser/app/lib/sdk/backoff.js create mode 100644 gpu-browser/app/lib/sdk/heartbeat.js create mode 100644 gpu-browser/app/lib/sdk/index.js create mode 100644 gpu-browser/app/lib/sdk/preamble.js create mode 100644 gpu-browser/app/lib/sdk/session.js create mode 100644 gpu-browser/app/lib/sdk/transport.js create mode 100644 gpu-browser/app/lib/sdk/types.js create mode 100644 gpu-browser/app/package.json create mode 100644 gpu-browser/app/policy.mjs create mode 100644 gpu-browser/app/server.mjs create mode 100644 gpu-browser/scripts/README.md create mode 100755 gpu-browser/scripts/vendor-sdk.sh create mode 100644 gpu-browser/test/README.md create mode 100644 gpu-browser/test/policy.unit.mjs create mode 100644 harness/scenarios/63-gpu-browser.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 243ff3e..6c86407 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,8 +60,19 @@ jobs: - run: npm ci - run: npm run ext:test:unit + gpu-browser-unit: + name: GPU browser — unit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + - run: npm run gpu:test:unit + vendor-freshness: - name: chrome-extension/lib/sdk vendor-freshness + name: vendored SDK freshness (chrome-extension, gpu-browser) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -75,11 +86,12 @@ jobs: # check below fails the build and the PR is blocked. - run: npm run build -w sdk - run: bash chrome-extension/scripts/vendor-sdk.sh + - run: bash gpu-browser/scripts/vendor-sdk.sh - name: Assert no drift in vendored SDK run: | - git diff --exit-code -- chrome-extension/lib/sdk \ - || (echo "::error::Vendored SDK in chrome-extension/lib/sdk/ is stale."; \ - echo "Run: npm run build -w sdk && bash chrome-extension/scripts/vendor-sdk.sh"; \ + git diff --exit-code -- chrome-extension/lib/sdk gpu-browser/app/lib/sdk \ + || (echo "::error::A vendored SDK copy (chrome-extension/lib/sdk/ or gpu-browser/app/lib/sdk/) is stale."; \ + echo "Run: npm run build"; \ exit 1) chrome-ext-e2e: diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aa0525..7d17886 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- **GPU browser** (`gpu-browser/`, new): the server for letting an agent anywhere render on your machine's GPU through an agent-socket link: uploads served at `https://uploads.gpu-browser.internal/`, pages, JavaScript, screenshots (in parts when bigger than one reply), agent-defined tools for the session, and `/request` to ask the owner for more hosts. Every Chromium request goes through a per-run, password-protected filtering proxy (allowlisted public hosts only; loopback, private, link-local and IPv4-embedding IPv6 addresses refused on the address it connects to), with Chromium's sandbox on. Harness scenario 63 and unit tests. The installer and CLI that run it under its own OS account come next; until then it runs only as a development run. + ## 0.3.0 Chrome extension 0.5.0. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4461da9..f48e11f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,7 @@ npm install npm test ``` -`npm test` type-checks every package and runs the SDK tests, the registry tests, the integration harness and the extension unit tests. The harness builds the SDK and starts its own `wrangler dev`, so nothing needs to be running first. To run part of it: +`npm test` type-checks every package and runs the SDK tests, the registry tests, the integration harness, the extension unit tests and the GPU browser unit tests (`npm run gpu:test:unit`). The harness builds the SDK and starts its own `wrangler dev`, so nothing needs to be running first. To run part of it: ```bash npm run build -w sdk @@ -32,7 +32,7 @@ To run the relay on its own: `npm run dev` (port 8787). Layout and deploy notes ## Changing the SDK -The extension ships a copy of the compiled SDK in `chrome-extension/lib/sdk/`. After changing `sdk/src`, run `npm run build` and commit the updated copy; CI fails if it is stale. +The extension and the GPU browser ship a copy of the compiled SDK, in `chrome-extension/lib/sdk/` and `gpu-browser/app/lib/sdk/`. After changing `sdk/src`, run `npm run build` and commit the updated copies; CI fails if either is stale. ## Pull requests diff --git a/README.md b/README.md index 047a3b5..561d578 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ Details and how to report a vulnerability: [SECURITY.md](SECURITY.md). - [docs/protocol.md](docs/protocol.md): URLs, frames, limits, threat model - [docs/self-hosting.md](docs/self-hosting.md) - [CONTRIBUTING.md](CONTRIBUTING.md): `npm install`, `npm test` +- [gpu-browser/](gpu-browser/README.md): let an agent anywhere render on your machine's GPU (server; the installer is in progress) - [agent-socket-channel](https://github.com/blitzdotdev/agent-socket-channel): a chat room where several AIs talk through agent-socket ## License diff --git a/SECURITY.md b/SECURITY.md index e76ed0d..450e4cf 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,6 +12,7 @@ Include the affected component (relay, SDK, Chrome extension, registry), steps o - `@agent-socket/sdk` (`sdk/`). - The Chrome extension (`chrome-extension/`). - The registry at registry.agentsocket.dev and any deployment of `registry/`. +- The GPU browser (`gpu-browser/`). ## How it is meant to work @@ -26,6 +27,8 @@ The protocol and threat model are in [docs/protocol.md](docs/protocol.md). In sh - **Tool output is untrusted input for the AI**, and agent input is untrusted input for the app. - **The extension gives the link holder the tab.** `/eval` runs any JavaScript in the bound tab, with the user's logged-in session on that site. The `/navigate` check for local and private-network hosts is a guardrail, not a boundary, because `/eval` can navigate too. The site lock is the boundary for where the tab goes: every tool call checks the tab's origin against the sites the user allowed (at first the one at Connect) and runs page code only in the document it checked, so a navigation by `/eval`, a link or a redirect pauses the tools instead of extending them. Within an allowed site, `/eval` can do whatever that site's pages can (including the site's own cross-origin requests). Only the bound tab is reachable, and Stop, disconnecting, closing the tab or the session timer (60 minutes by default) ends access. +- **The GPU browser gives the link holder a browser, not your machine.** The agent's JavaScript runs in pages of a separate headless Chromium (its sandbox on), not in your browser. Every request goes through the server's filtering proxy: hosts the owner allowed, ports 80/443, names only, and never an address that is loopback, private, link-local, CGNAT, multicast or an IPv6 form that embeds IPv4 (checked on the address it connects to). The proxy listens on loopback with a password that changes every run. Uploads are served from memory at an `.internal` origin. Widening the allowlist needs the owner's answer. The agent can use the machine's GPU, CPU and memory until the session ends. + Reports that only restate these properties are not treated as vulnerabilities. ## Limits @@ -68,3 +71,4 @@ There is no global cap on sessions beyond the per-IP rate limit. - Memory exhaustion or session disruption beyond the limits above. - The extension acting on a tab other than the bound one, or after the session ended. - Bypassing registry review or admin authentication. +- A GPU browser page reaching a host that isn't allowed, a loopback or private address, or a local file, or another program using the GPU browser's proxy; an agent widening the allowlist without the owner's answer. diff --git a/gpu-browser/README.md b/gpu-browser/README.md new file mode 100644 index 0000000..4dea43b --- /dev/null +++ b/gpu-browser/README.md @@ -0,0 +1,56 @@ +# GPU browser + +A Chromium on your own machine, with your machine's GPU, that an agent anywhere (a cloud Claude session, any chat that can make HTTPS calls) uses through an agent-socket link: it uploads a page and its assets, opens it, runs JavaScript in it and gets screenshots back. Made for WebGL/WebGPU work (three.js and the like), which cloud sandboxes render in software or not at all. + +It is a separate app from the [Chrome extension](../chrome-extension/README.md): the extension lets an agent use a tab of *your* browser; the GPU browser is a different, headless Chromium that never sees your tabs, logins or files, and keeps rendering while you use your computer. + +## Status + +| Part | Where | State | +|---|---|---| +| Server: the browser, its network boundary, the agent's tools, the link | [`app/`](app/README.md) | this folder | +| Installer + CLI: `npx @agent-socket/gpu-browser`, creates the separate OS account it runs as, approvals in your terminal | `install/`, `bin/` | next PR | + +Until the CLI lands, the server only runs as a development run (below). + +## What the agent can do + +The link's `agents.md` explains it to the agent; in short: + +| Tool | | +|---|---| +| `GET /info` | GPU (WebGL renderer, WebGPU), versions, allowed hosts, open pages, limits, when the session ends | +| `POST /files`, `GET /files`, `POST /files/delete` | Upload files; pages load them from `https://uploads.gpu-browser.internal/` (served from memory, never the network). Big files go in base64 pieces (`append`). | +| `POST /page` | Open an uploaded page (or an https page on an allowed host) in a fresh context, any viewport and scale | +| `POST /eval` | Run JavaScript in a page; returns its value and the console output | +| `POST /screenshot`, `POST /image` | PNG/JPEG, viewport, element, clip or full page; images too big for one reply (the relay's 4 MiB frame) come in parts | +| `POST /close` | Close pages (idle ones close after 15 minutes) | +| `POST /request` | Ask the owner to allow more hosts (a CDN, an API); returns a task the agent polls until the owner answers | +| `POST /define`, `POST /undefine` | Turn a page script into a tool on the same link, for this session | + +## The boundary + +Fixed in code ([`app/policy.mjs`](app/policy.mjs), applied by [`app/server.mjs`](app/server.mjs)); everything inside it needs no approval: + +- **Network.** Every request Chromium makes goes through a proxy inside the server, protected by a password that changes every run (so no other program on the machine can use it). It allows only hosts on the allowlist (empty by default), ports 80 and 443, names (no IP addresses), and only when every address the name resolves to is public. Loopback, private ranges, link-local (cloud metadata), CGNAT, multicast and the IPv6 forms that embed an IPv4 address are refused, checked on the address it then connects to, so DNS rebinding doesn't get around it. Chromium resolves nothing itself, and QUIC and WebRTC UDP are off. Blocked requests are reported to the agent so it can upload the file instead or ask. +- **Code.** The agent's code runs only inside pages, with Chromium's sandbox on. Pages open only http(s) URLs. +- **Widening needs the owner.** `/request` sends an `ask` to whoever runs the server; the answer comes back on its stdin. Owner-supplied Chromium flags are limited to GPU settings; anything else (`--no-sandbox`, proxy or debugging flags) is dropped. +- **The link** is a bearer credential, as everywhere in agent-socket, and dies when the server stops or its timer runs out. + +In an install (next PR) the server also runs as its own OS account, `agent-socket-gpu-browser`, which can't read your files. + +## Development run + +```bash +npm install && npm run build # from the repo root +# the config line, then your terminal as the rest of stdin: type `answer allow`, or Ctrl-D to stop +( echo '{"relay":"https://agentsocket.dev","key":"'"$(openssl rand -hex 16)"'","minutes":60}'; cat ) \ + | node gpu-browser/app/server.mjs # the link arrives as a `link` event +``` + +The first line on stdin is the config; see the header of [`app/server.mjs`](app/server.mjs) for the whole control protocol. A development run may set `"chromium": {"executablePath": "...", "sandbox": false}` (CI, root); an install can't. + +## Tests + +- `npm run gpu:test:unit`: the boundary checks ([`test/`](test/README.md)). +- `node harness/run.mjs 63` with `CHROMIUM_PATH` set: the server end to end against a local relay (uploads, pages, multi-part screenshots, the private-network refusal with a local server that must get 0 hits, the proxy's password, an approval round trip, forged events, `stop`). diff --git a/gpu-browser/app/README.md b/gpu-browser/app/README.md new file mode 100644 index 0000000..2dbc968 --- /dev/null +++ b/gpu-browser/app/README.md @@ -0,0 +1,12 @@ +# gpu-browser/app + +What runs as the `agent-socket-gpu-browser` account in an install (and as you in a development run). Copied as a whole into the install; nothing here is published on its own. + +| File | | +|---|---| +| `server.mjs` | The server: the proxy, Chromium (via `playwright-core`), the agent's tools, the agent-socket session. Its header describes the stdin/stdout protocol with the CLI. `--self-test` launches Chromium, draws a WebGL canvas and prints the GPU it got (for the installer). | +| `policy.mjs` | The boundary checks (hosts, addresses, upload paths, tool paths, Chromium flags, text shown to the owner). No I/O; unit tested in `../test/`. | +| `lib/sdk/` | This repo's `@agent-socket/sdk`, vendored by `../scripts/vendor-sdk.sh` (CI checks it is fresh). | +| `package.json` | Its one dependency, `playwright-core`, pinned exactly. | + +See [../README.md](../README.md) for what it does and its security boundary. diff --git a/gpu-browser/app/lib/README.md b/gpu-browser/app/lib/README.md new file mode 100644 index 0000000..f785d80 --- /dev/null +++ b/gpu-browser/app/lib/README.md @@ -0,0 +1,3 @@ +# gpu-browser/app/lib + +Code the server uses that doesn't live in this folder's sources: `sdk/` is this repo's `@agent-socket/sdk`, copied in by `../../scripts/vendor-sdk.sh` (see `sdk/VENDORED.md`). Don't edit it here. diff --git a/gpu-browser/app/lib/sdk/VENDORED.md b/gpu-browser/app/lib/sdk/VENDORED.md new file mode 100644 index 0000000..b4ec5df --- /dev/null +++ b/gpu-browser/app/lib/sdk/VENDORED.md @@ -0,0 +1,16 @@ +# Vendored SDK + +Files in this directory are mechanically copied from `../../../sdk/dist/` +(the `@agent-socket/sdk` in this repo), so the GPU browser server runs the +SDK it was tested with. + +**Do not edit them directly** — changes will be overwritten next time +`gpu-browser/scripts/vendor-sdk.sh` runs. + +To refresh after changing the SDK source: + +``` +npm run build # builds the SDK and vendors it here and into the extension +``` + +CI fails if the vendored files drift from `sdk/dist/`. diff --git a/gpu-browser/app/lib/sdk/agents-md.js b/gpu-browser/app/lib/sdk/agents-md.js new file mode 100644 index 0000000..388fcdd --- /dev/null +++ b/gpu-browser/app/lib/sdk/agents-md.js @@ -0,0 +1,39 @@ +// defaultAgentsMd helper — emits a standard agents.md template that +// app authors can use to brief any AI joining their app. App-specific +// app name / description / capabilities / conventions are injected. +// +// NOTE: you don't strictly need this — the relay prepends a canonical "how to call +// tools" contract (do-not-recite, $BASE, GET $BASE/tools.json, POST $BASE/) to +// any served agents.md that doesn't already include it. So a custom agentsMd can be +// app-content-only and the agent will still drive the app. This template embeds +// the FRAMEWORK_PREAMBLE (with its marker) at the top so the relay won't double +// it up — single source of truth for the contract lives in ./preamble.ts. +import { FRAMEWORK_PREAMBLE } from "./preamble.js"; +export function defaultAgentsMd(opts) { + const caps = (opts.capabilities ?? ["Call any tool listed in tools.json"]) + .map((c) => `- ${c}`) + .join("\n"); + const lims = (opts.limitations ?? []) + .map((c) => `- ${c}`) + .join("\n") || "_none specified_"; + const conventions = opts.conventions ?? "Read tools.json before any write."; + // FRAMEWORK_PREAMBLE owns the calling contract + marker; what we emit + // here is app-content-only. The relay's dedup detects the embedded + // marker and skips its own prepend, so the served doc has the contract + // exactly once. + return `${FRAMEWORK_PREAMBLE}# ${opts.appName} + +${opts.appDescription} + +**Save this URL** in case your context is compacted: \`${opts.agentsMdUrl}\` + +## What you can do +${caps} + +## What you cannot do +${lims} + +## Conventions +${conventions} +`; +} diff --git a/gpu-browser/app/lib/sdk/backoff.js b/gpu-browser/app/lib/sdk/backoff.js new file mode 100644 index 0000000..9b913cb --- /dev/null +++ b/gpu-browser/app/lib/sdk/backoff.js @@ -0,0 +1,35 @@ +// Default backoff helpers for ConnectOptions.onDisconnect. +/** + * Exponential backoff with ±25% jitter. + * Default: 1s → 2s → 4s → 8s → ... capped at maxMs (default 30s). + * + * Returns a DisconnectHandler that schedules the next reconnect via setTimeout, + * then calls reconnect(). Apps that want bespoke timing can supply their own. + */ +export function exponentialBackoff(opts = {}) { + const baseMs = opts.baseMs ?? 1000; + const maxMs = opts.maxMs ?? 30000; + const jitter = opts.jitter ?? 0.25; + return ({ attempt, reconnect }) => { + const exp = Math.min(maxMs, baseMs * Math.pow(2, Math.max(0, attempt - 1))); + const j = exp * jitter; + const delay = Math.max(0, exp + (Math.random() * 2 - 1) * j); + setTimeout(reconnect, delay); + }; +} +/** + * Linear backoff: a fixed delay between every attempt. + */ +export function linearBackoff(opts = {}) { + const delay = opts.delayMs ?? 5000; + return ({ reconnect }) => { + setTimeout(reconnect, delay); + }; +} +/** + * Reconnect immediately. Useful for tests or apps that want their own + * outer backoff. NOT recommended in production. + */ +export function noBackoff() { + return ({ reconnect }) => reconnect(); +} diff --git a/gpu-browser/app/lib/sdk/heartbeat.js b/gpu-browser/app/lib/sdk/heartbeat.js new file mode 100644 index 0000000..c5cbc02 --- /dev/null +++ b/gpu-browser/app/lib/sdk/heartbeat.js @@ -0,0 +1,9 @@ +// The heartbeat frames, byte for byte. The relay registers this pair with +// the Durable Object runtime (setWebSocketAutoResponse), which answers the +// ping itself without waking a hibernated session; that only works for an +// exact string match, so the SDK sends exactly HEARTBEAT_PING. Relays that +// predate auto-response answer it like any ping ({type:"pong", id}), which is +// the same string. +export const HEARTBEAT_ID = "as_hb"; +export const HEARTBEAT_PING = `{"type":"ping","id":"${HEARTBEAT_ID}"}`; +export const HEARTBEAT_PONG = `{"type":"pong","id":"${HEARTBEAT_ID}"}`; diff --git a/gpu-browser/app/lib/sdk/index.js b/gpu-browser/app/lib/sdk/index.js new file mode 100644 index 0000000..f792565 --- /dev/null +++ b/gpu-browser/app/lib/sdk/index.js @@ -0,0 +1,4 @@ +// Public entry point for @agent-socket/sdk. +export { connect, endSession } from "./session.js"; +export { exponentialBackoff, linearBackoff, noBackoff } from "./backoff.js"; +export { defaultAgentsMd } from "./agents-md.js"; diff --git a/gpu-browser/app/lib/sdk/preamble.js b/gpu-browser/app/lib/sdk/preamble.js new file mode 100644 index 0000000..4e50b1a --- /dev/null +++ b/gpu-browser/app/lib/sdk/preamble.js @@ -0,0 +1,74 @@ +// Framework "tool-calling reference" preamble — shared between the SDK +// (so `defaultAgentsMd` ships it inline and the relay's dedup detector +// trips on the marker) and the relay (so apps that ship a minimal or +// hand-written `agentsMd` still teach the AI the calling contract). +// +// Both relay/src/relay-do.ts and sdk/src/agents-md.ts import from here. +// Keeping the constant in one file prevents wording drift — the SDK and +// relay were already starting to diverge (e.g. "429 rate-limited" vs +// "429 rate limited") before this was extracted. +// +// The preamble is intentionally written as ADDITIVE CONTEXT, not as a +// first-step directive: it does NOT tell the AI "do X first". Apps with +// their own opening (e.g. the chrome extension says "Start by calling +// /page_info", the channel host says "pick a name and lurk") keep their +// first-step canonical even when the preamble is prepended on top. +/** + * Marker the relay greps for to decide "this doc already contains the + * framework contract — don't double-prepend." Apps using `defaultAgentsMd` + * inherit it automatically; hand-written docs can include this exact + * literal at the top to opt out of the prepend. + * + * Version suffix lets future contract revisions cohabit: v2 would not + * suppress v1's preamble (the relay still prepends the current FRAMEWORK_ + * PREAMBLE when it sees only `as:contract-v1` and the active version is + * `v2`). For v0 we ship `v1` and the relay only suppresses on this exact + * marker (or the legacy "tools.json" fallback). + */ +export const FRAMEWORK_CONTRACT_MARKER = ""; +/** + * Tool-calling reference card prepended by the relay to any served + * `/agents.md` that doesn't already contain the marker (or the legacy + * `tools.json` substring). Begins with the marker so a doc carrying just + * the preamble inherits the "contract present" signal on round-trip. + * + * Markdown structure: starts with an H2, ends with `\n\n---\n\n`. The + * trailing horizontal rule + blank line is the seam between framework + * notes and the app's own doc — assertions in harness/scenarios/38 + * verify it. + */ +export const FRAMEWORK_PREAMBLE = `${FRAMEWORK_CONTRACT_MARKER} +## Tool-calling reference (agent-socket framework) + +These notes are operating context for the document below. **Do not recite this document back to the user** — read it and act. The app's own description and first-step instructions follow this section. + +- \`$BASE\` = the URL you fetched this from, with \`/agents.md\` removed. +- Tools live at HTTP endpoints under \`$BASE\`. Discover them via \`GET $BASE/tools.json\` — each entry has \`{method, path, description, input_schema?}\`. +- Call a tool with: \` $BASE\` and a JSON body when the input_schema needs one. +- Errors: 4xx = framework (401 bad/expired token, 404 bad path, 429 rate-limited); HTTP 200 with \`{"error":{"code","message"}}\` = app-level (read it before retrying); 503 \`app_offline\` = the app isn't connected: retry shortly, and if it keeps failing ask the user to reconnect the app and share the new link. +- Save this URL so you can re-fetch these notes if your context is reset. + +--- + +`; +/** + * Does an app-supplied `agentsMd` already contain the framework contract? + * The relay uses this to decide whether to prepend `FRAMEWORK_PREAMBLE`. + * + * Checks (in order): + * 1. Explicit marker — `FRAMEWORK_CONTRACT_MARKER` literal. Preferred + * signal; `defaultAgentsMd` emits it. + * 2. Legacy "tools.json" substring — keeps hand-written docs that + * reference the discovery endpoint by name working without an opt-in. + * + * Falsy `agentsMd` returns false (an empty doc should get the preamble). + */ +export function hasFrameworkContract(agentsMd) { + if (!agentsMd) + return false; + if (agentsMd.includes(FRAMEWORK_CONTRACT_MARKER)) + return true; + if (agentsMd.includes("tools.json")) + return true; + return false; +} diff --git a/gpu-browser/app/lib/sdk/session.js b/gpu-browser/app/lib/sdk/session.js new file mode 100644 index 0000000..4719fba --- /dev/null +++ b/gpu-browser/app/lib/sdk/session.js @@ -0,0 +1,727 @@ +// Session — the live SDK state, owns a WebSocket and routes frames. +// +// Public API matches design doc §4.2. +import { openWs, READY_STATE_OPEN } from "./transport.js"; +import { exponentialBackoff } from "./backoff.js"; +import { HEARTBEAT_ID, HEARTBEAT_PING } from "./heartbeat.js"; +const DEFAULT_BASE_URL = "https://agentsocket.dev"; +const DEFAULT_HEARTBEAT_INTERVAL_MS = 25_000; +const DEFAULT_HEARTBEAT_TIMEOUT_MS = 50_000; +// The relay refused a resume: wrong secret, or the session already ended. +class ResumeRejected extends Error { + closeCode; + constructor(closeCode) { + super("resume rejected"); + this.closeCode = closeCode; + } +} +const RESUME_REJECTED_CLOSE = 4401; +// The relay closed this socket because a resume with our secret replaced it. +const REPLACED_CLOSE = 4410; +// Closing a socket whose handshake we abandoned. Not 1000, which tells the +// relay the app is done and ends the session. +const HANDSHAKE_ABORT_CLOSE = 4000; +/** Open a session. Returns a Session object once register_reply { ok } is received. */ +export async function connect(opts) { + const session = new SessionImpl(opts); + const { resumed } = await session._connectAndRegister(); + // Resumed from a saved secret: adopt the session's live tokens so a later + // fallback to a fresh session can re-mint them. + if (resumed) + await session._adoptTokens().catch(() => { }); + // A refused `resume` option shows as a new sessionId; nothing to report. + session._freshCause = null; + return session; +} +/** + * End a session the relay may be holding for a resume, given its id and + * resume secret (e.g. saved for `connect({ resume })`): every agent URL of + * the session stops working at once instead of when the relay's hold runs + * out. Resolves true when the relay confirmed, false when it refused (wrong + * secret, or the session had already ended) or couldn't be reached within + * 10 s. `session.close()` does this itself when it is called while + * disconnected. + */ +export function endSession(opts) { + const base = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); + return new Promise((resolve) => { + let ws; + try { + ws = openWs(base.replace(/^http/, "ws") + `/v1/_ws?session=${encodeURIComponent(opts.sessionId)}`); + } + catch { + return resolve(false); + } + let ok = false; + const done = () => { + clearTimeout(timer); + try { + ws.close(1000, "session ended"); + } + catch { } + resolve(ok); + }; + const timer = setTimeout(done, 10_000); + timer.unref?.(); + ws.addListener("open", () => { + try { + ws.send(JSON.stringify({ type: "end", sessionId: opts.sessionId, secret: opts.secret })); + } + catch { + done(); + } + }); + ws.addListener("message", (data) => { + try { + ok = JSON.parse(String(data))?.ok === true; + } + catch { } + done(); + }); + ws.addListener("close", () => done()); + ws.addListener("error", () => { }); + }); +} +class SessionImpl { + baseUrl; + appId; + agentsMd; + appDescription; + // Tools by `${METHOD} ${path}` for fast dispatch + toolsByRoute = new Map(); + toolDefs; + autoReconnect; + onDisconnect; + onSessionChanged; + onReconnect; + heartbeatIntervalMs; + heartbeatTimeoutMs; + ws = null; + _sessionId = ""; + _resumeSecret = null; + // Tokens revoked while disconnected; the resume frame carries them. + pendingRevokes = new Set(); + registered = false; + giveUpReconnect = false; + // Why the next registration will be (or was) a fresh session rather than a + // resume. Reported via onSessionChanged once the reconnect lands. + _freshCause = null; + // When the relay last sent us anything; measures how long we were offline. + _lastSeenAt = 0; + attempt = 0; + pendingFrameReplies = new Map(); + // Tokens we've minted in *this* session (for autoReconnect remint) + myTokens = new Map(); // keyed by full token string + // updateTools calls run one at a time, in call order. + toolsChain = Promise.resolve(); + // Callers waiting for the session to be (re)connected. + connectedWaiters = []; + // Heartbeat state + heartbeatPingTimer = null; + heartbeatTimeoutTimer = null; + pendingPingId = null; + constructor(opts) { + this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); + this.appId = opts.appId; + this.agentsMd = opts.agentsMd; + this.appDescription = opts.appDescription ?? ""; + this.toolDefs = opts.tools.map((t) => ({ + ...t, + method: (t.method ?? "POST").toUpperCase(), + })); + for (const t of this.toolDefs) { + this.toolsByRoute.set(`${t.method} ${t.path}`, t.handler); + } + this.autoReconnect = opts.autoReconnect ?? true; + this.onDisconnect = opts.onDisconnect ?? (this.autoReconnect ? exponentialBackoff() : ({ giveUp }) => giveUp()); + this.onSessionChanged = opts.onSessionChanged; + this.onReconnect = opts.onReconnect; + if (opts.resume) { + this._sessionId = opts.resume.sessionId; + this._resumeSecret = opts.resume.secret; + } + this.heartbeatIntervalMs = opts.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS; + this.heartbeatTimeoutMs = opts.heartbeatTimeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS; + } + get sessionId() { return this._sessionId; } + get resumeSecret() { return this._resumeSecret; } + get connected() { return this.registered && this.ws !== null && this.ws.readyState === READY_STATE_OPEN; } + // Resume the current session if we hold its secret; if the relay refuses + // (4401: bad secret or session ended), fall back to a fresh one at once. + // Other failures (network, timeout) throw so the caller backs off and the + // next attempt tries the resume again. + async _connectAndRegister() { + if (this._resumeSecret && this._sessionId) { + try { + await this._handshake(true); + return { resumed: true }; + } + catch (e) { + if (!(e instanceof ResumeRejected)) + throw e; + this._resumeSecret = null; + this.pendingRevokes.clear(); // those tokens died with the session + this._freshCause = { reason: "resume_refused", closeCode: e.closeCode }; + } + } + else if (this._sessionId && !this._freshCause) { + this._freshCause = { reason: "no_resume_secret" }; + } + await this._handshake(false); + return { resumed: false }; + } + async _handshake(resume) { + const wsUrl = this.baseUrl.replace(/^http/, "ws") + "/v1/_ws" + + (resume ? `?session=${encodeURIComponent(this._sessionId)}` : ""); + const ws = openWs(wsUrl); + this.ws = ws; + try { + await this._waitOpen(ws); + // Always the current tool set: after updateTools, a resume or a fresh + // session registers the updated tools, not the ones passed to connect(). + const registration = { + appId: this.appId, + agentsMd: this.agentsMd, + appDescription: this.appDescription, + tools: wireTools(this.toolDefs), + }; + // The secret goes in the first frame, not the URL, so it stays out of logs. + this._sendFrame(resume + ? { type: "resume", sessionId: this._sessionId, secret: this._resumeSecret, ...registration, revokeTokens: [...this.pendingRevokes] } + : { type: "register", ...registration }); + const reply = await this._waitForRegisterReply(ws, 10_000); + if (!reply.ok) { + if (resume && reply.error?.code === "resume_failed") + throw new ResumeRejected(RESUME_REJECTED_CLOSE); + throw new Error(`register failed: ${reply.error?.code ?? "unknown"}`); + } + if (this.giveUpReconnect) + throw new Error("session closed"); + this._sessionId = reply.sessionId; + this._resumeSecret = typeof reply.resumeSecret === "string" ? reply.resumeSecret : null; + if (resume) + this.pendingRevokes.clear(); + } + catch (e) { + if (resume && e.closeCode === RESUME_REJECTED_CLOSE) + e = new ResumeRejected(RESUME_REJECTED_CLOSE); + // Handlers aren't installed yet, so this close can't trigger a reconnect. + try { + ws.close(this.giveUpReconnect ? 1000 : HANDSHAKE_ABORT_CLOSE, "handshake failed"); + } + catch { } + if (this.ws === ws) + this.ws = null; + throw e; + } + this._installHandlers(ws); + this._lastSeenAt = Date.now(); + this.registered = true; + this._scheduleNextPing(); + this._settleConnectedWaiters(null); + } + // Track the live session's tokens as ours (after resuming from a saved secret). + async _adoptTokens() { + for (const t of await this.listAgentTokens()) { + if (!this.myTokens.has(t.token)) { + this.myTokens.set(t.token, { token: t.token, url: t.url, label: t.label, mintedAt: t.mintedAt, sessionId: this._sessionId }); + } + } + } + // ── Public methods ────────────────────────────────────────────────── + async mintAgentToken(opts) { + const id = this._uid(); + this._sendFrame({ type: "mint_agent_token", id, label: opts.label }); + const reply = await this._awaitReply(id, 10_000); + if (!reply.ok) { + const code = reply.error?.code ?? "unknown"; + throw new Error(`mint failed: ${code}`); + } + const token = reply.token; + const url = this._rewriteUrl(reply.url); + const info = { + token, + url, + label: reply.label ?? opts.label, + mintedAt: Date.now(), + sessionId: this._sessionId, + }; + this.myTokens.set(token, info); + return { token, url, label: info.label, expiresAt: reply.expiresAt ?? null }; + } + async revokeAgentToken(token) { + // Forget it first so a reconnect can't re-mint it. While disconnected the + // relay may be holding the session for a resume, so the resume frame + // revokes it before the app goes live again. + const known = this.myTokens.delete(token); + if (!this.connected) { + if (this._resumeSecret) + this.pendingRevokes.add(token); + return { ok: known }; + } + const id = this._uid(); + this._sendFrame({ type: "revoke_agent_token", id, token }); + const reply = await this._awaitReply(id, 10_000); + return { ok: !!reply.ok || known }; + } + async listAgentTokens() { + const id = this._uid(); + this._sendFrame({ type: "list_agent_tokens", id }); + const reply = await this._awaitReply(id, 10_000); + const tokens = reply.tokens ?? []; + return tokens.map((t) => ({ + token: t.token, + url: this._rewriteUrl(t.url), + label: t.label ?? "", + expiresAt: null, + mintedAt: t.mintedAt ?? 0, + })); + } + updateTools(tools, agentsMd) { + const run = () => this._updateTools(tools, agentsMd); + const p = this.toolsChain.then(run, run); + this.toolsChain = p.catch(() => { }); + return p; + } + async _updateTools(tools, agentsMd) { + if (!Array.isArray(tools)) + throw new TypeError("updateTools: tools must be an array"); + if (agentsMd !== undefined && typeof agentsMd !== "string") + throw new TypeError("updateTools: agentsMd must be a string"); + const defs = tools.map((t) => ({ ...t, method: (t.method ?? "POST").toUpperCase() })); + for (const t of defs) { + if (typeof t.handler !== "function") + throw new TypeError(`updateTools: ${t.method} ${t.path} has no handler`); + } + for (;;) { + if (this.giveUpReconnect) + throw new Error("updateTools: session closed"); + // Disconnected: wait for the reconnect (whose resume re-sends the + // current tools), then send the update on the new socket. + if (!this.connected) { + await this._waitConnected(); + continue; + } + const ws = this.ws; + // Serve old and new handlers until the relay confirms, so a call routed + // just after the relay switched still finds its handler. + const prev = this.toolsByRoute; + const during = new Map(prev); + for (const t of defs) + during.set(`${t.method} ${t.path}`, t.handler); + this.toolsByRoute = during; + const id = this._uid(); + this._sendFrame({ type: "update_tools", id, tools: wireTools(defs), ...(agentsMd !== undefined ? { agentsMd } : {}) }); + let reply; + try { + reply = await this._awaitReply(id, 10_000); + } + catch (e) { + if (this.toolsByRoute === during) + this.toolsByRoute = prev; + // The socket dropped before the reply. The relay may or may not have + // applied it, but the resume replaces its tools with ours (the old + // set), so just send the update again once reconnected. + if (this.ws !== ws || !this.connected) + continue; + throw e; + } + if (!reply.ok) { + if (this.toolsByRoute === during) + this.toolsByRoute = prev; + const code = reply.error?.code ?? "unknown"; + throw Object.assign(new Error(`update_tools failed: ${code}${reply.error?.message ? ` (${reply.error.message})` : ""}`), { code }); + } + this.toolDefs = defs; + this.toolsByRoute = new Map(defs.map((t) => [`${t.method} ${t.path}`, t.handler])); + if (agentsMd !== undefined) + this.agentsMd = agentsMd; + return; + } + } + _waitConnected() { + if (this.connected) + return Promise.resolve(); + return new Promise((resolve, reject) => this.connectedWaiters.push({ resolve, reject })); + } + _settleConnectedWaiters(err) { + const waiters = this.connectedWaiters; + this.connectedWaiters = []; + for (const w of waiters) + err ? w.reject(err) : w.resolve(); + } + completeTask(taskId, result) { + if (typeof taskId !== "string" || taskId.length === 0) { + throw new Error("completeTask: taskId must be a non-empty string"); + } + if (!this.connected) { + throw new Error("completeTask: WS not open"); + } + const status = result?.status ?? 200; + const frame = { type: "task_complete", taskId, status, body: result?.body }; + if (result?.headers && typeof result.headers === "object") { + frame.headers = result.headers; + } + this._sendFrame(frame); + } + ping() { + if (!this.connected) + return; + if (this.pendingPingId !== null) + return; + this._sendPing(); + } + close() { + // Not connected (offline, or mid-reconnect): the relay may be holding the + // session for a resume, so its links still answer. Ask it to end now. + const endHeld = !this.giveUpReconnect && !this.connected && !!this._resumeSecret && !!this._sessionId; + this.giveUpReconnect = true; + this.registered = false; + this._teardownHeartbeat(); + this._failPending(); + this._settleConnectedWaiters(new Error("session closed")); + try { + this.ws?.close(1000, "client closed"); + } + catch { } + this.ws = null; + if (endHeld) + void endSession({ baseUrl: this.baseUrl, sessionId: this._sessionId, secret: this._resumeSecret }); + this._resumeSecret = null; + this.pendingRevokes.clear(); + } + // ── Internals ─────────────────────────────────────────────────────── + _uid() { return Math.random().toString(36).slice(2, 12); } + _waitOpen(ws) { + if (ws.readyState === READY_STATE_OPEN) + return Promise.resolve(); + return new Promise((resolve, reject) => { + const onOpen = () => { cleanup(); resolve(); }; + const onError = (e) => { cleanup(); reject(e instanceof Error ? e : new Error("ws failed to open")); }; + const onClose = () => onError(null); + const cleanup = () => { + ws.removeListener("open", onOpen); + ws.removeListener("error", onError); + ws.removeListener("close", onClose); + }; + ws.addListener("open", onOpen); + ws.addListener("error", onError); + ws.addListener("close", onClose); + }); + } + _installHandlers(ws) { + ws.addListener("message", (data) => { if (ws === this.ws) + this._onMessage(String(data)); }); + ws.addListener("close", (...args) => { + if (ws === this.ws) + this._onClose(args[0] ?? 1006, args[1] ?? ""); + }); + ws.addListener("error", (_e) => { }); + } + _onMessage(data) { + let msg; + try { + msg = JSON.parse(data); + } + catch { + return; + } + this._lastSeenAt = Date.now(); + this._scheduleNextPing(); // any inbound traffic resets the idle timer + switch (msg.type) { + case "tool_call": + void this._handleToolCall(msg); + return; + case "ping": + this._sendFrame({ type: "pong", id: msg.id }); + return; + case "pong": + if (msg.id === this.pendingPingId) { + this.pendingPingId = null; + if (this.heartbeatTimeoutTimer) { + clearTimeout(this.heartbeatTimeoutTimer); + this.heartbeatTimeoutTimer = null; + } + } + return; + default: { + if (typeof msg.id === "string") { + const p = this.pendingFrameReplies.get(msg.id); + if (p) { + this.pendingFrameReplies.delete(msg.id); + p.resolve(msg); + return; + } + } + // unmatched — ignore + } + } + } + async _handleToolCall(msg) { + // Validate the frame BEFORE building the route. A malformed tool_call + // (missing/non-string method or path) used to throw here on + // `.toUpperCase()` — outside the try below — producing an unhandled + // rejection (the call site is `void this._handleToolCall(msg)`) AND no + // tool_reply, so the agent's HTTP request hung until the relay's + // tool_timeout. Reply with an error instead so the agent gets a prompt + // response for any frame carrying an id. + if (typeof msg.method !== "string" || typeof msg.path !== "string") { + if (typeof msg.id === "string") { + this._sendFrame({ + type: "tool_reply", + id: msg.id, + status: 400, + body: { error: { code: "bad_tool_call", message: "tool_call requires string method and path" } }, + }); + } + return; + } + const route = `${msg.method.toUpperCase()} ${msg.path}`; + const handler = this.toolsByRoute.get(route); + if (!handler) { + this._sendFrame({ + type: "tool_reply", + id: msg.id, + status: 404, + body: { error: { code: "not_found", message: `no handler for ${route}` } }, + }); + return; + } + const ctx = { + method: msg.method, + path: msg.path, + body: msg.body ?? "", + headers: msg.headers ?? {}, + }; + try { + const result = await handler(ctx); + const { status, body, taskId, headers } = normalizeResult(result); + const frame = { type: "tool_reply", id: msg.id, status, body }; + if (status === 202 && typeof taskId === "string" && taskId.length > 0) { + frame.taskId = taskId; + } + if (headers && typeof headers === "object") { + frame.headers = headers; + } + this._sendFrame(frame); + } + catch (e) { + // Prefer a duck-typed `.message` over String(e) — handlers that throw + // plain objects like `{message, code}` (idiomatic in older JS without + // Error subclasses) otherwise stringify to "[object Object]". + const eMaybe = e; + const message = typeof eMaybe?.message === "string" + ? eMaybe.message + : (e instanceof Error ? e.message : String(e)); + this._sendFrame({ + type: "tool_reply", + id: msg.id, + status: 500, + body: { error: { code: "handler_error", message } }, + }); + } + } + _onClose(code, reason) { + this.registered = false; + this._teardownHeartbeat(); + this._failPending(); + this.ws = null; + // Another connection resumed this session with our secret, so it's theirs + // now. Start a fresh session on reconnect rather than taking it back. + if (code === REPLACED_CLOSE) { + this._resumeSecret = null; + this.pendingRevokes.clear(); + this._freshCause = { reason: "replaced", closeCode: REPLACED_CLOSE }; + } + this._disconnected(reason || "ws closed", code); + } + // The single reconnect path: after a drop and after each failed attempt. + _disconnected(reason, code) { + if (this.giveUpReconnect) + return; + this.attempt += 1; + let resolved = false; + const reconnect = () => { + if (resolved) + return; + resolved = true; + // Re-check giveUpReconnect at fire-time. A consumer's onDisconnect can + // schedule reconnect() via setTimeout (e.g. exponentialBackoff). If the + // app calls session.close() during that delay, the timer still fires — + // without this check it would open a brand-new WS on a closed session. + if (this.giveUpReconnect) + return; + void this._reconnectAndRemint(); + }; + const giveUp = () => { + if (resolved) + return; + resolved = true; + this.giveUpReconnect = true; + this._settleConnectedWaiters(new Error("session closed")); + }; + void this.onDisconnect({ reason, ...(code !== undefined ? { code } : {}), attempt: this.attempt, reconnect, giveUp }); + } + async _reconnectAndRemint() { + const priorSessionId = this._sessionId; + const lastSeenAt = this._lastSeenAt; // a successful handshake resets it + let resumed; + try { + ({ resumed } = await this._connectAndRegister()); + } + catch (e) { + const code = e?.closeCode; + this._disconnected(e instanceof Error ? e.message : "reconnect failed", typeof code === "number" ? code : undefined); + return; + } + this.attempt = 0; + const offlineMs = Math.max(0, Date.now() - lastSeenAt); + const cause = this._freshCause; + this._freshCause = null; + // A resume keeps every token. A fresh session doesn't: re-mint the ones + // still in myTokens (revoke removes them) under the new session-id. A + // resume can still find stale ones when an earlier re-mint was cut short. + const tokensRemapped = new Map(); + if (!resumed && !this.autoReconnect) + this.myTokens.clear(); + for (const old of Array.from(this.myTokens.values())) { + if (old.sessionId === this._sessionId || !this.myTokens.has(old.token)) + continue; + let fresh; + try { + fresh = await this.mintAgentToken({ label: old.label }); + } + catch { + if (!this.connected) + break; // dropped again; the next reconnect retries the rest + this.myTokens.delete(old.token); + continue; + } + if (!this.myTokens.delete(old.token)) { + void this.revokeAgentToken(fresh.token).catch(() => { }); // revoked while re-minting + continue; + } + tokensRemapped.set(old.url, fresh.url); + } + if ((priorSessionId !== this._sessionId || tokensRemapped.size > 0) && this.onSessionChanged) { + const sessionChanged = priorSessionId !== this._sessionId; + void this.onSessionChanged({ + priorSessionId, + sessionId: this._sessionId, + tokensRemapped, + reason: sessionChanged ? cause?.reason ?? "resume_refused" : "remint", + ...(sessionChanged && cause?.closeCode !== undefined ? { closeCode: cause.closeCode } : {}), + offlineMs, + }); + } + if (this.onReconnect) + void this.onReconnect({ sessionId: this._sessionId, resumed }); + } + _failPending() { + for (const p of this.pendingFrameReplies.values()) + p.reject(new Error("ws closed")); + this.pendingFrameReplies.clear(); + } + _sendFrame(frame) { + if (!this.ws) + return; + try { + this.ws.send(JSON.stringify(frame)); + } + catch { } + } + _waitForRegisterReply(ws, timeoutMs) { + return new Promise((resolve, reject) => { + const done = (err, msg) => { + clearTimeout(timer); + ws.removeListener("message", onMessage); + ws.removeListener("close", onClose); + err ? reject(err) : resolve(msg); + }; + const onMessage = (data) => { + let msg; + try { + msg = JSON.parse(String(data)); + } + catch { + return; + } + if (msg.type === "register_reply") + done(null, msg); + }; + const onClose = (...args) => { + const closeCode = args[0] ?? 1006; + done(Object.assign(new Error(`ws closed before register_reply (${closeCode})`), { closeCode })); + }; + const timer = setTimeout(() => done(new Error(`register_reply timeout after ${timeoutMs}ms`)), timeoutMs); + ws.addListener("message", onMessage); + ws.addListener("close", onClose); + }); + } + _awaitReply(id, timeoutMs) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingFrameReplies.delete(id); + reject(new Error(`awaitReply(${id}) timeout after ${timeoutMs}ms`)); + }, timeoutMs); + this.pendingFrameReplies.set(id, { + resolve: (v) => { clearTimeout(timer); resolve(v); }, + reject: (e) => { clearTimeout(timer); reject(e); }, + }); + }); + } + _rewriteUrl(url) { + return url.replace(/^__BASE__/, this.baseUrl); + } + _scheduleNextPing() { + if (this.heartbeatPingTimer) + clearTimeout(this.heartbeatPingTimer); + this.heartbeatPingTimer = setTimeout(() => this._sendPing(), this.heartbeatIntervalMs); + } + _sendPing() { + if (!this.ws || this.ws.readyState !== READY_STATE_OPEN) + return; + // A fixed frame, so the relay's runtime can answer it without waking a + // hibernated session (see heartbeat.ts). + this.pendingPingId = HEARTBEAT_ID; + try { + this.ws.send(HEARTBEAT_PING); + } + catch { } + if (this.heartbeatTimeoutTimer) + clearTimeout(this.heartbeatTimeoutTimer); + this.heartbeatTimeoutTimer = setTimeout(() => { + // No pong in window — close as dead. + try { + this.ws?.close(1011, "dead heartbeat"); + } + catch { } + }, this.heartbeatTimeoutMs); + } + _teardownHeartbeat() { + if (this.heartbeatPingTimer) { + clearTimeout(this.heartbeatPingTimer); + this.heartbeatPingTimer = null; + } + if (this.heartbeatTimeoutTimer) { + clearTimeout(this.heartbeatTimeoutTimer); + this.heartbeatTimeoutTimer = null; + } + this.pendingPingId = null; + } +} +function wireTools(defs) { + return defs.map((t) => ({ + method: t.method, + path: t.path, + description: t.description, + ...(t.input_schema !== undefined ? { input_schema: t.input_schema } : {}), + })); +} +function normalizeResult(result) { + if (result && typeof result === "object" && "status" in result && typeof result.status === "number") { + const r = result; + return { status: r.status, body: r.body, taskId: r.taskId, headers: r.headers }; + } + return { status: 200, body: result }; +} diff --git a/gpu-browser/app/lib/sdk/transport.js b/gpu-browser/app/lib/sdk/transport.js new file mode 100644 index 0000000..50a3de3 --- /dev/null +++ b/gpu-browser/app/lib/sdk/transport.js @@ -0,0 +1,48 @@ +// WebSocket transport over the runtime's native WebSocket (browsers, Workers, +// Node 22+). Wraps it in a minimal send/close/addListener surface. +const READY_OPEN = 1; +export function openWs(url) { + const Native = globalThis.WebSocket; + if (!Native) + throw new Error("No global WebSocket — agent-socket needs a browser, Workers, or Node 22+."); + return wrapNative(new Native(url)); +} +function wrapNative(ws) { + // Map our addListener/removeListener naming to addEventListener/removeEventListener + // and adapt event objects to the listener signatures the SDK expects. + const adapters = new WeakMap(); + return { + send: (data) => ws.send(data), + close: (code, reason) => ws.close(code, reason), + get readyState() { return ws.readyState; }, + addListener: (event, fn) => { + let adapter; + switch (event) { + case "message": + adapter = (ev) => fn(ev.data); + break; + case "close": + adapter = (ev) => { + const ce = ev; + fn(ce.code, ce.reason); + }; + break; + case "error": + adapter = (ev) => fn(ev); + break; + case "open": + default: + adapter = () => fn(); + break; + } + adapters.set(fn, adapter); + ws.addEventListener(event, adapter); + }, + removeListener: (event, fn) => { + const adapter = adapters.get(fn); + if (adapter) + ws.removeEventListener(event, adapter); + }, + }; +} +export const READY_STATE_OPEN = READY_OPEN; diff --git a/gpu-browser/app/lib/sdk/types.js b/gpu-browser/app/lib/sdk/types.js new file mode 100644 index 0000000..c824d74 --- /dev/null +++ b/gpu-browser/app/lib/sdk/types.js @@ -0,0 +1,2 @@ +// Public SDK types. +export {}; diff --git a/gpu-browser/app/package.json b/gpu-browser/app/package.json new file mode 100644 index 0000000..a5faa94 --- /dev/null +++ b/gpu-browser/app/package.json @@ -0,0 +1,14 @@ +{ + "name": "@agent-socket/gpu-browser-app", + "version": "0.1.0", + "private": true, + "type": "module", + "license": "Apache-2.0", + "description": "The part of @agent-socket/gpu-browser that runs as the agent-socket-gpu-browser account: the server (server.mjs), its boundary checks (policy.mjs) and the vendored SDK (lib/sdk). Not published on its own.", + "engines": { + "node": ">=22" + }, + "dependencies": { + "playwright-core": "1.64.0" + } +} diff --git a/gpu-browser/app/policy.mjs b/gpu-browser/app/policy.mjs new file mode 100644 index 0000000..5d5076a --- /dev/null +++ b/gpu-browser/app/policy.mjs @@ -0,0 +1,91 @@ +// The checks that make up the GPU browser's boundary, kept free of I/O so +// they can be unit tested (gpu-browser/test/policy.unit.mjs). server.mjs +// applies them; nothing here talks to the network or the browser. + +import net from "node:net" + +// Where uploaded files are served. Requests to it are answered from memory by +// request interception and never reach the network. `.internal` is reserved +// for private use, so this can never be a real public site. +export const UPLOAD_ORIGIN = "https://uploads.gpu-browser.internal" + +// Hostnames an agent may ask for: plain names or "*.example.com", lowercase, +// with a dot (so no "localhost"), no IP literals (those never match: the last +// label must start with a letter). +const HOST_RE = /^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,62}$/ +export const validHost = (h) => typeof h === "string" && h.length <= 253 && HOST_RE.test(h) + +// Paths for uploads: letters, digits, . _ - /, no "." or ".." segments. +const FILE_RE = /^(?!.*(^|\/)\.\.?(\/|$))[A-Za-z0-9._\-/]{1,256}$/ +export const validFilePath = (p) => typeof p === "string" && FILE_RE.test(p) + +// Agent-defined tool paths: one segment, so they can't shadow a built-in +// (all built-ins are listed in server.mjs) or a relay-reserved path. +const TOOL_RE = /^\/[a-z0-9][a-z0-9_-]{0,63}$/ +export const validToolPath = (p) => typeof p === "string" && TOOL_RE.test(p) + +// Separate lists: a BlockList checks IPv4 addresses against IPv6 rules as +// IPv4-mapped, so one ::ffff:0:0/96 rule would refuse every IPv4 address. +const private4 = new net.BlockList() +const private6 = new net.BlockList() +for (const [a, p] of [ + ["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], + ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["224.0.0.0", 3], +]) private4.addSubnet(a, p, "ipv4") +// ::/96 also covers :: and ::1 (IPv4-compatible); 64:ff9b::/96 and +// 64:ff9b:1::/48 are NAT64, 2002::/16 6to4 and 2001::/32 Teredo: each can +// carry an IPv4 address, so all are refused. +for (const [a, p] of [ + ["::", 96], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8], ["::ffff:0:0", 96], + ["64:ff9b::", 96], ["64:ff9b:1::", 48], ["2002::", 16], ["2001::", 32], +]) private6.addSubnet(a, p, "ipv6") + +/** True for loopback, private, link-local (cloud metadata), CGNAT, multicast, + * reserved, unique-local, and IPv6 forms that embed an IPv4 address. */ +export function privateAddr(ip) { + if (net.isIPv4(ip)) return private4.check(ip, "ipv4") + if (net.isIPv6(ip)) return private6.check(ip, "ipv6") + return true +} + +/** Does the allowlist (a Set of hosts and "*.domain" entries) cover host? */ +export function hostAllowed(allowed, host) { + const h = String(host).toLowerCase().replace(/\.$/, "") + if (allowed.has(h)) return true + for (let i = h.indexOf("."); i >= 0; i = h.indexOf(".", i + 1)) if (allowed.has("*" + h.slice(i))) return true + return false +} + +/** Why a proxied connection to host:port may not be made, or null. Resolution + * happens after this, and every resolved address goes through privateAddr. */ +export function refuseTarget(allowed, host, port) { + const h = String(host || "").toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "") + if (net.isIP(h)) return "IP addresses are never reachable; use a hostname" + if (port !== 443 && port !== 80) return "only ports 80 and 443" + if (!hostAllowed(allowed, h)) return "not allowed yet; ask with POST /request" + return null +} + +/** Text that came from the agent and is shown to the owner (prompts, logs): + * no control characters, so no terminal escape sequences. Newlines are kept + * only where the caller asks. */ +export function clean(text, { newlines = false } = {}) { + const s = String(text).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "?") + return newlines ? s : s.replace(/[\r\n]+/g, " | ") +} + +// Chromium flags the owner may add (config), for GPU selection only. Anything +// else (--no-sandbox, --proxy-*, --remote-debugging-*, ...) could undo the +// boundary, so it is dropped. Returns { args, dropped }. +const FLAG_RE = /^--(ignore-gpu-blocklist|enable-gpu|enable-gpu-rasterization|enable-zero-copy|enable-unsafe-webgpu|use-angle=(metal|vulkan|gl|gles|swiftshader|default)|use-gl=(angle|egl|desktop)|enable-features=[A-Za-z0-9,]+|disable-features=[A-Za-z0-9,]+)$/ +const FEATURES = new Set(["Vulkan", "VulkanFromANGLE", "DefaultANGLEVulkan", "SkiaGraphite", "WebGPUService", "UseSkiaRenderer", "CanvasOopRasterization"]) +export function gpuFlags(list) { + const args = [] + const dropped = [] + for (const f of Array.isArray(list) ? list.map(String) : []) { + const m = /^--(enable|disable)-features=(.*)$/.exec(f) + const ok = FLAG_RE.test(f) && (!m || m[2].split(",").every((x) => FEATURES.has(x))) + ;(ok ? args : dropped).push(f) + } + return { args, dropped } +} diff --git a/gpu-browser/app/server.mjs b/gpu-browser/app/server.mjs new file mode 100644 index 0000000..0d03dfb --- /dev/null +++ b/gpu-browser/app/server.mjs @@ -0,0 +1,671 @@ +// The GPU browser server: a Chromium on this machine, shared with agents +// through an agent-socket link. In an install it runs as the dedicated +// agent-socket-gpu-browser account; see ../README.md. +// +// The boundary is fixed here; everything inside it is free: +// - its own Chromium with its sandbox on, every request forced through the +// filtering proxy below (password-protected, so nothing else on this +// machine can use it): hosts on the allowlist only, ports 80/443, +// hostnames only, and nothing that resolves to a private, loopback or +// link-local address (checked on the address it then connects to, so DNS +// rebinding can't slip through). Uploads are served at UPLOAD_ORIGIN from +// memory. +// - agent code runs only inside pages. +// - widening (a new host) needs the owner: an `ask` event goes out, the +// answer comes back on stdin. +// +// stdin: first line a JSON config (relay, key, allow, minutes, chromium), +// then `answer ` or `stop`. stdout: log lines for +// the owner, and events `@@gpu-browser: ...` that only whoever +// chose the key (the CLI) can tell apart from log text. +// +// `node server.mjs --self-test` launches Chromium, draws a WebGL canvas, +// prints one JSON line about the GPU and exits (used by the installer). + +import http from "node:http" +import net from "node:net" +import dns from "node:dns/promises" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import readline from "node:readline" +import crypto from "node:crypto" +import { fileURLToPath } from "node:url" +import { UPLOAD_ORIGIN, clean, gpuFlags, hostAllowed, privateAddr, refuseTarget, validFilePath, validHost, validToolPath } from "./policy.mjs" + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +// Installed: /app/server.mjs next to /browsers and /VERSION. +const INSTALLED = fs.existsSync(path.join(HERE, "..", "VERSION")) +if (INSTALLED) { + process.env.PLAYWRIGHT_BROWSERS_PATH ??= path.join(HERE, "..", "browsers") + // Chromium profiles and Playwright temp files stay in this account's home. + process.env.TMPDIR = path.join(os.homedir(), "tmp") + fs.rmSync(process.env.TMPDIR, { recursive: true, force: true }) + fs.mkdirSync(process.env.TMPDIR, { mode: 0o700, recursive: true }) +} +const { chromium } = await import("playwright-core") + +const LIMITS = { + pages: 8, fileBytes: 64 << 20, files: 2000, resultChars: 256_000, opMs: 25_000, idleMs: 15 * 60_000, + tools: 30, askMs: 15 * 60_000, hostsPerAsk: 10, logLines: 100, + // One reply is one WebSocket frame, and the relay ends the whole session + // on a frame over 4,194,304 characters. Replies are measured against this + // budget first; images over a part's size come back in parts (/image). + replyChars: 4_000_000, partBytes: 2_800_000, imageBytes: 64 << 20, imageMs: 10 * 60_000, +} +const TYPES = { + html: "text/html", htm: "text/html", js: "text/javascript", mjs: "text/javascript", css: "text/css", + json: "application/json", map: "application/json", txt: "text/plain", svg: "image/svg+xml", + png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", + avif: "image/avif", ktx2: "image/ktx2", glb: "model/gltf-binary", gltf: "model/gltf+json", + wasm: "application/wasm", woff2: "font/woff2", woff: "font/woff", ttf: "font/ttf", + mp4: "video/mp4", webm: "video/webm", mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", +} + +const out = (line) => process.stdout.write(line + "\n") +const log = (msg) => out(clean(msg)) +let KEY = "" +const event = (e) => out("@@gpu-browser:" + KEY + " " + e) +const b64 = (s) => Buffer.from(s, "utf8").toString("base64") +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +const fail = (code, message) => Object.assign(new Error(message), { code }) +const withTimeout = (p, ms, what) => Promise.race([p, sleep(ms).then(() => { throw fail("timeout", what + " timed out after " + ms + " ms") })]) +const err = (status, code, message, extra = {}) => ({ status, body: { error: { code, message, ...extra } } }) + +// ── network: allowlist + filtering proxy ───────────────────────────── +const allowed = new Set() +const refused = new Map() // host -> why the proxy refused it (newest 200), for per-page reports + +async function vet(host, port) { + const h = String(host || "").toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "") + const deny = (reason) => { + refused.delete(h); refused.set(h, reason) + if (refused.size > 200) refused.delete(refused.keys().next().value) + return { reason } + } + const why = refuseTarget(allowed, h, port) + if (why) return deny(why) + let addrs + try { addrs = await dns.lookup(h, { all: true, verbatim: true }) } catch { return deny("does not resolve") } + if (!addrs.length) return deny("does not resolve") + for (const a of addrs) if (privateAddr(a.address)) return deny("resolves to a private address") + return { address: addrs[0].address, addrs: addrs.map((a) => a.address) } +} + +// Per-run credentials: the proxy listens on loopback, which other local +// programs (and containers that reach the host's loopback) can connect to. +const PROXY_USER = "gpu-browser" +const PROXY_PASS = crypto.randomBytes(18).toString("base64url") +const PROXY_AUTH = Buffer.from("Basic " + Buffer.from(PROXY_USER + ":" + PROXY_PASS).toString("base64")) +function authorized(req) { + const got = Buffer.from(String(req.headers["proxy-authorization"] || "")) + return got.length === PROXY_AUTH.length && crypto.timingSafeEqual(got, PROXY_AUTH) +} +const NEED_AUTH = "HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"gpu-browser\"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + +// Connect to the first address that answers (all were vetted as public). +function connectAny(addrs, port, onConnect, onFail) { + const next = (i) => { + if (i >= addrs.length) return onFail() + const sock = net.connect(port, addrs[i]) + sock.once("connect", () => onConnect(sock)) + sock.once("error", () => { sock.destroy(); next(i + 1) }) + } + next(0) +} + +function startProxy() { + const server = http.createServer(async (req, res) => { + if (!authorized(req)) { res.socket?.end(NEED_AUTH); return } + let u + try { u = new URL(req.url) } catch { res.writeHead(400).end(); return } + if (u.protocol !== "http:") { res.writeHead(400).end(); return } + const port = Number(u.port || 80) + const v = await vet(u.hostname, port) + if (v.reason) { res.writeHead(403, { "content-type": "text/plain", "x-agent-socket-blocked": "1" }).end(u.hostname + ": " + v.reason); return } + const headers = { ...req.headers, host: u.host } + delete headers["proxy-connection"] + delete headers["proxy-authorization"] + const up = http.request({ host: v.address, port, method: req.method, path: u.pathname + u.search, headers }, (r) => { + res.writeHead(r.statusCode, r.headers) + r.pipe(res) + }) + up.on("error", () => res.destroy()) + req.pipe(up) + }) + server.on("connect", async (req, sock, head) => { + sock.on("error", () => {}) + if (!authorized(req)) { sock.end(NEED_AUTH); return } + const i = req.url.lastIndexOf(":") + const port = Number(req.url.slice(i + 1)) + const v = await vet(req.url.slice(0, i), port) + if (v.reason) { sock.end("HTTP/1.1 403 Forbidden\r\n\r\n"); return } + connectAny(v.addrs, port, (up) => { + sock.write("HTTP/1.1 200 Connection Established\r\n\r\n") + if (head?.length) up.write(head) + up.pipe(sock) + sock.pipe(up) + up.on("error", () => sock.destroy()) + sock.on("close", () => up.destroy()) + }, () => sock.end("HTTP/1.1 502 Bad Gateway\r\n\r\n")) + }) + return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server))) +} + +// ── uploads, served at UPLOAD_ORIGIN ───────────────────────────────── +const files = new Map() // path -> { buf, type } +let fileBytes = 0 +const typeOf = (p) => TYPES[(p.split(".").pop() || "").toLowerCase()] || "application/octet-stream" +function putFile(f) { + const p = typeof f?.path === "string" ? f.path.replace(/^\/+/, "") : "" + if (!validFilePath(p)) return "bad path (letters, digits, . _ - /; no . or .. segments)" + if (typeof f.data !== "string") return "data must be a string" + const chunk = Buffer.from(f.data, f.encoding === "base64" ? "base64" : "utf8") + const prev = files.get(p) + const buf = f.append && prev ? Buffer.concat([prev.buf, chunk]) : chunk + const next = fileBytes - (prev?.buf.length || 0) + buf.length + if (next > LIMITS.fileBytes) return "over the " + (LIMITS.fileBytes >> 20) + " MiB total for files" + if (!prev && files.size >= LIMITS.files) return "too many files" + const type = typeof f.content_type === "string" && /^[\w.+-]+\/[\w.+-]+$/.test(f.content_type) + ? f.content_type : (prev && f.append ? prev.type : typeOf(p)) + files.set(p, { buf, type }) + fileBytes = next + return { path: p, bytes: buf.length } +} +async function serveFile(route) { + let p = decodeURIComponent(new URL(route.request().url()).pathname).replace(/^\/+/, "") + if (p === "" || p.endsWith("/")) p += "index.html" + const f = files.get(p) + if (!f) return route.fulfill({ status: 404, contentType: "text/plain", body: "not uploaded: " + p }) + return route.fulfill({ status: 200, contentType: f.type, body: f.buf, headers: { "cache-control": "no-store", "access-control-allow-origin": "*" } }) +} + +// ── Chromium and pages ─────────────────────────────────────────────── +let browser = null +let gpuInfo = null +let proxyPort = 0 +let launchOpts = {} +let stopping = false +const pages = new Map() // id -> { ctx, page, logs, blocked, last } + +async function launch() { + browser = await chromium.launch({ + // channel "chromium": the full Chromium in new headless mode, which uses + // the GPU (the default headless shell renders in software). + channel: launchOpts.executablePath ? undefined : "chromium", + executablePath: launchOpts.executablePath, + headless: true, + // Playwright turns the sandbox off unless asked; it stays on except in a + // development run that explicitly says otherwise (root, CI containers). + chromiumSandbox: launchOpts.sandbox, + // Playwright's own handlers exit the process before stop() can end the link. + handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false, + // Playwright adds <-loopback> to the bypass list, so loopback goes + // through the proxy too (and is refused there), and answers the 407. + proxy: { server: "http://127.0.0.1:" + proxyPort, username: PROXY_USER, password: PROXY_PASS }, + args: [ + ...(launchOpts.args || []), + // Nothing resolves outside the proxy; no QUIC or WebRTC UDP around it. + "--host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE 127.0.0.1", "--disable-quic", + "--force-webrtc-ip-handling-policy=disable_non_proxied_udp", "--webrtc-ip-handling-policy=disable_non_proxied_udp", + ], + }) + // Without a display session (this account never logs in), Chromium on + // macOS crashes when its last window closes; one page stays open for good. + await (await browser.newContext()).newPage().catch(() => {}) + browser.on("disconnected", () => { + pages.clear() + browser = null + if (stopping) return + log("browser exited, relaunching (open pages are gone)") + setTimeout(() => launch().catch((e) => log("relaunch failed: " + e.message)), 1000) + }) +} +async function ready() { + for (let i = 0; !browser && i < 50; i++) await sleep(100) + if (!browser) throw fail("unavailable", "the browser is restarting; retry in a few seconds") + return browser +} + +// Console output and blocked hosts since the last call, for one page. +function report(p) { + const r = {} + if (p?.logs.length) r.console = p.logs.splice(0) + if (p?.blocked.size) { + r.blocked = [...p.blocked].map(([host, reason]) => ({ host, reason })) + r.hint = "Blocked hosts: upload what you need with POST /files (served at " + UPLOAD_ORIGIN + "/), or ask for the host with POST /request." + p.blocked.clear() + } + return r +} +function pageOf(id) { + const p = pages.get(id) + if (!p) throw fail("no_page", "no page " + JSON.stringify(id) + " (open pages: " + ([...pages.keys()].join(", ") || "none") + ")") + p.last = Date.now() + return p +} +async function openPage(a) { + if (pages.size >= LIMITS.pages) throw fail("too_many_pages", "at most " + LIMITS.pages + " pages; close some with POST /close") + const target = new URL(typeof a.url === "string" && a.url ? a.url : "index.html", UPLOAD_ORIGIN + "/") + if (!/^https?:$/.test(target.protocol)) throw fail("bad_url", "only http(s) URLs") + if (target.origin !== UPLOAD_ORIGIN && !hostAllowed(allowed, target.hostname)) { + throw fail("host_not_allowed", target.hostname + " is not allowed yet: upload the page with POST /files and open it by path, or POST /request for the host") + } + const w = Math.min(Math.max(a.width | 0 || 1280, 16), 4096) + const h = Math.min(Math.max(a.height | 0 || 720, 16), 4096) + const scale = Math.min(Math.max(Number(a.scale) || 1, 0.25), 3) + const ctx = await (await ready()).newContext({ viewport: { width: w, height: h }, deviceScaleFactor: scale, serviceWorkers: "block", acceptDownloads: false }) + await ctx.route(UPLOAD_ORIGIN + "/**", serveFile) + const page = await ctx.newPage() + const id = crypto.randomBytes(3).toString("hex") + const rec = { ctx, page, logs: [], blocked: new Map(), last: Date.now() } + const push = (s) => { if (rec.logs.length < LIMITS.logLines) rec.logs.push(s.slice(0, 500)) } + const block = (url) => { + let host = "" + try { host = new URL(url).hostname.replace(/^\[|\]$/g, "") } catch {} + rec.blocked.set(host, refused.get(host) || "blocked") + } + page.on("console", (m) => push(m.type() + ": " + m.text())) + page.on("pageerror", (e) => push("pageerror: " + (e.stack || e.message))) + page.on("requestfailed", (r) => { + const why = r.failure()?.errorText || "" + if (/TUNNEL_CONNECTION_FAILED|NAME_NOT_RESOLVED/.test(why)) block(r.url()) + else push("requestfailed: " + r.url().slice(0, 200) + " " + why) + }) + page.on("response", (r) => { if (r.status() === 403 && r.headers()["x-agent-socket-blocked"]) block(r.url()) }) + pages.set(id, rec) + const wait = ["load", "domcontentloaded", "networkidle", "commit"].includes(a.wait) ? a.wait : "load" + const resp = await page.goto(target.href, { waitUntil: wait, timeout: LIMITS.opMs - 2000 }) + .catch((e) => { push("goto: " + e.message.split("\n")[0]); return null }) + return { page: id, url: target.href, http_status: resp?.status() ?? null, viewport: { width: w, height: h, scale } } +} +async function closePage(id) { + const p = pages.get(id) + if (!p) return false + pages.delete(id) + await p.ctx.close().catch(() => {}) + return true +} + +function serialize(v) { + const s = JSON.stringify(v === undefined ? null : v) + return s.length > LIMITS.resultChars ? { result_truncated: s.slice(0, LIMITS.resultChars) } : { result: JSON.parse(s) } +} +async function evalIn(p, js, arg) { + if (typeof js !== "string" || !js.trim()) throw fail("bad_js", "js must be the body of an async function; return a JSON value") + // A string expression runs through CDP, so the page's own CSP can't block it. + return withTimeout(p.page.evaluate("(async (arg) => {\n" + js + "\n})(" + JSON.stringify(arg ?? null) + ")"), LIMITS.opMs - 2000, "js") +} + +// Screenshots too big for one reply are kept here and fetched in parts. +const images = new Map() // id -> { buf, type, parts, expires } +let imageBytes = 0 +function dropImage(id) { + const im = images.get(id) + if (!im) return + images.delete(id) + imageBytes -= im.buf.length +} +function sweepImages() { for (const [id, im] of images) if (im.expires < Date.now()) dropImage(id) } +function imagePart(im, id, part) { + const data = im.buf.subarray(part * LIMITS.partBytes, (part + 1) * LIMITS.partBytes).toString("base64") + return { image: id, type: im.type, bytes: im.buf.length, parts: im.parts, part, data } +} +async function shoot(p, a = {}) { + const frames = Math.min(Math.max(a.frames ?? 2, 0), 120) + if (frames) { + await withTimeout(p.page.evaluate((n) => new Promise((r) => { + const f = () => (--n <= 0 ? r() : requestAnimationFrame(f)) + requestAnimationFrame(f) + }), frames), 10_000, "frames").catch(() => {}) + } + const type = a.type === "jpeg" ? "jpeg" : "png" + const opts = { type, fullPage: !!a.full_page, omitBackground: !!a.omit_background && type === "png", timeout: LIMITS.opMs - 5000 } + if (type === "jpeg") opts.quality = Math.min(Math.max(a.quality | 0 || 85, 1), 100) + if (a.clip && typeof a.clip === "object") opts.clip = { x: +a.clip.x || 0, y: +a.clip.y || 0, width: +a.clip.width || 1, height: +a.clip.height || 1 } + const buf = typeof a.selector === "string" ? await p.page.locator(a.selector).first().screenshot(opts) : await p.page.screenshot(opts) + if (buf.length <= LIMITS.partBytes) return { type, bytes: buf.length, parts: 1, data: buf.toString("base64") } + sweepImages() + if (buf.length > LIMITS.imageBytes) throw fail("image_too_large", "image is " + buf.length + " bytes, over " + LIMITS.imageBytes + ": use jpeg, a clip or a smaller viewport/scale") + while (imageBytes + buf.length > LIMITS.imageBytes && images.size) dropImage(images.keys().next().value) + const id = crypto.randomBytes(4).toString("hex") + const im = { buf, type, parts: Math.ceil(buf.length / LIMITS.partBytes), expires: Date.now() + LIMITS.imageMs } + images.set(id, im) + imageBytes += buf.length + return imagePart(im, id, 0) +} +async function gpu() { + if (gpuInfo) return gpuInfo + const ctx = await (await ready()).newContext() + try { + const page = await ctx.newPage() + gpuInfo = await withTimeout(page.evaluate(async () => { + const c = document.createElement("canvas") + const gl = c.getContext("webgl2") || c.getContext("webgl") + const ext = gl && gl.getExtension("WEBGL_debug_renderer_info") + let webgpu = null + try { + const ad = navigator.gpu && await navigator.gpu.requestAdapter() + webgpu = ad ? (ad.info ? { vendor: ad.info.vendor, architecture: ad.info.architecture } : true) : false + } catch { webgpu = false } + const renderer = ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : (gl ? gl.getParameter(gl.RENDERER) : null) + return { webgl: gl ? (gl instanceof WebGL2RenderingContext ? 2 : 1) : 0, renderer, webgpu } + }), 10_000, "gpu probe") + gpuInfo.software = /swiftshader|llvmpipe|software/i.test(gpuInfo.renderer || "") + } finally { await ctx.close().catch(() => {}) } + return gpuInfo +} + +// ── approvals: `ask` event out, `answer` line in ───────────────────── +const asks = new Map() // id -> { kind, hosts, taskId, timer } +let session = null +function ask(kind, text, extra) { + const id = crypto.randomBytes(3).toString("hex") + const a = { kind, ...extra } + a.timer = setTimeout(() => answer(id, "timeout"), LIMITS.askMs) + asks.set(id, a) + event("ask " + id + " " + kind + " " + b64(text) + (a.hosts ? " " + a.hosts.join(" ") : "")) + return id +} +function answer(id, choice) { + const a = asks.get(id) + if (!a) return log("no pending request " + id) + asks.delete(id) + clearTimeout(a.timer) + event("done " + id) + const yes = choice === "allow" || choice === "always" + if (a.kind === "hosts") { + if (yes) for (const h of a.hosts) allowed.add(h) + if (choice === "always") event("persist-hosts " + a.hosts.join(" ")) + log((yes ? "allowed " : "refused ") + a.hosts.join(", ") + (choice === "always" ? " (always)" : yes ? " (this session)" : choice === "timeout" ? " (no answer)" : "")) + // Throws while the link is down; the agent's poll then never completes. + try { session?.completeTask(a.taskId, { body: { approved: yes, answer: choice, hosts: a.hosts, allowed_hosts: [...allowed].slice(0, 100) } }) } + catch (e) { log("could not tell the agent: " + e.message) } + } +} + +// ── agent-defined tools: they run in pages, so defining needs no approval; +// they last for this session only (a later agent would see another agent's +// code under a trusted-looking name) ── +const defined = new Map() // path -> definition +const pick = (d) => ({ path: d.path, description: d.description, js: d.js, input_schema: d.input_schema, url: d.url, width: d.width, height: d.height, scale: d.scale, wait: d.wait, screenshot: d.screenshot }) +function toolProblem(d) { + if (!d || typeof d !== "object") return "body must be an object" + if (!validToolPath(d.path)) return "path must look like /orbit_shots (a-z 0-9 _ -, one segment)" + if (builtins.some((t) => t.path === d.path || t.path.startsWith(d.path + "/"))) return d.path + " is a built-in" + if (typeof d.description !== "string" || !d.description.trim() || d.description.length > 4000) return "description: 1-4000 characters" + if (typeof d.js !== "string" || !d.js.trim() || d.js.length > 32_000) return "js: the body of an async function, at most 32000 characters" + if (d.input_schema !== undefined && (typeof d.input_schema !== "object" || JSON.stringify(d.input_schema).length > 16_000)) return "input_schema: a JSON Schema object" + if (d.url !== undefined && typeof d.url !== "string") return "url must be a string" + if (!defined.has(d.path) && defined.size >= LIMITS.tools) return "at most " + LIMITS.tools + " defined tools" + return null +} +function definedTool(d) { + return { + path: d.path, + description: d.description + "\n\n(Agent-defined. Body: your arguments as JSON, passed to the tool's js as `arg`; add \"page\": \"\" to run in an open page instead of a fresh one.)", + input_schema: d.input_schema || { type: "object" }, + handler: guard(d.path, async (a) => { + let id = typeof a.page === "string" ? a.page : null + let opened = false + if (id) pageOf(id) + else { id = (await openPage({ url: d.url, width: d.width, height: d.height, scale: d.scale, wait: d.wait })).page; opened = true } + try { + const p = pageOf(id) + const r = serialize(await evalIn(p, d.js, a)) + if (d.screenshot) r.image = await shoot(p, typeof d.screenshot === "object" ? d.screenshot : {}) + return { ...r, ...(opened ? {} : { page: id }), ...report(p) } + } finally { if (opened) await closePage(id) } + }), + } +} +function define(d) { + const why = toolProblem(d) + if (!why) defined.set(d.path, pick(d)) + return why +} +const toolList = () => [...builtins, ...[...defined.values()].map(definedTool)] +let syncing = Promise.resolve() +const syncTools = () => (syncing = syncing.then(() => session?.updateTools(toolList(), agentsMd())).catch((e) => log("tool update failed: " + e.message))) + +// Every handler: parse, log one line, map errors to HTTP answers, and never +// send a reply over the relay's frame budget (that would end the session). +function guard(name, fn) { + return async ({ body }) => { + let a = {} + if (body) { + try { a = JSON.parse(body) } catch { a = null } + if (!a || typeof a !== "object" || Array.isArray(a)) return err(400, "bad_json", "the body must be a JSON object") + } + const t = Date.now() + try { + const r = await fn(a) + if (JSON.stringify(r).length > LIMITS.replyChars) { + log(name + " reply too large") + return err(413, "reply_too_large", "the reply would be over " + LIMITS.replyChars + " characters; return less (a smaller result, jpeg, a clip)") + } + log(name + " (" + (Date.now() - t) + " ms)") + return r + } catch (e) { + const msg = String(e.message || e).split("\n").slice(0, 3).join(" ") + log(name + " failed: " + msg) + const status = { no_page: 404, too_many_pages: 429, host_not_allowed: 403, image_too_large: 413, no_image: 404, timeout: 504, unavailable: 503 }[e.code] || 400 + return err(status, e.code || "failed", msg) + } + } +} + +let endsAt = null +let pwVersion = null +const builtins = [ + { method: "GET", path: "/info", description: "GPU (WebGL renderer, WebGPU), Chromium and Playwright versions, allowed hosts, open pages, uploaded files, when the session ends, limits and agent-defined tools.", + handler: guard("info", async () => ({ + gpu: await gpu(), chromium: (await ready()).version(), playwright: pwVersion, upload_origin: UPLOAD_ORIGIN, + allowed_hosts: [...allowed], pages: [...pages.keys()], files: { count: files.size, bytes: fileBytes }, + session_ends_at: endsAt ? new Date(endsAt).toISOString() : null, tools: [...defined.keys()], limits: LIMITS, + isolation: { account: os.userInfo().username, installed: INSTALLED, chromium_sandbox: launchOpts.sandbox }, + })) }, + { path: "/files", description: "Upload files, served at " + UPLOAD_ORIGIN + "/ to pages opened here. Body: {\"files\": [{\"path\": \"index.html\", \"data\": \"...\", \"encoding\": \"utf8\" | \"base64\", \"append\": false, \"content_type\": optional}]}. A request body is at most 1 MiB, so send big files in base64 pieces with append: true. The content type comes from the extension.", + input_schema: { type: "object", required: ["files"], properties: { files: { type: "array", items: { type: "object", required: ["path", "data"], properties: { + path: { type: "string" }, data: { type: "string" }, encoding: { enum: ["utf8", "base64"] }, append: { type: "boolean" }, content_type: { type: "string" } } } } } }, + handler: guard("files", async (a) => { + if (!Array.isArray(a.files) || !a.files.length) throw fail("bad_files", "files: a non-empty array") + const stored = [] + const errors = [] + for (const f of a.files) { + const r = putFile(f) + if (typeof r === "string") errors.push({ path: f?.path, error: r }) + else stored.push(r) + } + return { stored, errors, total: { count: files.size, bytes: fileBytes } } + }) }, + { method: "GET", path: "/files", description: "List uploaded files with sizes.", + handler: guard("files list", async () => ({ files: [...files].map(([p, f]) => ({ path: p, bytes: f.buf.length, type: f.type })), bytes: fileBytes })) }, + { path: "/files/delete", description: "Delete uploaded files. Body: {\"paths\": [...]} or {\"all\": true}.", + handler: guard("files delete", async (a) => { + const ps = a.all ? [...files.keys()] : Array.isArray(a.paths) ? a.paths : [] + for (const p of ps) { + const f = files.get(p) + if (f) { fileBytes -= f.buf.length; files.delete(p) } + } + return { deleted: ps.length, total: { count: files.size, bytes: fileBytes } } + }) }, + { path: "/page", description: "Open a page in a fresh, isolated context. Body: {\"url\": \"index.html\" (a path under " + UPLOAD_ORIGIN + "/, or an https URL on an allowed host), \"width\": 1280, \"height\": 720, \"scale\": 1, \"wait\": \"load\" | \"domcontentloaded\" | \"networkidle\"}. Returns the page id, plus console output and blocked hosts so far.", + input_schema: { type: "object", properties: { url: { type: "string" }, width: { type: "integer" }, height: { type: "integer" }, scale: { type: "number" }, wait: { type: "string" } } }, + handler: guard("page", async (a) => { + const r = await openPage(a) + return { ...r, ...report(pages.get(r.page)) } + }) }, + { path: "/eval", description: "Run JavaScript in a page. Body: {\"page\": \"\", \"js\": \"\", \"arg\": }. Use `return` to send back a JSON value (truncated past 256k characters). Also returns console output since the last call.", + input_schema: { type: "object", required: ["page", "js"], properties: { page: { type: "string" }, js: { type: "string" }, arg: {} } }, + handler: guard("eval", async (a) => { + const p = pageOf(a.page) + return { ...serialize(await evalIn(p, a.js, a.arg)), ...report(p) } + }) }, + { path: "/screenshot", description: "Screenshot a page after `frames` animation frames (default 2). Body: {\"page\": \"\", \"type\": \"png\" | \"jpeg\", \"quality\": 85, \"full_page\": false, \"selector\": optional, \"clip\": {x,y,width,height} optional, \"omit_background\": false, \"frames\": 2}. Returns {type, bytes, parts, data} with data in base64 (e.g. `jq -r .data | base64 -d > shot.png`). When parts > 1 the reply also has an `image` id and holds part 0: fetch parts 1..parts-1 with POST /image and concatenate the decoded bytes in order.", + input_schema: { type: "object", required: ["page"], properties: { page: { type: "string" }, type: { enum: ["png", "jpeg"] }, quality: { type: "integer" }, full_page: { type: "boolean" }, selector: { type: "string" }, clip: { type: "object" }, omit_background: { type: "boolean" }, frames: { type: "integer" } } }, + handler: guard("screenshot", async (a) => { + const p = pageOf(a.page) + return { ...(await shoot(p, a)), ...report(p) } + }) }, + { path: "/image", description: "One part of a large screenshot. Body: {\"image\": \"\", \"part\": 1}. Parts are kept 10 minutes; the image is dropped once its last part is fetched.", + input_schema: { type: "object", required: ["image", "part"], properties: { image: { type: "string" }, part: { type: "integer" } } }, + handler: guard("image", async (a) => { + sweepImages() + const im = images.get(a.image) + if (!im) throw fail("no_image", "no image " + JSON.stringify(a.image) + " (expired, fully fetched, or never made)") + const part = a.part | 0 + if (part < 0 || part >= im.parts) throw fail("bad_part", "part must be 0.." + (im.parts - 1)) + const r = imagePart(im, a.image, part) + if (part === im.parts - 1) dropImage(a.image) + return r + }) }, + { path: "/close", description: "Close a page. Body: {\"page\": \"\"} or {\"all\": true}. Idle pages close after 15 minutes anyway.", + handler: guard("close", async (a) => { + const ids = a.all ? [...pages.keys()] : [a.page] + let n = 0 + for (const id of ids) if (await closePage(id)) n++ + return { closed: n, pages: [...pages.keys()] } + }) }, + { path: "/request", description: "Ask the owner to let pages reach more hosts (a CDN, an API). Body: {\"hosts\": [\"cdn.jsdelivr.net\", \"*.example.com\"], \"reason\": \"one line the owner will see\"}. The owner is asked on their machine; this returns 202 with a taskId: poll /_as_tasks/ every few seconds until it answers {approved, allowed_hosts}. Uploading what you need with /files needs no approval.", + input_schema: { type: "object", required: ["hosts", "reason"], properties: { hosts: { type: "array", items: { type: "string" } }, reason: { type: "string" } } }, + handler: guard("request", async (a) => { + const hosts = Array.isArray(a.hosts) ? [...new Set(a.hosts.map((h) => String(h).toLowerCase()))] : [] + const bad = hosts.filter((h) => !validHost(h)) + if (!hosts.length || hosts.length > LIMITS.hostsPerAsk || bad.length) { + throw fail("bad_hosts", "hosts: 1-" + LIMITS.hostsPerAsk + " hostnames like cdn.jsdelivr.net or *.example.com" + (bad.length ? " (bad: " + clean(bad.join(", ")).slice(0, 200) + ")" : "")) + } + // A plain host a wildcard already covers isn't missing; a wildcard is + // missing unless that exact wildcard was granted. + const missing = hosts.filter((h) => !allowed.has(h) && (h.startsWith("*.") || !hostAllowed(allowed, h))) + if (!missing.length) return { approved: true, answer: "already", allowed_hosts: [...allowed] } + const reason = clean(String(a.reason || "").replace(/\s+/g, " ").trim()).slice(0, 300) || "(no reason given)" + const taskId = crypto.randomUUID() + const id = ask("hosts", "An agent asks to let the GPU browser reach:\n\n " + missing.join("\n ") + "\n\nIts reason: " + reason, { hosts: missing, taskId }) + log("request " + id + ": " + missing.join(", ") + " - " + reason) + return { status: 202, taskId } + }) }, + { path: "/define", description: "Add your own tool to this link, live: it opens a fresh page at `url` (default index.html), runs `js` with your call's body as `arg`, optionally screenshots, and closes the page; it lasts for this session. Body: {\"path\": \"/orbit_shots\", \"description\": \"...\", \"js\": \"\", \"input_schema\": optional, \"url\": optional, \"width\"/\"height\"/\"scale\"/\"wait\": optional, \"screenshot\": false | true | {screenshot options}}. Re-fetch the tool list afterwards. Needs no approval. Redefining a path replaces it.", + input_schema: { type: "object", required: ["path", "description", "js"], properties: { path: { type: "string" }, description: { type: "string" }, js: { type: "string" }, input_schema: { type: "object" }, url: { type: "string" }, screenshot: {} } }, + handler: guard("define", async (a) => { + const why = define(a) + if (why) throw fail("bad_tool", why) + log("tool " + a.path + " defined") + await syncTools() + return { defined: a.path, tools: [...defined.keys()] } + }) }, + { path: "/undefine", description: "Remove an agent-defined tool for this session. Body: {\"path\": \"/orbit_shots\"}.", + handler: guard("undefine", async (a) => { + const had = defined.delete(a.path) + if (had) await syncTools() + return { removed: had, tools: [...defined.keys()] } + }) }, +] + +function agentsMd() { + return `# GPU browser (agent-socket) + +A real Chromium on its owner's machine, with that machine's GPU, shared with you through this link. Use it to render WebGL/WebGPU (three.js and the like) and get screenshots back. It runs under a separate, locked-down account: it can't see the owner's files or local network. + +Typical flow: +1. \`GET /info\` - GPU, versions, what is allowed, when the session ends. +2. \`POST /files\` - upload your page and assets; they are served at ${UPLOAD_ORIGIN}/. Bundle or inline your libraries: pages can reach no other host unless the owner allows it. +3. \`POST /page\` with {"url": "index.html"} - opens it, returns a page id. +4. \`POST /eval\` to drive the scene, \`POST /screenshot\` to get an image (base64 in \`data\`; large images come in parts, fetched with \`POST /image\`). +5. \`POST /close\` when done. + +Network: pages reach only ${UPLOAD_ORIGIN}/ (your uploads) and hosts the owner allowed${allowed.size ? " (now: " + [...allowed].join(", ") + ")" : " (none yet)"}. Private and local network addresses are never reachable. Blocked requests are listed under \`blocked\` in the next reply for that page. To use a CDN or API, \`POST /request\` with the hosts and a one-line reason, then poll the returned task until the owner answers. + +Your own tools: \`POST /define\` turns a page script into a tool on this same link for this session (no approval needed); re-fetch the tool list after. +${defined.size ? "\nAgent-defined tools now: " + [...defined.keys()].join(", ") + "\n" : ""} +Limits: request bodies 1 MiB (upload big files in base64 pieces with append), ${LIMITS.pages} open pages, calls time out after about ${Math.round(LIMITS.opMs / 1000)} s, idle pages close after 15 minutes.${endsAt ? " This session ends at " + new Date(endsAt).toISOString() + "." : ""} The link stops working when the owner stops sharing. +` +} + +// ── self-test (installer) ──────────────────────────────────────────── +async function selfTest() { + launchOpts = { args: [], sandbox: true } + proxyPort = (await startProxy()).address().port + try { + await launch() + const g = await gpu() + stopping = true + await browser?.close() + out(JSON.stringify({ ok: true, chromium: "launched", ...g })) + process.exit(0) + } catch (e) { + out(JSON.stringify({ ok: false, error: String(e.message || e).split("\n").slice(0, 6).join(" ") })) + process.exit(1) + } +} + +// ── start ──────────────────────────────────────────────────────────── +async function main() { + const lines = readline.createInterface({ input: process.stdin }) + const first = await new Promise((resolve) => { lines.once("line", resolve); lines.once("close", () => resolve(null)) }) + let cfg + try { cfg = JSON.parse(first) } catch { cfg = null } + if (!cfg || typeof cfg.key !== "string" || !/^[0-9a-f]{16,64}$/.test(cfg.key) || typeof cfg.relay !== "string") { + log("expected a config line on stdin (this server is started by: npx @agent-socket/gpu-browser)") + process.exit(2) + } + KEY = cfg.key + for (const h of cfg.allow || []) if (validHost(h)) allowed.add(h) + const flags = gpuFlags(cfg.chromium?.args) + if (flags.dropped.length) log("ignoring Chromium flags that aren't GPU settings: " + flags.dropped.join(" ")) + // A custom Chromium binary and turning the sandbox off are for development + // runs only (the harness, a checkout); an install always uses its own + // Chromium, sandboxed. + launchOpts = { + args: flags.args, + executablePath: !INSTALLED && cfg.chromium?.executablePath ? String(cfg.chromium.executablePath) : undefined, + sandbox: INSTALLED || cfg.chromium?.sandbox !== false, + } + pwVersion = cfg.playwrightVersion || null + if (Number(cfg.minutes) > 0) endsAt = Date.now() + Number(cfg.minutes) * 60_000 + // Proves the CLI's stdin reaches us (it passes through sudo, and launchctl on macOS). + event("ready") + + // Ending: the owner's `stop`, Ctrl-C (the CLI forwards it, and sudo may + // too), the CLI dying (stdin closes), or the timer. + const { connect, endSession } = await import("./lib/sdk/index.js") + const stop = async (why) => { + if (stopping) return + stopping = true + event("ended " + why) + if (session) { + const held = { baseUrl: cfg.relay, sessionId: session.sessionId, secret: session.resumeSecret } + session.close() + // close() only starts the socket's close; exiting right after can drop + // the frame and leave the link held. endSession confirms. + if (held.secret) await withTimeout(endSession(held), 3000, "end").catch(() => {}) + } + await withTimeout(browser?.close() ?? Promise.resolve(), 3000, "browser close").catch(() => {}) + process.exit(0) + } + for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(sig, () => stop("signal")) + lines.on("line", (line) => { + try { + const [cmd, x, y] = line.trim().split(/\s+/) + if (cmd === "stop") stop("stopped") + else if (cmd === "answer" && x) answer(x, ["allow", "always", "deny"].includes(y) ? y : "deny") + } catch (e) { log("control line failed: " + e.message) } + }) + lines.on("close", () => stop("owner gone")) + if (endsAt) setTimeout(() => stop("timer"), endsAt - Date.now()).unref() + setInterval(() => { for (const [id, p] of pages) if (Date.now() - p.last > LIMITS.idleMs) closePage(id) }, 60_000).unref() + + proxyPort = (await startProxy()).address().port + log("proxy on 127.0.0.1:" + proxyPort + " (password-protected)") + await launch() + session = await connect({ + baseUrl: cfg.relay, appId: "agent-socket-gpu-browser", appDescription: "Chromium with its owner's GPU, for WebGL/WebGPU renders", + agentsMd: agentsMd(), tools: toolList(), + onSessionChanged: ({ tokensRemapped }) => { for (const url of Object.values(tokensRemapped || {})) event("link " + url) }, + }) + const link = await session.mintAgentToken({ label: "gpu-browser" }) + event("link " + link.url) +} + +if (process.argv.includes("--self-test")) await selfTest() +else await main().catch((e) => { log("could not start: " + (e.message || e)); process.exit(1) }) diff --git a/gpu-browser/scripts/README.md b/gpu-browser/scripts/README.md new file mode 100644 index 0000000..ec0a194 --- /dev/null +++ b/gpu-browser/scripts/README.md @@ -0,0 +1,3 @@ +# gpu-browser/scripts + +`vendor-sdk.sh` copies the built SDK (`sdk/dist`) into `../app/lib/sdk/`. `npm run build` at the repo root runs it; CI fails if the copy is stale. diff --git a/gpu-browser/scripts/vendor-sdk.sh b/gpu-browser/scripts/vendor-sdk.sh new file mode 100755 index 0000000..4f8df19 --- /dev/null +++ b/gpu-browser/scripts/vendor-sdk.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Copy the compiled SDK into gpu-browser/app/lib/sdk/, so what the server runs +# (and the harness tests) is exactly this repo's SDK, installed with the app +# and pinned with it. Same file list and rules as the Chrome extension's +# chrome-extension/scripts/vendor-sdk.sh; run after `npm run build -w sdk` +# (or just `npm run build`). CI fails on a diff. + +set -euo pipefail + +HERE="$(cd "$(dirname "$0")/.." && pwd)" # gpu-browser/ +SDK_DIST="$HERE/../sdk/dist" +DEST="$HERE/app/lib/sdk" + +if [ ! -d "$SDK_DIST" ]; then + echo "vendor-sdk: SDK dist not found at $SDK_DIST — run 'npm run build -w sdk' first" >&2 + exit 1 +fi + +files=(index.js session.js transport.js backoff.js agents-md.js preamble.js heartbeat.js types.js) +for f in "${files[@]}"; do + if [ ! -f "$SDK_DIST/$f" ]; then + echo "vendor-sdk: expected $SDK_DIST/$f missing — SDK build incomplete?" >&2 + exit 1 + fi +done + +mkdir -p "$DEST" +rm -f "$DEST"/*.js +for f in "${files[@]}"; do + cp "$SDK_DIST/$f" "$DEST/$f" +done + +cat > "$DEST/VENDORED.md" < { + const refused = [ + "127.0.0.1", "10.1.2.3", "172.16.0.1", "172.31.255.255", "192.168.1.1", "169.254.169.254", "100.64.0.1", + "0.0.0.0", "224.0.0.1", "255.255.255.255", "198.18.0.1", + "::1", "::", "fe80::1", "fd00::1", "fc00::1", "ff02::1", "::ffff:127.0.0.1", "::ffff:8.8.8.8", + "64:ff9b::a00:1", "64:ff9b:1::a00:1", "::a00:1", "2002:a00:1::", "2001:0:4136:e378::1", "not-an-ip", + ] + const allowed = ["104.20.26.136", "172.66.157.237", "172.32.0.1", "8.8.8.8", "2606:4700::6810:1a88", "2001:4860:4860::8888"] + for (const ip of refused) assert.equal(privateAddr(ip), true, ip) + // The bug this guards: an IPv6 ::ffff:0:0/96 rule in a shared BlockList refused every IPv4 address. + for (const ip of allowed) assert.equal(privateAddr(ip), false, ip) +}) + +test("validHost: names and *.wildcards only", () => { + for (const h of ["cdn.jsdelivr.net", "*.unpkg.com", "a.b.c.example.org", "x-y.io"]) assert.ok(validHost(h), h) + for (const h of ["localhost", "10.0.0.1", "127.0.0.1", "[::1]", "*.com.", "CDN.example.com", "a..b.com", "*", "*.*.com", "", "ex ample.com", "-a.com", "a.com/x", null]) + assert.ok(!validHost(h), String(h)) +}) + +test("hostAllowed: exact entries and *.domain wildcards, case and trailing dot ignored", () => { + const allowed = new Set(["cdn.jsdelivr.net", "*.unpkg.com"]) + assert.ok(hostAllowed(allowed, "cdn.jsdelivr.net")) + assert.ok(hostAllowed(allowed, "CDN.jsdelivr.net.")) + assert.ok(hostAllowed(allowed, "a.unpkg.com")) + assert.ok(hostAllowed(allowed, "a.b.unpkg.com")) + assert.ok(!hostAllowed(allowed, "unpkg.com"), "a wildcard doesn't cover the bare domain") + assert.ok(!hostAllowed(allowed, "jsdelivr.net")) + assert.ok(!hostAllowed(allowed, "evilunpkg.com")) +}) + +test("refuseTarget: IP literals, other ports and unlisted hosts are refused with a reason", () => { + const allowed = new Set(["example.org"]) + assert.equal(refuseTarget(allowed, "example.org", 443), null) + assert.equal(refuseTarget(allowed, "example.org", 80), null) + assert.match(refuseTarget(allowed, "example.org", 8080), /ports/) + assert.match(refuseTarget(allowed, "127.0.0.1", 443), /IP addresses/) + assert.match(refuseTarget(allowed, "[::1]", 443), /IP addresses/) + assert.match(refuseTarget(allowed, "example.com", 443), /not allowed/) + assert.match(refuseTarget(allowed, "", 443), /not allowed/) +}) + +test("validFilePath and validToolPath", () => { + for (const p of ["index.html", "assets/model.glb", "a/b/c.js", "x_y-z.1"]) assert.ok(validFilePath(p), p) + for (const p of ["../etc/passwd", "a/../b", "./a", "a/./b", "..", "a b", "", "x".repeat(257), "a\\b"]) assert.ok(!validFilePath(p), p) + for (const p of ["/orbit_shots", "/a", "/render-3"]) assert.ok(validToolPath(p), p) + for (const p of ["/files/x", "/_as_tasks", "/Info", "orbit", "/agents.md", "/", "/-a"]) assert.ok(!validToolPath(p), p) +}) + +test("clean: no control characters or escapes reach the terminal", () => { + assert.equal(clean("a\u001b[31mred\u0007"), "a?[31mred?") + assert.equal(clean("one\ntwo\r\nthree"), "one | two? | three") + assert.equal(clean("one\ntwo", { newlines: true }), "one\ntwo") + assert.equal(clean("tab\tkept"), "tab\tkept") + assert.equal(clean("\u009b31m"), "?31m", "C1 CSI too") +}) + +test("gpuFlags: GPU settings pass, anything that could undo the boundary is dropped", () => { + const { args, dropped } = gpuFlags([ + "--ignore-gpu-blocklist", "--use-angle=vulkan", "--enable-features=Vulkan,VulkanFromANGLE", "--enable-unsafe-webgpu", + "--no-sandbox", "--proxy-server=http://evil", "--proxy-bypass-list=*", "--remote-debugging-port=9222", + "--enable-features=Vulkan,NetworkServiceInProcess", "--use-angle=vulkan --no-sandbox", "--disable-web-security", "--user-data-dir=/tmp/x", + ]) + assert.deepEqual(args, ["--ignore-gpu-blocklist", "--use-angle=vulkan", "--enable-features=Vulkan,VulkanFromANGLE", "--enable-unsafe-webgpu"]) + assert.equal(dropped.length, 8) + assert.deepEqual(gpuFlags(undefined), { args: [], dropped: [] }) + assert.deepEqual(gpuFlags("--no-sandbox"), { args: [], dropped: [] }, "not a list: nothing") +}) + +test("UPLOAD_ORIGIN is under .internal, which can never be a public site", () => { + assert.match(UPLOAD_ORIGIN, /^https:\/\/[a-z.-]+\.internal$/) +}) diff --git a/harness/README.md b/harness/README.md index 7b32b9f..d4c5a29 100644 --- a/harness/README.md +++ b/harness/README.md @@ -34,6 +34,7 @@ Without `RELAY_URL`, `run.mjs` boots its own `wrangler dev` (`DEBUG=1`, `MAX_SYN - **52–55** Session resume: raw protocol (same URL, gap behaviour, tools replaced), refused secrets, expiry after the grace window (SKIP unless `RESUME_GRACE_MS` is short), concurrent attempts vs. the live socket - **56–57** `update_tools` (raw + SDK) - **58–62** Durable sessions: a 62 s gap with an eviction in it, a reset while the app is connected, every way a session ends wiping storage (clean close, alarm expiry, `end`, SDK close while away), hibernation + heartbeat auto-response + liveness alarm, and the limits nothing else covers + - **63** GPU browser server end to end: uploads, pages, multi-part screenshots, the private-network refusal and proxy password, an approval round trip, forged events, `stop` (SKIP without chromium) - **90** `/v1/_ws` rate limit (last, so its burst can't starve the others) ## Adding a scenario diff --git a/harness/scenarios/63-gpu-browser.mjs b/harness/scenarios/63-gpu-browser.mjs new file mode 100644 index 0000000..166660f --- /dev/null +++ b/harness/scenarios/63-gpu-browser.mjs @@ -0,0 +1,200 @@ +// 63-gpu-browser — gpu-browser/app/server.mjs end to end against this relay. +// +// Runs the server as the current user (a development run: no install, no +// separate account, Chromium from CHROMIUM_PATH, Chromium's sandbox off +// because CI runners and root can't use it; the install self-test covers +// the sandbox). Drives it like the CLI (config line on stdin, keyed events +// on stdout) and like an agent (HTTP calls on the link): +// +// 1. Events carry the run's key; `ready`, then `link`. +// 2. agents.md and /info answer; uploads are served at the upload origin. +// 3. /page + /eval work on an uploaded page. +// 4. A screenshot bigger than one reply comes back in parts (/image) that +// reassemble into a valid PNG of the right size. +// 5. The boundary: a page can't reach a server on this machine (it gets 0 +// hits), and the blocked hosts are reported; the proxy refuses anyone +// without its per-run password (407). +// 6. /request asks the owner (an `ask` event); an `answer` on stdin +// completes the agent's task. +// 7. Agent text in an error can't forge an event line. +// 8. /define adds a tool to the same link. +// 9. `stop` ends the session: the server exits 0 and the link is dead. +// +// SKIP without CHROMIUM_PATH (same as 50). + +import { Assert } from "../lib/assert.mjs" +import { RELAY_HTTP } from "../lib/relay.mjs" +import { spawn } from "node:child_process" +import crypto from "node:crypto" +import fs from "node:fs" +import http from "node:http" +import path from "node:path" +import readline from "node:readline" +import { fileURLToPath } from "node:url" + +const CHROMIUM_PATH = process.env.CHROMIUM_PATH ?? "/usr/bin/chromium" +const SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), "../../gpu-browser/app/server.mjs") +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +export default async function () { + const a = new Assert("63-gpu-browser") + if (!fs.existsSync(CHROMIUM_PATH)) return { skip: `${CHROMIUM_PATH} not present (set CHROMIUM_PATH)` } + + // A "local service" the browser must never reach. + let hits = 0 + const local = http.createServer((q, r) => { hits++; r.end("local") }) + await new Promise((r) => local.listen(0, "127.0.0.1", r)) + const localPort = local.address().port + + const key = crypto.randomBytes(16).toString("hex") + const child = spawn(process.execPath, [SERVER], { stdio: ["pipe", "pipe", "pipe"] }) + const lines = [] + const events = [] + const forged = [] + let proxyPort = 0 + const onLine = (line) => { + lines.push(line) + if (line.startsWith("@@gpu-browser:")) { + const [tag, name, ...rest] = line.split(" ") + if (tag === "@@gpu-browser:" + key) events.push({ name, rest }) + else forged.push(line) + } + const m = /proxy on 127\.0\.0\.1:(\d+)/.exec(line) + if (m) proxyPort = Number(m[1]) + } + readline.createInterface({ input: child.stdout }).on("line", onLine) + readline.createInterface({ input: child.stderr }).on("line", (l) => lines.push("stderr: " + l)) + const exited = new Promise((r) => child.on("exit", (code) => r(code))) + const waitEvent = async (name, ms = 30_000) => { + for (const end = Date.now() + ms; Date.now() < end; await sleep(100)) { + const i = events.findIndex((e) => e.name === name) + if (i >= 0) return events.splice(i, 1)[0] + } + throw new Error("no " + name + " event; output:\n" + lines.slice(-20).join("\n")) + } + + try { + child.stdin.write(JSON.stringify({ + relay: RELAY_HTTP, key, allow: [], minutes: 0, + chromium: { executablePath: CHROMIUM_PATH, sandbox: false, args: ["--no-sandbox"] }, + }) + "\n") + await waitEvent("ready", 10_000) + a.pass("ready event, keyed") + const link = (await waitEvent("link", 60_000)).rest[0] + a.ok(/\/v1\/t\/as_[^/]+\/agents\.md$/.test(link), "link event has an agent link", { link }) + a.ok(lines.some((l) => l.includes("ignoring Chromium flags") && l.includes("--no-sandbox")), "a non-GPU Chromium flag from the config is dropped") + const base = link.replace(/\/agents\.md$/, "") + const call = async (method, p, body) => { + const r = await fetch(base + p, { method, headers: { "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined }) + const text = await r.text() + let json = null + try { json = JSON.parse(text) } catch {} + return { status: r.status, json, text } + } + + const md = await fetch(link).then((r) => r.text()) + a.ok(md.includes("GPU browser") && md.includes("uploads.gpu-browser.internal"), "agents.md describes the app and its upload origin") + const info = await call("GET", "/info") + a.ok(info.status === 200 && info.json.upload_origin === "https://uploads.gpu-browser.internal" && info.json.gpu.webgl >= 1, + "/info: upload origin and a WebGL context", info.json) + + // 2-3: uploads, a page, eval + const noise = `` + const up = await call("POST", "/files", { files: [ + { path: "index.html", data: noise }, + { path: "lib/x.js", data: Buffer.from("export const x = 42").toString("base64"), encoding: "base64" }, + { path: "../escape.html", data: "x" }, + ] }) + a.ok(up.status === 200 && up.json.stored.length === 2 && up.json.errors.length === 1, "/files stores good paths, refuses ../", up.json) + const pg = await call("POST", "/page", { url: "index.html", width: 1200, height: 1200 }) + a.ok(pg.status === 200 && pg.json.http_status === 200 && /^[0-9a-f]{6}$/.test(pg.json.page), "/page opens the upload", pg.json) + const page = pg.json.page + const ev = await call("POST", "/eval", { page, js: "const m = await import('./lib/x.js'); return { x: m.x, arg }", arg: 7 }) + a.equal(ev.json.result, { x: 42, arg: 7 }, "/eval runs in the page and imports an uploaded module") + + // 4: a screenshot over one reply comes back in parts + const shot = await call("POST", "/screenshot", { page, frames: 1 }) + a.ok(shot.status === 200 && shot.json.parts > 1 && shot.json.part === 0 && typeof shot.json.image === "string", + "a large screenshot is split into parts", { status: shot.status, parts: shot.json?.parts, bytes: shot.json?.bytes, text: shot.text.slice(0, 300) }) + const chunks = [Buffer.from(shot.json.data, "base64")] + for (let i = 1; i < shot.json.parts; i++) { + const part = await call("POST", "/image", { image: shot.json.image, part: i }) + a.ok(part.status === 200 && part.json.part === i, "/image part " + i, { status: part.status }) + chunks.push(Buffer.from(part.json.data, "base64")) + } + const png = Buffer.concat(chunks) + a.ok(png.length === shot.json.bytes && png.subarray(1, 4).toString() === "PNG" && png.readUInt32BE(16) === 1200 && png.readUInt32BE(20) === 1200, + "the parts reassemble into the 1200x1200 PNG", { bytes: png.length, expected: shot.json.bytes }) + const gone = await call("POST", "/image", { image: shot.json.image, part: 0 }) + a.equal(gone.status, 404, "the image is dropped once its last part was fetched") + + // 5: the boundary + const again = await call("POST", "/eval", { page, js: ` + const out = {} + for (const u of ["http://127.0.0.1:${localPort}/", "http://localhost:${localPort}/", "http://[::1]:${localPort}/", "http://10.0.0.1/", "http://169.254.169.254/"]) { + try { await fetch(u, { mode: "no-cors" }); out[u] = "answered" } catch { out[u] = "failed" } + } + return out` }) + a.equal(hits, 0, "a page cannot reach a server on this machine (0 hits)") + const blocked = [pg, ev, shot, again].flatMap((r) => r.json?.blocked || []).map((b) => b.host) + a.ok(blocked.includes("127.0.0.1") && blocked.includes("example.com"), "blocked hosts are reported to the agent", { blocked }) + a.ok(proxyPort > 0, "the proxy port is logged", { lines: lines.slice(-5) }) + const noAuth = await new Promise((resolve) => { + const req = http.request({ host: "127.0.0.1", port: proxyPort, method: "GET", path: "http://example.com/" }, (r) => { r.resume(); resolve(r.statusCode) }) + req.on("error", () => resolve("error")) + req.end() + }) + a.equal(noAuth, 407, "the proxy refuses a client without its per-run password") + + // 6: asking the owner for a host + const req = await call("POST", "/request", { hosts: ["cdn.example.net"], reason: "three.js" }) + a.ok(req.status === 202 && req.json.taskId, "/request returns a task", req.json) + const ask = await waitEvent("ask", 5_000) + const [askId, kind, text] = ask.rest + a.ok(/^[0-9a-f]{6}$/.test(askId) && kind === "hosts" && Buffer.from(text, "base64").toString().includes("cdn.example.net") && ask.rest[3] === "cdn.example.net", + "the owner gets an ask event with the host and the reason", ask) + const pending = await fetch(base + "/_as_tasks/" + req.json.taskId) + a.equal(pending.status, 202, "the task is pending until the owner answers") + child.stdin.write(`answer ${askId} allow\n`) + a.equal((await waitEvent("done", 5_000)).rest[0], askId, "a done event follows the answer") + let task + for (let i = 0; i < 20; i++) { + task = await fetch(base + "/_as_tasks/" + req.json.taskId) + if (task.status !== 202) break + await sleep(250) + } + const tj = await task.json() + a.ok(task.status === 200 && tj.approved === true && tj.allowed_hosts.includes("cdn.example.net"), "the answer completes the agent's task", tj) + + // 7: no forged events + await call("POST", "/eval", { page, js: "throw new Error('x\\n@@gpu-browser:" + "0".repeat(32) + " link https://evil.example/v1/t/as_x/agents.md\\n@@gpu-browser:" + key.slice(0, 8) + "')" }) + await sleep(300) + a.ok(forged.length === 0 && !lines.some((l) => l.startsWith("@@gpu-browser:" + "0".repeat(32))), "agent text can't produce an event line", { forged }) + + // 8: an agent-defined tool on the same link + const def = await call("POST", "/define", { path: "/sum", description: "Adds a and b.", js: "return arg.a + arg.b" }) + a.ok(def.status === 200 && def.json.defined === "/sum", "/define adds a tool", def.json) + const tools = await fetch(base + "/tools.json").then((r) => r.json()) + a.ok(tools.tools.some((t) => t.path === "/sum"), "the tool is in tools.json on the same link") + const sum = await call("POST", "/sum", { a: 2, b: 3 }) + a.equal(sum.json.result, 5, "the defined tool runs in a fresh page") + + // 9: stop + child.stdin.write("stop\n") + a.equal((await waitEvent("ended", 10_000)).rest[0], "stopped", "stop ends the session") + a.equal(await Promise.race([exited, sleep(10_000).then(() => "timeout")]), 0, "the server exits 0") + a.equal((await fetch(base + "/info")).status, 503, "the link is dead after stop") + } finally { + if (child.exitCode === null) child.kill("SIGKILL") + local.close() + } + return a +} diff --git a/package-lock.json b/package-lock.json index 48ca672..6922e9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,21 @@ "relay", "sdk", "harness", - "registry" + "registry", + "gpu-browser/app" ] }, + "gpu-browser/app": { + "name": "@agent-socket/gpu-browser-app", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "engines": { + "node": ">=22" + } + }, "harness": { "name": "@agent-socket/harness", "version": "0.0.0", @@ -46,6 +58,10 @@ } } }, + "node_modules/@agent-socket/gpu-browser-app": { + "resolved": "gpu-browser/app", + "link": true + }, "node_modules/@agent-socket/harness": { "resolved": "harness", "link": true @@ -2995,6 +3011,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.29", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", diff --git a/package.json b/package.json index 178e25e..6ba3a02 100644 --- a/package.json +++ b/package.json @@ -15,20 +15,22 @@ "relay", "sdk", "harness", - "registry" + "registry", + "gpu-browser/app" ], "scripts": { "dev": "bash scripts/deploy.sh dev", "check": "npm run check -w relay && npm run check -w sdk && npm run check -w registry", - "build": "npm run build -w sdk && bash chrome-extension/scripts/vendor-sdk.sh", + "build": "npm run build -w sdk && bash chrome-extension/scripts/vendor-sdk.sh && bash gpu-browser/scripts/vendor-sdk.sh", "ext:vendor": "bash chrome-extension/scripts/vendor-sdk.sh", "ext:zip": "bash chrome-extension/scripts/build-zip.sh", "harness": "npm run build -w sdk && node harness/run.mjs all", "ext:test": "xvfb-run -a -s \"-screen 0 1280x900x24\" node chrome-extension/test/e2e.mjs", "ext:test:unit": "node chrome-extension/test/reconnect.unit.mjs && node chrome-extension/test/nav-guard.unit.mjs && node --test chrome-extension/test/profiles.unit.mjs chrome-extension/test/limits.unit.mjs", "ext:test:reconnect": "node chrome-extension/test/reconnect.e2e.mjs", - "test": "npm run check && npm test -w sdk && npm test -w registry && npm run harness && npm run ext:test:unit", + "test": "npm run check && npm test -w sdk && npm test -w registry && npm run harness && npm run ext:test:unit && npm run gpu:test:unit", "deploy": "bash scripts/deploy.sh deploy", - "privacy:html": "bash scripts/privacy-html.sh" + "privacy:html": "bash scripts/privacy-html.sh", + "gpu:test:unit": "node --test gpu-browser/test/policy.unit.mjs" } } From 55e553d9019b05fc2644e5b9089fe8a2eba5f253 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:29:15 +0000 Subject: [PATCH 02/19] gpu-browser: block fec0::/10, keep approvals across a dropped link, reserve page slots - fec0::/10 (deprecated site-local, still routable inside networks) is refused like the other local ranges. - An owner's answer given while the relay link is down was lost: completeTask throws then, and the request was already gone, so the agent's poll waited forever. Unsent answers now go out on reconnect. - Concurrent /page (or defined-tool) calls all passed the 8-page check before any page existed. A slot is now taken before the first await; harness 63 opens 10 at once and expects exactly 8 (it opened 10 before). - The server refuses a session length that isn't a number of minutes, instead of running without a timer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/app/policy.mjs | 9 ++++--- gpu-browser/app/server.mjs | 37 +++++++++++++++++++++++++--- gpu-browser/test/policy.unit.mjs | 2 +- harness/scenarios/63-gpu-browser.mjs | 10 +++++++- 4 files changed, 48 insertions(+), 10 deletions(-) diff --git a/gpu-browser/app/policy.mjs b/gpu-browser/app/policy.mjs index 5d5076a..fc9e649 100644 --- a/gpu-browser/app/policy.mjs +++ b/gpu-browser/app/policy.mjs @@ -32,11 +32,12 @@ for (const [a, p] of [ ["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["224.0.0.0", 3], ]) private4.addSubnet(a, p, "ipv4") -// ::/96 also covers :: and ::1 (IPv4-compatible); 64:ff9b::/96 and -// 64:ff9b:1::/48 are NAT64, 2002::/16 6to4 and 2001::/32 Teredo: each can -// carry an IPv4 address, so all are refused. +// ::/96 also covers :: and ::1 (IPv4-compatible); fec0::/10 is the old +// site-local range (deprecated, still routable inside networks); 64:ff9b::/96 +// and 64:ff9b:1::/48 are NAT64, 2002::/16 6to4 and 2001::/32 Teredo: each +// can carry an IPv4 address, so all are refused. for (const [a, p] of [ - ["::", 96], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8], ["::ffff:0:0", 96], + ["::", 96], ["fc00::", 7], ["fe80::", 10], ["fec0::", 10], ["ff00::", 8], ["::ffff:0:0", 96], ["64:ff9b::", 96], ["64:ff9b:1::", 48], ["2002::", 16], ["2001::", 32], ]) private6.addSubnet(a, p, "ipv6") diff --git a/gpu-browser/app/server.mjs b/gpu-browser/app/server.mjs index 0d03dfb..bde3b55 100644 --- a/gpu-browser/app/server.mjs +++ b/gpu-browser/app/server.mjs @@ -244,8 +244,15 @@ function pageOf(id) { p.last = Date.now() return p } +// Pages being opened count against the limit from the first moment: calls +// run concurrently, and each awaits Chromium before its page is in `pages`. +let opening = 0 async function openPage(a) { - if (pages.size >= LIMITS.pages) throw fail("too_many_pages", "at most " + LIMITS.pages + " pages; close some with POST /close") + if (pages.size + opening >= LIMITS.pages) throw fail("too_many_pages", "at most " + LIMITS.pages + " pages; close some with POST /close") + opening++ + try { return await openPageNow(a) } finally { opening-- } +} +async function openPageNow(a) { const target = new URL(typeof a.url === "string" && a.url ? a.url : "index.html", UPLOAD_ORIGIN + "/") if (!/^https?:$/.test(target.protocol)) throw fail("bad_url", "only http(s) URLs") if (target.origin !== UPLOAD_ORIGIN && !hostAllowed(allowed, target.hostname)) { @@ -367,6 +374,21 @@ function ask(kind, text, extra) { event("ask " + id + " " + kind + " " + b64(text) + (a.hosts ? " " + a.hosts.join(" ") : "")) return id } +// completeTask throws while the link to the relay is down; such answers are +// kept and sent once the SDK has resumed the session (the task survives a +// resume), so the agent's poll still gets them. +const unsent = [] +function complete(taskId, reply) { + try { session?.completeTask(taskId, reply) } + catch { unsent.push([taskId, reply]); log("the relay link is down; the answer goes to the agent when it's back") } +} +function sendUnsent() { + while (unsent.length) { + const [taskId, reply] = unsent[0] + try { session.completeTask(taskId, reply) } catch { return } + unsent.shift() + } +} function answer(id, choice) { const a = asks.get(id) if (!a) return log("no pending request " + id) @@ -379,8 +401,7 @@ function answer(id, choice) { if (choice === "always") event("persist-hosts " + a.hosts.join(" ")) log((yes ? "allowed " : "refused ") + a.hosts.join(", ") + (choice === "always" ? " (always)" : yes ? " (this session)" : choice === "timeout" ? " (no answer)" : "")) // Throws while the link is down; the agent's poll then never completes. - try { session?.completeTask(a.taskId, { body: { approved: yes, answer: choice, hosts: a.hosts, allowed_hosts: [...allowed].slice(0, 100) } }) } - catch (e) { log("could not tell the agent: " + e.message) } + complete(a.taskId, { body: { approved: yes, answer: choice, hosts: a.hosts, allowed_hosts: [...allowed].slice(0, 100) } }) } } @@ -622,7 +643,14 @@ async function main() { sandbox: INSTALLED || cfg.chromium?.sandbox !== false, } pwVersion = cfg.playwrightVersion || null - if (Number(cfg.minutes) > 0) endsAt = Date.now() + Number(cfg.minutes) * 60_000 + // The link's lifetime is a safety limit: a value that isn't a number of + // minutes (0 = until stopped) refuses to start rather than meaning "forever". + const minutes = cfg.minutes ?? 0 + if (typeof minutes !== "number" || !Number.isFinite(minutes) || minutes < 0) { + log("minutes must be a number of minutes, 0 for no limit (got " + JSON.stringify(cfg.minutes) + ")") + process.exit(2) + } + if (minutes > 0) endsAt = Date.now() + minutes * 60_000 // Proves the CLI's stdin reaches us (it passes through sudo, and launchctl on macOS). event("ready") @@ -662,6 +690,7 @@ async function main() { baseUrl: cfg.relay, appId: "agent-socket-gpu-browser", appDescription: "Chromium with its owner's GPU, for WebGL/WebGPU renders", agentsMd: agentsMd(), tools: toolList(), onSessionChanged: ({ tokensRemapped }) => { for (const url of Object.values(tokensRemapped || {})) event("link " + url) }, + onReconnect: () => sendUnsent(), }) const link = await session.mintAgentToken({ label: "gpu-browser" }) event("link " + link.url) diff --git a/gpu-browser/test/policy.unit.mjs b/gpu-browser/test/policy.unit.mjs index 2a819b3..aa07723 100644 --- a/gpu-browser/test/policy.unit.mjs +++ b/gpu-browser/test/policy.unit.mjs @@ -9,7 +9,7 @@ test("privateAddr: private, local and embedded-IPv4 forms are refused; public on const refused = [ "127.0.0.1", "10.1.2.3", "172.16.0.1", "172.31.255.255", "192.168.1.1", "169.254.169.254", "100.64.0.1", "0.0.0.0", "224.0.0.1", "255.255.255.255", "198.18.0.1", - "::1", "::", "fe80::1", "fd00::1", "fc00::1", "ff02::1", "::ffff:127.0.0.1", "::ffff:8.8.8.8", + "::1", "::", "fe80::1", "fec0::1", "feff::1", "fd00::1", "fc00::1", "ff02::1", "::ffff:127.0.0.1", "::ffff:8.8.8.8", "64:ff9b::a00:1", "64:ff9b:1::a00:1", "::a00:1", "2002:a00:1::", "2001:0:4136:e378::1", "not-an-ip", ] const allowed = ["104.20.26.136", "172.66.157.237", "172.32.0.1", "8.8.8.8", "2606:4700::6810:1a88", "2001:4860:4860::8888"] diff --git a/harness/scenarios/63-gpu-browser.mjs b/harness/scenarios/63-gpu-browser.mjs index 166660f..a095e5c 100644 --- a/harness/scenarios/63-gpu-browser.mjs +++ b/harness/scenarios/63-gpu-browser.mjs @@ -17,7 +17,8 @@ // 6. /request asks the owner (an `ask` event); an `answer` on stdin // completes the agent's task. // 7. Agent text in an error can't forge an event line. -// 8. /define adds a tool to the same link. +// 8. /define adds a tool to the same link; the 8-page limit holds when +// calls arrive at once. // 9. `stop` ends the session: the server exits 0 and the link is dead. // // SKIP without CHROMIUM_PATH (same as 50). @@ -187,6 +188,13 @@ export default async function () { const sum = await call("POST", "/sum", { a: 2, b: 3 }) a.equal(sum.json.result, 5, "the defined tool runs in a fresh page") + // 8b: the page limit holds under concurrent calls + await call("POST", "/close", { all: true }) + const burst = await Promise.all(Array.from({ length: 10 }, () => call("POST", "/page", { url: "lib/x.js", width: 64, height: 64 }))) + const opened = burst.filter((r) => r.status === 200).length + a.ok(opened === 8 && burst.filter((r) => r.status === 429).length === 2, "10 concurrent /page calls open exactly 8 pages", { statuses: burst.map((r) => r.status) }) + await call("POST", "/close", { all: true }) + // 9: stop child.stdin.write("stop\n") a.equal((await waitEvent("ended", 10_000)).rest[0], "stopped", "stop ends the session") From 432eab01e145d3dadb69615e053197ddd50b43b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:36:52 +0000 Subject: [PATCH 03/19] gpu-browser: probe the GPU in the keeper's context; retry a page on a relaunched browser On macOS without a display session, closing a context could take Chromium down, and the next /page failed with "browser has been closed". The GPU probe now opens a second page in the keeper's context and closes only that page; the keeper page is required (no longer best effort). A new context on a browser that has just exited waits for the relaunch and retries once. The self-test now opens and closes a context and checks the browser is still there afterwards, so an install catches this. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/app/server.mjs | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/gpu-browser/app/server.mjs b/gpu-browser/app/server.mjs index bde3b55..c055071 100644 --- a/gpu-browser/app/server.mjs +++ b/gpu-browser/app/server.mjs @@ -182,6 +182,7 @@ async function serveFile(route) { // ── Chromium and pages ─────────────────────────────────────────────── let browser = null +let keeper = null let gpuInfo = null let proxyPort = 0 let launchOpts = {} @@ -211,11 +212,15 @@ async function launch() { ], }) // Without a display session (this account never logs in), Chromium on - // macOS crashes when its last window closes; one page stays open for good. - await (await browser.newContext()).newPage().catch(() => {}) + // macOS crashes when its last window closes; one page stays open for good, + // and the GPU probe runs in a second page of its context. + const keeperCtx = await browser.newContext() + await keeperCtx.newPage() + keeper = keeperCtx browser.on("disconnected", () => { pages.clear() browser = null + keeper = null if (stopping) return log("browser exited, relaunching (open pages are gone)") setTimeout(() => launch().catch((e) => log("relaunch failed: " + e.message)), 1000) @@ -226,6 +231,16 @@ async function ready() { if (!browser) throw fail("unavailable", "the browser is restarting; retry in a few seconds") return browser } +// A browser that has just exited still looks open until its `disconnected` +// event; one retry lands on the relaunched one. +async function newContext(opts) { + try { return await (await ready()).newContext(opts) } catch (e) { + if (!/has been closed/.test(e.message)) throw e + log("the browser closed under a new page; retrying on the relaunched one") + await sleep(1500) + return (await ready()).newContext(opts) + } +} // Console output and blocked hosts since the last call, for one page. function report(p) { @@ -261,7 +276,7 @@ async function openPageNow(a) { const w = Math.min(Math.max(a.width | 0 || 1280, 16), 4096) const h = Math.min(Math.max(a.height | 0 || 720, 16), 4096) const scale = Math.min(Math.max(Number(a.scale) || 1, 0.25), 3) - const ctx = await (await ready()).newContext({ viewport: { width: w, height: h }, deviceScaleFactor: scale, serviceWorkers: "block", acceptDownloads: false }) + const ctx = await newContext({ viewport: { width: w, height: h }, deviceScaleFactor: scale, serviceWorkers: "block", acceptDownloads: false }) await ctx.route(UPLOAD_ORIGIN + "/**", serveFile) const page = await ctx.newPage() const id = crypto.randomBytes(3).toString("hex") @@ -343,9 +358,10 @@ async function shoot(p, a = {}) { } async function gpu() { if (gpuInfo) return gpuInfo - const ctx = await (await ready()).newContext() + await ready() + if (!keeper) throw fail("unavailable", "the browser is restarting; retry in a few seconds") + const page = await keeper.newPage() try { - const page = await ctx.newPage() gpuInfo = await withTimeout(page.evaluate(async () => { const c = document.createElement("canvas") const gl = c.getContext("webgl2") || c.getContext("webgl") @@ -359,7 +375,7 @@ async function gpu() { return { webgl: gl ? (gl instanceof WebGL2RenderingContext ? 2 : 1) : 0, renderer, webgpu } }), 10_000, "gpu probe") gpuInfo.software = /swiftshader|llvmpipe|software/i.test(gpuInfo.renderer || "") - } finally { await ctx.close().catch(() => {}) } + } finally { await page.close().catch(() => {}) } return gpuInfo } @@ -610,6 +626,13 @@ async function selfTest() { try { await launch() const g = await gpu() + // What every run does next: open a context and close it again. The + // browser must still be there afterwards (on macOS a crash here would + // only show up later, as "browser has been closed"). + await (await (await browser.newContext()).newPage()).context().close() + await sleep(1500) + if (!browser?.isConnected()) throw new Error("Chromium exited after a context closed") + await (await browser.newContext()).close() stopping = true await browser?.close() out(JSON.stringify({ ok: true, chromium: "launched", ...g })) From c65df7077a1e0d2f9ca09e67d8c1e3caa681cde9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:40:54 +0000 Subject: [PATCH 04/19] gpu-browser: no address fallback after a tunnel connects; resend answers; long timers - connectAny drops its fallback once a socket connects, so a reset on a live tunnel can't open the next address and write a second 200; an upstream that connects after the client left is closed. - task_complete is fire-and-forget, so the last 50 approval answers are sent again after every reconnect (completing a task twice stores the same reply). - The session timer re-arms in steps of at most 2^31-1 ms, so sessions longer than ~24.8 days don't end at once. - A self-test whose Chromium dies at launch launches it once more with Chromium's log on stderr and reports the end of it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/app/server.mjs | 41 ++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/gpu-browser/app/server.mjs b/gpu-browser/app/server.mjs index c055071..648a654 100644 --- a/gpu-browser/app/server.mjs +++ b/gpu-browser/app/server.mjs @@ -108,8 +108,10 @@ function connectAny(addrs, port, onConnect, onFail) { const next = (i) => { if (i >= addrs.length) return onFail() const sock = net.connect(port, addrs[i]) - sock.once("connect", () => onConnect(sock)) - sock.once("error", () => { sock.destroy(); next(i + 1) }) + const fallback = () => { sock.destroy(); next(i + 1) } + sock.once("error", fallback) + // Once connected, errors belong to the tunnel, never to the next address. + sock.once("connect", () => { sock.off("error", fallback); onConnect(sock) }) } next(0) } @@ -141,6 +143,7 @@ function startProxy() { const v = await vet(req.url.slice(0, i), port) if (v.reason) { sock.end("HTTP/1.1 403 Forbidden\r\n\r\n"); return } connectAny(v.addrs, port, (up) => { + if (sock.destroyed) return up.destroy() sock.write("HTTP/1.1 200 Connection Established\r\n\r\n") if (head?.length) up.write(head) up.pipe(sock) @@ -393,16 +396,19 @@ function ask(kind, text, extra) { // completeTask throws while the link to the relay is down; such answers are // kept and sent once the SDK has resumed the session (the task survives a // resume), so the agent's poll still gets them. -const unsent = [] +// completeTask is also fire-and-forget: a frame sent just as the link drops +// is lost without an error. So the last answers are kept and sent again +// after every reconnect; completing a task twice stores the same reply. +const answered = [] function complete(taskId, reply) { + answered.push([taskId, reply]) + if (answered.length > 50) answered.shift() try { session?.completeTask(taskId, reply) } - catch { unsent.push([taskId, reply]); log("the relay link is down; the answer goes to the agent when it's back") } + catch { log("the relay link is down; the answer goes to the agent when it's back") } } -function sendUnsent() { - while (unsent.length) { - const [taskId, reply] = unsent[0] +function resendAnswers() { + for (const [taskId, reply] of answered) { try { session.completeTask(taskId, reply) } catch { return } - unsent.shift() } } function answer(id, choice) { @@ -638,7 +644,14 @@ async function selfTest() { out(JSON.stringify({ ok: true, chromium: "launched", ...g })) process.exit(0) } catch (e) { - out(JSON.stringify({ ok: false, error: String(e.message || e).split("\n").slice(0, 6).join(" ") })) + // Chromium can die before its own logging starts; once more with its + // log on stderr, so the reason shows up in the error. + let detail = "" + if (!browser) { + launchOpts.args = ["--enable-logging=stderr", "--v=0"] + try { await launch(); stopping = true; await browser?.close() } catch (e2) { detail = String(e2.message || e2).split("\n").slice(-15).join(" | ") } + } + out(JSON.stringify({ ok: false, error: String(e.message || e).split("\n").slice(0, 6).join(" "), ...(detail && { chromium_log: detail }) })) process.exit(1) } } @@ -703,7 +716,13 @@ async function main() { } catch (e) { log("control line failed: " + e.message) } }) lines.on("close", () => stop("owner gone")) - if (endsAt) setTimeout(() => stop("timer"), endsAt - Date.now()).unref() + // A timer holds at most 2^31-1 ms (~24.8 days); longer sessions re-arm. + const arm = () => { + const left = endsAt - Date.now() + if (left <= 0) stop("timer") + else setTimeout(arm, Math.min(left, 2 ** 31 - 1)).unref() + } + if (endsAt) arm() setInterval(() => { for (const [id, p] of pages) if (Date.now() - p.last > LIMITS.idleMs) closePage(id) }, 60_000).unref() proxyPort = (await startProxy()).address().port @@ -713,7 +732,7 @@ async function main() { baseUrl: cfg.relay, appId: "agent-socket-gpu-browser", appDescription: "Chromium with its owner's GPU, for WebGL/WebGPU renders", agentsMd: agentsMd(), tools: toolList(), onSessionChanged: ({ tokensRemapped }) => { for (const url of Object.values(tokensRemapped || {})) event("link " + url) }, - onReconnect: () => sendUnsent(), + onReconnect: () => resendAnswers(), }) const link = await session.mintAgentToken({ label: "gpu-browser" }) event("link " + link.url) From ba1923d8c13aa2d21d99f02adb250de6b4329f64 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:43:47 +0000 Subject: [PATCH 05/19] gpu-browser: block IPv4-translated IPv6; relaunch Chromium with backoff, then end - ::ffff:0:0:0/96 (IPv4-translated, SIIT) is refused like ::ffff:0:0/96. - A failed relaunch is retried after 1, 2, 4, 8 and 16 s; if Chromium still doesn't start, the session ends instead of answering "unavailable" for good. A launch whose keeper page fails closes its browser, so a retry never leaves one behind. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/app/policy.mjs | 5 +++-- gpu-browser/app/server.mjs | 27 +++++++++++++++++++++------ gpu-browser/test/policy.unit.mjs | 2 +- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/gpu-browser/app/policy.mjs b/gpu-browser/app/policy.mjs index fc9e649..bdb0fa4 100644 --- a/gpu-browser/app/policy.mjs +++ b/gpu-browser/app/policy.mjs @@ -32,12 +32,13 @@ for (const [a, p] of [ ["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["224.0.0.0", 3], ]) private4.addSubnet(a, p, "ipv4") -// ::/96 also covers :: and ::1 (IPv4-compatible); fec0::/10 is the old +// ::/96 also covers :: and ::1 (IPv4-compatible); ::ffff:0:0/96 is +// IPv4-mapped and ::ffff:0:0:0/96 IPv4-translated (SIIT); fec0::/10 is the old // site-local range (deprecated, still routable inside networks); 64:ff9b::/96 // and 64:ff9b:1::/48 are NAT64, 2002::/16 6to4 and 2001::/32 Teredo: each // can carry an IPv4 address, so all are refused. for (const [a, p] of [ - ["::", 96], ["fc00::", 7], ["fe80::", 10], ["fec0::", 10], ["ff00::", 8], ["::ffff:0:0", 96], + ["::", 96], ["fc00::", 7], ["fe80::", 10], ["fec0::", 10], ["ff00::", 8], ["::ffff:0:0", 96], ["::ffff:0:0:0", 96], ["64:ff9b::", 96], ["64:ff9b:1::", 48], ["2002::", 16], ["2001::", 32], ]) private6.addSubnet(a, p, "ipv6") diff --git a/gpu-browser/app/server.mjs b/gpu-browser/app/server.mjs index 648a654..05111e7 100644 --- a/gpu-browser/app/server.mjs +++ b/gpu-browser/app/server.mjs @@ -193,7 +193,7 @@ let stopping = false const pages = new Map() // id -> { ctx, page, logs, blocked, last } async function launch() { - browser = await chromium.launch({ + const b = await chromium.launch({ // channel "chromium": the full Chromium in new headless mode, which uses // the GPU (the default headless shell renders in software). channel: launchOpts.executablePath ? undefined : "chromium", @@ -217,18 +217,32 @@ async function launch() { // Without a display session (this account never logs in), Chromium on // macOS crashes when its last window closes; one page stays open for good, // and the GPU probe runs in a second page of its context. - const keeperCtx = await browser.newContext() - await keeperCtx.newPage() - keeper = keeperCtx - browser.on("disconnected", () => { + try { + const keeperCtx = await b.newContext() + await keeperCtx.newPage() + keeper = keeperCtx + } catch (e) { await b.close().catch(() => {}); throw e } + browser = b + b.on("disconnected", () => { pages.clear() browser = null keeper = null if (stopping) return log("browser exited, relaunching (open pages are gone)") - setTimeout(() => launch().catch((e) => log("relaunch failed: " + e.message)), 1000) + relaunch(1) }) } +// Relaunches back off (1, 2, 4, 8, 16 s). If Chromium still won't start, +// the session ends rather than answer "unavailable" until someone notices. +let onBrowserGone = () => process.exit(1) +function relaunch(n) { + setTimeout(() => launch().catch((e) => { + log("relaunch failed: " + String(e.message).split("\n")[0]) + if (stopping) return + if (n < 5) relaunch(n + 1) + else { log("Chromium doesn't start any more; ending the session"); onBrowserGone() } + }), 1000 * 2 ** (n - 1)) +} async function ready() { for (let i = 0; !browser && i < 50; i++) await sleep(100) if (!browser) throw fail("unavailable", "the browser is restarting; retry in a few seconds") @@ -716,6 +730,7 @@ async function main() { } catch (e) { log("control line failed: " + e.message) } }) lines.on("close", () => stop("owner gone")) + onBrowserGone = () => stop("browser failed") // A timer holds at most 2^31-1 ms (~24.8 days); longer sessions re-arm. const arm = () => { const left = endsAt - Date.now() diff --git a/gpu-browser/test/policy.unit.mjs b/gpu-browser/test/policy.unit.mjs index aa07723..ef52ae2 100644 --- a/gpu-browser/test/policy.unit.mjs +++ b/gpu-browser/test/policy.unit.mjs @@ -9,7 +9,7 @@ test("privateAddr: private, local and embedded-IPv4 forms are refused; public on const refused = [ "127.0.0.1", "10.1.2.3", "172.16.0.1", "172.31.255.255", "192.168.1.1", "169.254.169.254", "100.64.0.1", "0.0.0.0", "224.0.0.1", "255.255.255.255", "198.18.0.1", - "::1", "::", "fe80::1", "fec0::1", "feff::1", "fd00::1", "fc00::1", "ff02::1", "::ffff:127.0.0.1", "::ffff:8.8.8.8", + "::1", "::", "fe80::1", "fec0::1", "feff::1", "fd00::1", "fc00::1", "ff02::1", "::ffff:127.0.0.1", "::ffff:8.8.8.8", "::ffff:0:127.0.0.1", "::ffff:0:a00:1", "64:ff9b::a00:1", "64:ff9b:1::a00:1", "::a00:1", "2002:a00:1::", "2001:0:4136:e378::1", "not-an-ip", ] const allowed = ["104.20.26.136", "172.66.157.237", "172.32.0.1", "8.8.8.8", "2606:4700::6810:1a88", "2001:4860:4860::8888"] From f22e51351acdd82ee5ba86fc0f00391e0a487aa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:46:54 +0000 Subject: [PATCH 06/19] gpu-browser: a failing self-test reports Chromium's own last log lines The self-test runs with Playwright's DEBUG=pw:browser and Chromium's --enable-logging, keeps the last lines instead of printing them, and adds them to the failure report (Chromium on CI runners died at launch or after a context closed with nothing else to go on). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/app/server.mjs | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/gpu-browser/app/server.mjs b/gpu-browser/app/server.mjs index 05111e7..91a974d 100644 --- a/gpu-browser/app/server.mjs +++ b/gpu-browser/app/server.mjs @@ -43,6 +43,22 @@ if (INSTALLED) { fs.rmSync(process.env.TMPDIR, { recursive: true, force: true }) fs.mkdirSync(process.env.TMPDIR, { mode: 0o700, recursive: true }) } +// A self-test that fails reports Chromium's own last log lines: Playwright +// prints them under DEBUG=pw:browser (read when it is imported); they are +// kept here instead of going to stderr. +const SELF_TEST = process.argv.includes("--self-test") +const chromiumLog = [] +if (SELF_TEST) { + process.env.DEBUG = "pw:browser" + process.stderr.write = (chunk) => { + for (const l of String(chunk).split("\n")) { + if (!l.trim() || /dbus|ssl_client|handshake failed/.test(l)) continue + chromiumLog.push(l.slice(0, 300)) + if (chromiumLog.length > 30) chromiumLog.shift() + } + return true + } +} const { chromium } = await import("playwright-core") const LIMITS = { @@ -641,7 +657,7 @@ Limits: request bodies 1 MiB (upload big files in base64 pieces with append), ${ // ── self-test (installer) ──────────────────────────────────────────── async function selfTest() { - launchOpts = { args: [], sandbox: true } + launchOpts = { args: ["--enable-logging=stderr", "--v=0"], sandbox: true } proxyPort = (await startProxy()).address().port try { await launch() @@ -658,14 +674,7 @@ async function selfTest() { out(JSON.stringify({ ok: true, chromium: "launched", ...g })) process.exit(0) } catch (e) { - // Chromium can die before its own logging starts; once more with its - // log on stderr, so the reason shows up in the error. - let detail = "" - if (!browser) { - launchOpts.args = ["--enable-logging=stderr", "--v=0"] - try { await launch(); stopping = true; await browser?.close() } catch (e2) { detail = String(e2.message || e2).split("\n").slice(-15).join(" | ") } - } - out(JSON.stringify({ ok: false, error: String(e.message || e).split("\n").slice(0, 6).join(" "), ...(detail && { chromium_log: detail }) })) + out(JSON.stringify({ ok: false, error: String(e.message || e).split("\n")[0], chromium_log: chromiumLog.slice(-20) })) process.exit(1) } } @@ -753,5 +762,5 @@ async function main() { event("link " + link.url) } -if (process.argv.includes("--self-test")) await selfTest() +if (SELF_TEST) await selfTest() else await main().catch((e) => { log("could not start: " + (e.message || e)); process.exit(1) }) From ce59e2024ba420283c43e5242cc01aff6d3dbb53 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:49:43 +0000 Subject: [PATCH 07/19] gpu-browser: self-test failure report keeps Chromium's error lines apart from its stack Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/app/server.mjs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/gpu-browser/app/server.mjs b/gpu-browser/app/server.mjs index 91a974d..59e8f2a 100644 --- a/gpu-browser/app/server.mjs +++ b/gpu-browser/app/server.mjs @@ -47,14 +47,16 @@ if (INSTALLED) { // prints them under DEBUG=pw:browser (read when it is imported); they are // kept here instead of going to stderr. const SELF_TEST = process.argv.includes("--self-test") -const chromiumLog = [] +const chromiumLog = { errors: [], tail: [] } // errors: the first lines that name a failure if (SELF_TEST) { process.env.DEBUG = "pw:browser" process.stderr.write = (chunk) => { - for (const l of String(chunk).split("\n")) { - if (!l.trim() || /dbus|ssl_client|handshake failed/.test(l)) continue - chromiumLog.push(l.slice(0, 300)) - if (chromiumLog.length > 30) chromiumLog.shift() + for (let l of String(chunk).split("\n")) { + if (!l.trim() || /dbus|ssl_client|handshake failed|cv_display_link/.test(l)) continue + l = l.replace(/^\S+ pw:browser /, "").slice(0, 300) + if (/ERROR|FATAL|Check failed|signal|Received|Terminating|exception/i.test(l) && chromiumLog.errors.length < 15) chromiumLog.errors.push(l) + chromiumLog.tail.push(l) + if (chromiumLog.tail.length > 8) chromiumLog.tail.shift() } return true } @@ -674,7 +676,7 @@ async function selfTest() { out(JSON.stringify({ ok: true, chromium: "launched", ...g })) process.exit(0) } catch (e) { - out(JSON.stringify({ ok: false, error: String(e.message || e).split("\n")[0], chromium_log: chromiumLog.slice(-20) })) + out(JSON.stringify({ ok: false, error: String(e.message || e).split("\n")[0], chromium_log: chromiumLog })) process.exit(1) } } From 4412c95af3daee164618ca059495d908fb0696db Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:52:46 +0000 Subject: [PATCH 08/19] gpu-browser: an installed server uses its account's home, whatever HOME sudo passed macOS's sudoers keeps the caller's HOME; the server now takes the account's home from the user database, so Chromium's profile and temp files never point at the caller's home. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/app/server.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/gpu-browser/app/server.mjs b/gpu-browser/app/server.mjs index 59e8f2a..aded630 100644 --- a/gpu-browser/app/server.mjs +++ b/gpu-browser/app/server.mjs @@ -38,7 +38,9 @@ const HERE = path.dirname(fileURLToPath(import.meta.url)) const INSTALLED = fs.existsSync(path.join(HERE, "..", "VERSION")) if (INSTALLED) { process.env.PLAYWRIGHT_BROWSERS_PATH ??= path.join(HERE, "..", "browsers") - // Chromium profiles and Playwright temp files stay in this account's home. + // The account's own home, whatever HOME sudo passed on (Apple's sudoers + // keeps the caller's). Chromium profiles and Playwright temp files stay there. + process.env.HOME = os.userInfo().homedir process.env.TMPDIR = path.join(os.homedir(), "tmp") fs.rmSync(process.env.TMPDIR, { recursive: true, force: true }) fs.mkdirSync(process.env.TMPDIR, { mode: 0o700, recursive: true }) @@ -712,7 +714,7 @@ async function main() { process.exit(2) } if (minutes > 0) endsAt = Date.now() + minutes * 60_000 - // Proves the CLI's stdin reaches us (it passes through sudo, and launchctl on macOS). + // Proves the CLI's stdin reaches us (it passes through sudo). event("ready") // Ending: the owner's `stop`, Ctrl-C (the CLI forwards it, and sudo may From f8faa7fe58407a4cd5af83855a3172607bcbb21c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:54:49 +0000 Subject: [PATCH 09/19] gpu-browser: an installed server drops inherited XDG_* dirs Some sudoers keep XDG_CONFIG_HOME (GitHub's Ubuntu runners do). Chromium then tries to create its crash reports under the caller's home, can't, and dies at launch (strace on CI: mkdir /home/runner/.config EACCES, then SIGTRAP). The installed server clears XDG_* and points the config and cache dirs at its own account's home. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/app/server.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/gpu-browser/app/server.mjs b/gpu-browser/app/server.mjs index aded630..6e981b7 100644 --- a/gpu-browser/app/server.mjs +++ b/gpu-browser/app/server.mjs @@ -41,6 +41,11 @@ if (INSTALLED) { // The account's own home, whatever HOME sudo passed on (Apple's sudoers // keeps the caller's). Chromium profiles and Playwright temp files stay there. process.env.HOME = os.userInfo().homedir + // Likewise the XDG directories (some sudoers keep them): Chromium puts its + // crash reports under XDG_CONFIG_HOME and dies if it can't create them. + for (const k of Object.keys(process.env)) if (k.startsWith("XDG_")) delete process.env[k] + process.env.XDG_CONFIG_HOME = path.join(os.homedir(), ".config") + process.env.XDG_CACHE_HOME = path.join(os.homedir(), ".cache") process.env.TMPDIR = path.join(os.homedir(), "tmp") fs.rmSync(process.env.TMPDIR, { recursive: true, force: true }) fs.mkdirSync(process.env.TMPDIR, { mode: 0o700, recursive: true }) From dd32d085f724cd45300865545969cd8307c1ca82 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:04:20 +0000 Subject: [PATCH 10/19] gpu-browser: keep control lines that arrive with the config; close popups; more reserved IPv4 - stdin has one line/close listener from the start, so a "stop" (or the close) arriving in the same chunk as the config line is acted on; it was dropped and the link stayed up. - Pages opened with window.open() are closed (and noted in the page's console report), so the page limit can't be bypassed. - 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 (documentation) and 192.88.99.0/24 (6to4 relay) are refused like the other non-global ranges. - Up to 100 approval answers are kept for resending, as many tasks as the relay keeps for a session. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/app/policy.mjs | 3 ++- gpu-browser/app/server.mjs | 28 +++++++++++++++++++++------- gpu-browser/test/policy.unit.mjs | 2 +- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/gpu-browser/app/policy.mjs b/gpu-browser/app/policy.mjs index bdb0fa4..53f395d 100644 --- a/gpu-browser/app/policy.mjs +++ b/gpu-browser/app/policy.mjs @@ -30,7 +30,8 @@ const private4 = new net.BlockList() const private6 = new net.BlockList() for (const [a, p] of [ ["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], - ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["224.0.0.0", 3], + ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24], ["192.88.99.0", 24], ["192.168.0.0", 16], + ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24], ["224.0.0.0", 3], ]) private4.addSubnet(a, p, "ipv4") // ::/96 also covers :: and ::1 (IPv4-compatible); ::ffff:0:0/96 is // IPv4-mapped and ::ffff:0:0:0/96 IPv4-translated (SIIT); fec0::/10 is the old diff --git a/gpu-browser/app/server.mjs b/gpu-browser/app/server.mjs index 6e981b7..6d42579 100644 --- a/gpu-browser/app/server.mjs +++ b/gpu-browser/app/server.mjs @@ -329,6 +329,8 @@ async function openPageNow(a) { try { host = new URL(url).hostname.replace(/^\[|\]$/g, "") } catch {} rec.blocked.set(host, refused.get(host) || "blocked") } + // window.open() would make pages outside the page limit; they are closed. + ctx.on("page", (p) => { if (p !== page) { push("popup closed: " + p.url().slice(0, 200)); p.close().catch(() => {}) } }) page.on("console", (m) => push(m.type() + ": " + m.text())) page.on("pageerror", (e) => push("pageerror: " + (e.stack || e.message))) page.on("requestfailed", (r) => { @@ -436,12 +438,13 @@ function ask(kind, text, extra) { // kept and sent once the SDK has resumed the session (the task survives a // resume), so the agent's poll still gets them. // completeTask is also fire-and-forget: a frame sent just as the link drops -// is lost without an error. So the last answers are kept and sent again -// after every reconnect; completing a task twice stores the same reply. +// is lost without an error. So answers are kept and sent again after every +// reconnect (completing a task twice stores the same reply), as many as the +// relay keeps tasks for one session. const answered = [] function complete(taskId, reply) { answered.push([taskId, reply]) - if (answered.length > 50) answered.shift() + if (answered.length > 100) answered.shift() try { session?.completeTask(taskId, reply) } catch { log("the relay link is down; the answer goes to the agent when it's back") } } @@ -691,7 +694,16 @@ async function selfTest() { // ── start ──────────────────────────────────────────────────────────── async function main() { const lines = readline.createInterface({ input: process.stdin }) - const first = await new Promise((resolve) => { lines.once("line", resolve); lines.once("close", () => resolve(null)) }) + // One listener from the start: later lines and the close can arrive in the + // same chunk as the config, before the control handlers below exist. + const early = [] + let onLine = null, onClose = null, closed = false + lines.on("line", (l) => (onLine ? onLine(l) : early.push(l))) + lines.on("close", () => { closed = true; onClose?.() }) + const first = await new Promise((resolve) => { + onLine = (l) => { onLine = null; resolve(l) } + onClose = () => resolve(null) + }) let cfg try { cfg = JSON.parse(first) } catch { cfg = null } if (!cfg || typeof cfg.key !== "string" || !/^[0-9a-f]{16,64}$/.test(cfg.key) || typeof cfg.relay !== "string") { @@ -740,14 +752,16 @@ async function main() { process.exit(0) } for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(sig, () => stop("signal")) - lines.on("line", (line) => { + onLine = (line) => { try { const [cmd, x, y] = line.trim().split(/\s+/) if (cmd === "stop") stop("stopped") else if (cmd === "answer" && x) answer(x, ["allow", "always", "deny"].includes(y) ? y : "deny") } catch (e) { log("control line failed: " + e.message) } - }) - lines.on("close", () => stop("owner gone")) + } + for (const l of early.splice(0)) onLine(l) + onClose = () => stop("owner gone") + if (closed) onClose() onBrowserGone = () => stop("browser failed") // A timer holds at most 2^31-1 ms (~24.8 days); longer sessions re-arm. const arm = () => { diff --git a/gpu-browser/test/policy.unit.mjs b/gpu-browser/test/policy.unit.mjs index ef52ae2..c9c11fa 100644 --- a/gpu-browser/test/policy.unit.mjs +++ b/gpu-browser/test/policy.unit.mjs @@ -8,7 +8,7 @@ import { UPLOAD_ORIGIN, clean, gpuFlags, hostAllowed, privateAddr, refuseTarget, test("privateAddr: private, local and embedded-IPv4 forms are refused; public ones pass", () => { const refused = [ "127.0.0.1", "10.1.2.3", "172.16.0.1", "172.31.255.255", "192.168.1.1", "169.254.169.254", "100.64.0.1", - "0.0.0.0", "224.0.0.1", "255.255.255.255", "198.18.0.1", + "0.0.0.0", "224.0.0.1", "255.255.255.255", "198.18.0.1", "192.0.2.1", "198.51.100.7", "203.0.113.9", "192.88.99.1", "::1", "::", "fe80::1", "fec0::1", "feff::1", "fd00::1", "fc00::1", "ff02::1", "::ffff:127.0.0.1", "::ffff:8.8.8.8", "::ffff:0:127.0.0.1", "::ffff:0:a00:1", "64:ff9b::a00:1", "64:ff9b:1::a00:1", "::a00:1", "2002:a00:1::", "2001:0:4136:e378::1", "not-an-ip", ] From bb8fd8382801760b210711fc4d19de80d1cd7f16 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:08:43 +0000 Subject: [PATCH 11/19] gpu-browser: one shared GPU probe; announce remapped links; IPv6 global unicast only - Concurrent /info calls share one in-flight GPU probe instead of each opening a page in the keeper's context. - onSessionChanged gets tokensRemapped as a Map; it was read with Object.values (always empty), so a new link after an unresumable reconnect was never shown to the owner. - IPv6 destinations must be global unicast (2000::/3) and outside the documentation, benchmarking, ORCHID and SRv6 SID ranges. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/app/policy.mjs | 10 ++++++++-- gpu-browser/app/server.mjs | 12 +++++++++--- gpu-browser/test/policy.unit.mjs | 2 +- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/gpu-browser/app/policy.mjs b/gpu-browser/app/policy.mjs index 53f395d..9f13413 100644 --- a/gpu-browser/app/policy.mjs +++ b/gpu-browser/app/policy.mjs @@ -37,17 +37,23 @@ for (const [a, p] of [ // IPv4-mapped and ::ffff:0:0:0/96 IPv4-translated (SIIT); fec0::/10 is the old // site-local range (deprecated, still routable inside networks); 64:ff9b::/96 // and 64:ff9b:1::/48 are NAT64, 2002::/16 6to4 and 2001::/32 Teredo: each -// can carry an IPv4 address, so all are refused. +// can carry an IPv4 address, so all are refused. Beyond these, only global +// unicast (2000::/3) is allowed at all, minus its documentation (2001:db8::/32, +// 3fff::/20), benchmarking (2001:2::/48), ORCHID (2001:10::/28, 2001:20::/28) +// and SRv6 SID (5f00::/16) ranges. for (const [a, p] of [ ["::", 96], ["fc00::", 7], ["fe80::", 10], ["fec0::", 10], ["ff00::", 8], ["::ffff:0:0", 96], ["::ffff:0:0:0", 96], ["64:ff9b::", 96], ["64:ff9b:1::", 48], ["2002::", 16], ["2001::", 32], + ["2001:db8::", 32], ["3fff::", 20], ["2001:2::", 48], ["2001:10::", 28], ["2001:20::", 28], ["5f00::", 16], ]) private6.addSubnet(a, p, "ipv6") +const global6 = new net.BlockList() +global6.addSubnet("2000::", 3, "ipv6") /** True for loopback, private, link-local (cloud metadata), CGNAT, multicast, * reserved, unique-local, and IPv6 forms that embed an IPv4 address. */ export function privateAddr(ip) { if (net.isIPv4(ip)) return private4.check(ip, "ipv4") - if (net.isIPv6(ip)) return private6.check(ip, "ipv6") + if (net.isIPv6(ip)) return !global6.check(ip, "ipv6") || private6.check(ip, "ipv6") return true } diff --git a/gpu-browser/app/server.mjs b/gpu-browser/app/server.mjs index 6d42579..103efcf 100644 --- a/gpu-browser/app/server.mjs +++ b/gpu-browser/app/server.mjs @@ -400,8 +400,14 @@ async function shoot(p, a = {}) { imageBytes += buf.length return imagePart(im, id, 0) } -async function gpu() { - if (gpuInfo) return gpuInfo +// Concurrent calls share one probe (each would open a page of its own). +let gpuProbe = null +function gpu() { + if (gpuInfo) return Promise.resolve(gpuInfo) + gpuProbe ??= probeGpu().finally(() => { gpuProbe = null }) + return gpuProbe +} +async function probeGpu() { await ready() if (!keeper) throw fail("unavailable", "the browser is restarting; retry in a few seconds") const page = await keeper.newPage() @@ -778,7 +784,7 @@ async function main() { session = await connect({ baseUrl: cfg.relay, appId: "agent-socket-gpu-browser", appDescription: "Chromium with its owner's GPU, for WebGL/WebGPU renders", agentsMd: agentsMd(), tools: toolList(), - onSessionChanged: ({ tokensRemapped }) => { for (const url of Object.values(tokensRemapped || {})) event("link " + url) }, + onSessionChanged: ({ tokensRemapped }) => { for (const url of tokensRemapped?.values() ?? []) event("link " + url) }, // a Map: old URL -> new onReconnect: () => resendAnswers(), }) const link = await session.mintAgentToken({ label: "gpu-browser" }) diff --git a/gpu-browser/test/policy.unit.mjs b/gpu-browser/test/policy.unit.mjs index c9c11fa..5310520 100644 --- a/gpu-browser/test/policy.unit.mjs +++ b/gpu-browser/test/policy.unit.mjs @@ -10,7 +10,7 @@ test("privateAddr: private, local and embedded-IPv4 forms are refused; public on "127.0.0.1", "10.1.2.3", "172.16.0.1", "172.31.255.255", "192.168.1.1", "169.254.169.254", "100.64.0.1", "0.0.0.0", "224.0.0.1", "255.255.255.255", "198.18.0.1", "192.0.2.1", "198.51.100.7", "203.0.113.9", "192.88.99.1", "::1", "::", "fe80::1", "fec0::1", "feff::1", "fd00::1", "fc00::1", "ff02::1", "::ffff:127.0.0.1", "::ffff:8.8.8.8", "::ffff:0:127.0.0.1", "::ffff:0:a00:1", - "64:ff9b::a00:1", "64:ff9b:1::a00:1", "::a00:1", "2002:a00:1::", "2001:0:4136:e378::1", "not-an-ip", + "64:ff9b::a00:1", "2001:db8::1", "3fff::1", "2001:2::1", "2001:10::1", "5f00::1", "100::1", "4000::1", "64:ff9b:1::a00:1", "::a00:1", "2002:a00:1::", "2001:0:4136:e378::1", "not-an-ip", ] const allowed = ["104.20.26.136", "172.66.157.237", "172.32.0.1", "8.8.8.8", "2606:4700::6810:1a88", "2001:4860:4860::8888"] for (const ip of refused) assert.equal(privateAddr(ip), true, ip) From f28a614927623d7bb1245e926f2ceed527564935 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:13:58 +0000 Subject: [PATCH 12/19] gpu-browser: 5 s per proxy address, cancelled with the client; cap pending approval requests - connectAny gives each vetted address at most 5 s before trying the next, so one that drops packets can't use up the page's navigation time, and a CONNECT whose client goes away cancels its pending attempt. - At most 10 /request approvals wait at a time; more get 429 too_many_requests before the owner is prompted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/app/server.mjs | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/gpu-browser/app/server.mjs b/gpu-browser/app/server.mjs index 103efcf..03792f2 100644 --- a/gpu-browser/app/server.mjs +++ b/gpu-browser/app/server.mjs @@ -72,7 +72,7 @@ const { chromium } = await import("playwright-core") const LIMITS = { pages: 8, fileBytes: 64 << 20, files: 2000, resultChars: 256_000, opMs: 25_000, idleMs: 15 * 60_000, - tools: 30, askMs: 15 * 60_000, hostsPerAsk: 10, logLines: 100, + tools: 30, askMs: 15 * 60_000, hostsPerAsk: 10, asks: 10, logLines: 100, // One reply is one WebSocket frame, and the relay ends the whole session // on a frame over 4,194,304 characters. Replies are measured against this // budget first; images over a part's size come back in parts (/image). @@ -128,17 +128,24 @@ function authorized(req) { } const NEED_AUTH = "HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"gpu-browser\"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" -// Connect to the first address that answers (all were vetted as public). +// Connect to the first address that answers (all were vetted as public), +// giving each at most 5 s so one that drops packets can't use up the page's +// time. Returns a cancel function for when the client goes away first. function connectAny(addrs, port, onConnect, onFail) { + let sock = null, timer = null, done = false const next = (i) => { - if (i >= addrs.length) return onFail() - const sock = net.connect(port, addrs[i]) - const fallback = () => { sock.destroy(); next(i + 1) } - sock.once("error", fallback) + if (done) return + if (i >= addrs.length) { done = true; return onFail() } + const s = sock = net.connect(port, addrs[i]) + let moved = false + const fallback = () => { if (moved) return; moved = true; clearTimeout(timer); s.destroy(); next(i + 1) } + timer = setTimeout(fallback, 5000) + s.once("error", fallback) // Once connected, errors belong to the tunnel, never to the next address. - sock.once("connect", () => { sock.off("error", fallback); onConnect(sock) }) + s.once("connect", () => { moved = true; clearTimeout(timer); s.off("error", fallback); done = true; onConnect(s) }) } next(0) + return () => { if (!done) { done = true; clearTimeout(timer); sock?.destroy() } } } function startProxy() { @@ -167,7 +174,7 @@ function startProxy() { const port = Number(req.url.slice(i + 1)) const v = await vet(req.url.slice(0, i), port) if (v.reason) { sock.end("HTTP/1.1 403 Forbidden\r\n\r\n"); return } - connectAny(v.addrs, port, (up) => { + const cancel = connectAny(v.addrs, port, (up) => { if (sock.destroyed) return up.destroy() sock.write("HTTP/1.1 200 Connection Established\r\n\r\n") if (head?.length) up.write(head) @@ -176,6 +183,7 @@ function startProxy() { up.on("error", () => sock.destroy()) sock.on("close", () => up.destroy()) }, () => sock.end("HTTP/1.1 502 Bad Gateway\r\n\r\n")) + sock.on("close", cancel) }) return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server))) } @@ -540,7 +548,7 @@ function guard(name, fn) { } catch (e) { const msg = String(e.message || e).split("\n").slice(0, 3).join(" ") log(name + " failed: " + msg) - const status = { no_page: 404, too_many_pages: 429, host_not_allowed: 403, image_too_large: 413, no_image: 404, timeout: 504, unavailable: 503 }[e.code] || 400 + const status = { no_page: 404, too_many_pages: 429, too_many_requests: 429, host_not_allowed: 403, image_too_large: 413, no_image: 404, timeout: 504, unavailable: 503 }[e.code] || 400 return err(status, e.code || "failed", msg) } } @@ -618,7 +626,7 @@ const builtins = [ for (const id of ids) if (await closePage(id)) n++ return { closed: n, pages: [...pages.keys()] } }) }, - { path: "/request", description: "Ask the owner to let pages reach more hosts (a CDN, an API). Body: {\"hosts\": [\"cdn.jsdelivr.net\", \"*.example.com\"], \"reason\": \"one line the owner will see\"}. The owner is asked on their machine; this returns 202 with a taskId: poll /_as_tasks/ every few seconds until it answers {approved, allowed_hosts}. Uploading what you need with /files needs no approval.", + { path: "/request", description: "Ask the owner to let pages reach more hosts (a CDN, an API). Body: {\"hosts\": [\"cdn.jsdelivr.net\", \"*.example.com\"], \"reason\": \"one line the owner will see\"}. The owner is asked on their machine; this returns 202 with a taskId: poll /_as_tasks/ every few seconds until it answers {approved, allowed_hosts}. At most 10 requests wait at a time (429 beyond that). Uploading what you need with /files needs no approval.", input_schema: { type: "object", required: ["hosts", "reason"], properties: { hosts: { type: "array", items: { type: "string" } }, reason: { type: "string" } } }, handler: guard("request", async (a) => { const hosts = Array.isArray(a.hosts) ? [...new Set(a.hosts.map((h) => String(h).toLowerCase()))] : [] @@ -630,6 +638,8 @@ const builtins = [ // missing unless that exact wildcard was granted. const missing = hosts.filter((h) => !allowed.has(h) && (h.startsWith("*.") || !hostAllowed(allowed, h))) if (!missing.length) return { approved: true, answer: "already", allowed_hosts: [...allowed] } + // Checked before the owner is prompted, so requests can't flood them. + if (asks.size >= LIMITS.asks) throw fail("too_many_requests", LIMITS.asks + " requests are already waiting for the owner; poll those first") const reason = clean(String(a.reason || "").replace(/\s+/g, " ").trim()).slice(0, 300) || "(no reason given)" const taskId = crypto.randomUUID() const id = ask("hosts", "An agent asks to let the GPU browser reach:\n\n " + missing.join("\n ") + "\n\nIts reason: " + reason, { hosts: missing, taskId }) From e57b9450f0a0ee11d0a83b89cce818df8f4ae0ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:19:43 +0000 Subject: [PATCH 13/19] gpu-browser: plain HTTP uses the same address fallback; refuse all of 2001::/23 - Proxied http:// requests go through connectAny too: each vetted address gets 5 s, the next is tried on failure, and a pending attempt is cancelled when the browser side closes. - 2001::/23 (IETF protocol assignments: Teredo, benchmarking, ORCHID, AMT, AS112 and anycast services, none of them websites) is refused as a whole instead of a few of its prefixes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/app/policy.mjs | 11 ++++++----- gpu-browser/app/server.mjs | 18 ++++++++++++------ gpu-browser/test/policy.unit.mjs | 2 +- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/gpu-browser/app/policy.mjs b/gpu-browser/app/policy.mjs index 9f13413..0b9be91 100644 --- a/gpu-browser/app/policy.mjs +++ b/gpu-browser/app/policy.mjs @@ -38,13 +38,14 @@ for (const [a, p] of [ // site-local range (deprecated, still routable inside networks); 64:ff9b::/96 // and 64:ff9b:1::/48 are NAT64, 2002::/16 6to4 and 2001::/32 Teredo: each // can carry an IPv4 address, so all are refused. Beyond these, only global -// unicast (2000::/3) is allowed at all, minus its documentation (2001:db8::/32, -// 3fff::/20), benchmarking (2001:2::/48), ORCHID (2001:10::/28, 2001:20::/28) -// and SRv6 SID (5f00::/16) ranges. +// unicast (2000::/3) is allowed at all, minus 2001::/23 (IETF protocol +// assignments: Teredo, benchmarking, ORCHID, a few anycast services, none +// of them websites), documentation (2001:db8::/32, 3fff::/20) and SRv6 SIDs +// (5f00::/16). for (const [a, p] of [ ["::", 96], ["fc00::", 7], ["fe80::", 10], ["fec0::", 10], ["ff00::", 8], ["::ffff:0:0", 96], ["::ffff:0:0:0", 96], - ["64:ff9b::", 96], ["64:ff9b:1::", 48], ["2002::", 16], ["2001::", 32], - ["2001:db8::", 32], ["3fff::", 20], ["2001:2::", 48], ["2001:10::", 28], ["2001:20::", 28], ["5f00::", 16], + ["64:ff9b::", 96], ["64:ff9b:1::", 48], ["2002::", 16], ["2001::", 23], + ["2001:db8::", 32], ["3fff::", 20], ["5f00::", 16], ]) private6.addSubnet(a, p, "ipv6") const global6 = new net.BlockList() global6.addSubnet("2000::", 3, "ipv6") diff --git a/gpu-browser/app/server.mjs b/gpu-browser/app/server.mjs index 03792f2..a3515af 100644 --- a/gpu-browser/app/server.mjs +++ b/gpu-browser/app/server.mjs @@ -160,12 +160,18 @@ function startProxy() { const headers = { ...req.headers, host: u.host } delete headers["proxy-connection"] delete headers["proxy-authorization"] - const up = http.request({ host: v.address, port, method: req.method, path: u.pathname + u.search, headers }, (r) => { - res.writeHead(r.statusCode, r.headers) - r.pipe(res) - }) - up.on("error", () => res.destroy()) - req.pipe(up) + // The same bounded, cancellable fallback over the vetted addresses as CONNECT. + const cancel = connectAny(v.addrs, port, (conn) => { + if (res.destroyed) return conn.destroy() + const up = http.request({ createConnection: () => conn, method: req.method, path: u.pathname + u.search, headers }, (r) => { + res.writeHead(r.statusCode, r.headers) + r.pipe(res) + }) + up.on("error", () => res.destroy()) + res.on("close", () => up.destroy()) + req.pipe(up) + }, () => { if (res.headersSent) res.destroy(); else res.writeHead(502).end() }) + res.on("close", cancel) }) server.on("connect", async (req, sock, head) => { sock.on("error", () => {}) diff --git a/gpu-browser/test/policy.unit.mjs b/gpu-browser/test/policy.unit.mjs index 5310520..80deb2d 100644 --- a/gpu-browser/test/policy.unit.mjs +++ b/gpu-browser/test/policy.unit.mjs @@ -10,7 +10,7 @@ test("privateAddr: private, local and embedded-IPv4 forms are refused; public on "127.0.0.1", "10.1.2.3", "172.16.0.1", "172.31.255.255", "192.168.1.1", "169.254.169.254", "100.64.0.1", "0.0.0.0", "224.0.0.1", "255.255.255.255", "198.18.0.1", "192.0.2.1", "198.51.100.7", "203.0.113.9", "192.88.99.1", "::1", "::", "fe80::1", "fec0::1", "feff::1", "fd00::1", "fc00::1", "ff02::1", "::ffff:127.0.0.1", "::ffff:8.8.8.8", "::ffff:0:127.0.0.1", "::ffff:0:a00:1", - "64:ff9b::a00:1", "2001:db8::1", "3fff::1", "2001:2::1", "2001:10::1", "5f00::1", "100::1", "4000::1", "64:ff9b:1::a00:1", "::a00:1", "2002:a00:1::", "2001:0:4136:e378::1", "not-an-ip", + "64:ff9b::a00:1", "2001:db8::1", "3fff::1", "2001:2::1", "2001:10::1", "2001:100::1", "2001:1ff::1", "5f00::1", "100::1", "4000::1", "64:ff9b:1::a00:1", "::a00:1", "2002:a00:1::", "2001:0:4136:e378::1", "not-an-ip", ] const allowed = ["104.20.26.136", "172.66.157.237", "172.32.0.1", "8.8.8.8", "2606:4700::6810:1a88", "2001:4860:4860::8888"] for (const ip of refused) assert.equal(privateAddr(ip), true, ip) From 5e70d8d5523f2279f10f10a21d200ee8483901ad Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:23:30 +0000 Subject: [PATCH 14/19] gpu-browser: one installed server at a time; the self-test gets its own temp dir Two starts racing past the CLI's pgrep check could both run, the second wiping the first's temp dir. The installed server now takes a pid file in its home atomically (O_EXCL); a second one exits 3 "already running", a stale one (dead pid) is taken over, and it's removed on exit. The self-test, which an update runs while a server may be up, takes no lock and uses tmp-self-test, so it no longer wipes a running server's files. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/app/server.mjs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/gpu-browser/app/server.mjs b/gpu-browser/app/server.mjs index a3515af..96a0bc2 100644 --- a/gpu-browser/app/server.mjs +++ b/gpu-browser/app/server.mjs @@ -46,7 +46,26 @@ if (INSTALLED) { for (const k of Object.keys(process.env)) if (k.startsWith("XDG_")) delete process.env[k] process.env.XDG_CONFIG_HOME = path.join(os.homedir(), ".config") process.env.XDG_CACHE_HOME = path.join(os.homedir(), ".cache") - process.env.TMPDIR = path.join(os.homedir(), "tmp") + // One server per install: two would share (and wipe) the temp dir below. + // The pid file is created atomically; one left by a dead server is taken + // over. The self-test (run by an update while a server may be running) + // takes no lock and uses a temp dir of its own. + const selfTest = process.argv.includes("--self-test") + if (!selfTest) { + const pidFile = path.join(os.homedir(), "server.pid") + for (;;) { + try { fs.writeFileSync(pidFile, String(process.pid), { flag: "wx", mode: 0o600 }); break } catch (e) { + if (e.code !== "EEXIST") throw e + const other = Number(fs.readFileSync(pidFile, "utf8")) || 0 + let alive = false + try { if (other) { process.kill(other, 0); alive = true } } catch (k) { alive = k.code === "EPERM" } + if (alive) { process.stdout.write("the GPU browser is already running (pid " + other + ")\n"); process.exit(3) } + fs.rmSync(pidFile, { force: true }) + } + } + process.on("exit", () => { try { if (fs.readFileSync(pidFile, "utf8") === String(process.pid)) fs.rmSync(pidFile) } catch {} }) + } + process.env.TMPDIR = path.join(os.homedir(), selfTest ? "tmp-self-test" : "tmp") fs.rmSync(process.env.TMPDIR, { recursive: true, force: true }) fs.mkdirSync(process.env.TMPDIR, { mode: 0o700, recursive: true }) } From 973f2d7a6e59af17e34ad3a9d38b36d284e2e33d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:27:32 +0000 Subject: [PATCH 15/19] gpu-browser: proxy skips the connect if the browser left during DNS; at most 4 addresses Both proxy paths check whether the client is still there after the DNS lookup before starting connectAny, and at most the first 4 vetted addresses are tried (5 s each), so cancelled requests to a name with many blackholed addresses can't pile up sockets and timers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/app/server.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/gpu-browser/app/server.mjs b/gpu-browser/app/server.mjs index 96a0bc2..49867a7 100644 --- a/gpu-browser/app/server.mjs +++ b/gpu-browser/app/server.mjs @@ -133,7 +133,8 @@ async function vet(host, port) { try { addrs = await dns.lookup(h, { all: true, verbatim: true }) } catch { return deny("does not resolve") } if (!addrs.length) return deny("does not resolve") for (const a of addrs) if (privateAddr(a.address)) return deny("resolves to a private address") - return { address: addrs[0].address, addrs: addrs.map((a) => a.address) } + // At most 4 to try (5 s each), however many the name resolves to. + return { address: addrs[0].address, addrs: addrs.slice(0, 4).map((a) => a.address) } } // Per-run credentials: the proxy listens on loopback, which other local @@ -179,7 +180,9 @@ function startProxy() { const headers = { ...req.headers, host: u.host } delete headers["proxy-connection"] delete headers["proxy-authorization"] - // The same bounded, cancellable fallback over the vetted addresses as CONNECT. + // The same bounded, cancellable fallback over the vetted addresses as + // CONNECT; nothing if the browser already gave up during the DNS lookup. + if (res.destroyed || req.destroyed) return const cancel = connectAny(v.addrs, port, (conn) => { if (res.destroyed) return conn.destroy() const up = http.request({ createConnection: () => conn, method: req.method, path: u.pathname + u.search, headers }, (r) => { @@ -199,6 +202,7 @@ function startProxy() { const port = Number(req.url.slice(i + 1)) const v = await vet(req.url.slice(0, i), port) if (v.reason) { sock.end("HTTP/1.1 403 Forbidden\r\n\r\n"); return } + if (sock.destroyed) return // the browser gave up during the DNS lookup const cancel = connectAny(v.addrs, port, (up) => { if (sock.destroyed) return up.destroy() sock.write("HTTP/1.1 200 Connection Established\r\n\r\n") From adf72ead6384ac4c065d1f51aad2b047c722dea9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:32:42 +0000 Subject: [PATCH 16/19] gpu-browser: an installed server doesn't start while an update holds the install lock After taking its pid file the server checks the installer's lock (a symlink to the installer's pid); while that pid is alive it exits 3 "being updated". The installer checks for a running server's pid file while holding the lock, so a start racing an update can't end up with its files swapped underneath it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/app/server.mjs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/gpu-browser/app/server.mjs b/gpu-browser/app/server.mjs index 49867a7..caa9842 100644 --- a/gpu-browser/app/server.mjs +++ b/gpu-browser/app/server.mjs @@ -53,17 +53,22 @@ if (INSTALLED) { const selfTest = process.argv.includes("--self-test") if (!selfTest) { const pidFile = path.join(os.homedir(), "server.pid") + const alive = (pid) => { try { process.kill(pid, 0); return true } catch (k) { return k.code === "EPERM" } } for (;;) { try { fs.writeFileSync(pidFile, String(process.pid), { flag: "wx", mode: 0o600 }); break } catch (e) { if (e.code !== "EEXIST") throw e const other = Number(fs.readFileSync(pidFile, "utf8")) || 0 - let alive = false - try { if (other) { process.kill(other, 0); alive = true } } catch (k) { alive = k.code === "EPERM" } - if (alive) { process.stdout.write("the GPU browser is already running (pid " + other + ")\n"); process.exit(3) } + if (other && alive(other)) { process.stdout.write("the GPU browser is already running (pid " + other + ")\n"); process.exit(3) } fs.rmSync(pidFile, { force: true }) } } process.on("exit", () => { try { if (fs.readFileSync(pidFile, "utf8") === String(process.pid)) fs.rmSync(pidFile) } catch {} }) + // An update in progress (its lock: a symlink to the installer's pid) may + // be about to swap these files. It checks for the pid file above while + // holding that lock, so of a racing start and update one backs off. + let installer = 0 + try { installer = Number(fs.readlinkSync("/var/run/agent-socket-gpu-browser-install.lock")) || 0 } catch {} + if (installer && alive(installer)) { process.stdout.write("the GPU browser is being updated (pid " + installer + "); start it again when that has finished\n"); process.exit(3) } } process.env.TMPDIR = path.join(os.homedir(), selfTest ? "tmp-self-test" : "tmp") fs.rmSync(process.env.TMPDIR, { recursive: true, force: true }) From 829e8d65cf62203a300630506995a7284042cf4e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:39:12 +0000 Subject: [PATCH 17/19] gpu-browser: a stale pid-file takeover re-checks it still owns the file Two starts that both found the same stale pid file could each remove it, the later removal deleting the earlier one's fresh file, and both run. After a takeover the server waits 300 ms and re-reads the file before touching its temp dir; if it isn't its own, it exits 3. Tested: two starts racing over a stale file, three rounds, exactly one server each. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/app/server.mjs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/gpu-browser/app/server.mjs b/gpu-browser/app/server.mjs index caa9842..7405c3f 100644 --- a/gpu-browser/app/server.mjs +++ b/gpu-browser/app/server.mjs @@ -54,14 +54,27 @@ if (INSTALLED) { if (!selfTest) { const pidFile = path.join(os.homedir(), "server.pid") const alive = (pid) => { try { process.kill(pid, 0); return true } catch (k) { return k.code === "EPERM" } } + const running = (pid) => { process.stdout.write("the GPU browser is already running (pid " + pid + ")\n"); process.exit(3) } + let tookOver = false for (;;) { try { fs.writeFileSync(pidFile, String(process.pid), { flag: "wx", mode: 0o600 }); break } catch (e) { if (e.code !== "EEXIST") throw e const other = Number(fs.readFileSync(pidFile, "utf8")) || 0 - if (other && alive(other)) { process.stdout.write("the GPU browser is already running (pid " + other + ")\n"); process.exit(3) } + if (other && alive(other)) running(other) fs.rmSync(pidFile, { force: true }) + tookOver = true } } + // Two starts that both found the same stale file can each remove it, the + // later removal taking the earlier one's fresh file: after a takeover, + // wait a moment and make sure the file is still ours (before the temp + // dir below is touched). + if (tookOver) { + await new Promise((r) => setTimeout(r, 300)) + let now = "" + try { now = fs.readFileSync(pidFile, "utf8") } catch {} + if (now !== String(process.pid)) running(Number(now) || "unknown") + } process.on("exit", () => { try { if (fs.readFileSync(pidFile, "utf8") === String(process.pid)) fs.rmSync(pidFile) } catch {} }) // An update in progress (its lock: a symlink to the installer's pid) may // be about to swap these files. It checks for the pid file above while From dbda7393acc85df84d9f44b3cfbdcc0d1087526d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:46:09 +0000 Subject: [PATCH 18/19] gpu-browser: a pid file or install lock counts only while that program runs The server's checks of its own pid file and of the installer's lock verify with ps that the pid is still server.mjs / install.sh, so a pid reused by something else after a crash doesn't block starts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/app/server.mjs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/gpu-browser/app/server.mjs b/gpu-browser/app/server.mjs index 7405c3f..b1ded4f 100644 --- a/gpu-browser/app/server.mjs +++ b/gpu-browser/app/server.mjs @@ -30,6 +30,7 @@ import os from "node:os" import path from "node:path" import readline from "node:readline" import crypto from "node:crypto" +import { spawnSync } from "node:child_process" import { fileURLToPath } from "node:url" import { UPLOAD_ORIGIN, clean, gpuFlags, hostAllowed, privateAddr, refuseTarget, validFilePath, validHost, validToolPath } from "./policy.mjs" @@ -53,14 +54,19 @@ if (INSTALLED) { const selfTest = process.argv.includes("--self-test") if (!selfTest) { const pidFile = path.join(os.homedir(), "server.pid") - const alive = (pid) => { try { process.kill(pid, 0); return true } catch (k) { return k.code === "EPERM" } } + // A pid counts only while it is still that program (pids get reused). + const runs = (pid, what) => { + try { process.kill(pid, 0) } catch (k) { if (k.code !== "EPERM") return false } + const r = spawnSync("ps", ["-p", String(pid), "-o", "args="], { encoding: "utf8" }) + return r.status === 0 && what.test(r.stdout) + } const running = (pid) => { process.stdout.write("the GPU browser is already running (pid " + pid + ")\n"); process.exit(3) } let tookOver = false for (;;) { try { fs.writeFileSync(pidFile, String(process.pid), { flag: "wx", mode: 0o600 }); break } catch (e) { if (e.code !== "EEXIST") throw e const other = Number(fs.readFileSync(pidFile, "utf8")) || 0 - if (other && alive(other)) running(other) + if (other && runs(other, /server\.mjs/)) running(other) fs.rmSync(pidFile, { force: true }) tookOver = true } @@ -81,7 +87,7 @@ if (INSTALLED) { // holding that lock, so of a racing start and update one backs off. let installer = 0 try { installer = Number(fs.readlinkSync("/var/run/agent-socket-gpu-browser-install.lock")) || 0 } catch {} - if (installer && alive(installer)) { process.stdout.write("the GPU browser is being updated (pid " + installer + "); start it again when that has finished\n"); process.exit(3) } + if (installer && runs(installer, /install\.sh/)) { process.stdout.write("the GPU browser is being updated (pid " + installer + "); start it again when that has finished\n"); process.exit(3) } } process.env.TMPDIR = path.join(os.homedir(), selfTest ? "tmp-self-test" : "tmp") fs.rmSync(process.env.TMPDIR, { recursive: true, force: true }) From a51b868568e01d9dd0d6480b9d9fd1562147e6e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:46:59 +0000 Subject: [PATCH 19/19] gpu-browser: a page's slot moves from "opening" to "pages" when it is registered A page counted in both while its navigation ran, so slow navigations could hit too_many_pages with half the slots free. The reservation is released as the page is added to the map (harness 63's 10-concurrent -> exactly 8 still holds). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/app/server.mjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/gpu-browser/app/server.mjs b/gpu-browser/app/server.mjs index b1ded4f..12f1ef0 100644 --- a/gpu-browser/app/server.mjs +++ b/gpu-browser/app/server.mjs @@ -368,9 +368,13 @@ let opening = 0 async function openPage(a) { if (pages.size + opening >= LIMITS.pages) throw fail("too_many_pages", "at most " + LIMITS.pages + " pages; close some with POST /close") opening++ - try { return await openPageNow(a) } finally { opening-- } + // The slot moves from `opening` to `pages` when the page is registered, + // not when its navigation ends (it would count twice meanwhile). + let held = true + const release = () => { if (held) { held = false; opening-- } } + try { return await openPageNow(a, release) } finally { release() } } -async function openPageNow(a) { +async function openPageNow(a, release) { const target = new URL(typeof a.url === "string" && a.url ? a.url : "index.html", UPLOAD_ORIGIN + "/") if (!/^https?:$/.test(target.protocol)) throw fail("bad_url", "only http(s) URLs") if (target.origin !== UPLOAD_ORIGIN && !hostAllowed(allowed, target.hostname)) { @@ -401,6 +405,7 @@ async function openPageNow(a) { }) page.on("response", (r) => { if (r.status() === 403 && r.headers()["x-agent-socket-blocked"]) block(r.url()) }) pages.set(id, rec) + release() const wait = ["load", "domcontentloaded", "networkidle", "commit"].includes(a.wait) ? a.wait : "load" const resp = await page.goto(target.href, { waitUntil: wait, timeout: LIMITS.opMs - 2000 }) .catch((e) => { push("goto: " + e.message.split("\n")[0]); return null })