From 1e7820ccdda1a1819d5ff0c9f049e70bf6fd5767 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:17:54 +0000 Subject: [PATCH 01/17] gpu-browser: npx @agent-socket/gpu-browser installs it under its own account and shares it The CLI (bin/, runs as you) and the root installer (install/) for the server from the previous commit. First run: explains what it will do, asks once, one sudo prompt. The installer creates the agent-socket-gpu-browser account (no login, no password, own group; on macOS via dscl the way MacPorts makes its user, with the implicit login attributes removed; on Linux a system user, in render/video for the GPU), fetches Node.js (pinned by SHA-256), playwright-core (no install scripts) and Chromium as that account, makes a root-owned copy, self-tests it by starting Chromium exactly the way every run starts it (adding an AppArmor userns profile on Ubuntu 23.10+), and only then swaps it in and writes one sudo rule: the installing user may start that server as that account without a password, nothing else. The rule's file is zz-agent-socket-gpu-browser because sudo applies the last matching rule; if a broader rule still wins, the CLI asks for the password instead of failing. Running: prints the link (60 min by default, --for), relays approvals to the terminal (TTY only; `always` remembers the host in ~/.agent-socket/gpu-browser/allowed-hosts.txt), re-validates everything the server says, and ends the link on Ctrl-C. status, stop, install, uninstall (a root-owned uninstall.sh stays in /opt for when the package is gone). Every folder it creates has a README. Tested on Linux for real (a non-root user, real account, downloads, sudo rule, sandboxed Chromium, approval, update, uninstall leaving nothing). CI does the same on fresh Ubuntu and macOS runners, path-filtered, plus shellcheck and dash. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- .github/workflows/gpu-browser.yml | 41 ++ CHANGELOG.md | 3 +- CONTRIBUTING.md | 2 + PRIVACY.md | 4 + README.md | 9 +- SECURITY.md | 4 +- gpu-browser/README.md | 54 ++- gpu-browser/bin/README.md | 3 + gpu-browser/bin/agent-socket-gpu-browser.mjs | 396 +++++++++++++++++++ gpu-browser/install/README.md | 32 ++ gpu-browser/install/agent-socket-README.md | 7 + gpu-browser/install/home-README.md | 5 + gpu-browser/install/install.sh | 276 +++++++++++++ gpu-browser/install/node.txt | 9 + gpu-browser/install/system-README.md | 18 + gpu-browser/install/uninstall.sh | 69 ++++ gpu-browser/install/user-README.md | 11 + gpu-browser/package.json | 34 ++ gpu-browser/test/README.md | 2 + gpu-browser/test/install-smoke.sh | 70 ++++ 20 files changed, 1037 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/gpu-browser.yml create mode 100644 gpu-browser/bin/README.md create mode 100755 gpu-browser/bin/agent-socket-gpu-browser.mjs create mode 100644 gpu-browser/install/README.md create mode 100644 gpu-browser/install/agent-socket-README.md create mode 100644 gpu-browser/install/home-README.md create mode 100755 gpu-browser/install/install.sh create mode 100644 gpu-browser/install/node.txt create mode 100644 gpu-browser/install/system-README.md create mode 100755 gpu-browser/install/uninstall.sh create mode 100644 gpu-browser/install/user-README.md create mode 100644 gpu-browser/package.json create mode 100644 gpu-browser/test/install-smoke.sh diff --git a/.github/workflows/gpu-browser.yml b/.github/workflows/gpu-browser.yml new file mode 100644 index 0000000..b120b93 --- /dev/null +++ b/.github/workflows/gpu-browser.yml @@ -0,0 +1,41 @@ +name: GPU browser install + +# A real install → start → use the link → stop → uninstall on fresh Linux +# and macOS runners (both have passwordless sudo). Only when gpu-browser/ +# changes: it downloads Node.js and Chromium (~250 MB) each time. +on: + push: + branches: [master, main] + paths: ['gpu-browser/**', '.github/workflows/gpu-browser.yml'] + pull_request: + paths: ['gpu-browser/**', '.github/workflows/gpu-browser.yml'] + +concurrency: + group: gpu-browser-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + scripts: + name: Shell scripts (shellcheck, dash, sh) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: sudo apt-get install -y -qq shellcheck dash >/dev/null + - run: shellcheck -s sh gpu-browser/install/install.sh gpu-browser/install/uninstall.sh gpu-browser/test/install-smoke.sh + - run: shellcheck gpu-browser/scripts/vendor-sdk.sh + - run: for f in gpu-browser/install/*.sh gpu-browser/test/install-smoke.sh; do dash -n "$f"; done + + install: + name: Install, run, uninstall (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: sh gpu-browser/test/install-smoke.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d17886..44db5ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## 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. +- **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. +- **GPU browser CLI** (`npx @agent-socket/gpu-browser`, package `@agent-socket/gpu-browser` 0.1.0): installs the server under its own OS account, `agent-socket-gpu-browser` (no login, no password, own group; on macOS made like MacPorts' user), into root-owned `/opt/agent-socket-gpu-browser` with Node.js pinned by checksum, after one confirmation and one sudo prompt. A self-test starts Chromium the way every run does before an install or update is swapped in. One sudo rule (`/etc/sudoers.d/zz-agent-socket-gpu-browser`) lets the installing user start that server, and nothing else, without a password. Prints the link (60 minutes by default, `--for`), asks in the terminal when the agent wants another host (`always` remembers it in `~/.agent-socket/gpu-browser/allowed-hosts.txt`), and `status`, `stop`, `uninstall`. CI installs, runs and uninstalls it on Ubuntu and macOS runners. ## 0.3.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f48e11f..8a973ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,6 +28,8 @@ npm run ext:test:reconnect # headless npm run ext:test # full suite, needs xvfb-run ``` +The GPU browser's real install (an OS account, sudo, ~250 MB of downloads) is tested by `gpu-browser/test/install-smoke.sh` on fresh CI runners (`.github/workflows/gpu-browser.yml`, only when `gpu-browser/` changes); run it only on a throwaway machine, since it ends with an uninstall. + To run the relay on its own: `npm run dev` (port 8787). Layout and deploy notes are in each package's README and in [docs/](docs/). ## Changing the SDK diff --git a/PRIVACY.md b/PRIVACY.md index aabb4a5..329af15 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -49,6 +49,10 @@ The registry runs on Cloudflare Workers with Cloudflare's request logging turned The session URL, token and reconnect secret are discarded on disconnect. None of this is transmitted except the relay base URL, which decides where the session connects, the session id and reconnect secret, which go only to that relay when reconnecting, and the tools of kept profiles, which are registered with the relay (tool names, descriptions and input schemas, and the notes in the session's agents.md; the code stays in the extension) for sessions on that site. Uninstalling the extension deletes all of it. +## GPU browser (`npx @agent-socket/gpu-browser`) + +A separate tool from the extension. While it runs, the files an agent uploads, the pages it opens, the JavaScript it runs and the screenshots it takes travel between the agent and your machine through the relay, like any tool call: forwarded, not stored, except the answer to a host request (an async result) until the agent fetches it. Pages it opens on hosts you allowed are fetched from your machine, with your IP address. It sends nothing else anywhere: no analytics. Installing it downloads Node.js from nodejs.org, `playwright-core` from the npm registry and Chromium from Playwright's CDN. On your machine it keeps your settings in `~/.agent-socket/gpu-browser/` (hosts you allowed with `always`, optional config) and the browser in `/opt/agent-socket-gpu-browser/`; uploads, pages and the temporary Chromium profile exist only while it runs. `npx @agent-socket/gpu-browser uninstall` removes the install. + ## Third parties We do not sell, rent, or share user data with third parties for advertising, marketing, or any other purpose unrelated to making the active session work. The relay and the registry are hosted on Cloudflare Workers; Cloudflare's data-handling practices are governed by its own privacy policy at . diff --git a/README.md b/README.md index 561d578..e5689df 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,14 @@ The AI can read the page, click, type, scroll, navigate and take screenshots, in More in [chrome-extension/README.md](chrome-extension/README.md). +## Let an agent render on your GPU + +```bash +npx @agent-socket/gpu-browser +``` + +Prints a link for an agent anywhere (a cloud Claude session has no GPU) to use a headless Chromium on your machine: it uploads a page, runs it on your GPU and gets screenshots back, while you keep using your computer. The browser runs under its own OS account, which can't read your files; pages reach only the agent's uploads and hosts you allow; the first run sets it all up after asking once. More in [gpu-browser/README.md](gpu-browser/README.md). + ## Add "Connect with AI" to your app ```bash @@ -80,7 +88,6 @@ 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 450e4cf..8dd0112 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -27,7 +27,7 @@ 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. +- **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, typed in the terminal that started it. Installed, it runs as the `agent-socket-gpu-browser` account, which can't log in and can't read the installing user's home; world-readable files elsewhere are readable by it, as by any local account. Its code in `/opt/agent-socket-gpu-browser` belongs to root. The one sudo rule lets the installing user (or any program running as them) start that server as that account without a password, nothing more. The agent can use the machine's GPU, CPU and memory until the session ends (60 minutes by default). Reports that only restate these properties are not treated as vulnerabilities. @@ -71,4 +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. +- 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; the GPU browser's account reading the installing user's files or changing its own code; its sudo rule allowing more than starting the server. diff --git a/gpu-browser/README.md b/gpu-browser/README.md index 4dea43b..3393e31 100644 --- a/gpu-browser/README.md +++ b/gpu-browser/README.md @@ -4,14 +4,47 @@ A Chromium on your own machine, with your machine's GPU, that an agent anywhere 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 +## Use it -| 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 | +```bash +npx @agent-socket/gpu-browser +``` + +The first run explains what it will install and asks once (one sudo password prompt). Then it prints a link and copies it to the clipboard: paste it into the agent's chat. The link works for 60 minutes (`--for 2h`, `--for none`); Ctrl-C ends it at once. + +| | | +|---|---| +| `npx @agent-socket/gpu-browser` | Start (installs or updates first if needed) | +| `npx @agent-socket/gpu-browser status` | What is installed, where, the GPU it found, whether the sudo rule works, whether it runs | +| `npx @agent-socket/gpu-browser stop` | Stop one running in another terminal | +| `npx @agent-socket/gpu-browser uninstall` | Remove the account, the sudo rule and `/opt/agent-socket-gpu-browser` | + +When the agent wants a page to reach another host (a CDN, an API), the terminal rings and asks: `y` allows it for this session, `always` also remembers it, anything else refuses. Only that terminal can answer. + +macOS and Linux. Needs Node.js 18+ for `npx`; the browser brings its own Node.js 22. On a machine without a usable GPU (a VM, CI) it still works, rendering in software, and says so. + +### What gets installed -Until the CLI lands, the server only runs as a development run (below). +One sudo prompt covers all of it (details and order: [install/README.md](install/README.md)): + +- **An account, `agent-socket-gpu-browser`**, that runs the browser. It can't log in, has no password, has its own group, and can't read your files. On macOS it doesn't appear in Users & Groups or at the login window. +- **`/opt/agent-socket-gpu-browser/`**: Node.js (pinned by checksum), `playwright-core`, Chromium (~400 MB together). Owned by root, so the account can run it but not change it. +- **One sudo rule**, `/etc/sudoers.d/zz-agent-socket-gpu-browser`. It lets you start that exact server as that account without a password. Nothing else. +- **On Ubuntu 23.10+**, an AppArmor profile that lets this Chromium use its sandbox. +- **If other accounts can open your home folder**, it offers to close it (`chmod 700`), and uninstall offers to reopen it. + +Before switching to a new version, it starts Chromium from the new copy the way every run does and checks it renders. Updates replace the copy only if that works. + +Your settings are in `~/.agent-socket/gpu-browser/`: `allowed-hosts.txt` (hosts you allowed with `always`; edit freely) and an optional `config.json`. See the README there. + +### Where things are + +| Part | Folder | +|---|---| +| The server: browser, network boundary, the agent's tools, the link | [`app/`](app/README.md) | +| The CLI (`npx`), runs as you | [`bin/`](bin/README.md) | +| What runs as root, once per install | [`install/`](install/README.md) | +| Tests | [`test/`](test/README.md) | ## What the agent can do @@ -37,12 +70,16 @@ Fixed in code ([`app/policy.mjs`](app/policy.mjs), applied by [`app/server.mjs`] - **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. +- **The account.** The server and Chromium run as `agent-socket-gpu-browser`, which can't log in and can't read your files (the installer checks your home folder from that account's side). World-readable files elsewhere on the machine are readable by it, as by any local account. + +The agent can use the machine's GPU, CPU and memory until the link ends. ## Development run +Without installing (no separate account), from the repo root: + ```bash -npm install && npm run build # from the repo root +npm install && npm run build # 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 @@ -54,3 +91,4 @@ The first line on stdin is the config; see the header of [`app/server.mjs`](app/ - `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`). +- `sh gpu-browser/test/install-smoke.sh`: a real install, start, use and uninstall. CI runs it on fresh Ubuntu and macOS runners (`.github/workflows/gpu-browser.yml`); don't run it on a machine where you use the GPU browser, since it ends by uninstalling. diff --git a/gpu-browser/bin/README.md b/gpu-browser/bin/README.md new file mode 100644 index 0000000..249f039 --- /dev/null +++ b/gpu-browser/bin/README.md @@ -0,0 +1,3 @@ +# gpu-browser/bin + +`agent-socket-gpu-browser.mjs` is what `npx @agent-socket/gpu-browser` runs, as you: it installs or updates the browser (asking first, one sudo prompt, via `../install/install.sh`), starts the server as the `agent-socket-gpu-browser` account, prints the link, and asks you in this terminal when the agent wants more hosts. Also `status`, `stop`, `uninstall`, `help`. No dependencies. diff --git a/gpu-browser/bin/agent-socket-gpu-browser.mjs b/gpu-browser/bin/agent-socket-gpu-browser.mjs new file mode 100755 index 0000000..adadae9 --- /dev/null +++ b/gpu-browser/bin/agent-socket-gpu-browser.mjs @@ -0,0 +1,396 @@ +#!/usr/bin/env node +// npx @agent-socket/gpu-browser — share this machine's GPU browser with an +// agent through an agent-socket link. Runs as you; starts the server +// (../app/server.mjs) as the agent-socket-gpu-browser account, which the +// installer (../install/install.sh, run once with sudo after asking) creates. +// +// npx @agent-socket/gpu-browser [--for 60m|2h|none] [--relay URL] [--yes] +// npx @agent-socket/gpu-browser install | status | stop | uninstall | help +// +// Approvals (an agent asking for more hosts) are answered here, in this +// terminal; nothing else can answer them. + +import { spawn, spawnSync } from "node:child_process" +import crypto from "node:crypto" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import readline from "node:readline" +import { fileURLToPath } from "node:url" + +const NAME = "agent-socket-gpu-browser" +const DIR = "/opt/" + NAME +const NODE = DIR + "/node/bin/node" +const SERVER = DIR + "/app/server.mjs" +const PKG = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") +const VERSION = JSON.parse(fs.readFileSync(path.join(PKG, "package.json"), "utf8")).version +const SETTINGS = path.join(os.homedir(), ".agent-socket", "gpu-browser") +const DEFAULT_RELAY = "https://agentsocket.dev" +const HOST_RE = /^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,62}$/ +const MAC = process.platform === "darwin" +const TTY = !!(process.stdin.isTTY && process.stdout.isTTY) + +const c = (code) => (s) => (process.stdout.isTTY ? `\x1b[${code}m${s}\x1b[0m` : s) +const dim = c(2), bold = c(1), green = c(32), yellow = c(33), red = c(31) +const say = (s = "") => process.stdout.write(s + "\n") +const die = (s, code = 1) => { process.stderr.write(red("✗ ") + s + "\n"); process.exit(code) } +// Server output is the account's (less trusted than you): no control, +// bidi or format characters reach your terminal. +const safe = (s) => String(s).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g, "?").slice(0, 2000) + +// ── what is installed ───────────────────────────────────────────────── +// A hash of what the install is made from, so a changed package reinstalls +// even when its version number didn't move. +function packageHash() { + const h = crypto.createHash("sha256") + const walk = (d) => { + for (const e of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + if (e.name === "node_modules") continue + const p = path.join(d, e.name) + if (e.isDirectory()) walk(p) + else { h.update(path.relative(PKG, p) + "\0"); h.update(fs.readFileSync(p)); h.update("\0") } + } + } + walk(path.join(PKG, "app")) + walk(path.join(PKG, "install")) + return h.digest("hex") +} +function installed() { + try { + const env = Object.fromEntries(fs.readFileSync(DIR + "/install.env", "utf8").split("\n").filter(Boolean).map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1)])) + return fs.existsSync(SERVER) ? env : null + } catch { return null } +} +const newer = (a, b) => { + const x = String(a).split(".").map(Number), y = String(b).split(".").map(Number) + for (let i = 0; i < 3; i++) if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) > (y[i] || 0) + return false +} +const running = () => spawnSync("pgrep", ["-f", SERVER], { encoding: "utf8" }).stdout.trim() +// Exactly the command the installed sudo rule allows. macOS: root joins the +// account's launchd domain (Chromium needs it) and runs the inner sudo, whose +// -H is needed because Apple's sudoers keeps HOME. Linux: sudo sets HOME to +// the account's anyway, and -H would make sudo ask for a password. +const startArgs = (acctUid) => MAC + ? ["/bin/launchctl", "asuser", String(acctUid), "/usr/bin/sudo", "-u", NAME, "-H", NODE, SERVER] + : ["-u", NAME, NODE, SERVER] +// Does the installed sudo rule let us start without a password? +const ruleActive = (acctUid) => spawnSync("sudo", ["-n", "-l", ...startArgs(acctUid)], { stdio: "ignore" }).status === 0 + +async function ask(question, def) { + if (!TTY) return def + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) + const a = await new Promise((r) => rl.question(question, r)) + rl.close() + return a.trim() +} + +// ── install / update ────────────────────────────────────────────────── +async function install(yes, reason) { + say(bold(reason)) + say(`It will, with one sudo password prompt: + • create a separate account, ${NAME}, that can't log in and can't read your files + • put Node.js ${fs.readFileSync(path.join(PKG, "install/node.txt"), "utf8").match(/^version (.+)$/m)[1]}, Playwright and Chromium (~400 MB) in ${DIR} + • add one sudo rule that lets you start the browser as that account without a password + • check that Chromium starts there with its sandbox and the GPU +Remove everything later with: npx @agent-socket/gpu-browser uninstall`) + if (!yes) { + if (!TTY) die("run it in a terminal to confirm, or pass --yes") + if (!/^(|y|yes)$/i.test(await ask("Continue? [Y/n] ", "n"))) die("nothing changed", 0) + } + // Streamed: the install takes a few minutes and says what it is doing. + const child = spawn("sudo", ["/bin/sh", path.join(PKG, "install/install.sh"), "--user", os.userInfo().username, "--home", os.homedir(), + "--pkg", PKG, "--version", VERSION, "--hash", packageHash()], { cwd: "/", stdio: ["inherit", "pipe", "inherit"] }) + const events = [] + readline.createInterface({ input: child.stdout }).on("line", (line) => { + if (line.startsWith("@@install ")) events.push(line.slice(10)) + else if (line) say(dim(line)) + }) + const status = await new Promise((r) => child.on("close", r)) + if (status !== 0 || !events.includes("done")) die("install failed (above); run the same command again after fixing it") + if (events.includes("software-rendering")) say(yellow("⚠ Chromium renders in software here (no usable GPU), so renders will be slow.")) + if (events.includes("sudo-rule skipped")) say(yellow("⚠ No sudo rule: each start will ask for your password.")) + say(green("✓ installed")) + if (events.includes("home-open")) { + const mode = (fs.statSync(os.homedir()).mode & 0o777).toString(8) + say(yellow(`⚠ Your home folder (${os.homedir()}, mode ${mode}) can be opened by other accounts, so the browser's account could read files in it.`)) + say(dim(" Closing it (chmod 700) is what macOS does for new accounts; it can break sharing it with other local users (web servers in ~/public_html, Samba).")) + if (!yes && /^(|y|yes)$/i.test(await ask("Close it? [Y/n] ", "n"))) { + fs.mkdirSync(SETTINGS, { recursive: true }) + fs.writeFileSync(path.join(SETTINGS, "home-mode-before.txt"), mode + "\n") + fs.chmodSync(os.homedir(), 0o700) + say(green("✓ closed (uninstall offers to put it back)")) + } + } +} + +// ── your settings ───────────────────────────────────────────────────── +function settings() { + const top = path.dirname(SETTINGS) + fs.mkdirSync(SETTINGS, { recursive: true }) + const copy = (from, to) => { if (!fs.existsSync(to)) fs.copyFileSync(path.join(PKG, "install", from), to) } + copy("agent-socket-README.md", path.join(top, "README.md")) + copy("user-README.md", path.join(SETTINGS, "README.md")) + const hostsFile = path.join(SETTINGS, "allowed-hosts.txt") + if (!fs.existsSync(hostsFile)) fs.writeFileSync(hostsFile, "# Hosts the GPU browser's pages may reach, one per line (*.example.com works).\n# \"always\" answers add to this file; edit it freely.\n") + const allow = fs.readFileSync(hostsFile, "utf8").split("\n").map((l) => l.replace(/#.*/, "").trim().toLowerCase()).filter((h) => h && HOST_RE.test(h)) + let config = {} + try { config = JSON.parse(fs.readFileSync(path.join(SETTINGS, "config.json"), "utf8")) } catch (e) { if (e.code !== "ENOENT") say(yellow("⚠ ignoring config.json: " + e.message)) } + return { hostsFile, allow, config } +} + +// ── run ─────────────────────────────────────────────────────────────── +function parseFor(v) { + if (v === "none" || v === "0") return 0 + const m = /^(\d+)(m|h)$/.exec(v || "") + if (!m) die("--for takes e.g. 30m, 2h or none") + return Number(m[1]) * (m[2] === "h" ? 60 : 1) +} + +async function start(opts) { + const { hostsFile, allow, config } = settings() + const relay = String(opts.relay || config.relay || DEFAULT_RELAY).replace(/\/+$/, "") + if (!/^https:\/\/[A-Za-z0-9.-]+(:\d+)?$/.test(relay) && !/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(relay)) die("the relay must be an https URL (got " + relay + ")") + const minutes = opts.for !== undefined ? parseFor(opts.for) : config.minutes ?? 60 + const inst = installed() + if (running()) die("the GPU browser is already running (another terminal?). Stop it there, or: npx @agent-socket/gpu-browser stop") + const key = crypto.randomBytes(16).toString("hex") + + const endsAt = minutes ? Date.now() + minutes * 60_000 : null + + let ready = false, link = null, stopping = false, child = null + const queue = [] // pending asks, answered one at a time + let current = null, rl = null + const send = (line) => { try { child?.stdin.write(line + "\n") } catch {} } + const prompt = () => { + if (current || !queue.length || stopping) return + current = queue.shift() + process.stdout.write("\x07") + say("") + say(yellow("⚠ The agent asks to let the browser's pages reach:")) + for (const h of current.hosts) say(" " + bold(h) + (h.startsWith("*.") ? dim(" (every subdomain)") : "")) + for (const l of current.text.split("\n").filter((l) => l.startsWith("Its reason:"))) say(" " + l) + if (MAC) spawnSync("osascript", ["-e", "on run argv", "-e", 'display notification (item 1 of argv) with title "agent-socket GPU browser"', "-e", "end run", "The agent asks for " + current.hosts.join(", ") + " — answer in the terminal"], { stdio: "ignore" }) + if (!TTY) { say(dim(" (no terminal to answer in: denied)")); send(`answer ${current.id} deny`); current = null; return prompt() } + rl.setPrompt(" Allow for this session? [y/N], or type 'always' to also remember it: ") + rl.prompt() + } + const onAnswer = (input) => { + if (!current) return + const a = input.trim().toLowerCase() + const choice = a === "always" ? "always" : /^y(es)?$/.test(a) ? "allow" : "deny" + send(`answer ${current.id} ${choice}`) + current = null + prompt() + } + if (TTY) { + rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }) + rl.on("line", onAnswer) + rl.on("SIGINT", () => stop("Ctrl-C")) + } + + const event = (name, rest) => { + if (name === "ready") { ready = true; return } + if (name === "link") { + const url = rest[0] || "" + if (!url.startsWith(relay + "/v1/t/") || !/^[\x21-\x7e]+$/.test(url)) return say(yellow("⚠ ignored a malformed link from the server")) + link = url + say("") + say(green("✓ Sharing. Paste this link into the agent's chat") + (copy(url) ? dim(" (copied to the clipboard)") : "") + ":") + say(" " + bold(url)) + say(dim(` Anyone with the link can use this browser ${endsAt ? "for " + minutes + " min (until " + new Date(endsAt).toLocaleTimeString() + ")" : "until you stop it"}. Ctrl-C stops it.`)) + say(dim(` Pages reach only the agent's uploads${allow.length ? " and " + allow.join(", ") : ""}; the agent asks here for anything else.`)) + say("") + return + } + if (name === "ask") { + const [id, kind, b64, ...hosts] = rest + if (!/^[0-9a-f]{6}$/.test(id) || kind !== "hosts" || !hosts.length || !hosts.every((h) => HOST_RE.test(h))) return + let text = "" + try { text = safe(Buffer.from(b64, "base64").toString("utf8")) } catch {} + queue.push({ id, hosts, text }) + return prompt() + } + if (name === "done") { + // Answered (here) or timed out (server side): drop it from the queue. + const i = queue.findIndex((q) => q.id === rest[0]) + if (i >= 0) queue.splice(i, 1) + if (current && current.id === rest[0]) { current = null; say(dim(" (request timed out)")); prompt() } + return + } + if (name === "persist-hosts") { + const add = rest.filter((h) => HOST_RE.test(h)) + const have = new Set(fs.readFileSync(hostsFile, "utf8").split("\n").map((l) => l.trim())) + const fresh = add.filter((h) => !have.has(h)) + if (fresh.length) fs.appendFileSync(hostsFile, fresh.join("\n") + "\n") + return say(green("✓ remembered in " + hostsFile + ": " + add.join(", "))) + } + if (name === "ended") return say(dim("session ended (" + safe(rest[0] || "") + ")")) + } + const prefix = "@@gpu-browser:" + key + " " + // Server output goes above an open approval prompt, not into it. + const show = (text) => { + if (current && rl) { + readline.clearLine(process.stdout, 0) + readline.cursorTo(process.stdout, 0) + say(text) + rl.prompt(true) + } else say(text) + } + let timer = null + const watchReady = () => { + clearTimeout(timer) + timer = setTimeout(() => { + if (ready) return + say(red("✗ The browser didn't confirm its start within 20 s (its input didn't arrive through sudo).")) + stop("no answer") + }, 20_000) + } + + // Start the server through the sudo rule, detached: it then isn't in this + // terminal's process group, and Ctrl-C reaches only us (we end the link + // cleanly). Without the rule (or when a later, broader sudo rule still + // wants a password, which sudo -l doesn't show) ask for the password once + // and start it attached. + let retried = false + const launch = (nopass) => { + if (!nopass) { + say(dim("sudo asks for your password to start the browser as " + NAME + ".")) + if (spawnSync("sudo", ["-v"], { stdio: "inherit" }).status !== 0) die("sudo failed") + } + const p = spawn("sudo", ["-n", ...startArgs(inst.account_uid)], { cwd: "/", stdio: ["pipe", "pipe", "pipe"], detached: nopass }) + child = p + let errText = "" + readline.createInterface({ input: p.stdout }).on("line", (line) => { + if (line.startsWith(prefix)) { const [name, ...rest] = line.slice(prefix.length).split(" "); return event(name, rest) } + if (line.startsWith("@@gpu-browser:")) return // not ours: never shown, never acted on + show(dim(safe(line))) + }) + readline.createInterface({ input: p.stderr }).on("line", (line) => { + errText += line + "\n" + if (!/password is required/.test(line)) show(dim(safe(line))) + }) + p.on("exit", (code) => { + if (p !== child) return + if (!ready && !stopping && !retried && /password is required/.test(errText)) { retried = true; return launch(false) } + rl?.close() + if (!stopping && !link) die("the browser stopped (exit status " + code + "); its output is above") + process.exit(stopping || code === 0 ? 0 : 1) + }) + p.stdin.on("error", () => {}) + p.stdin.write(JSON.stringify({ relay, key, allow, minutes, chromium: { args: Array.isArray(config.chromiumArgs) ? config.chromiumArgs : [] } }) + "\n") + watchReady() + } + + function stop(why) { + if (stopping) return + stopping = true + clearTimeout(timer) + say(dim("stopping (" + why + ")...")) + send("stop") + child?.stdin.end() + setTimeout(() => process.exit(0), 8_000).unref() + } + for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => stop(sig)) + launch(ruleActive(inst.account_uid)) +} + +function copy(text) { + const tools = MAC ? [["pbcopy"]] : [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]] + for (const [cmd, ...args] of tools) { + if (spawnSync("sh", ["-c", "command -v " + cmd], { stdio: "ignore" }).status !== 0) continue + if (spawnSync(cmd, args, { input: text, stdio: ["pipe", "ignore", "ignore"] }).status === 0) return true + } + return false +} + +// ── status / stop / uninstall ───────────────────────────────────────── +function status() { + const inst = installed() + say(bold("agent-socket GPU browser ") + dim("(npx @agent-socket/gpu-browser " + VERSION + ")")) + if (!inst) return say(" not installed (the first run installs it)") + say(` installed: ${DIR}, version ${inst.version}${inst.hash === packageHash() ? "" : dim(" (this package differs: the next start updates it)")}`) + say(` account: ${NAME} (uid ${inst.account_uid}), installed by ${inst.user}`) + say(` GPU: ${inst.gpu || "unknown"}`) + say(` sudo rule: ${ruleActive(inst.account_uid) ? "active (starts without a password)" : "not active (each start asks for your password)"}`) + say(` running: ${running() ? "yes" : "no"}`) + say(` settings: ${SETTINGS}`) + say(` remove: npx @agent-socket/gpu-browser uninstall (or: sudo sh ${DIR}/uninstall.sh)`) +} +function stopOthers() { + if (!running()) return say("not running") + say(dim("stopping the server (sudo may ask for your password)...")) + spawnSync("sudo", ["-u", NAME, "pkill", "-f", SERVER], { stdio: "inherit" }) + say(running() ? red("✗ still running") : green("✓ stopped")) +} +async function uninstall(yes) { + if (!fs.existsSync(DIR)) return say("not installed") + say("This removes the browser's account (" + NAME + "), its sudo rule and " + DIR + ".") + if (!yes) { + if (!TTY) die("run it in a terminal to confirm, or pass --yes") + if (!/^(|y|yes)$/i.test(await ask("Remove? [Y/n] ", "n"))) die("nothing changed", 0) + } + const r = spawnSync("sudo", ["/bin/sh", DIR + "/uninstall.sh"], { cwd: "/", stdio: "inherit" }) + if (r.status !== 0) die("uninstall failed (above)") + const before = path.join(SETTINGS, "home-mode-before.txt") + if (fs.existsSync(before)) { + const mode = fs.readFileSync(before, "utf8").trim() + if (/^[0-7]{3}$/.test(mode) && !yes && /^(y|yes)$/i.test(await ask(`Install closed your home folder (it was ${mode}). Open it again? [y/N] `, "n"))) { + fs.chmodSync(os.homedir(), parseInt(mode, 8)) + say(green("✓ home folder back to " + mode)) + } + fs.rmSync(before, { force: true }) + } + say(green("✓ removed") + dim(" (your settings stay in " + SETTINGS + ")")) +} + +// ── main ────────────────────────────────────────────────────────────── +const HELP = `npx @agent-socket/gpu-browser — let an agent anywhere render on this machine's GPU + + npx @agent-socket/gpu-browser start; prints a link to paste into the agent's chat + --for 30m | 2h | none how long the link lives (default 60m) + --relay https://... another agent-socket relay (default ${DEFAULT_RELAY}) + --yes don't ask before installing or updating + npx @agent-socket/gpu-browser install only install or update (the first start does it too) + npx @agent-socket/gpu-browser status what is installed, where, and whether it runs + npx @agent-socket/gpu-browser stop stop a browser running in another terminal + npx @agent-socket/gpu-browser uninstall remove the account, the sudo rule and ${DIR} + +The first run installs it (one sudo prompt). Settings: ${SETTINGS}` + +async function main() { + const argv = process.argv.slice(2) + const opts = {} + let cmd = "start" + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (a === "--yes" || a === "-y") opts.yes = true + else if (a === "--for" || a === "--relay") opts[a.slice(2)] = argv[++i] + else if (a.startsWith("--for=") || a.startsWith("--relay=")) opts[a.slice(2, a.indexOf("="))] = a.slice(a.indexOf("=") + 1) + else if (["start", "install", "status", "stop", "uninstall", "help", "version"].includes(a)) cmd = a + else if (a === "-h" || a === "--help") cmd = "help" + else die("unknown argument: " + a + "\n\n" + HELP) + } + if (cmd === "help") return say(HELP) + if (cmd === "version") return say(VERSION) + if (!MAC && process.platform !== "linux") die("macOS and Linux only") + if (process.getuid?.() === 0) die("run it as your normal user, not root (it asks for sudo when it needs it)") + if (cmd === "status") return status() + if (cmd === "stop") return stopOthers() + if (cmd === "uninstall") return uninstall(opts.yes) + + if (process.env.HTTPS_PROXY || process.env.https_proxy) say(yellow("⚠ HTTPS_PROXY is set, but the GPU browser connects directly (proxies aren't supported yet).")) + const inst = installed() + if (cmd === "install" && inst && inst.hash === packageHash()) return say(green("✓ already installed and current")) + if (!inst) await install(opts.yes, "The GPU browser isn't installed yet.") + else if (inst.user !== os.userInfo().username) die(`it is installed for ${inst.user} on this machine; they can remove it with: npx @agent-socket/gpu-browser uninstall`) + else if (newer(inst.version, VERSION)) die(`a newer version (${inst.version}) is installed; run: npx @agent-socket/gpu-browser@latest`) + else if (inst.hash !== packageHash()) { + if (running()) die("an update is ready, but the browser is running in another terminal; stop it first") + await install(opts.yes, `Updating the GPU browser (${inst.version} → ${VERSION}).`) + } + if (cmd === "install") return + await start(opts) +} + +main().catch((e) => die(e.stack || String(e))) diff --git a/gpu-browser/install/README.md b/gpu-browser/install/README.md new file mode 100644 index 0000000..383dc25 --- /dev/null +++ b/gpu-browser/install/README.md @@ -0,0 +1,32 @@ +# gpu-browser/install + +What `npx @agent-socket/gpu-browser` runs as root, once per install or update, after asking you: one sudo password prompt for all of it. + +| File | | +|---|---| +| `install.sh` | Creates the account, downloads, self-tests, installs. POSIX `sh` (macOS's bash 3.2 and Debian's dash). | +| `uninstall.sh` | Removes all of it. A root-owned copy is kept at `/opt/agent-socket-gpu-browser/uninstall.sh`, so removal works even if this package is gone. | +| `node.txt` | The Node.js version the server runs on, with the SHA-256 of each tarball; a download that doesn't match is refused. | +| `system-README.md` | Becomes `/opt/agent-socket-gpu-browser/README.md`. | +| `home-README.md` | Becomes the README in the account's home, `/opt/agent-socket-gpu-browser/home/`. | +| `user-README.md`, `agent-socket-README.md` | Become `~/.agent-socket/gpu-browser/README.md` and `~/.agent-socket/README.md` (the CLI writes these, as you). | + +## What install.sh does, in order + +1. **The account `agent-socket-gpu-browser`**, unless it exists already, in which case it must carry this installer's marker or nothing is touched. + - **macOS:** its own group, then the user with `dscl`, the way MacPorts creates its `macports` user: no password (`*`), shell `/usr/bin/false`, and the attributes macOS adds on its own (`AuthenticationAuthority` and friends) deleted. It can't log in and doesn't appear in Users & Groups or the login window. First free uid from 501. + - **Linux:** `groupadd --system`, `useradd --system`, shell `nologin`. It is also added to the `render`/`video` groups if they exist, so Chromium can open the GPU. +2. **`/opt/agent-socket-gpu-browser/`**, owned by root, and its `home/` (the account's, mode 700). +3. **Downloads, run as the account** (no third-party code runs as root): + - Node.js, checked against `node.txt`; + - `playwright-core`, with `npm install --ignore-scripts`; + - Chromium. +4. **A fresh root-owned copy** of all that: the account can run it but not change it. +5. **A self-test of the new copy, started exactly the way every run starts it** (macOS: `launchctl asuser sudo -u …`). It launches Chromium with its sandbox, draws a WebGL canvas and reports the GPU. On Ubuntu 23.10+, where Chromium's sandbox needs an AppArmor profile, it adds one for this Chromium only. If the test fails, nothing is swapped in: an existing install keeps working. +6. **Swap** the new copy in, and write `install.env` (who installed it, version, the GPU). +7. **The sudo rule** `/etc/sudoers.d/zz-agent-socket-gpu-browser`, validated with `visudo` first. It is named `zz-…` so it is read last: sudo applies the last rule that matches, and a broader rule of yours for the same command would otherwise ask for the password. It lets the installing user run exactly the start command (that one command, those arguments) without a password: + - **macOS:** `launchctl asuser /usr/bin/sudo -u agent-socket-gpu-browser -H …/node …/server.mjs`; + - **Linux:** `…/node …/server.mjs` as the account. + + If `/etc/sudoers` doesn't read `/etc/sudoers.d`, there's no rule and each start asks for the password. +8. **Checks whether the account can open your home folder.** If it can, the CLI offers to close it (`chmod 700`) and remembers the old mode for uninstall. diff --git a/gpu-browser/install/agent-socket-README.md b/gpu-browser/install/agent-socket-README.md new file mode 100644 index 0000000..d0c7182 --- /dev/null +++ b/gpu-browser/install/agent-socket-README.md @@ -0,0 +1,7 @@ +# ~/.agent-socket + +Settings for agent-socket tools on this machine ([agent-socket](https://github.com/blitzdotdev/agent-socket): links that let an AI chat use an app). One folder per tool: + +- `gpu-browser/` — `npx @agent-socket/gpu-browser`, a GPU browser an agent can render in. See its README. + +Deleting this folder removes only these settings, not the tools. diff --git a/gpu-browser/install/home-README.md b/gpu-browser/install/home-README.md new file mode 100644 index 0000000..4862f0f --- /dev/null +++ b/gpu-browser/install/home-README.md @@ -0,0 +1,5 @@ +# Home of agent-socket-gpu-browser + +The home folder of the `agent-socket-gpu-browser` account, which runs the agent-socket GPU browser (see `../README.md`). While the browser runs, Chromium keeps its temporary profile in `tmp/` here; it is emptied on every start. Nothing here is needed between runs. + +Remove the whole install: `sudo sh /opt/agent-socket-gpu-browser/uninstall.sh`. diff --git a/gpu-browser/install/install.sh b/gpu-browser/install/install.sh new file mode 100755 index 0000000..03fb2f3 --- /dev/null +++ b/gpu-browser/install/install.sh @@ -0,0 +1,276 @@ +#!/bin/sh +# Installs or updates the agent-socket GPU browser. Run as root by the CLI, +# after it asked you (npx @agent-socket/gpu-browser): +# sudo /bin/sh install.sh --user NAME --home DIR --pkg DIR --version V --hash H +# What it creates and why: README.md in this folder. Undo everything with +# sudo sh /opt/agent-socket-gpu-browser/uninstall.sh +# +# POSIX sh: macOS runs /bin/sh as bash 3.2, Debian/Ubuntu as dash. +set -eu + +NAME=agent-socket-gpu-browser +DIR=/opt/agent-socket-gpu-browser +MARK="agent-socket GPU browser" +COMMENT="$MARK - created by npx @agent-socket/gpu-browser - remove with sudo sh $DIR/uninstall.sh" +OS=$(uname -s) + +say() { printf ' %s\n' "$*"; } +die() { printf 'install failed: %s\n' "$*" >&2; rm -rf "$DIR/.staging" "$DIR/.new" 2>/dev/null || true; exit 1; } +event() { printf '@@install %s\n' "$*"; } + +USER_NAME="" USER_HOME="" PKG="" VERSION="" HASH="" +while [ $# -gt 0 ]; do + [ $# -ge 2 ] || die "missing value for $1" + case "$1" in + --user) USER_NAME=$2 ;; + --home) USER_HOME=$2 ;; + --pkg) PKG=$2 ;; + --version) VERSION=$2 ;; + --hash) HASH=$2 ;; + *) die "unknown argument: $1" ;; + esac + shift 2 +done +[ "$(id -u)" = 0 ] || die "run as root (the CLI runs it with sudo)" +case "$OS" in Darwin|Linux) ;; *) die "macOS and Linux only" ;; esac +case "$USER_NAME" in ""|*[!A-Za-z0-9._-]*) die "--user must be a plain user name" ;; esac +id -u "$USER_NAME" >/dev/null 2>&1 || die "no user $USER_NAME" +[ "$(id -u "$USER_NAME")" != 0 ] || die "install for your own user, not root" +[ -d "$USER_HOME" ] || die "--home must be a directory" +if [ ! -f "$PKG/app/server.mjs" ] || [ ! -f "$PKG/install/node.txt" ]; then die "--pkg must be the @agent-socket/gpu-browser package"; fi +printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+' || die "bad --version" +printf '%s' "$HASH" | grep -Eq '^[0-9a-f]{64}$' || die "bad --hash" + +# One owner per machine: the sudo rule names the user who installed it. +if [ -f "$DIR/install.env" ]; then + owner=$(sed -n 's/^user=//p' "$DIR/install.env") + if [ -n "$owner" ] && [ "$owner" != "$USER_NAME" ]; then + die "already installed for $owner on this machine; they can remove it with: sudo sh $DIR/uninstall.sh" + fi +fi + +ROOTGROUP=root +[ "$OS" = Darwin ] && ROOTGROUP=wheel +sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi; } +# Everything downloaded is fetched and unpacked by the account, never by root. +as_acct() { (cd / && sudo -u "$NAME" -H /bin/sh -c "$1"); } + +# ── 1. The account ─────────────────────────────────────────────────── +if [ "$OS" = Darwin ]; then + if dscl . -read "/Users/$NAME" >/dev/null 2>&1; then + case "$(dscl . -read "/Users/$NAME" Comment 2>/dev/null)" in + *"$MARK"*) say "account $NAME: exists" ;; + *) die "an account named $NAME exists that this installer didn't create; not touching it" ;; + esac + else + say "account $NAME: creating (no password, can't log in; the way MacPorts makes its macports user)" + if ! dscl . -read "/Groups/$NAME" >/dev/null 2>&1; then + dseditgroup -o create -r "$MARK" "$NAME" || die "could not create the group $NAME" + fi + gid=$(dscl . -read "/Groups/$NAME" PrimaryGroupID | awk '{print $2}') + uid=501 + while dscl /Search -search /Users UniqueID "$uid" 2>/dev/null | grep -q .; do uid=$((uid + 1)); done + if ! err=$(dscl . -create "/Users/$NAME" 2>&1); then + case "$err" in + *14120*) die "macOS refused to create the account (eDSPermissionError -14120). If a prompt asked to let your terminal administer this computer, allow it (System Settings > Privacy & Security > App Management), then run the command again." ;; + *) die "dscl: $err" ;; + esac + fi + dscl . -create "/Users/$NAME" UniqueID "$uid" + dscl . -create "/Users/$NAME" PrimaryGroupID "$gid" + dscl . -create "/Users/$NAME" UserShell /usr/bin/false + dscl . -create "/Users/$NAME" NFSHomeDirectory "$DIR/home" + dscl . -create "/Users/$NAME" RealName "$MARK" + dscl . -create "/Users/$NAME" Comment "$COMMENT" + dscl . -create "/Users/$NAME" Password '*' + # macOS adds these on its own; without them the account can't log in + # and stays out of Users & Groups and the login window. + for a in AuthenticationAuthority PasswordPolicyOptions dsAttrTypeNative:KerberosKeys dsAttrTypeNative:ShadowHashData; do + dscl . -delete "/Users/$NAME" "$a" 2>/dev/null || true + done + fi +else + if getent passwd "$NAME" >/dev/null 2>&1; then + case "$(getent passwd "$NAME" | cut -d: -f5)" in + "$MARK"*) say "account $NAME: exists" ;; + *) die "an account named $NAME exists that this installer didn't create; not touching it" ;; + esac + else + say "account $NAME: creating (system account, no login)" + nologin=/usr/sbin/nologin + [ -x "$nologin" ] || nologin=/sbin/nologin + getent group "$NAME" >/dev/null 2>&1 || groupadd --system "$NAME" + useradd --system --gid "$NAME" --home-dir "$DIR/home" --no-create-home --shell "$nologin" --comment "$COMMENT" "$NAME" + fi + # The GPU device nodes (/dev/dri/*) belong to these groups. + for g in render video; do + if getent group "$g" >/dev/null 2>&1; then usermod -aG "$g" "$NAME"; fi + done +fi +ACCT_UID=$(id -u "$NAME") + +# ── 2. Directories ─────────────────────────────────────────────────── +mkdir -p "$DIR" +chown "root:$ROOTGROUP" "$DIR" +chmod 755 "$DIR" +mkdir -p "$DIR/home" +chown "$NAME:$NAME" "$DIR/home" +chmod 700 "$DIR/home" +cp "$PKG/install/home-README.md" "$DIR/home/README.md" +chmod 644 "$DIR/home/README.md" +rm -rf "$DIR/.staging" "$DIR/.new" "$DIR/.old" +mkdir "$DIR/.staging" +chown "$NAME:$NAME" "$DIR/.staging" + +# ── 3. Node, the app's dependency and Chromium, fetched as the account ── +case "$OS-$(uname -m)" in + Darwin-arm64) plat=darwin-arm64 ;; + Darwin-x86_64) plat=darwin-x64 ;; + Linux-x86_64) plat=linux-x64 ;; + Linux-aarch64|Linux-arm64) plat=linux-arm64 ;; + *) die "unsupported machine: $OS $(uname -m)" ;; +esac +node_v=$(sed -n 's/^version //p' "$PKG/install/node.txt") +node_sha=$(sed -n "s/^$plat //p" "$PKG/install/node.txt") +if [ -z "$node_v" ] || [ -z "$node_sha" ]; then die "no pinned Node.js for $plat"; fi +command -v curl >/dev/null 2>&1 || die "curl is needed" +say "Node.js $node_v: downloading" +as_acct "cd '$DIR/.staging' && curl -fsSL --retry 2 -o node.tar.gz 'https://nodejs.org/dist/v$node_v/node-v$node_v-$plat.tar.gz'" \ + || die "could not download Node.js (no internet, or a proxy this installer can't use)" +[ "$(sha256 "$DIR/.staging/node.tar.gz")" = "$node_sha" ] || die "the Node.js download doesn't match its pinned SHA-256" +as_acct "cd '$DIR/.staging' && tar xzf node.tar.gz && mv 'node-v$node_v-$plat' node && rm node.tar.gz" + +mkdir "$DIR/.staging/app" +cp -R "$PKG/app/server.mjs" "$PKG/app/policy.mjs" "$PKG/app/package.json" "$PKG/app/README.md" "$PKG/app/lib" "$DIR/.staging/app/" +chown -R "$NAME:$NAME" "$DIR/.staging/app" +say "playwright-core: installing (no install scripts run)" +as_acct "cd '$DIR/.staging/app' && npm_config_update_notifier=false ../node/bin/node ../node/lib/node_modules/npm/bin/npm-cli.js install --omit=dev --ignore-scripts --no-audit --no-fund --no-package-lock --loglevel=error" \ + || die "npm install failed" +say "Chromium: downloading (a few minutes, ~200 MB)" +as_acct "cd '$DIR/.staging/app' && PLAYWRIGHT_BROWSERS_PATH='$DIR/.staging/browsers' ../node/bin/node node_modules/playwright-core/cli.js install --no-shell chromium" \ + || die "the Chromium download failed" + +# ── 4. A fresh root-owned copy: the account can run it, not change it ── +mkdir "$DIR/.new" +cp -R "$DIR/.staging/node" "$DIR/.staging/app" "$DIR/.staging/browsers" "$DIR/.new/" +rm -rf "$DIR/.staging" +printf '%s %s\n' "$VERSION" "$HASH" > "$DIR/.new/VERSION" +chown -R "root:$ROOTGROUP" "$DIR/.new" +chmod -R go-w,a+rX "$DIR/.new" + +# ── 5. Self-test the new copy, started the way every run starts it ───── +self_test() { + if [ "$OS" = Darwin ]; then + (cd / && /bin/launchctl asuser "$ACCT_UID" /usr/bin/sudo -u "$NAME" -H "$DIR/.new/node/bin/node" "$DIR/.new/app/server.mjs" --self-test 2>&1) + else + (cd / && sudo -u "$NAME" -H "$DIR/.new/node/bin/node" "$DIR/.new/app/server.mjs" --self-test 2>&1) + fi +} +say "self-test: starting Chromium as $NAME" +st=0 +out=$(self_test) || st=$? +if [ "$st" != 0 ] && [ "$OS" = Linux ]; then + # Ubuntu 23.10+ lets only profiled programs create the user namespaces + # Chromium's sandbox needs; give this Chromium such a profile. + if [ "$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns 2>/dev/null || echo 0)" = 1 ] && command -v apparmor_parser >/dev/null 2>&1; then + say "AppArmor: allowing this Chromium its sandbox (user namespaces)" + cat > "/etc/apparmor.d/$NAME" <, +include +profile $NAME $DIR/{.new,}/browsers/**/chrome flags=(unconfined) { + userns, + include if exists +} +EOF + apparmor_parser -r "/etc/apparmor.d/$NAME" || die "apparmor_parser could not load /etc/apparmor.d/$NAME" + st=0 + out=$(self_test) || st=$? + fi + case "$out" in + *"missing dependencies"*|*"error while loading shared libraries"*) + cmd=$(as_acct "cd '$DIR/.new/app' && ../node/bin/node node_modules/playwright-core/cli.js install-deps --dry-run chromium" 2>/dev/null \ + | grep -E '^(sudo -- )?(sh -c ")?apt-get install' | head -1 | sed -e 's/^sudo -- //' -e 's/^sh -c "//' -e 's/"$//') + printf '%s\n' "$out" | tail -5 >&2 + if [ -n "$cmd" ]; then + die "Chromium needs system libraries. Install them with: sudo $cmd ; then run npx @agent-socket/gpu-browser again" + fi + die "Chromium needs system libraries that this installer doesn't know how to install here (see above)" + ;; + esac +fi +if [ "$st" != 0 ]; then + printf '%s\n' "$out" | tail -15 >&2 + die "the self-test failed (above); nothing was changed" +fi +result=$(printf '%s\n' "$out" | grep '^{' | tail -1) +case "$result" in *'"ok":true'*) ;; *) printf '%s\n' "$out" | tail -15 >&2; die "the self-test failed (above); nothing was changed" ;; esac +renderer=$(printf '%s' "$result" | sed -n 's/.*"renderer":"\([^"]*\)".*/\1/p') +say "self-test: Chromium starts with its sandbox; GPU: ${renderer:-unknown}" +event "gpu ${renderer:-unknown}" +case "$result" in *'"software":true'*) event "software-rendering" ;; esac + +# ── 6. Swap it in ──────────────────────────────────────────────────── +mkdir "$DIR/.old" +for d in node app browsers VERSION; do + if [ -e "$DIR/$d" ]; then mv "$DIR/$d" "$DIR/.old/"; fi +done +for d in node app browsers VERSION; do mv "$DIR/.new/$d" "$DIR/"; done +rm -rf "$DIR/.new" "$DIR/.old" +cp "$PKG/install/uninstall.sh" "$DIR/uninstall.sh" +cp "$PKG/install/system-README.md" "$DIR/README.md" +chmod 755 "$DIR/uninstall.sh" +chmod 644 "$DIR/README.md" +cat > "$DIR/install.env" </dev/null; then + mkdir -p /etc/sudoers.d + tmp=$(mktemp /etc/sudoers.d/.agent-socket-XXXXXX) # a name with a dot: sudo skips it until it's moved + { + echo "# $COMMENT" + echo "# Lets $USER_NAME start the GPU browser server as $NAME without a password." + echo "# Exactly this command, with exactly these arguments; nothing else." + echo "# Named zz-... to come last: sudo applies the last rule that matches." + if [ "$OS" = Darwin ]; then + echo "$USER_NAME ALL=(root) NOPASSWD: /bin/launchctl asuser $ACCT_UID /usr/bin/sudo -u $NAME -H $NODE $SERVER" + else + echo "$USER_NAME ALL=($NAME) NOPASSWD: $NODE $SERVER" + fi + } > "$tmp" + if visudo -cf "$tmp" >/dev/null 2>&1; then + chown "root:$ROOTGROUP" "$tmp" + chmod 0440 "$tmp" + mv -f "$tmp" "$SUDOERS" + say "sudo rule: $SUDOERS (starts the server without a password)" + event "sudo-rule installed" + else + rm -f "$tmp" + say "sudo rule: didn't validate; each start will ask for your password" + event "sudo-rule skipped" + fi +else + say "sudo rule: /etc/sudoers doesn't read /etc/sudoers.d; each start will ask for your password" + event "sudo-rule skipped" +fi + +# ── 8. Can the account see into your home? ─────────────────────────── +if sudo -u "$NAME" test -x "$USER_HOME" 2>/dev/null; then event "home-open"; fi +event "done" diff --git a/gpu-browser/install/node.txt b/gpu-browser/install/node.txt new file mode 100644 index 0000000..c0b8591 --- /dev/null +++ b/gpu-browser/install/node.txt @@ -0,0 +1,9 @@ +# Node.js that the GPU browser server runs on, pinned by version and SHA-256 +# (copied from https://nodejs.org/dist/v22.23.3/SHASUMS256.txt). install.sh +# downloads the tarball for this machine and refuses it if the hash differs. +# To update: change the version and all four lines together. +version 22.23.3 +darwin-arm64 23b25245dcfb9af7262f8ff142e9e2e0af025368117329e7a7458a51e5922f53 +darwin-x64 8a677b0219178efd6eb0e475457c4afb452b521a92f6e67845a73bd85727f2a8 +linux-arm64 5ced2d48d1d7198739b7f86804de0171aefb6823b684b12341d3321afc3cb0b2 +linux-x64 1084aa36196bba4c3a5e69a1ee388a6e4ff729dad09445fbcd434b28fe3c24af diff --git a/gpu-browser/install/system-README.md b/gpu-browser/install/system-README.md new file mode 100644 index 0000000..11197ce --- /dev/null +++ b/gpu-browser/install/system-README.md @@ -0,0 +1,18 @@ +# /opt/agent-socket-gpu-browser + +Installed by `npx @agent-socket/gpu-browser` ([source](https://github.com/blitzdotdev/agent-socket/tree/master/gpu-browser)): a headless Chromium that an AI agent you give a link to can use to render on this machine's GPU. It runs as the account `agent-socket-gpu-browser`, which can't log in and can't read your files. + +| | | +|---|---| +| `node/` | Node.js, pinned by version and checksum | +| `app/` | The server (`server.mjs`) and `playwright-core` | +| `browsers/` | Chromium | +| `home/` | The account's home: Chromium's temporary profile while it runs | +| `install.env` | Who installed it, the version, the GPU the self-test found | +| `uninstall.sh` | Removes everything listed here | + +Everything except `home/` belongs to root: the account can run it, not change it. There is also a sudo rule, `/etc/sudoers.d/zz-agent-socket-gpu-browser`, that lets the user who installed it start the server as the account without a password (that one command only), and on Ubuntu possibly an AppArmor profile, `/etc/apparmor.d/agent-socket-gpu-browser`, for Chromium's sandbox. + +- Start: `npx @agent-socket/gpu-browser` +- See what's installed: `npx @agent-socket/gpu-browser status` +- Remove all of it: `npx @agent-socket/gpu-browser uninstall`, or `sudo sh /opt/agent-socket-gpu-browser/uninstall.sh` diff --git a/gpu-browser/install/uninstall.sh b/gpu-browser/install/uninstall.sh new file mode 100755 index 0000000..b7775e0 --- /dev/null +++ b/gpu-browser/install/uninstall.sh @@ -0,0 +1,69 @@ +#!/bin/sh +# Removes the agent-socket GPU browser from this machine: its processes, the +# sudo rule, the AppArmor profile (Linux), the agent-socket-gpu-browser +# account and group, and /opt/agent-socket-gpu-browser. Leaves your settings +# in ~/.agent-socket/gpu-browser/ (delete that folder yourself if you like). +# +# sudo sh /opt/agent-socket-gpu-browser/uninstall.sh +# (or: npx @agent-socket/gpu-browser uninstall) +# +# Touches an account, group or file only if it carries this installer's +# marker. POSIX sh. +set -eu + +NAME=agent-socket-gpu-browser +DIR=/opt/agent-socket-gpu-browser +MARK="agent-socket GPU browser" +OS=$(uname -s) +say() { printf ' %s\n' "$*"; } + +[ "$(id -u)" = 0 ] || { echo "run with sudo: sudo sh $0" >&2; exit 1; } + +ours=false +if [ "$OS" = Darwin ]; then + case "$(dscl . -read "/Users/$NAME" Comment 2>/dev/null)" in *"$MARK"*) ours=true ;; esac +else + case "$(getent passwd "$NAME" 2>/dev/null | cut -d: -f5)" in "$MARK"*) ours=true ;; esac +fi + +if [ "$ours" = true ]; then + pkill -u "$NAME" 2>/dev/null || true + sleep 1 + pkill -9 -u "$NAME" 2>/dev/null || true +fi + +if [ -f "/etc/sudoers.d/zz-$NAME" ] && grep -q "$MARK" "/etc/sudoers.d/zz-$NAME"; then + rm -f "/etc/sudoers.d/zz-$NAME" + say "removed the sudo rule" +fi +if [ -f "/etc/apparmor.d/$NAME" ] && grep -q "$MARK" "/etc/apparmor.d/$NAME"; then + apparmor_parser -R "/etc/apparmor.d/$NAME" 2>/dev/null || true + rm -f "/etc/apparmor.d/$NAME" + say "removed the AppArmor profile" +fi + +if [ "$ours" = true ]; then + if [ "$OS" = Darwin ]; then + uid=$(id -u "$NAME") + launchctl bootout "user/$uid" 2>/dev/null || true + dscl . -delete "/Users/$NAME" + case "$(dscl . -read "/Groups/$NAME" RealName 2>/dev/null)" in *"$MARK"*) dseditgroup -o delete "$NAME" 2>/dev/null || true ;; esac + else + userdel "$NAME" + if getent group "$NAME" >/dev/null 2>&1; then groupdel "$NAME" 2>/dev/null || true; fi + fi + say "removed the account $NAME" +else + exists=false + if [ "$OS" = Darwin ]; then + if dscl . -read "/Users/$NAME" >/dev/null 2>&1; then exists=true; fi + elif getent passwd "$NAME" >/dev/null 2>&1; then exists=true + fi + if [ "$exists" = true ]; then say "an account named $NAME exists but wasn't made by this installer; left alone"; fi +fi + +if [ -d "$DIR" ]; then + rm -rf "$DIR" + say "removed $DIR" +fi +say "done (your settings stay in ~/.agent-socket/gpu-browser/)" diff --git a/gpu-browser/install/user-README.md b/gpu-browser/install/user-README.md new file mode 100644 index 0000000..b105473 --- /dev/null +++ b/gpu-browser/install/user-README.md @@ -0,0 +1,11 @@ +# ~/.agent-socket/gpu-browser + +Your settings for the agent-socket GPU browser (`npx @agent-socket/gpu-browser`), which lets an AI agent you give a link to render on this machine's GPU. All of it is optional and plain text. + +| File | | +|---|---| +| `allowed-hosts.txt` | Hosts the browser's pages may reach, one per line (`*.example.com` covers its subdomains). When an agent asks for a host and you answer `always`, it is added here. Delete a line to take it back. Empty means: only the agent's own uploads. | +| `config.json` | Optional: `{"minutes": 120}` (how long a link lives; `0` = until you stop it), `{"relay": "https://..."}` (another agent-socket relay), `{"chromiumArgs": ["--use-angle=vulkan"]}` (GPU flags only; others are ignored). | +| `home-mode-before.txt` | Only if the installer closed your home folder: its mode before, so uninstall can put it back. | + +The browser itself is installed in `/opt/agent-socket-gpu-browser` (see its README). Remove it with `npx @agent-socket/gpu-browser uninstall`; that leaves this folder, which you can delete by hand. diff --git a/gpu-browser/package.json b/gpu-browser/package.json new file mode 100644 index 0000000..845d7b8 --- /dev/null +++ b/gpu-browser/package.json @@ -0,0 +1,34 @@ +{ + "name": "@agent-socket/gpu-browser", + "version": "0.1.0", + "type": "module", + "license": "Apache-2.0", + "description": "Let an AI agent anywhere render on your machine's GPU through an agent-socket link: a headless Chromium under its own locked-down OS account.", + "repository": { + "type": "git", + "url": "git+https://github.com/blitzdotdev/agent-socket.git", + "directory": "gpu-browser" + }, + "homepage": "https://github.com/blitzdotdev/agent-socket/tree/master/gpu-browser", + "bugs": "https://github.com/blitzdotdev/agent-socket/issues", + "bin": { + "agent-socket-gpu-browser": "bin/agent-socket-gpu-browser.mjs" + }, + "files": [ + "README.md", + "bin/agent-socket-gpu-browser.mjs", + "install/", + "app/README.md", + "app/package.json", + "app/policy.mjs", + "app/server.mjs", + "app/lib/" + ], + "os": [ + "darwin", + "linux" + ], + "engines": { + "node": ">=18" + } +} diff --git a/gpu-browser/test/README.md b/gpu-browser/test/README.md index 9c5ee68..5fd9928 100644 --- a/gpu-browser/test/README.md +++ b/gpu-browser/test/README.md @@ -3,3 +3,5 @@ Unit tests for the GPU browser's boundary checks (`../app/policy.mjs`): `npm run gpu:test:unit` from the repo root. The end-to-end test (the real server, Chromium and a local relay) is harness scenario 63: `CHROMIUM_PATH=... node harness/run.mjs 63`. + +`install-smoke.sh` installs for real, starts it, uses the link like an agent, stops it and uninstalls, checking that nothing is left. CI runs it on fresh Ubuntu and macOS runners; it needs passwordless sudo and ends with an uninstall, so don't run it on a machine you use it on. diff --git a/gpu-browser/test/install-smoke.sh b/gpu-browser/test/install-smoke.sh new file mode 100644 index 0000000..03dfb16 --- /dev/null +++ b/gpu-browser/test/install-smoke.sh @@ -0,0 +1,70 @@ +#!/bin/sh +# Installs the GPU browser for real, starts it, uses the link like an agent, +# stops it and uninstalls it, checking that nothing is left. For CI runners +# (passwordless sudo, throwaway machines); don't run it on a machine where you +# use the GPU browser, since it ends with an uninstall. +# sh gpu-browser/test/install-smoke.sh +set -eu + +HERE=$(cd "$(dirname "$0")/.." && pwd) +CLI="node $HERE/bin/agent-socket-gpu-browser.mjs" +NAME=agent-socket-gpu-browser +LOG=$(mktemp) +fail() { echo "FAIL: $*" >&2; echo "--- CLI output:" >&2; cat "$LOG" >&2 || true; exit 1; } + +echo "== install" +$CLI install --yes +$CLI install --yes | grep -q "already installed" || fail "a second install should be a no-op" + +echo "== status" +$CLI status | tee "$LOG" +grep -q "sudo rule: active" "$LOG" || fail "the sudo rule isn't active" +[ "$(sudo grep -c NOPASSWD "/etc/sudoers.d/zz-$NAME")" = 1 ] || fail "the sudo rule should hold exactly one command" + +echo "== start, and use the link like an agent" +$CLI --for 5m > "$LOG" 2>&1 & +cli=$! +i=0 +until grep -q "/v1/t/as_" "$LOG"; do + i=$((i + 1)); [ "$i" -lt 180 ] || fail "no link after 3 minutes" + kill -0 "$cli" 2>/dev/null || fail "the CLI exited" + sleep 1 +done +link=$(grep -o 'https://[^ ]*/v1/t/as_[^ ]*/agents.md' "$LOG" | head -1) +echo "link: $link" +node - "$link" <<'EOF' +const base = process.argv[2].replace(/\/agents\.md$/, "") +const call = async (m, p, b) => { + const r = await fetch(base + p, { method: m, headers: { "content-type": "application/json" }, body: b ? JSON.stringify(b) : undefined }) + return { s: r.status, j: await r.json() } +} +const ok = (c, what, extra) => { if (!c) { console.error("FAIL:", what, JSON.stringify(extra)); process.exit(1) } console.log("ok:", what) } +const info = await call("GET", "/info") +ok(info.s === 200 && info.j.isolation.installed && info.j.isolation.chromium_sandbox && info.j.isolation.account === "agent-socket-gpu-browser", + "runs installed, as the account, with Chromium's sandbox", info.j.isolation) +console.log("GPU:", info.j.gpu.renderer, info.j.gpu.software ? "(software)" : "") +await call("POST", "/files", { files: [{ path: "index.html", data: "" }] }) +const pg = await call("POST", "/page", { width: 320, height: 200 }) +ok(pg.s === 200 && pg.j.http_status === 200, "opens an uploaded page", pg.j) +const shot = await call("POST", "/screenshot", { page: pg.j.page }) +ok(shot.s === 200 && shot.j.bytes > 100, "takes a screenshot", { s: shot.s, bytes: shot.j.bytes }) +const lo = await call("POST", "/eval", { page: pg.j.page, js: "try { await fetch('http://127.0.0.1/', { mode: 'no-cors' }); return 'reached' } catch { return 'blocked' }" }) +ok((lo.j.blocked || []).some((b) => b.host === "127.0.0.1"), "loopback is blocked", lo.j) +EOF + +echo "== stop" +kill -INT "$cli" +i=0 +while kill -0 "$cli" 2>/dev/null; do i=$((i + 1)); [ "$i" -lt 20 ] || fail "the CLI didn't stop"; sleep 1; done +sleep 2 +node -e 'fetch(process.argv[1].replace(/agents\.md$/, "info")).then(async (r) => { const t = await r.text(); if (!/not connected/.test(t)) { console.error("FAIL: link still alive:", r.status, t.slice(0, 200)); process.exit(1) } console.log("ok: the link is dead") })' "$link" +pgrep -f "/opt/$NAME/app/server.mjs" >/dev/null && fail "the server is still running" + +echo "== uninstall" +$CLI uninstall --yes +id "$NAME" >/dev/null 2>&1 && fail "the account is still there" +[ ! -e "/opt/$NAME" ] || fail "/opt/$NAME is still there" +[ ! -e "/etc/sudoers.d/zz-$NAME" ] || fail "the sudo rule is still there" +[ ! -e "/etc/apparmor.d/$NAME" ] || fail "the AppArmor profile is still there" +rm -f "$LOG" +echo "PASS" From 41ff2104dc49a255d60155da800501b24faa644e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:26:53 +0000 Subject: [PATCH 02/17] gpu-browser: say the real install size (about 650 MB on disk, measured on Linux) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/README.md | 2 +- gpu-browser/bin/agent-socket-gpu-browser.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gpu-browser/README.md b/gpu-browser/README.md index 3393e31..e2b7087 100644 --- a/gpu-browser/README.md +++ b/gpu-browser/README.md @@ -28,7 +28,7 @@ macOS and Linux. Needs Node.js 18+ for `npx`; the browser brings its own Node.js One sudo prompt covers all of it (details and order: [install/README.md](install/README.md)): - **An account, `agent-socket-gpu-browser`**, that runs the browser. It can't log in, has no password, has its own group, and can't read your files. On macOS it doesn't appear in Users & Groups or at the login window. -- **`/opt/agent-socket-gpu-browser/`**: Node.js (pinned by checksum), `playwright-core`, Chromium (~400 MB together). Owned by root, so the account can run it but not change it. +- **`/opt/agent-socket-gpu-browser/`**: Node.js (pinned by checksum), `playwright-core`, Chromium (about 650 MB on disk, 250 MB to download). Owned by root, so the account can run it but not change it. - **One sudo rule**, `/etc/sudoers.d/zz-agent-socket-gpu-browser`. It lets you start that exact server as that account without a password. Nothing else. - **On Ubuntu 23.10+**, an AppArmor profile that lets this Chromium use its sandbox. - **If other accounts can open your home folder**, it offers to close it (`chmod 700`), and uninstall offers to reopen it. diff --git a/gpu-browser/bin/agent-socket-gpu-browser.mjs b/gpu-browser/bin/agent-socket-gpu-browser.mjs index adadae9..0477b1c 100755 --- a/gpu-browser/bin/agent-socket-gpu-browser.mjs +++ b/gpu-browser/bin/agent-socket-gpu-browser.mjs @@ -90,7 +90,7 @@ async function install(yes, reason) { say(bold(reason)) say(`It will, with one sudo password prompt: • create a separate account, ${NAME}, that can't log in and can't read your files - • put Node.js ${fs.readFileSync(path.join(PKG, "install/node.txt"), "utf8").match(/^version (.+)$/m)[1]}, Playwright and Chromium (~400 MB) in ${DIR} + • put Node.js ${fs.readFileSync(path.join(PKG, "install/node.txt"), "utf8").match(/^version (.+)$/m)[1]}, Playwright and Chromium (about 650 MB on disk) in ${DIR} • add one sudo rule that lets you start the browser as that account without a password • check that Chromium starts there with its sandbox and the GPU Remove everything later with: npx @agent-socket/gpu-browser uninstall`) From 5af392f398701ba40229700e3ae6d022f68fdfc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:36:46 +0000 Subject: [PATCH 03/17] gpu-browser: undo a failed first install, leave other groups alone, AppArmor glob - install.sh: a failed or interrupted first install removes what it created (account, group, AppArmor profile, /opt dir); a failed update keeps the working install. An existing group of the account's name is never reused; uninstall removes only the account's own group. - AppArmor profile attaches to $DIR/**/chrome; when the self-test still fails, both runs' output is shown. - CLI: "minutes" in config.json must be a number >= 0; the note of the home folder's old mode is kept unless it was restored or you declined. - install-smoke.sh shows the CLI's output when an agent call fails; CI prints AppArmor kernel messages on a Linux failure. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- .github/workflows/gpu-browser.yml | 5 ++ gpu-browser/bin/agent-socket-gpu-browser.mjs | 15 ++++-- gpu-browser/install/README.md | 4 +- gpu-browser/install/install.sh | 55 ++++++++++++++++++-- gpu-browser/install/uninstall.sh | 4 +- gpu-browser/test/install-smoke.sh | 9 +++- 6 files changed, 79 insertions(+), 13 deletions(-) diff --git a/.github/workflows/gpu-browser.yml b/.github/workflows/gpu-browser.yml index b120b93..02b78d9 100644 --- a/.github/workflows/gpu-browser.yml +++ b/.github/workflows/gpu-browser.yml @@ -39,3 +39,8 @@ jobs: with: node-version: '22' - run: sh gpu-browser/test/install-smoke.sh + - name: Diagnostics (on failure) + if: failure() && runner.os == 'Linux' + run: | + cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns || true + sudo dmesg | grep -iE 'apparmor|chrome|segfault' | tail -40 || true diff --git a/gpu-browser/bin/agent-socket-gpu-browser.mjs b/gpu-browser/bin/agent-socket-gpu-browser.mjs index 0477b1c..b311ed8 100755 --- a/gpu-browser/bin/agent-socket-gpu-browser.mjs +++ b/gpu-browser/bin/agent-socket-gpu-browser.mjs @@ -152,6 +152,7 @@ async function start(opts) { const relay = String(opts.relay || config.relay || DEFAULT_RELAY).replace(/\/+$/, "") if (!/^https:\/\/[A-Za-z0-9.-]+(:\d+)?$/.test(relay) && !/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(relay)) die("the relay must be an https URL (got " + relay + ")") const minutes = opts.for !== undefined ? parseFor(opts.for) : config.minutes ?? 60 + if (typeof minutes !== "number" || !Number.isFinite(minutes) || minutes < 0) die("\"minutes\" in " + path.join(SETTINGS, "config.json") + " must be a number of minutes (0: no time limit)") const inst = installed() if (running()) die("the GPU browser is already running (another terminal?). Stop it there, or: npx @agent-socket/gpu-browser stop") const key = crypto.randomBytes(16).toString("hex") @@ -335,11 +336,17 @@ async function uninstall(yes) { const before = path.join(SETTINGS, "home-mode-before.txt") if (fs.existsSync(before)) { const mode = fs.readFileSync(before, "utf8").trim() - if (/^[0-7]{3}$/.test(mode) && !yes && /^(y|yes)$/i.test(await ask(`Install closed your home folder (it was ${mode}). Open it again? [y/N] `, "n"))) { - fs.chmodSync(os.homedir(), parseInt(mode, 8)) - say(green("✓ home folder back to " + mode)) + if (!/^[0-7]{3}$/.test(mode)) fs.rmSync(before, { force: true }) + else if (yes || !TTY) { + // Nobody to ask: keep the note, so a later run (or you) can still put it back. + say(dim(`Install closed your home folder (it was ${mode}); to open it again: chmod ${mode} ~ (noted in ${before})`)) + } else { + if (/^(y|yes)$/i.test(await ask(`Install closed your home folder (it was ${mode}). Open it again? [y/N] `, "n"))) { + fs.chmodSync(os.homedir(), parseInt(mode, 8)) + say(green("✓ home folder back to " + mode)) + } + fs.rmSync(before, { force: true }) } - fs.rmSync(before, { force: true }) } say(green("✓ removed") + dim(" (your settings stay in " + SETTINGS + ")")) } diff --git a/gpu-browser/install/README.md b/gpu-browser/install/README.md index 383dc25..176c6a4 100644 --- a/gpu-browser/install/README.md +++ b/gpu-browser/install/README.md @@ -15,14 +15,14 @@ What `npx @agent-socket/gpu-browser` runs as root, once per install or update, a 1. **The account `agent-socket-gpu-browser`**, unless it exists already, in which case it must carry this installer's marker or nothing is touched. - **macOS:** its own group, then the user with `dscl`, the way MacPorts creates its `macports` user: no password (`*`), shell `/usr/bin/false`, and the attributes macOS adds on its own (`AuthenticationAuthority` and friends) deleted. It can't log in and doesn't appear in Users & Groups or the login window. First free uid from 501. - - **Linux:** `groupadd --system`, `useradd --system`, shell `nologin`. It is also added to the `render`/`video` groups if they exist, so Chromium can open the GPU. + - **Linux:** `groupadd --system`, `useradd --system`, shell `nologin`. A group of that name that already exists is someone else's, so the install stops rather than put the account in it. It is also added to the `render`/`video` groups if they exist, so Chromium can open the GPU. 2. **`/opt/agent-socket-gpu-browser/`**, owned by root, and its `home/` (the account's, mode 700). 3. **Downloads, run as the account** (no third-party code runs as root): - Node.js, checked against `node.txt`; - `playwright-core`, with `npm install --ignore-scripts`; - Chromium. 4. **A fresh root-owned copy** of all that: the account can run it but not change it. -5. **A self-test of the new copy, started exactly the way every run starts it** (macOS: `launchctl asuser sudo -u …`). It launches Chromium with its sandbox, draws a WebGL canvas and reports the GPU. On Ubuntu 23.10+, where Chromium's sandbox needs an AppArmor profile, it adds one for this Chromium only. If the test fails, nothing is swapped in: an existing install keeps working. +5. **A self-test of the new copy, started exactly the way every run starts it** (macOS: `launchctl asuser sudo -u …`). It launches Chromium with its sandbox, draws a WebGL canvas and reports the GPU. On Ubuntu 23.10+, where Chromium's sandbox needs an AppArmor profile, it adds one for this Chromium only. If the test fails, nothing is swapped in: an existing install keeps working, and a first install removes what it created (the account, its group, the AppArmor profile, `/opt/agent-socket-gpu-browser`); any other failure or Ctrl-C during a first install does the same. 6. **Swap** the new copy in, and write `install.env` (who installed it, version, the GPU). 7. **The sudo rule** `/etc/sudoers.d/zz-agent-socket-gpu-browser`, validated with `visudo` first. It is named `zz-…` so it is read last: sudo applies the last rule that matches, and a broader rule of yours for the same command would otherwise ask for the password. It lets the installing user run exactly the start command (that one command, those arguments) without a password: - **macOS:** `launchctl asuser /usr/bin/sudo -u agent-socket-gpu-browser -H …/node …/server.mjs`; diff --git a/gpu-browser/install/install.sh b/gpu-browser/install/install.sh index 03fb2f3..fbb96fc 100755 --- a/gpu-browser/install/install.sh +++ b/gpu-browser/install/install.sh @@ -15,7 +15,7 @@ COMMENT="$MARK - created by npx @agent-socket/gpu-browser - remove with sudo sh OS=$(uname -s) say() { printf ' %s\n' "$*"; } -die() { printf 'install failed: %s\n' "$*" >&2; rm -rf "$DIR/.staging" "$DIR/.new" 2>/dev/null || true; exit 1; } +die() { printf 'install failed: %s\n' "$*" >&2; exit 1; } event() { printf '@@install %s\n' "$*"; } USER_NAME="" USER_HOME="" PKG="" VERSION="" HASH="" @@ -49,6 +49,34 @@ if [ -f "$DIR/install.env" ]; then fi fi +# A failed first install leaves nothing behind: whatever this run created +# (account, group, AppArmor profile, $DIR) is removed again on any exit +# that isn't success. A failed update keeps the working install it had. +FRESH=true +[ -f "$DIR/install.env" ] && FRESH=false +made_acct=false made_group=false made_dir=false made_aa=false done_ok=false +cleanup() { + [ "$done_ok" = true ] && return + rm -rf "$DIR/.staging" "$DIR/.new" 2>/dev/null || true + [ "$FRESH" = true ] || return 0 + if [ "$made_aa" = true ]; then + apparmor_parser -R "/etc/apparmor.d/$NAME" 2>/dev/null || true + rm -f "/etc/apparmor.d/$NAME" + fi + if [ "$made_acct" = true ]; then + pkill -9 -u "$NAME" 2>/dev/null || true + if [ "$OS" = Darwin ]; then dscl . -delete "/Users/$NAME" 2>/dev/null || true; else userdel "$NAME" 2>/dev/null || true; fi + fi + if [ "$made_group" = true ]; then + if [ "$OS" = Darwin ]; then dseditgroup -o delete "$NAME" 2>/dev/null || true + elif getent group "$NAME" >/dev/null 2>&1; then groupdel "$NAME" 2>/dev/null || true; fi + fi + if [ "$made_dir" = true ]; then rm -rf "$DIR"; fi + echo " undone: this install's account and files are removed again" >&2 +} +trap cleanup EXIT +trap 'exit 130' INT TERM HUP + ROOTGROUP=root [ "$OS" = Darwin ] && ROOTGROUP=wheel sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi; } @@ -64,12 +92,19 @@ if [ "$OS" = Darwin ]; then esac else say "account $NAME: creating (no password, can't log in; the way MacPorts makes its macports user)" - if ! dscl . -read "/Groups/$NAME" >/dev/null 2>&1; then + if dscl . -read "/Groups/$NAME" >/dev/null 2>&1; then + case "$(dscl . -read "/Groups/$NAME" RealName 2>/dev/null)" in + *"$MARK"*) ;; + *) die "a group named $NAME exists that this installer didn't create; not touching it" ;; + esac + else dseditgroup -o create -r "$MARK" "$NAME" || die "could not create the group $NAME" + made_group=true fi gid=$(dscl . -read "/Groups/$NAME" PrimaryGroupID | awk '{print $2}') uid=501 while dscl /Search -search /Users UniqueID "$uid" 2>/dev/null | grep -q .; do uid=$((uid + 1)); done + made_acct=true if ! err=$(dscl . -create "/Users/$NAME" 2>&1); then case "$err" in *14120*) die "macOS refused to create the account (eDSPermissionError -14120). If a prompt asked to let your terminal administer this computer, allow it (System Settings > Privacy & Security > App Management), then run the command again." ;; @@ -96,10 +131,17 @@ else *) die "an account named $NAME exists that this installer didn't create; not touching it" ;; esac else + # A group with this name and no account is someone else's (a group has + # no comment to carry the marker); the account must not join it. + if getent group "$NAME" >/dev/null 2>&1; then + die "a group named $NAME exists that this installer didn't create; not touching it (if it is left from an interrupted install: sudo groupdel $NAME)" + fi say "account $NAME: creating (system account, no login)" nologin=/usr/sbin/nologin [ -x "$nologin" ] || nologin=/sbin/nologin - getent group "$NAME" >/dev/null 2>&1 || groupadd --system "$NAME" + groupadd --system "$NAME" + made_group=true + made_acct=true useradd --system --gid "$NAME" --home-dir "$DIR/home" --no-create-home --shell "$nologin" --comment "$COMMENT" "$NAME" fi # The GPU device nodes (/dev/dri/*) belong to these groups. @@ -110,6 +152,7 @@ fi ACCT_UID=$(id -u "$NAME") # ── 2. Directories ─────────────────────────────────────────────────── +[ -d "$DIR" ] || made_dir=true mkdir -p "$DIR" chown "root:$ROOTGROUP" "$DIR" chmod 755 "$DIR" @@ -174,18 +217,20 @@ if [ "$st" != 0 ] && [ "$OS" = Linux ]; then # Chromium's sandbox needs; give this Chromium such a profile. if [ "$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns 2>/dev/null || echo 0)" = 1 ] && command -v apparmor_parser >/dev/null 2>&1; then say "AppArmor: allowing this Chromium its sandbox (user namespaces)" + [ -e "/etc/apparmor.d/$NAME" ] || made_aa=true cat > "/etc/apparmor.d/$NAME" <, include -profile $NAME $DIR/{.new,}/browsers/**/chrome flags=(unconfined) { +profile $NAME $DIR/**/chrome flags=(unconfined) { userns, include if exists } EOF apparmor_parser -r "/etc/apparmor.d/$NAME" || die "apparmor_parser could not load /etc/apparmor.d/$NAME" + first=$out st=0 out=$(self_test) || st=$? fi @@ -202,6 +247,7 @@ EOF esac fi if [ "$st" != 0 ]; then + if [ -n "${first:-}" ]; then printf 'before the AppArmor profile:\n%s\nwith it:\n' "$(printf '%s\n' "$first" | tail -15)" >&2; fi printf '%s\n' "$out" | tail -15 >&2 die "the self-test failed (above); nothing was changed" fi @@ -273,4 +319,5 @@ fi # ── 8. Can the account see into your home? ─────────────────────────── if sudo -u "$NAME" test -x "$USER_HOME" 2>/dev/null; then event "home-open"; fi +done_ok=true event "done" diff --git a/gpu-browser/install/uninstall.sh b/gpu-browser/install/uninstall.sh index b7775e0..036817e 100755 --- a/gpu-browser/install/uninstall.sh +++ b/gpu-browser/install/uninstall.sh @@ -49,8 +49,10 @@ if [ "$ours" = true ]; then dscl . -delete "/Users/$NAME" case "$(dscl . -read "/Groups/$NAME" RealName 2>/dev/null)" in *"$MARK"*) dseditgroup -o delete "$NAME" 2>/dev/null || true ;; esac else + gid=$(id -g "$NAME") userdel "$NAME" - if getent group "$NAME" >/dev/null 2>&1; then groupdel "$NAME" 2>/dev/null || true; fi + # Only the account's own group (userdel may already have removed it). + if [ "$(getent group "$NAME" | cut -d: -f3)" = "$gid" ]; then groupdel "$NAME" 2>/dev/null || true; fi fi say "removed the account $NAME" else diff --git a/gpu-browser/test/install-smoke.sh b/gpu-browser/test/install-smoke.sh index 03dfb16..b89f9d2 100644 --- a/gpu-browser/test/install-smoke.sh +++ b/gpu-browser/test/install-smoke.sh @@ -10,7 +10,12 @@ HERE=$(cd "$(dirname "$0")/.." && pwd) CLI="node $HERE/bin/agent-socket-gpu-browser.mjs" NAME=agent-socket-gpu-browser LOG=$(mktemp) -fail() { echo "FAIL: $*" >&2; echo "--- CLI output:" >&2; cat "$LOG" >&2 || true; exit 1; } +fail() { + echo "FAIL: $*" >&2 + echo "--- CLI output:" >&2; cat "$LOG" >&2 || true + [ -z "${cli:-}" ] || kill -INT "$cli" 2>/dev/null || true + exit 1 +} echo "== install" $CLI install --yes @@ -32,7 +37,7 @@ until grep -q "/v1/t/as_" "$LOG"; do done link=$(grep -o 'https://[^ ]*/v1/t/as_[^ ]*/agents.md' "$LOG" | head -1) echo "link: $link" -node - "$link" <<'EOF' +node - "$link" <<'EOF' || fail "an agent call failed (above)" const base = process.argv[2].replace(/\/agents\.md$/, "") const call = async (m, p, b) => { const r = await fetch(base + p, { method: m, headers: { "content-type": "application/json" }, body: b ? JSON.stringify(b) : undefined }) From 0093415bb2fe89a388e44fd5c2a86b399d4943ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:41:11 +0000 Subject: [PATCH 04/17] CI: on a Linux failure, run the GPU browser's Chromium with its log, sandbox on/off/profiled Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- .github/workflows/gpu-browser.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/gpu-browser.yml b/.github/workflows/gpu-browser.yml index 02b78d9..e50ed85 100644 --- a/.github/workflows/gpu-browser.yml +++ b/.github/workflows/gpu-browser.yml @@ -42,5 +42,16 @@ jobs: - name: Diagnostics (on failure) if: failure() && runner.os == 'Linux' run: | - cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns || true - sudo dmesg | grep -iE 'apparmor|chrome|segfault' | tail -40 || true + echo "apparmor_restrict_unprivileged_userns=$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns 2>/dev/null)" + sudo dmesg | grep -iE 'apparmor="DENIED"|traps: chrome|segfault' | tail -20 || true + # The same Chromium as the runner user: sandbox on, off, and with + # the installer's AppArmor profile, each with Chromium's own log. + export PLAYWRIGHT_BROWSERS_PATH=/tmp/diag-browsers + npx -y playwright-core@1.64.0 install --no-shell chromium >/dev/null + chrome=$(ls -d /tmp/diag-browsers/chromium-*/chrome-linux64/chrome) + try() { echo "== $1"; shift; timeout 30 "$chrome" --headless --enable-logging=stderr --v=0 "$@" --dump-dom about:blank 2>&1 | grep -v 'dbus\|ssl_client' | tail -12; echo "exit=${PIPESTATUS[0]}"; } + try "sandbox on" + try "sandbox off" --no-sandbox + printf 'abi ,\ninclude \nprofile diag-chrome /tmp/diag-browsers/**/chrome flags=(unconfined) {\n userns,\n}\n' | sudo tee /etc/apparmor.d/diag-chrome >/dev/null + sudo apparmor_parser -r /etc/apparmor.d/diag-chrome + try "sandbox on, AppArmor profile" From 63817db2545004e8233bea39d2e282cbb2141e99 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:47:18 +0000 Subject: [PATCH 05/17] CI: targeted Chromium diagnostics on install failures (Linux, macOS) Linux: the AppArmor profile over a dot directory and a system account started through sudo. macOS: whether closing a context ends Chromium for the runner user too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- .github/workflows/gpu-browser.yml | 50 +++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/.github/workflows/gpu-browser.yml b/.github/workflows/gpu-browser.yml index e50ed85..f0d67c7 100644 --- a/.github/workflows/gpu-browser.yml +++ b/.github/workflows/gpu-browser.yml @@ -39,19 +39,45 @@ jobs: with: node-version: '22' - run: sh gpu-browser/test/install-smoke.sh - - name: Diagnostics (on failure) + - name: Diagnostics (on failure, Linux) if: failure() && runner.os == 'Linux' run: | echo "apparmor_restrict_unprivileged_userns=$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns 2>/dev/null)" - sudo dmesg | grep -iE 'apparmor="DENIED"|traps: chrome|segfault' | tail -20 || true - # The same Chromium as the runner user: sandbox on, off, and with - # the installer's AppArmor profile, each with Chromium's own log. + sudo dmesg | grep -iE 'apparmor="DENIED"' | tail -20 || true + # The same Chromium, sandbox on, each way the installer differs + # from a plain run: a profile glob over a dot directory, and a + # system account started through sudo. export PLAYWRIGHT_BROWSERS_PATH=/tmp/diag-browsers - npx -y playwright-core@1.64.0 install --no-shell chromium >/dev/null - chrome=$(ls -d /tmp/diag-browsers/chromium-*/chrome-linux64/chrome) - try() { echo "== $1"; shift; timeout 30 "$chrome" --headless --enable-logging=stderr --v=0 "$@" --dump-dom about:blank 2>&1 | grep -v 'dbus\|ssl_client' | tail -12; echo "exit=${PIPESTATUS[0]}"; } - try "sandbox on" - try "sandbox off" --no-sandbox - printf 'abi ,\ninclude \nprofile diag-chrome /tmp/diag-browsers/**/chrome flags=(unconfined) {\n userns,\n}\n' | sudo tee /etc/apparmor.d/diag-chrome >/dev/null - sudo apparmor_parser -r /etc/apparmor.d/diag-chrome - try "sandbox on, AppArmor profile" + npx -y playwright-core@1.64.0 install --no-shell chromium >/dev/null 2>&1 + try() { echo "== $1"; shift; timeout 30 "$@" --headless --dump-dom about:blank 2>&1 | grep -v 'dbus\|ssl_client\|WARNING' | tail -4; echo "exit=${PIPESTATUS[0]}"; } + profile() { printf 'abi ,\ninclude \nprofile %s %s flags=(unconfined) {\n userns,\n}\n' "$1" "$2" | sudo tee "/etc/apparmor.d/$1" >/dev/null; sudo apparmor_parser -r "/etc/apparmor.d/$1"; } + sudo mkdir -p /opt/diagx/.new && sudo cp -R /tmp/diag-browsers /opt/diagx/.new/browsers && sudo chmod -R a+rX /opt/diagx + dotchrome=$(ls -d /opt/diagx/.new/browsers/chromium-*/chrome-linux64/chrome) + tmpchrome=$(ls -d /tmp/diag-browsers/chromium-*/chrome-linux64/chrome) + profile diag-tmp '/tmp/diag-browsers/**/chrome' + try "runner, /tmp, profile /tmp/diag-browsers/**/chrome" "$tmpchrome" + profile diag-dot '/opt/diagx/**/chrome' + try "runner, .new dir, profile /opt/diagx/**/chrome" "$dotchrome" + profile diag-dot '/opt/diagx/.new/browsers/**/chrome' + try "runner, .new dir, profile /opt/diagx/.new/browsers/**/chrome" "$dotchrome" + sudo useradd --system --home-dir /opt/diagx/home --no-create-home --shell /usr/sbin/nologin diaguser + sudo mkdir -p /opt/diagx/home && sudo chown diaguser /opt/diagx/home + (cd / && try "system account via sudo -u -H, /tmp, profiled" sudo -u diaguser -H "$tmpchrome") + - name: Diagnostics (on failure, macOS) + if: failure() && runner.os == 'macOS' + run: | + # Does closing a browser context end Chromium for the runner (who + # has a window-server session) too, or only for the account? + mkdir -p /tmp/diag && cd /tmp/diag && npm i --no-save --silent playwright-core@1.64.0 + PLAYWRIGHT_BROWSERS_PATH=/tmp/diag-browsers npx playwright-core install --no-shell chromium >/dev/null 2>&1 + cat > t.mjs <<'EOF' + const { chromium } = await import("playwright-core") + const b = await chromium.launch({ channel: "chromium", headless: true, chromiumSandbox: true, args: ["--enable-logging=stderr", "--v=0"] }) + let gone = false; b.on("disconnected", () => { gone = true }) + await (await b.newContext()).newPage() + const c = await b.newContext(); await c.newPage(); await c.close() + await new Promise((r) => setTimeout(r, 3000)) + console.log(gone ? "FAIL: Chromium exited after a context closed" : "ok: Chromium still running after a context closed") + if (!gone) await b.close() + EOF + PLAYWRIGHT_BROWSERS_PATH=/tmp/diag-browsers DEBUG=pw:browser node t.mjs 2>&1 | grep -v 'dbus' | tail -25 From 3ef6929eff622c916b15937af877fddd91b327fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:48:31 +0000 Subject: [PATCH 06/17] CI: Linux diagnostics compare the runner and a system account, directly and through Playwright Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- .github/workflows/gpu-browser.yml | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/.github/workflows/gpu-browser.yml b/.github/workflows/gpu-browser.yml index f0d67c7..2afea0a 100644 --- a/.github/workflows/gpu-browser.yml +++ b/.github/workflows/gpu-browser.yml @@ -51,18 +51,22 @@ jobs: npx -y playwright-core@1.64.0 install --no-shell chromium >/dev/null 2>&1 try() { echo "== $1"; shift; timeout 30 "$@" --headless --dump-dom about:blank 2>&1 | grep -v 'dbus\|ssl_client\|WARNING' | tail -4; echo "exit=${PIPESTATUS[0]}"; } profile() { printf 'abi ,\ninclude \nprofile %s %s flags=(unconfined) {\n userns,\n}\n' "$1" "$2" | sudo tee "/etc/apparmor.d/$1" >/dev/null; sudo apparmor_parser -r "/etc/apparmor.d/$1"; } - sudo mkdir -p /opt/diagx/.new && sudo cp -R /tmp/diag-browsers /opt/diagx/.new/browsers && sudo chmod -R a+rX /opt/diagx - dotchrome=$(ls -d /opt/diagx/.new/browsers/chromium-*/chrome-linux64/chrome) tmpchrome=$(ls -d /tmp/diag-browsers/chromium-*/chrome-linux64/chrome) - profile diag-tmp '/tmp/diag-browsers/**/chrome' - try "runner, /tmp, profile /tmp/diag-browsers/**/chrome" "$tmpchrome" - profile diag-dot '/opt/diagx/**/chrome' - try "runner, .new dir, profile /opt/diagx/**/chrome" "$dotchrome" - profile diag-dot '/opt/diagx/.new/browsers/**/chrome' - try "runner, .new dir, profile /opt/diagx/.new/browsers/**/chrome" "$dotchrome" sudo useradd --system --home-dir /opt/diagx/home --no-create-home --shell /usr/sbin/nologin diaguser sudo mkdir -p /opt/diagx/home && sudo chown diaguser /opt/diagx/home - (cd / && try "system account via sudo -u -H, /tmp, profiled" sudo -u diaguser -H "$tmpchrome") + ud() { echo "--user-data-dir=/opt/diagx/home/ud$1"; } + sudo rm -f /etc/apparmor.d/diag-tmp; sudo apparmor_parser -R /etc/apparmor.d/diag-tmp 2>/dev/null || true + (cd / && try "system account, no profile" sudo -u diaguser -H "$tmpchrome" "$(ud 1)") + profile diag-tmp '/tmp/diag-browsers/**/chrome' + (cd / && try "system account, profiled" sudo -u diaguser -H "$tmpchrome" "$(ud 2)") + try "runner, profiled" "$tmpchrome" --user-data-dir=/tmp/ud3 + # Through Playwright, like the self-test: as the runner, then as the account. + mkdir -p /tmp/pw && (cd /tmp/pw && npm i --no-save --silent playwright-core@1.64.0) + printf '%s\n' 'const { chromium } = await import("/tmp/pw/node_modules/playwright-core/index.mjs")' \ + 'try { const b = await chromium.launch({ executablePath: process.argv[2], chromiumSandbox: true }); console.log("ok"); await b.close() } catch (e) { console.log("FAIL", e.message.split("\n").slice(-4).join(" | ")) }' > /tmp/pw/t.mjs + chmod -R a+rX /tmp/pw + echo "== playwright, runner"; node /tmp/pw/t.mjs "$tmpchrome" + echo "== playwright, system account"; (cd / && sudo -u diaguser -H "$(command -v node)" /tmp/pw/t.mjs "$tmpchrome") - name: Diagnostics (on failure, macOS) if: failure() && runner.os == 'macOS' run: | From 407efd2eae82c1d86b2b8ebca6bbc4b932b88423 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:49:44 +0000 Subject: [PATCH 07/17] CI: macOS diagnostics try an account without a window-server session four ways Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- .github/workflows/gpu-browser.yml | 47 +++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/.github/workflows/gpu-browser.yml b/.github/workflows/gpu-browser.yml index 2afea0a..d778b20 100644 --- a/.github/workflows/gpu-browser.yml +++ b/.github/workflows/gpu-browser.yml @@ -70,18 +70,41 @@ jobs: - name: Diagnostics (on failure, macOS) if: failure() && runner.os == 'macOS' run: | - # Does closing a browser context end Chromium for the runner (who - # has a window-server session) too, or only for the account? + # A throwaway account like the installer's (no window-server + # session), started the same way; which variant survives closing + # a context? mkdir -p /tmp/diag && cd /tmp/diag && npm i --no-save --silent playwright-core@1.64.0 - PLAYWRIGHT_BROWSERS_PATH=/tmp/diag-browsers npx playwright-core install --no-shell chromium >/dev/null 2>&1 + PLAYWRIGHT_BROWSERS_PATH=/tmp/diag-browsers npx playwright-core install chromium >/dev/null 2>&1 + chmod -R a+rX /tmp/diag /tmp/diag-browsers cat > t.mjs <<'EOF' - const { chromium } = await import("playwright-core") - const b = await chromium.launch({ channel: "chromium", headless: true, chromiumSandbox: true, args: ["--enable-logging=stderr", "--v=0"] }) - let gone = false; b.on("disconnected", () => { gone = true }) - await (await b.newContext()).newPage() - const c = await b.newContext(); await c.newPage(); await c.close() - await new Promise((r) => setTimeout(r, 3000)) - console.log(gone ? "FAIL: Chromium exited after a context closed" : "ok: Chromium still running after a context closed") - if (!gone) await b.close() + process.env.DEBUG = "pw:browser" + const errs = [] + process.stderr.write = (c) => { for (const l of String(c).split("\n")) if (/ERROR|FATAL|Check failed|signal|exception/i.test(l) && !/cv_display_link|dbus/.test(l) && errs.length < 8) errs.push(l.slice(0, 250)); return true } + const { chromium } = await import("/tmp/diag/node_modules/playwright-core/index.mjs") + const [channel, ...args] = process.argv.slice(2) + try { + const b = await chromium.launch({ channel, headless: true, chromiumSandbox: true, args: ["--enable-logging=stderr", ...args] }) + let gone = false; b.on("disconnected", () => { gone = true }) + await (await b.newContext()).newPage() + const c = await b.newContext(); const p = await c.newPage() + const r = await p.evaluate(() => { const g = document.createElement("canvas").getContext("webgl2"); const e = g && g.getExtension("WEBGL_debug_renderer_info"); return e ? g.getParameter(e.UNMASKED_RENDERER_WEBGL) : String(!!g) }) + await c.close() + await new Promise((r) => setTimeout(r, 3000)) + console.log(gone ? "FAIL (exited after a context closed)" : "ok", "| renderer:", r) + if (!gone) await b.close() + } catch (e) { console.log("FAIL", String(e.message).split("\n")[0]) } + for (const l of errs) console.log(" ", l) EOF - PLAYWRIGHT_BROWSERS_PATH=/tmp/diag-browsers DEBUG=pw:browser node t.mjs 2>&1 | grep -v 'dbus' | tail -25 + uid=4501; while dscl /Search -search /Users UniqueID $uid 2>/dev/null | grep -q .; do uid=$((uid+1)); done + sudo dscl . -create /Users/diaguser + sudo dscl . -create /Users/diaguser UniqueID $uid + sudo dscl . -create /Users/diaguser PrimaryGroupID 20 + sudo dscl . -create /Users/diaguser UserShell /usr/bin/false + sudo dscl . -create /Users/diaguser NFSHomeDirectory /tmp/diaghome + sudo mkdir -p /tmp/diaghome && sudo chown $uid:20 /tmp/diaghome + node=$(command -v node) + run() { echo "== $1"; shift; (cd / && "$@" env PLAYWRIGHT_BROWSERS_PATH=/tmp/diag-browsers TMPDIR=/tmp/diaghome "$node" /tmp/diag/t.mjs "${T[@]}"); } + T=(chromium); run "account, launchctl asuser (as installed)" sudo launchctl asuser $uid sudo -u diaguser -H + T=(chromium --disable-gpu); run "account, launchctl asuser, --disable-gpu" sudo launchctl asuser $uid sudo -u diaguser -H + T=(chromium-headless-shell); run "account, launchctl asuser, headless shell" sudo launchctl asuser $uid sudo -u diaguser -H + T=(chromium); run "account, plain sudo -u" sudo -u diaguser -H From b6c93709c3031d82869d2615ad1be6fa7e23b5ad Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:50:36 +0000 Subject: [PATCH 08/17] CI: Linux diagnostics separate sudo, system account and sandbox; strace the crash Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- .github/workflows/gpu-browser.yml | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/workflows/gpu-browser.yml b/.github/workflows/gpu-browser.yml index d778b20..9f6d2df 100644 --- a/.github/workflows/gpu-browser.yml +++ b/.github/workflows/gpu-browser.yml @@ -12,7 +12,8 @@ on: concurrency: group: gpu-browser-${{ github.head_ref || github.run_id }} - cancel-in-progress: true + # A run already going finishes (its diagnostics are the point); newer pushes queue. + cancel-in-progress: false jobs: scripts: @@ -55,18 +56,16 @@ jobs: sudo useradd --system --home-dir /opt/diagx/home --no-create-home --shell /usr/sbin/nologin diaguser sudo mkdir -p /opt/diagx/home && sudo chown diaguser /opt/diagx/home ud() { echo "--user-data-dir=/opt/diagx/home/ud$1"; } - sudo rm -f /etc/apparmor.d/diag-tmp; sudo apparmor_parser -R /etc/apparmor.d/diag-tmp 2>/dev/null || true - (cd / && try "system account, no profile" sudo -u diaguser -H "$tmpchrome" "$(ud 1)") profile diag-tmp '/tmp/diag-browsers/**/chrome' - (cd / && try "system account, profiled" sudo -u diaguser -H "$tmpchrome" "$(ud 2)") - try "runner, profiled" "$tmpchrome" --user-data-dir=/tmp/ud3 - # Through Playwright, like the self-test: as the runner, then as the account. - mkdir -p /tmp/pw && (cd /tmp/pw && npm i --no-save --silent playwright-core@1.64.0) - printf '%s\n' 'const { chromium } = await import("/tmp/pw/node_modules/playwright-core/index.mjs")' \ - 'try { const b = await chromium.launch({ executablePath: process.argv[2], chromiumSandbox: true }); console.log("ok"); await b.close() } catch (e) { console.log("FAIL", e.message.split("\n").slice(-4).join(" | ")) }' > /tmp/pw/t.mjs - chmod -R a+rX /tmp/pw - echo "== playwright, runner"; node /tmp/pw/t.mjs "$tmpchrome" - echo "== playwright, system account"; (cd / && sudo -u diaguser -H "$(command -v node)" /tmp/pw/t.mjs "$tmpchrome") + sudo useradd -m diagregular + (cd / && try "runner through sudo -u runner, profiled" sudo -u runner -H "$tmpchrome" --user-data-dir=/tmp/ud4) + (cd / && try "regular account through sudo, profiled" sudo -u diagregular -H "$tmpchrome" --user-data-dir=/home/diagregular/ud) + (cd / && try "system account through sudo, profiled" sudo -u diaguser -H "$tmpchrome" "$(ud 2)") + (cd / && try "system account through sudo, --no-sandbox" sudo -u diaguser -H "$tmpchrome" "$(ud 3)" --no-sandbox) + sudo apt-get install -y -qq strace >/dev/null 2>&1 || true + echo "== strace: system account, profiled: failed calls before the crash" + (cd / && sudo strace -f -qq -o /tmp/st.txt -u diaguser env HOME=/opt/diagx/home "$tmpchrome" "$(ud 5)" --headless --dump-dom about:blank >/dev/null 2>&1) || true + grep -E '= -1 E(PERM|ACCES|INVAL|NOSYS)|SIGTRAP|clone|unshare' /tmp/st.txt | grep -v 'ENOENT' | tail -40 - name: Diagnostics (on failure, macOS) if: failure() && runner.os == 'macOS' run: | From cc356f776c4b1b7f8ff21ac3300562cbec34eadc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:53:41 +0000 Subject: [PATCH 09/17] gpu-browser: start the server with plain sudo -u on macOS too (no launchctl asuser) In the launchd domain of an account that never logs in, Chromium crashed (SIGSEGV) shortly after a browser context closed. Started with plain sudo -u it doesn't, and it still renders on the GPU (CI: "ANGLE Metal Renderer: Apple Paravirtual device"). The start command, the sudo rule and the self-test are now the same on macOS and Linux. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/bin/agent-socket-gpu-browser.mjs | 20 +++++++++----------- gpu-browser/install/README.md | 6 ++---- gpu-browser/install/install.sh | 14 ++------------ gpu-browser/install/uninstall.sh | 2 -- 4 files changed, 13 insertions(+), 29 deletions(-) diff --git a/gpu-browser/bin/agent-socket-gpu-browser.mjs b/gpu-browser/bin/agent-socket-gpu-browser.mjs index b311ed8..7732c27 100755 --- a/gpu-browser/bin/agent-socket-gpu-browser.mjs +++ b/gpu-browser/bin/agent-socket-gpu-browser.mjs @@ -67,15 +67,13 @@ const newer = (a, b) => { return false } const running = () => spawnSync("pgrep", ["-f", SERVER], { encoding: "utf8" }).stdout.trim() -// Exactly the command the installed sudo rule allows. macOS: root joins the -// account's launchd domain (Chromium needs it) and runs the inner sudo, whose -// -H is needed because Apple's sudoers keeps HOME. Linux: sudo sets HOME to -// the account's anyway, and -H would make sudo ask for a password. -const startArgs = (acctUid) => MAC - ? ["/bin/launchctl", "asuser", String(acctUid), "/usr/bin/sudo", "-u", NAME, "-H", NODE, SERVER] - : ["-u", NAME, NODE, SERVER] +// Exactly the command the installed sudo rule allows, on macOS and Linux +// alike. No -H (it can make sudo ask for a password); the server takes its +// account's home itself. Not launchctl asuser on macOS: in the launchd domain +// of an account that never logs in, Chromium crashes when a context closes. +const startArgs = ["-u", NAME, NODE, SERVER] // Does the installed sudo rule let us start without a password? -const ruleActive = (acctUid) => spawnSync("sudo", ["-n", "-l", ...startArgs(acctUid)], { stdio: "ignore" }).status === 0 +const ruleActive = () => spawnSync("sudo", ["-n", "-l", ...startArgs], { stdio: "ignore" }).status === 0 async function ask(question, def) { if (!TTY) return def @@ -259,7 +257,7 @@ async function start(opts) { say(dim("sudo asks for your password to start the browser as " + NAME + ".")) if (spawnSync("sudo", ["-v"], { stdio: "inherit" }).status !== 0) die("sudo failed") } - const p = spawn("sudo", ["-n", ...startArgs(inst.account_uid)], { cwd: "/", stdio: ["pipe", "pipe", "pipe"], detached: nopass }) + const p = spawn("sudo", ["-n", ...startArgs], { cwd: "/", stdio: ["pipe", "pipe", "pipe"], detached: nopass }) child = p let errText = "" readline.createInterface({ input: p.stdout }).on("line", (line) => { @@ -293,7 +291,7 @@ async function start(opts) { setTimeout(() => process.exit(0), 8_000).unref() } for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => stop(sig)) - launch(ruleActive(inst.account_uid)) + launch(ruleActive()) } function copy(text) { @@ -313,7 +311,7 @@ function status() { say(` installed: ${DIR}, version ${inst.version}${inst.hash === packageHash() ? "" : dim(" (this package differs: the next start updates it)")}`) say(` account: ${NAME} (uid ${inst.account_uid}), installed by ${inst.user}`) say(` GPU: ${inst.gpu || "unknown"}`) - say(` sudo rule: ${ruleActive(inst.account_uid) ? "active (starts without a password)" : "not active (each start asks for your password)"}`) + say(` sudo rule: ${ruleActive() ? "active (starts without a password)" : "not active (each start asks for your password)"}`) say(` running: ${running() ? "yes" : "no"}`) say(` settings: ${SETTINGS}`) say(` remove: npx @agent-socket/gpu-browser uninstall (or: sudo sh ${DIR}/uninstall.sh)`) diff --git a/gpu-browser/install/README.md b/gpu-browser/install/README.md index 176c6a4..e9aae30 100644 --- a/gpu-browser/install/README.md +++ b/gpu-browser/install/README.md @@ -22,11 +22,9 @@ What `npx @agent-socket/gpu-browser` runs as root, once per install or update, a - `playwright-core`, with `npm install --ignore-scripts`; - Chromium. 4. **A fresh root-owned copy** of all that: the account can run it but not change it. -5. **A self-test of the new copy, started exactly the way every run starts it** (macOS: `launchctl asuser sudo -u …`). It launches Chromium with its sandbox, draws a WebGL canvas and reports the GPU. On Ubuntu 23.10+, where Chromium's sandbox needs an AppArmor profile, it adds one for this Chromium only. If the test fails, nothing is swapped in: an existing install keeps working, and a first install removes what it created (the account, its group, the AppArmor profile, `/opt/agent-socket-gpu-browser`); any other failure or Ctrl-C during a first install does the same. +5. **A self-test of the new copy, started exactly the way every run starts it** (`sudo -u agent-socket-gpu-browser …`). It launches Chromium with its sandbox, draws a WebGL canvas and reports the GPU. On Ubuntu 23.10+, where Chromium's sandbox needs an AppArmor profile, it adds one for this Chromium only. If the test fails, nothing is swapped in: an existing install keeps working, and a first install removes what it created (the account, its group, the AppArmor profile, `/opt/agent-socket-gpu-browser`); any other failure or Ctrl-C during a first install does the same. 6. **Swap** the new copy in, and write `install.env` (who installed it, version, the GPU). -7. **The sudo rule** `/etc/sudoers.d/zz-agent-socket-gpu-browser`, validated with `visudo` first. It is named `zz-…` so it is read last: sudo applies the last rule that matches, and a broader rule of yours for the same command would otherwise ask for the password. It lets the installing user run exactly the start command (that one command, those arguments) without a password: - - **macOS:** `launchctl asuser /usr/bin/sudo -u agent-socket-gpu-browser -H …/node …/server.mjs`; - - **Linux:** `…/node …/server.mjs` as the account. +7. **The sudo rule** `/etc/sudoers.d/zz-agent-socket-gpu-browser`, validated with `visudo` first. It is named `zz-…` so it is read last: sudo applies the last rule that matches, and a broader rule of yours for the same command would otherwise ask for the password. It lets the installing user run exactly the start command, `…/node …/server.mjs` as the account (that one command, those arguments), without a password. If `/etc/sudoers` doesn't read `/etc/sudoers.d`, there's no rule and each start asks for the password. 8. **Checks whether the account can open your home folder.** If it can, the CLI offers to close it (`chmod 700`) and remembers the old mode for uninstall. diff --git a/gpu-browser/install/install.sh b/gpu-browser/install/install.sh index fbb96fc..90e0528 100755 --- a/gpu-browser/install/install.sh +++ b/gpu-browser/install/install.sh @@ -202,13 +202,7 @@ chown -R "root:$ROOTGROUP" "$DIR/.new" chmod -R go-w,a+rX "$DIR/.new" # ── 5. Self-test the new copy, started the way every run starts it ───── -self_test() { - if [ "$OS" = Darwin ]; then - (cd / && /bin/launchctl asuser "$ACCT_UID" /usr/bin/sudo -u "$NAME" -H "$DIR/.new/node/bin/node" "$DIR/.new/app/server.mjs" --self-test 2>&1) - else - (cd / && sudo -u "$NAME" -H "$DIR/.new/node/bin/node" "$DIR/.new/app/server.mjs" --self-test 2>&1) - fi -} +self_test() { (cd / && sudo -u "$NAME" "$DIR/.new/node/bin/node" "$DIR/.new/app/server.mjs" --self-test 2>&1); } say "self-test: starting Chromium as $NAME" st=0 out=$(self_test) || st=$? @@ -295,11 +289,7 @@ if grep -Eq '^[#@]includedir[[:space:]]+(/private)?/etc/sudoers\.d' /etc/sudoers echo "# Lets $USER_NAME start the GPU browser server as $NAME without a password." echo "# Exactly this command, with exactly these arguments; nothing else." echo "# Named zz-... to come last: sudo applies the last rule that matches." - if [ "$OS" = Darwin ]; then - echo "$USER_NAME ALL=(root) NOPASSWD: /bin/launchctl asuser $ACCT_UID /usr/bin/sudo -u $NAME -H $NODE $SERVER" - else - echo "$USER_NAME ALL=($NAME) NOPASSWD: $NODE $SERVER" - fi + echo "$USER_NAME ALL=($NAME) NOPASSWD: $NODE $SERVER" } > "$tmp" if visudo -cf "$tmp" >/dev/null 2>&1; then chown "root:$ROOTGROUP" "$tmp" diff --git a/gpu-browser/install/uninstall.sh b/gpu-browser/install/uninstall.sh index 036817e..ace1222 100755 --- a/gpu-browser/install/uninstall.sh +++ b/gpu-browser/install/uninstall.sh @@ -44,8 +44,6 @@ fi if [ "$ours" = true ]; then if [ "$OS" = Darwin ]; then - uid=$(id -u "$NAME") - launchctl bootout "user/$uid" 2>/dev/null || true dscl . -delete "/Users/$NAME" case "$(dscl . -read "/Groups/$NAME" RealName 2>/dev/null)" in *"$MARK"*) dseditgroup -o delete "$NAME" 2>/dev/null || true ;; esac else From 7475095d6d53929cc326ae8d91126dbb90396e5a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:59:47 +0000 Subject: [PATCH 10/17] gpu-browser: quiet group removal on macOS; drop the one-off CI diagnostics Both install jobs pass now; the diagnostics steps were for finding the two causes (launchctl asuser on macOS, an inherited XDG_CONFIG_HOME on Linux) and are gone again, as is the no-cancel concurrency they needed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- .github/workflows/gpu-browser.yml | 70 +------------------------------ gpu-browser/install/install.sh | 2 +- gpu-browser/install/uninstall.sh | 2 +- 3 files changed, 3 insertions(+), 71 deletions(-) diff --git a/.github/workflows/gpu-browser.yml b/.github/workflows/gpu-browser.yml index 9f6d2df..b120b93 100644 --- a/.github/workflows/gpu-browser.yml +++ b/.github/workflows/gpu-browser.yml @@ -12,8 +12,7 @@ on: concurrency: group: gpu-browser-${{ github.head_ref || github.run_id }} - # A run already going finishes (its diagnostics are the point); newer pushes queue. - cancel-in-progress: false + cancel-in-progress: true jobs: scripts: @@ -40,70 +39,3 @@ jobs: with: node-version: '22' - run: sh gpu-browser/test/install-smoke.sh - - name: Diagnostics (on failure, Linux) - if: failure() && runner.os == 'Linux' - run: | - echo "apparmor_restrict_unprivileged_userns=$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns 2>/dev/null)" - sudo dmesg | grep -iE 'apparmor="DENIED"' | tail -20 || true - # The same Chromium, sandbox on, each way the installer differs - # from a plain run: a profile glob over a dot directory, and a - # system account started through sudo. - export PLAYWRIGHT_BROWSERS_PATH=/tmp/diag-browsers - npx -y playwright-core@1.64.0 install --no-shell chromium >/dev/null 2>&1 - try() { echo "== $1"; shift; timeout 30 "$@" --headless --dump-dom about:blank 2>&1 | grep -v 'dbus\|ssl_client\|WARNING' | tail -4; echo "exit=${PIPESTATUS[0]}"; } - profile() { printf 'abi ,\ninclude \nprofile %s %s flags=(unconfined) {\n userns,\n}\n' "$1" "$2" | sudo tee "/etc/apparmor.d/$1" >/dev/null; sudo apparmor_parser -r "/etc/apparmor.d/$1"; } - tmpchrome=$(ls -d /tmp/diag-browsers/chromium-*/chrome-linux64/chrome) - sudo useradd --system --home-dir /opt/diagx/home --no-create-home --shell /usr/sbin/nologin diaguser - sudo mkdir -p /opt/diagx/home && sudo chown diaguser /opt/diagx/home - ud() { echo "--user-data-dir=/opt/diagx/home/ud$1"; } - profile diag-tmp '/tmp/diag-browsers/**/chrome' - sudo useradd -m diagregular - (cd / && try "runner through sudo -u runner, profiled" sudo -u runner -H "$tmpchrome" --user-data-dir=/tmp/ud4) - (cd / && try "regular account through sudo, profiled" sudo -u diagregular -H "$tmpchrome" --user-data-dir=/home/diagregular/ud) - (cd / && try "system account through sudo, profiled" sudo -u diaguser -H "$tmpchrome" "$(ud 2)") - (cd / && try "system account through sudo, --no-sandbox" sudo -u diaguser -H "$tmpchrome" "$(ud 3)" --no-sandbox) - sudo apt-get install -y -qq strace >/dev/null 2>&1 || true - echo "== strace: system account, profiled: failed calls before the crash" - (cd / && sudo strace -f -qq -o /tmp/st.txt -u diaguser env HOME=/opt/diagx/home "$tmpchrome" "$(ud 5)" --headless --dump-dom about:blank >/dev/null 2>&1) || true - grep -E '= -1 E(PERM|ACCES|INVAL|NOSYS)|SIGTRAP|clone|unshare' /tmp/st.txt | grep -v 'ENOENT' | tail -40 - - name: Diagnostics (on failure, macOS) - if: failure() && runner.os == 'macOS' - run: | - # A throwaway account like the installer's (no window-server - # session), started the same way; which variant survives closing - # a context? - mkdir -p /tmp/diag && cd /tmp/diag && npm i --no-save --silent playwright-core@1.64.0 - PLAYWRIGHT_BROWSERS_PATH=/tmp/diag-browsers npx playwright-core install chromium >/dev/null 2>&1 - chmod -R a+rX /tmp/diag /tmp/diag-browsers - cat > t.mjs <<'EOF' - process.env.DEBUG = "pw:browser" - const errs = [] - process.stderr.write = (c) => { for (const l of String(c).split("\n")) if (/ERROR|FATAL|Check failed|signal|exception/i.test(l) && !/cv_display_link|dbus/.test(l) && errs.length < 8) errs.push(l.slice(0, 250)); return true } - const { chromium } = await import("/tmp/diag/node_modules/playwright-core/index.mjs") - const [channel, ...args] = process.argv.slice(2) - try { - const b = await chromium.launch({ channel, headless: true, chromiumSandbox: true, args: ["--enable-logging=stderr", ...args] }) - let gone = false; b.on("disconnected", () => { gone = true }) - await (await b.newContext()).newPage() - const c = await b.newContext(); const p = await c.newPage() - const r = await p.evaluate(() => { const g = document.createElement("canvas").getContext("webgl2"); const e = g && g.getExtension("WEBGL_debug_renderer_info"); return e ? g.getParameter(e.UNMASKED_RENDERER_WEBGL) : String(!!g) }) - await c.close() - await new Promise((r) => setTimeout(r, 3000)) - console.log(gone ? "FAIL (exited after a context closed)" : "ok", "| renderer:", r) - if (!gone) await b.close() - } catch (e) { console.log("FAIL", String(e.message).split("\n")[0]) } - for (const l of errs) console.log(" ", l) - EOF - uid=4501; while dscl /Search -search /Users UniqueID $uid 2>/dev/null | grep -q .; do uid=$((uid+1)); done - sudo dscl . -create /Users/diaguser - sudo dscl . -create /Users/diaguser UniqueID $uid - sudo dscl . -create /Users/diaguser PrimaryGroupID 20 - sudo dscl . -create /Users/diaguser UserShell /usr/bin/false - sudo dscl . -create /Users/diaguser NFSHomeDirectory /tmp/diaghome - sudo mkdir -p /tmp/diaghome && sudo chown $uid:20 /tmp/diaghome - node=$(command -v node) - run() { echo "== $1"; shift; (cd / && "$@" env PLAYWRIGHT_BROWSERS_PATH=/tmp/diag-browsers TMPDIR=/tmp/diaghome "$node" /tmp/diag/t.mjs "${T[@]}"); } - T=(chromium); run "account, launchctl asuser (as installed)" sudo launchctl asuser $uid sudo -u diaguser -H - T=(chromium --disable-gpu); run "account, launchctl asuser, --disable-gpu" sudo launchctl asuser $uid sudo -u diaguser -H - T=(chromium-headless-shell); run "account, launchctl asuser, headless shell" sudo launchctl asuser $uid sudo -u diaguser -H - T=(chromium); run "account, plain sudo -u" sudo -u diaguser -H diff --git a/gpu-browser/install/install.sh b/gpu-browser/install/install.sh index 90e0528..564a8d6 100755 --- a/gpu-browser/install/install.sh +++ b/gpu-browser/install/install.sh @@ -68,7 +68,7 @@ cleanup() { if [ "$OS" = Darwin ]; then dscl . -delete "/Users/$NAME" 2>/dev/null || true; else userdel "$NAME" 2>/dev/null || true; fi fi if [ "$made_group" = true ]; then - if [ "$OS" = Darwin ]; then dseditgroup -o delete "$NAME" 2>/dev/null || true + if [ "$OS" = Darwin ]; then dseditgroup -o delete "$NAME" >/dev/null 2>&1 || true elif getent group "$NAME" >/dev/null 2>&1; then groupdel "$NAME" 2>/dev/null || true; fi fi if [ "$made_dir" = true ]; then rm -rf "$DIR"; fi diff --git a/gpu-browser/install/uninstall.sh b/gpu-browser/install/uninstall.sh index ace1222..7c9cde9 100755 --- a/gpu-browser/install/uninstall.sh +++ b/gpu-browser/install/uninstall.sh @@ -45,7 +45,7 @@ fi if [ "$ours" = true ]; then if [ "$OS" = Darwin ]; then dscl . -delete "/Users/$NAME" - case "$(dscl . -read "/Groups/$NAME" RealName 2>/dev/null)" in *"$MARK"*) dseditgroup -o delete "$NAME" 2>/dev/null || true ;; esac + case "$(dscl . -read "/Groups/$NAME" RealName 2>/dev/null)" in *"$MARK"*) dseditgroup -o delete "$NAME" >/dev/null 2>&1 || true ;; esac else gid=$(id -g "$NAME") userdel "$NAME" From c5a65eb559181405840f728068dfff9ee7df46b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:06:49 +0000 Subject: [PATCH 11/17] gpu-browser: an interrupted update restores the old copy; refuse a /opt dir we didn't make - A failure or signal in the middle of the swap moves what is in .old back, so the previous install keeps working (tested by failing an update right after its first move). - /opt/agent-socket-gpu-browser that has neither install.env nor the new .created-by-agent-socket-gpu-browser marker is refused by install and left alone by uninstall. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/install/README.md | 2 +- gpu-browser/install/install.sh | 14 ++++++++++++++ gpu-browser/install/uninstall.sh | 4 +++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/gpu-browser/install/README.md b/gpu-browser/install/README.md index e9aae30..1daddc0 100644 --- a/gpu-browser/install/README.md +++ b/gpu-browser/install/README.md @@ -22,7 +22,7 @@ What `npx @agent-socket/gpu-browser` runs as root, once per install or update, a - `playwright-core`, with `npm install --ignore-scripts`; - Chromium. 4. **A fresh root-owned copy** of all that: the account can run it but not change it. -5. **A self-test of the new copy, started exactly the way every run starts it** (`sudo -u agent-socket-gpu-browser …`). It launches Chromium with its sandbox, draws a WebGL canvas and reports the GPU. On Ubuntu 23.10+, where Chromium's sandbox needs an AppArmor profile, it adds one for this Chromium only. If the test fails, nothing is swapped in: an existing install keeps working, and a first install removes what it created (the account, its group, the AppArmor profile, `/opt/agent-socket-gpu-browser`); any other failure or Ctrl-C during a first install does the same. +5. **A self-test of the new copy, started exactly the way every run starts it** (`sudo -u agent-socket-gpu-browser …`). It launches Chromium with its sandbox, draws a WebGL canvas and reports the GPU. On Ubuntu 23.10+, where Chromium's sandbox needs an AppArmor profile, it adds one for this Chromium only. If the test fails, nothing is swapped in: an existing install keeps working, and a first install removes what it created (the account, its group, the AppArmor profile, `/opt/agent-socket-gpu-browser`); any other failure or Ctrl-C during a first install does the same. An update stopped halfway through the swap puts the old copy back. A `/opt/agent-socket-gpu-browser` this installer didn't make (no `install.env` or `.created-by-agent-socket-gpu-browser` in it) is refused, and uninstall leaves such a folder alone. 6. **Swap** the new copy in, and write `install.env` (who installed it, version, the GPU). 7. **The sudo rule** `/etc/sudoers.d/zz-agent-socket-gpu-browser`, validated with `visudo` first. It is named `zz-…` so it is read last: sudo applies the last rule that matches, and a broader rule of yours for the same command would otherwise ask for the password. It lets the installing user run exactly the start command, `…/node …/server.mjs` as the account (that one command, those arguments), without a password. diff --git a/gpu-browser/install/install.sh b/gpu-browser/install/install.sh index 564a8d6..ddf3e4c 100755 --- a/gpu-browser/install/install.sh +++ b/gpu-browser/install/install.sh @@ -54,10 +54,23 @@ fi # that isn't success. A failed update keeps the working install it had. FRESH=true [ -f "$DIR/install.env" ] && FRESH=false +# $DIR is ours if an install finished there (install.env) or one started +# there (this marker); anything else with that name is left alone. +DIRMARK="$DIR/.created-by-agent-socket-gpu-browser" +if [ -e "$DIR" ] && [ ! -f "$DIR/install.env" ] && [ ! -f "$DIRMARK" ]; then + die "$DIR exists and wasn't made by this installer; not touching it" +fi made_acct=false made_group=false made_dir=false made_aa=false done_ok=false cleanup() { [ "$done_ok" = true ] && return rm -rf "$DIR/.staging" "$DIR/.new" 2>/dev/null || true + # An update stopped in the middle of the swap: put the old copy back. + if [ -d "$DIR/.old" ]; then + for d in node app browsers VERSION; do + if [ -e "$DIR/.old/$d" ]; then rm -rf "${DIR:?}/$d"; mv "$DIR/.old/$d" "$DIR/"; fi + done + rm -rf "$DIR/.old" + fi [ "$FRESH" = true ] || return 0 if [ "$made_aa" = true ]; then apparmor_parser -R "/etc/apparmor.d/$NAME" 2>/dev/null || true @@ -154,6 +167,7 @@ ACCT_UID=$(id -u "$NAME") # ── 2. Directories ─────────────────────────────────────────────────── [ -d "$DIR" ] || made_dir=true mkdir -p "$DIR" +: > "$DIRMARK" chown "root:$ROOTGROUP" "$DIR" chmod 755 "$DIR" mkdir -p "$DIR/home" diff --git a/gpu-browser/install/uninstall.sh b/gpu-browser/install/uninstall.sh index 7c9cde9..0d30d5b 100755 --- a/gpu-browser/install/uninstall.sh +++ b/gpu-browser/install/uninstall.sh @@ -62,8 +62,10 @@ else if [ "$exists" = true ]; then say "an account named $NAME exists but wasn't made by this installer; left alone"; fi fi -if [ -d "$DIR" ]; then +if [ -f "$DIR/install.env" ] || [ -f "$DIR/.created-by-agent-socket-gpu-browser" ]; then rm -rf "$DIR" say "removed $DIR" +elif [ -e "$DIR" ]; then + say "$DIR wasn't made by this installer; left alone" fi say "done (your settings stay in ~/.agent-socket/gpu-browser/)" From 519dbdbb9a855d67ceaaaa6b5e2cfbca30400c14 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:15:14 +0000 Subject: [PATCH 12/17] gpu-browser: one install at a time; a failed update removes an AppArmor profile it added - A machine-wide lock (/var/run/agent-socket-gpu-browser-install.lock, mkdir + pid) keeps two installs from sharing .staging/.new/.old; one left by a killed run is taken over. Tested: a second install started during the first is refused, the first completes, the lock is gone. - The cleanup removes an AppArmor profile created by this run before the first-install-only part, so a failed update doesn't leave one behind. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/install/install.sh | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/gpu-browser/install/install.sh b/gpu-browser/install/install.sh index ddf3e4c..3a5f4b1 100755 --- a/gpu-browser/install/install.sh +++ b/gpu-browser/install/install.sh @@ -15,7 +15,7 @@ COMMENT="$MARK - created by npx @agent-socket/gpu-browser - remove with sudo sh OS=$(uname -s) say() { printf ' %s\n' "$*"; } -die() { printf 'install failed: %s\n' "$*" >&2; exit 1; } +die() { printf 'install failed: %s\n' "$*" >&2; if [ "${locked:-}" = true ] && [ "${trapped:-}" != true ]; then rm -rf "$LOCK"; fi; exit 1; } event() { printf '@@install %s\n' "$*"; } USER_NAME="" USER_HOME="" PKG="" VERSION="" HASH="" @@ -41,6 +41,20 @@ if [ ! -f "$PKG/app/server.mjs" ] || [ ! -f "$PKG/install/node.txt" ]; then die printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+' || die "bad --version" printf '%s' "$HASH" | grep -Eq '^[0-9a-f]{64}$' || die "bad --hash" +# One install at a time: two would share .staging/.new/.old and could undo +# each other. mkdir is atomic; the lock holds the installer's pid, so one +# left by a killed run is taken over. +LOCK=/var/run/agent-socket-gpu-browser-install.lock +locked=false +if ! mkdir "$LOCK" 2>/dev/null; then + other=$(cat "$LOCK/pid" 2>/dev/null || true) + if [ -n "$other" ] && kill -0 "$other" 2>/dev/null; then die "another install is running (pid $other); try again when it has finished"; fi + rm -rf "$LOCK" + mkdir "$LOCK" 2>/dev/null || die "another install is starting; try again in a moment" +fi +echo $$ > "$LOCK/pid" +locked=true + # One owner per machine: the sudo rule names the user who installed it. if [ -f "$DIR/install.env" ]; then owner=$(sed -n 's/^user=//p' "$DIR/install.env") @@ -64,6 +78,11 @@ made_acct=false made_group=false made_dir=false made_aa=false done_ok=false cleanup() { [ "$done_ok" = true ] && return rm -rf "$DIR/.staging" "$DIR/.new" 2>/dev/null || true + # A profile this run added goes again, on an update too. + if [ "$made_aa" = true ]; then + apparmor_parser -R "/etc/apparmor.d/$NAME" 2>/dev/null || true + rm -f "/etc/apparmor.d/$NAME" + fi # An update stopped in the middle of the swap: put the old copy back. if [ -d "$DIR/.old" ]; then for d in node app browsers VERSION; do @@ -72,10 +91,6 @@ cleanup() { rm -rf "$DIR/.old" fi [ "$FRESH" = true ] || return 0 - if [ "$made_aa" = true ]; then - apparmor_parser -R "/etc/apparmor.d/$NAME" 2>/dev/null || true - rm -f "/etc/apparmor.d/$NAME" - fi if [ "$made_acct" = true ]; then pkill -9 -u "$NAME" 2>/dev/null || true if [ "$OS" = Darwin ]; then dscl . -delete "/Users/$NAME" 2>/dev/null || true; else userdel "$NAME" 2>/dev/null || true; fi @@ -87,7 +102,9 @@ cleanup() { if [ "$made_dir" = true ]; then rm -rf "$DIR"; fi echo " undone: this install's account and files are removed again" >&2 } -trap cleanup EXIT +on_exit() { cleanup; rm -rf "$LOCK"; } +trap on_exit EXIT +trapped=true trap 'exit 130' INT TERM HUP ROOTGROUP=root From 3db759466ad63eef7398642f22d5009cca0735be Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:23:25 +0000 Subject: [PATCH 13/17] gpu-browser: refuse a sudo rule or AppArmor profile we didn't write; keep special home mode bits - /etc/sudoers.d/zz-agent-socket-gpu-browser or /etc/apparmor.d/agent-socket-gpu-browser without the installer's marker stops the install before anything is created (tested with a sudoers file of that name: refused, file unchanged). - The home folder's old mode is recorded with its setgid/sticky bits (e.g. 2750), so uninstall can restore it exactly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/bin/agent-socket-gpu-browser.mjs | 4 ++-- gpu-browser/install/install.sh | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/gpu-browser/bin/agent-socket-gpu-browser.mjs b/gpu-browser/bin/agent-socket-gpu-browser.mjs index 7732c27..1c803b5 100755 --- a/gpu-browser/bin/agent-socket-gpu-browser.mjs +++ b/gpu-browser/bin/agent-socket-gpu-browser.mjs @@ -110,7 +110,7 @@ Remove everything later with: npx @agent-socket/gpu-browser uninstall`) if (events.includes("sudo-rule skipped")) say(yellow("⚠ No sudo rule: each start will ask for your password.")) say(green("✓ installed")) if (events.includes("home-open")) { - const mode = (fs.statSync(os.homedir()).mode & 0o777).toString(8) + const mode = (fs.statSync(os.homedir()).mode & 0o7777).toString(8).padStart(3, "0") // setgid/sticky bits too say(yellow(`⚠ Your home folder (${os.homedir()}, mode ${mode}) can be opened by other accounts, so the browser's account could read files in it.`)) say(dim(" Closing it (chmod 700) is what macOS does for new accounts; it can break sharing it with other local users (web servers in ~/public_html, Samba).")) if (!yes && /^(|y|yes)$/i.test(await ask("Close it? [Y/n] ", "n"))) { @@ -334,7 +334,7 @@ async function uninstall(yes) { const before = path.join(SETTINGS, "home-mode-before.txt") if (fs.existsSync(before)) { const mode = fs.readFileSync(before, "utf8").trim() - if (!/^[0-7]{3}$/.test(mode)) fs.rmSync(before, { force: true }) + if (!/^[0-7]{3,4}$/.test(mode)) fs.rmSync(before, { force: true }) else if (yes || !TTY) { // Nobody to ask: keep the note, so a later run (or you) can still put it back. say(dim(`Install closed your home folder (it was ${mode}); to open it again: chmod ${mode} ~ (noted in ${before})`)) diff --git a/gpu-browser/install/install.sh b/gpu-browser/install/install.sh index 3a5f4b1..507a719 100755 --- a/gpu-browser/install/install.sh +++ b/gpu-browser/install/install.sh @@ -74,6 +74,10 @@ DIRMARK="$DIR/.created-by-agent-socket-gpu-browser" if [ -e "$DIR" ] && [ ! -f "$DIR/install.env" ] && [ ! -f "$DIRMARK" ]; then die "$DIR exists and wasn't made by this installer; not touching it" fi +# Likewise a sudo rule or AppArmor profile of these names without the marker. +for f in "/etc/sudoers.d/zz-$NAME" "/etc/apparmor.d/$NAME"; do + if [ -e "$f" ] && ! grep -q "$MARK" "$f" 2>/dev/null; then die "$f exists and wasn't written by this installer; not touching it"; fi +done made_acct=false made_group=false made_dir=false made_aa=false done_ok=false cleanup() { [ "$done_ok" = true ] && return From ee977975c06748075dc4e6e1d8e402740e9cf823 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:25:31 +0000 Subject: [PATCH 14/17] gpu-browser: install lock is a symlink to the pid; recover a .old left by a hard kill - The lock is created as a symlink whose target is the installer's pid (atomic, never ownerless), so a second installer can't mistake a just- created lock for a stale one; only the owner removes it. - A .old left by an update killed mid-swap (SIGKILL, power loss) is put back at the start of the next run instead of being deleted. Tested: a live lock refuses; a stale one (dead pid) is taken over; an install with node moved into .old is recovered and updates cleanly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/install/install.sh | 38 ++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/gpu-browser/install/install.sh b/gpu-browser/install/install.sh index 507a719..b6888e8 100755 --- a/gpu-browser/install/install.sh +++ b/gpu-browser/install/install.sh @@ -15,7 +15,7 @@ COMMENT="$MARK - created by npx @agent-socket/gpu-browser - remove with sudo sh OS=$(uname -s) say() { printf ' %s\n' "$*"; } -die() { printf 'install failed: %s\n' "$*" >&2; if [ "${locked:-}" = true ] && [ "${trapped:-}" != true ]; then rm -rf "$LOCK"; fi; exit 1; } +die() { printf 'install failed: %s\n' "$*" >&2; if [ "${locked:-}" = true ] && [ "${trapped:-}" != true ]; then unlock; fi; exit 1; } event() { printf '@@install %s\n' "$*"; } USER_NAME="" USER_HOME="" PKG="" VERSION="" HASH="" @@ -42,18 +42,19 @@ printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+' || die "bad --versio printf '%s' "$HASH" | grep -Eq '^[0-9a-f]{64}$' || die "bad --hash" # One install at a time: two would share .staging/.new/.old and could undo -# each other. mkdir is atomic; the lock holds the installer's pid, so one -# left by a killed run is taken over. +# each other. The lock is a symlink whose target is the installer's pid: +# creating it is atomic and it never exists without its owner, so one left +# by a killed run (a dead pid) can be taken over safely. LOCK=/var/run/agent-socket-gpu-browser-install.lock locked=false -if ! mkdir "$LOCK" 2>/dev/null; then - other=$(cat "$LOCK/pid" 2>/dev/null || true) +if ! ln -s "$$" "$LOCK" 2>/dev/null; then + other=$(readlink "$LOCK" 2>/dev/null || true) if [ -n "$other" ] && kill -0 "$other" 2>/dev/null; then die "another install is running (pid $other); try again when it has finished"; fi rm -rf "$LOCK" - mkdir "$LOCK" 2>/dev/null || die "another install is starting; try again in a moment" + ln -s "$$" "$LOCK" 2>/dev/null || die "another install is starting; try again in a moment" fi -echo $$ > "$LOCK/pid" locked=true +unlock() { if [ "$(readlink "$LOCK" 2>/dev/null)" = "$$" ]; then rm -f "$LOCK"; fi; } # One owner per machine: the sudo rule names the user who installed it. if [ -f "$DIR/install.env" ]; then @@ -79,6 +80,16 @@ for f in "/etc/sudoers.d/zz-$NAME" "/etc/apparmor.d/$NAME"; do if [ -e "$f" ] && ! grep -q "$MARK" "$f" 2>/dev/null; then die "$f exists and wasn't written by this installer; not touching it"; fi done made_acct=false made_group=false made_dir=false made_aa=false done_ok=false +# An update stopped in the middle of the swap (here, or a run killed with +# SIGKILL or a power cut) left part of the old copy in .old: put it back. +restore_old() { + if [ -d "$DIR/.old" ]; then + for d in node app browsers VERSION; do + if [ -e "$DIR/.old/$d" ]; then rm -rf "${DIR:?}/$d"; mv "$DIR/.old/$d" "$DIR/"; fi + done + rm -rf "$DIR/.old" + fi +} cleanup() { [ "$done_ok" = true ] && return rm -rf "$DIR/.staging" "$DIR/.new" 2>/dev/null || true @@ -87,13 +98,7 @@ cleanup() { apparmor_parser -R "/etc/apparmor.d/$NAME" 2>/dev/null || true rm -f "/etc/apparmor.d/$NAME" fi - # An update stopped in the middle of the swap: put the old copy back. - if [ -d "$DIR/.old" ]; then - for d in node app browsers VERSION; do - if [ -e "$DIR/.old/$d" ]; then rm -rf "${DIR:?}/$d"; mv "$DIR/.old/$d" "$DIR/"; fi - done - rm -rf "$DIR/.old" - fi + restore_old [ "$FRESH" = true ] || return 0 if [ "$made_acct" = true ]; then pkill -9 -u "$NAME" 2>/dev/null || true @@ -106,7 +111,7 @@ cleanup() { if [ "$made_dir" = true ]; then rm -rf "$DIR"; fi echo " undone: this install's account and files are removed again" >&2 } -on_exit() { cleanup; rm -rf "$LOCK"; } +on_exit() { cleanup; unlock; } trap on_exit EXIT trapped=true trap 'exit 130' INT TERM HUP @@ -196,7 +201,8 @@ chown "$NAME:$NAME" "$DIR/home" chmod 700 "$DIR/home" cp "$PKG/install/home-README.md" "$DIR/home/README.md" chmod 644 "$DIR/home/README.md" -rm -rf "$DIR/.staging" "$DIR/.new" "$DIR/.old" +restore_old +rm -rf "$DIR/.staging" "$DIR/.new" mkdir "$DIR/.staging" chown "$NAME:$NAME" "$DIR/.staging" From fea5fd983aab3e21d6bc6a6425a59c89bee698cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:34:38 +0000 Subject: [PATCH 15/17] gpu-browser: journal what a first install creates; no swap under a running server - The install directory and its marker come first, and the marker journals the group, account and AppArmor profile before each is made, so a first install killed hard (SIGKILL, power loss) is finished or undone by the next run, including a group left between groupadd and useradd (tested: reused, then removed when that run failed). The journal is emptied once an install finishes. - The swap is refused while a server runs (its pid file), checked while holding the install lock; the server refuses to start while the lock is held (PR #1). Tested both ways on an install. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/install/install.sh | 46 ++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/gpu-browser/install/install.sh b/gpu-browser/install/install.sh index b6888e8..2ebe29b 100755 --- a/gpu-browser/install/install.sh +++ b/gpu-browser/install/install.sh @@ -122,6 +122,22 @@ sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d # Everything downloaded is fetched and unpacked by the account, never by root. as_acct() { (cd / && sudo -u "$NAME" -H /bin/sh -c "$1"); } +# ── 0. The directory first: its marker journals what a first install +# creates, before creating it, so a first install killed hard (SIGKILL, +# power loss) is finished or undone by the next run ───────────────────── +[ -d "$DIR" ] || made_dir=true +mkdir -p "$DIR" +[ -f "$DIRMARK" ] || : > "$DIRMARK" +chown "root:$ROOTGROUP" "$DIR" "$DIRMARK" +chmod 755 "$DIR" +journal() { grep -qx "$1" "$DIRMARK" 2>/dev/null || echo "$1" >> "$DIRMARK"; } +if [ "$FRESH" = true ]; then + made_dir=true + if grep -qx group "$DIRMARK"; then made_group=true; fi + if grep -qx account "$DIRMARK"; then made_acct=true; fi + if grep -qx apparmor "$DIRMARK"; then made_aa=true; fi +fi + # ── 1. The account ─────────────────────────────────────────────────── if [ "$OS" = Darwin ]; then if dscl . -read "/Users/$NAME" >/dev/null 2>&1; then @@ -137,12 +153,14 @@ if [ "$OS" = Darwin ]; then *) die "a group named $NAME exists that this installer didn't create; not touching it" ;; esac else - dseditgroup -o create -r "$MARK" "$NAME" || die "could not create the group $NAME" + journal group made_group=true + dseditgroup -o create -r "$MARK" "$NAME" || die "could not create the group $NAME" fi gid=$(dscl . -read "/Groups/$NAME" PrimaryGroupID | awk '{print $2}') uid=501 while dscl /Search -search /Users UniqueID "$uid" 2>/dev/null | grep -q .; do uid=$((uid + 1)); done + journal account made_acct=true if ! err=$(dscl . -create "/Users/$NAME" 2>&1); then case "$err" in @@ -171,15 +189,19 @@ else esac else # A group with this name and no account is someone else's (a group has - # no comment to carry the marker); the account must not join it. + # no comment to carry the marker), unless the journal says an earlier, + # interrupted run of this installer made it. if getent group "$NAME" >/dev/null 2>&1; then - die "a group named $NAME exists that this installer didn't create; not touching it (if it is left from an interrupted install: sudo groupdel $NAME)" + grep -qx group "$DIRMARK" || die "a group named $NAME exists that this installer didn't create; not touching it" + else + journal group + made_group=true + groupadd --system "$NAME" fi say "account $NAME: creating (system account, no login)" nologin=/usr/sbin/nologin [ -x "$nologin" ] || nologin=/sbin/nologin - groupadd --system "$NAME" - made_group=true + journal account made_acct=true useradd --system --gid "$NAME" --home-dir "$DIR/home" --no-create-home --shell "$nologin" --comment "$COMMENT" "$NAME" fi @@ -191,11 +213,6 @@ fi ACCT_UID=$(id -u "$NAME") # ── 2. Directories ─────────────────────────────────────────────────── -[ -d "$DIR" ] || made_dir=true -mkdir -p "$DIR" -: > "$DIRMARK" -chown "root:$ROOTGROUP" "$DIR" -chmod 755 "$DIR" mkdir -p "$DIR/home" chown "$NAME:$NAME" "$DIR/home" chmod 700 "$DIR/home" @@ -252,7 +269,7 @@ if [ "$st" != 0 ] && [ "$OS" = Linux ]; then # Chromium's sandbox needs; give this Chromium such a profile. if [ "$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns 2>/dev/null || echo 0)" = 1 ] && command -v apparmor_parser >/dev/null 2>&1; then say "AppArmor: allowing this Chromium its sandbox (user namespaces)" - [ -e "/etc/apparmor.d/$NAME" ] || made_aa=true + if [ ! -e "/etc/apparmor.d/$NAME" ]; then journal apparmor; made_aa=true; fi cat > "/etc/apparmor.d/$NAME" </dev/null || true) +if [ -n "$spid" ] && kill -0 "$spid" 2>/dev/null; then die "the GPU browser is running (pid $spid); stop it, then run the install again"; fi mkdir "$DIR/.old" for d in node app browsers VERSION; do if [ -e "$DIR/$d" ]; then mv "$DIR/$d" "$DIR/.old/"; fi @@ -350,5 +373,6 @@ fi # ── 8. Can the account see into your home? ─────────────────────────── if sudo -u "$NAME" test -x "$USER_HOME" 2>/dev/null; then event "home-open"; fi +: > "$DIRMARK" # nothing left to undo: the journal is for unfinished first installs done_ok=true event "done" From dde6f249d9d799a726d2648262d5b60bea6cc820 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:41:35 +0000 Subject: [PATCH 16/17] gpu-browser: keep .old until an update is final; password-backed start can prompt; safe uninstall - An update keeps .old (and writes install.env aside) until its last step, so a failure after the swap still restores the working copy with its own install.env (tested by failing an update just before that step). - Without the sudo rule, the start no longer passes -n, so sudo can ask for the password itself when the earlier sudo -v isn't cached. - uninstall runs this package's own uninstall.sh (not whatever is in /opt/agent-socket-gpu-browser), and says there is nothing to remove when that directory has neither install.env nor the installer's marker. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/bin/agent-socket-gpu-browser.mjs | 11 +++++++++-- gpu-browser/install/install.sh | 14 +++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/gpu-browser/bin/agent-socket-gpu-browser.mjs b/gpu-browser/bin/agent-socket-gpu-browser.mjs index 1c803b5..8e1593b 100755 --- a/gpu-browser/bin/agent-socket-gpu-browser.mjs +++ b/gpu-browser/bin/agent-socket-gpu-browser.mjs @@ -257,7 +257,9 @@ async function start(opts) { say(dim("sudo asks for your password to start the browser as " + NAME + ".")) if (spawnSync("sudo", ["-v"], { stdio: "inherit" }).status !== 0) die("sudo failed") } - const p = spawn("sudo", ["-n", ...startArgs], { cwd: "/", stdio: ["pipe", "pipe", "pipe"], detached: nopass }) + // -n only with the rule: without it sudo must be free to ask again (a + // zero timestamp_timeout doesn't keep the -v above). + const p = spawn("sudo", nopass ? ["-n", ...startArgs] : startArgs, { cwd: "/", stdio: ["pipe", "pipe", "pipe"], detached: nopass }) child = p let errText = "" readline.createInterface({ input: p.stdout }).on("line", (line) => { @@ -324,12 +326,17 @@ function stopOthers() { } async function uninstall(yes) { if (!fs.existsSync(DIR)) return say("not installed") + if (!fs.existsSync(path.join(DIR, "install.env")) && !fs.existsSync(path.join(DIR, ".created-by-agent-socket-gpu-browser"))) { + return say(DIR + " wasn't made by this installer; nothing to remove") + } say("This removes the browser's account (" + NAME + "), its sudo rule and " + DIR + ".") if (!yes) { if (!TTY) die("run it in a terminal to confirm, or pass --yes") if (!/^(|y|yes)$/i.test(await ask("Remove? [Y/n] ", "n"))) die("nothing changed", 0) } - const r = spawnSync("sudo", ["/bin/sh", DIR + "/uninstall.sh"], { cwd: "/", stdio: "inherit" }) + // This package's own copy, not whatever DIR/uninstall.sh is: DIR may not + // be ours, and the script only removes what carries the installer's marks. + const r = spawnSync("sudo", ["/bin/sh", path.join(PKG, "install", "uninstall.sh")], { cwd: "/", stdio: "inherit" }) if (r.status !== 0) die("uninstall failed (above)") const before = path.join(SETTINGS, "home-mode-before.txt") if (fs.existsSync(before)) { diff --git a/gpu-browser/install/install.sh b/gpu-browser/install/install.sh index 2ebe29b..23be2ef 100755 --- a/gpu-browser/install/install.sh +++ b/gpu-browser/install/install.sh @@ -92,7 +92,7 @@ restore_old() { } cleanup() { [ "$done_ok" = true ] && return - rm -rf "$DIR/.staging" "$DIR/.new" 2>/dev/null || true + rm -rf "$DIR/.staging" "$DIR/.new" "$DIR/install.env.new" 2>/dev/null || true # A profile this run added goes again, on an update too. if [ "$made_aa" = true ]; then apparmor_parser -R "/etc/apparmor.d/$NAME" 2>/dev/null || true @@ -322,20 +322,22 @@ for d in node app browsers VERSION; do if [ -e "$DIR/$d" ]; then mv "$DIR/$d" "$DIR/.old/"; fi done for d in node app browsers VERSION; do mv "$DIR/.new/$d" "$DIR/"; done -rm -rf "$DIR/.new" "$DIR/.old" +rm -rf "$DIR/.new" +# .old stays until the very end, so a failure from here on still puts the +# working copy back; install.env is written aside and moved in last. cp "$PKG/install/uninstall.sh" "$DIR/uninstall.sh" cp "$PKG/install/system-README.md" "$DIR/README.md" chmod 755 "$DIR/uninstall.sh" chmod 644 "$DIR/README.md" -cat > "$DIR/install.env" < "$DIR/install.env.new" </dev/null; then event "home-open"; fi +mv -f "$DIR/install.env.new" "$DIR/install.env" +rm -rf "$DIR/.old" : > "$DIRMARK" # nothing left to undo: the journal is for unfinished first installs done_ok=true event "done" From 98466c607954342653116b310fcf11e479443a09 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:46:09 +0000 Subject: [PATCH 17/17] gpu-browser: install lock and server pid count only while that program runs; re-check a lock takeover - A recorded pid (the install lock's, the server's pid file) blocks only while ps shows that same program (install.sh; server.mjs under the account's uid), so a reused pid no longer refuses installs or updates. - After taking over a stale install lock, the installer waits a second and checks the lock still points at it, so two runs recovering the same stale lock can't both proceed. Tested on an install: pid files pointing at unrelated live processes no longer block an update, a start or an install; a real server still blocks an update. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CpTxf6FhMdiFuXFsbUam53 --- gpu-browser/install/install.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/gpu-browser/install/install.sh b/gpu-browser/install/install.sh index 23be2ef..a828028 100755 --- a/gpu-browser/install/install.sh +++ b/gpu-browser/install/install.sh @@ -47,11 +47,17 @@ printf '%s' "$HASH" | grep -Eq '^[0-9a-f]{64}$' || die "bad --hash" # by a killed run (a dead pid) can be taken over safely. LOCK=/var/run/agent-socket-gpu-browser-install.lock locked=false +# A pid counts only while it is still that program (pids get reused). +runs() { ps -p "$1" -o args= 2>/dev/null | grep -q "$2"; } if ! ln -s "$$" "$LOCK" 2>/dev/null; then other=$(readlink "$LOCK" 2>/dev/null || true) - if [ -n "$other" ] && kill -0 "$other" 2>/dev/null; then die "another install is running (pid $other); try again when it has finished"; fi + if [ -n "$other" ] && runs "$other" 'install\.sh'; then die "another install is running (pid $other); try again when it has finished"; fi rm -rf "$LOCK" ln -s "$$" "$LOCK" 2>/dev/null || die "another install is starting; try again in a moment" + # Two runs that found the same stale lock can each remove it, the later + # removal taking the earlier one's new lock: make sure it's still ours. + sleep 1 + [ "$(readlink "$LOCK" 2>/dev/null)" = "$$" ] || die "another install is starting; try again in a moment" fi locked=true unlock() { if [ "$(readlink "$LOCK" 2>/dev/null)" = "$$" ]; then rm -f "$LOCK"; fi; } @@ -316,7 +322,9 @@ case "$result" in *'"software":true'*) event "software-rendering" ;; esac # then checks for this install's lock; this checks for the pid file while # holding the lock, so of two racing, at least one backs off. spid=$(cat "$DIR/home/server.pid" 2>/dev/null || true) -if [ -n "$spid" ] && kill -0 "$spid" 2>/dev/null; then die "the GPU browser is running (pid $spid); stop it, then run the install again"; fi +if [ -n "$spid" ] && [ "$(ps -p "$spid" -o uid= 2>/dev/null | tr -d ' ')" = "$ACCT_UID" ] && runs "$spid" 'server\.mjs'; then + die "the GPU browser is running (pid $spid); stop it, then run the install again" +fi mkdir "$DIR/.old" for d in node app browsers VERSION; do if [ -e "$DIR/$d" ]; then mv "$DIR/$d" "$DIR/.old/"; fi