openvibers/openvibe.codes Commits
Chat plays: the sandbox worker a viewer's request runs in (bubblewrap, egress proxy on a socket, cgroup limits, Wipe now) (#295)
Changes
@@@ -42,3 +42,10 @@ OV_OAUTH_CLIENT_SECRET=# Know (the shared lessons store): on a Node, the portal's know/lessons.js. Its path is configuration, never a# hard-coded home; without it a job gets no lessons. Never a secret.#CODES_KNOW_LESSONS=/home/openvibe/agents/know/lessons.js++++# Chat plays sandbox workers (Agent Streams S6): the secret a worker signs its heartbeat with++# (POST /internal/sandbox-workers/heartbeat), a comma list during a rotation, and how long a worker that stopped++# checking in still counts. Without a secret no worker can check in and /internal/sandbox-workers/health says not ready.++# The worker's own settings (CODES_SANDBOX_* on its host) are in deploy/sandbox/sandbox-worker.env.example.++#CODES_SANDBOX_WORKER_SECRET=++#CODES_SANDBOX_STALE_MS=90000nnew file mode 100755@@@ -0,0 +1,31 @@++#!/usr/bin/env node++'use strict';++/**++ * The chat plays sandbox worker (sandbox/worker.js). Run by deploy/sandbox/openvibe-codes-sandbox.service as its own++ * unprivileged user; on a workstation: `node bin/openvibe-sandbox-worker.js` (a user systemd gives it its limits).++ *++ * openvibe-sandbox-worker run: self-test, the local API, the check-ins every 30 s++ * openvibe-sandbox-worker --check the self-test once, print the health and exit 0 (ready) or 1++ */++const { createWorker, loadConfig } = require('../sandbox/worker');++++(async () => {++ const config = loadConfig(process.env);++ // The secrets are read; nothing the worker starts gets this environment, but drop them from it anyway.++ for (const k of ['CODES_SANDBOX_WORKER_SECRET', 'CODES_SANDBOX_CLIENT_SECRET']) delete process.env[k];++ const worker = createWorker({ config });++ if (process.argv.includes('--check')) {++ const { probeSupport } = require('../sandbox/session');++ const { detectLimits } = require('../sandbox/limits');++ const check = createWorker({ config, support: await probeSupport(), limiter: await detectLimits({ mode: config.limitsMode }) });++ require('fs').mkdirSync(config.runtimeDir, { recursive: true, mode: 0o750 });++ await check.selfTest();++ const h = check.health();++ process.stdout.write(`${JSON.stringify(h, null, 2)}\n`);++ process.exit(h.ready ? 0 : 1);++ }++ await worker.start();++ const stop = async (sig) => { console.log(`[sandbox] ${sig}: wiping every sandbox`); await worker.stop(); process.exit(0); };++ process.on('SIGTERM', () => stop('SIGTERM'));++ process.on('SIGINT', () => stop('SIGINT'));++})().catch((err) => { console.error('[sandbox] the worker failed:', err && err.message); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,13 @@++# /etc/apparmor.d/openvibe-codes-sandbox-bwrap — only on a host where Ubuntu restricts unprivileged user namespaces++# (kernel.apparmor_restrict_unprivileged_userns=1, the default since 23.10/24.04). It lets /usr/bin/bwrap, and nothing++# else, create the user namespace a sandbox starts in; bubblewrap itself drops every capability and turns nested user++# namespaces off (--disable-userns) before anything of the sandbox runs. install.sh installs it only when needed and++# never changes the sysctl.++abi <abi/4.0>,++include <tunables/global>++++profile openvibe-codes-sandbox-bwrap /usr/bin/bwrap flags=(unconfined) {++ userns,++++ include if exists <local/openvibe-codes-sandbox-bwrap>++}nnew file mode 100755@@@ -0,0 +1,122 @@++#!/bin/sh++# Install the chat plays sandbox worker (Agent Streams S6) on the host that runs OpenVibe.Codes. Run as root, from the++# Codes release (/opt/openvibe.codes): `sudo deploy/sandbox/install.sh [--with-browser] [--start]`. Idempotent: it++# never overwrites a secret or an env file that exists, never changes a sysctl, and starts nothing without --start.++#++# What the worker needs from the host:++# 1. bubblewrap >= 0.8 (apt install bubblewrap) and unprivileged user namespaces. On Ubuntu 23.10+ with++# kernel.apparmor_restrict_unprivileged_userns=1 an AppArmor profile lets /usr/bin/bwrap (only it) create one.++# 2. cgroup v2 (the unified hierarchy): the unit has Delegate=cpu memory pids, one child cgroup per sandbox.++# 3. Node 22+ at /usr/bin/node (bound read-only into each sandbox for its supervisor) and util-linux script(1).++# 4. Its own system user, openvibe-sandbox, with no home and no login; Codes' user joins its group for the local socket.++# 5. Two secrets as systemd credentials in /etc/openvibe/codes-sandbox/ (root, 0600):++# codes-worker-secret generated here; the same value goes into /etc/openvibe/codes.env as++# CODES_SANDBOX_WORKER_SECRET (then restart openvibe-codes)++# network-client-secret the worker's own Network OAuth client (its service identity, holding the grant++# live.agent_sandbox.register); created by the owner on OpenVibe.Network, not here++# 6. Optional (--with-browser): Playwright's headless Chromium in /opt/openvibe-codes-sandbox/browser.++set -eu++++CODES_DIR=${CODES_DIR:-/opt/openvibe.codes}++CODES_USER=${CODES_USER:-ubuntu}++SANDBOX_USER=openvibe-sandbox++SECRETS=/etc/openvibe/codes-sandbox++ENV_FILE=/etc/openvibe/codes-sandbox.env++BROWSER_DIR=/opt/openvibe-codes-sandbox/browser++PLAYWRIGHT_VERSION=${PLAYWRIGHT_VERSION:-1.62.1}++WITH_BROWSER=0++START=0++for arg in "$@"; do++ case "$arg" in++ --with-browser) WITH_BROWSER=1 ;;++ --start) START=1 ;;++ *) echo "usage: $0 [--with-browser] [--start]" >&2; exit 2 ;;++ esac++done++[ "$(id -u)" -eq 0 ] || { echo "run as root" >&2; exit 1; }++say() { printf '[sandbox-install] %s\n' "$*"; }++here=$(cd "$(dirname "$0")" && pwd)++++# 1. bubblewrap and script(1)++if ! command -v bwrap >/dev/null 2>&1; then say "installing bubblewrap"; apt-get install -y bubblewrap; fi++command -v script >/dev/null 2>&1 || apt-get install -y bsdutils++ver=$(bwrap --version | sed -n 's/.* \([0-9]*\)\.\([0-9]*\)\..*/\1 \2/p')++# shellcheck disable=SC2086 # split "major minor" on purpose++set -- $ver++if [ "${1:-0}" -eq 0 ] && [ "${2:-0}" -lt 8 ]; then echo "bubblewrap $(bwrap --version) is too old: 0.8 or newer" >&2; exit 1; fi++if [ -u "$(command -v bwrap)" ]; then echo "bwrap is setuid here; the worker expects the unprivileged build (NoNewPrivileges=yes)" >&2; exit 1; fi++++# 2. cgroup v2++[ -f /sys/fs/cgroup/cgroup.controllers ] || { echo "no cgroup v2 unified hierarchy: the sandboxes' limits need it" >&2; exit 1; }++++# 3. Node++node_ok=$(/usr/bin/node -e 'process.stdout.write(String(Number(process.versions.node.split(".")[0]) >= 22))' 2>/dev/null || echo false)++[ "$node_ok" = true ] || { echo "/usr/bin/node 22+ is required" >&2; exit 1; }++++# 4. The user, and Codes' user in its group (the local API socket is 0660)++if ! id "$SANDBOX_USER" >/dev/null 2>&1; then++ say "creating the system user $SANDBOX_USER"++ useradd --system --user-group --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin "$SANDBOX_USER"++fi++if id "$CODES_USER" >/dev/null 2>&1; then usermod -aG "$SANDBOX_USER" "$CODES_USER"; fi++++# 1b. Unprivileged user namespaces: allowed, or allowed for bwrap by its own AppArmor profile (never the sysctl)++restrict=$(sysctl -n kernel.apparmor_restrict_unprivileged_userns 2>/dev/null || echo 0)++if [ "$restrict" = 1 ]; then++ say "Ubuntu restricts unprivileged user namespaces: installing the AppArmor profile for /usr/bin/bwrap"++ install -m 0644 "$here/apparmor-bwrap" /etc/apparmor.d/openvibe-codes-sandbox-bwrap++ apparmor_parser -r /etc/apparmor.d/openvibe-codes-sandbox-bwrap++fi++if [ "$(sysctl -n kernel.unprivileged_userns_clone 2>/dev/null || echo 1)" = 0 ]; then++ echo "kernel.unprivileged_userns_clone=0: unprivileged user namespaces are off on this host; the worker cannot run" >&2++ exit 1++fi++if ! runuser -u "$SANDBOX_USER" -- bwrap --unshare-all --unshare-user --disable-userns --ro-bind /usr /usr \++ --symlink usr/bin /bin --symlink usr/lib /lib --symlink usr/lib64 /lib64 --proc /proc --dev /dev -- /usr/bin/true; then++ echo "bubblewrap cannot make a sandbox as $SANDBOX_USER here" >&2++ exit 1++fi++++# 5. Secrets (credentials) and the env file++install -d -m 0700 -o root -g root "$SECRETS"++if [ ! -s "$SECRETS/codes-worker-secret" ]; then++ say "generating the Codes heartbeat secret ($SECRETS/codes-worker-secret)"++ (umask 077; head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$SECRETS/codes-worker-secret")++ say "put the same value in /etc/openvibe/codes.env as CODES_SANDBOX_WORKER_SECRET (sudo cat $SECRETS/codes-worker-secret), then restart openvibe-codes"++fi++if [ ! -s "$SECRETS/network-client-secret" ]; then++ (umask 077; : > "$SECRETS/network-client-secret")++ say "the worker's Network client secret is empty: write it to $SECRETS/network-client-secret and its client id to $ENV_FILE (CODES_SANDBOX_CLIENT_ID); until then it does not check in with Live"++fi++if [ ! -f "$ENV_FILE" ]; then++ install -m 0640 -o root -g "$SANDBOX_USER" "$here/sandbox-worker.env.example" "$ENV_FILE"++ say "wrote $ENV_FILE from the example: set CODES_SANDBOX_WORKER_ID, CODES_SANDBOX_CLIENT_ID and CODES_SANDBOX_LIVE_STREAMS"++fi++++# 6. The browser (optional)++if [ "$WITH_BROWSER" = 1 ]; then++ say "installing Playwright $PLAYWRIGHT_VERSION's headless Chromium into $BROWSER_DIR"++ install -d -m 0755 "$BROWSER_DIR"++ PLAYWRIGHT_BROWSERS_PATH="$BROWSER_DIR" npx --yes "playwright@$PLAYWRIGHT_VERSION" install --with-deps --only-shell chromium++ chmod -R a+rX "$BROWSER_DIR"++fi++++# The unit++install -m 0644 "$here/openvibe-codes-sandbox.service" /etc/systemd/system/openvibe-codes-sandbox.service++systemctl daemon-reload++say "installed openvibe-codes-sandbox.service"++++# The self-test, the way the unit runs it (its user, its delegated cgroup, its hardening): ready or why not.++say "self-test:"++systemd-run --wait --pipe --quiet --uid="$SANDBOX_USER" --gid="$SANDBOX_USER" -p Delegate=yes -p NoNewPrivileges=yes \++ -p ProtectSystem=strict -p ProtectHome=yes -p PrivateTmp=yes -p RuntimeDirectory=openvibe-codes-sandbox-check \++ -E CODES_SANDBOX_LIMITS=cgroup -E PATH=/usr/bin:/bin -E CODES_SANDBOX_RUNTIME_DIR=/run/openvibe-codes-sandbox-check \++ --working-directory="$CODES_DIR" -- /usr/bin/node "$CODES_DIR/bin/openvibe-sandbox-worker.js" --check \++ || { echo "the self-test failed: the worker would report itself not ready (see above)" >&2; exit 1; }++++if [ "$START" = 1 ]; then++ systemctl enable --now openvibe-codes-sandbox.service++ say "started; journalctl -u openvibe-codes-sandbox -f"++else++ say "not started (pass --start, or: systemctl enable --now openvibe-codes-sandbox)"++finnew file mode 100644@@@ -0,0 +1,61 @@++[Unit]++Description=OpenVibe.Codes chat plays sandbox worker (Agent Streams S6): bubblewrap sandboxes for viewers' requests++# The worker checks in with Codes (loopback) and Live; it waits for neither and says "not ready" until its own++# self-test passed. deploy/sandbox/install.sh installs this unit; nothing starts it until the owner does.++After=network-online.target openvibe-codes.service++Wants=network-online.target++++[Service]++Type=simple++# Its own unprivileged user: no login, no home, no other service's files. Codes' user is in its group, for the++# local API socket (0660) in the runtime directory.++User=openvibe-sandbox++Group=openvibe-sandbox++WorkingDirectory=/opt/openvibe.codes++EnvironmentFile=-/etc/openvibe/codes-sandbox.env++Environment=NODE_ENV=production++Environment=PATH=/usr/bin:/bin++# The limits come from this unit's own cgroup subtree: one child cgroup per sandbox with cpu.max, memory.max and++# pids.max (sandbox/limits.js). Without the delegation the worker reports itself not ready.++Environment=CODES_SANDBOX_LIMITS=cgroup++Delegate=cpu memory pids++# Every sandbox of the worker, together: a ceiling over the per-sandbox limits (2 sandboxes of 2 GiB + 3 × 512 MiB).++MemoryMax=10G++TasksMax=2048++ExecStart=/usr/bin/node /opt/openvibe.codes/bin/openvibe-sandbox-worker.js++# A short, private runtime directory: each sandbox's egress socket path must fit in 108 bytes.++RuntimeDirectory=openvibe-codes-sandbox++RuntimeDirectoryMode=0750++# Secrets as systemd credentials, never in the environment (sandbox/worker.js reads $CREDENTIALS_DIRECTORY).++LoadCredential=codes-worker-secret:/etc/openvibe/codes-sandbox/codes-worker-secret++LoadCredential=network-client-secret:/etc/openvibe/codes-sandbox/network-client-secret++Restart=always++RestartSec=5++# Stopping the worker wipes every sandbox: SIGTERM first (the worker wipes them), then the whole cgroup.++KillMode=control-group++KillSignal=SIGTERM++TimeoutStopSec=20++++# Hardening that leaves bubblewrap's unprivileged user namespaces working. The sandboxes inherit this view of the++# machine, so even a bind the worker never makes could not reach /home, /root or the services' files.++NoNewPrivileges=yes++ProtectSystem=strict++ProtectHome=yes++PrivateTmp=yes++PrivateDevices=yes++ProtectKernelModules=yes++ProtectClock=yes++LockPersonality=yes++SystemCallArchitectures=native++RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK++RestrictSUIDSGID=yes++RestrictRealtime=yes++UMask=0077++# Not set on purpose: RestrictNamespaces (bubblewrap needs user, mount, pid, net, ipc, uts and cgroup namespaces),++# ProtectControlGroups (the worker writes its delegated subtree), PrivateUsers (bubblewrap maps its own user), and++# ProtectKernelTunables / ProtectKernelLogs: they mount over parts of /proc, and the kernel then refuses the fresh++# /proc a sandbox's pid namespace needs ("Can't mount proc on /newroot/proc: Operation not permitted"). The sandbox's++# own /proc shows only its own processes, and it has no capability to write /proc/sys.++++[Install]++WantedBy=multi-user.targetnnew file mode 100644@@@ -0,0 +1,36 @@++# /etc/openvibe/codes-sandbox.env — the chat plays sandbox worker (sandbox/worker.js). No secret goes here: the two++# secrets are systemd credentials in /etc/openvibe/codes-sandbox/ (see install.sh).++++# The worker's name in Codes and Live.++CODES_SANDBOX_WORKER_ID=sbw-ovhost++++# Per sandbox: memory (MiB; the tmpfs areas are added on top), each tmpfs area's size (MiB: /workspace, /home/agent,++# /tmp), processes and threads, CPU (100 = one core). Sandboxes at once.++CODES_SANDBOX_MEMORY_MB=2048++CODES_SANDBOX_DISK_MB=512++CODES_SANDBOX_PIDS=256++CODES_SANDBOX_CPU_PERCENT=100++CODES_SANDBOX_MAX_SESSIONS=2++# The longest time box a session may have (the owner sets 5 to 30 minutes).++CODES_SANDBOX_TIME_BOX_MAX_MIN=30++++# Node for the supervisor inside the sandbox (bound read-only), and the headless Chromium install.sh put in place.++CODES_SANDBOX_NODE=/usr/bin/node++#CODES_SANDBOX_BROWSER=/opt/openvibe-codes-sandbox/browser/chromium_headless_shell-NNNN/chrome-headless-shell-linux64/chrome-headless-shell++++# Codes: where the signed heartbeat goes (loopback). The secret is the credential codes-worker-secret, and the same++# value is CODES_SANDBOX_WORKER_SECRET in /etc/openvibe/codes.env.++CODES_SANDBOX_CODES_URL=http://127.0.0.1:4900++++# Live: the check-in (POST /api/streams/agent/:id/plays/worker) with the worker's own Network service token++# (client credentials; audience openvibe.live; capability live.agent_sandbox.register). The client secret is the++# credential network-client-secret. CODES_SANDBOX_LIVE_STREAMS: the agent streams this worker serves (comma list);++# a running session's own stream is added for its lifetime.++CODES_SANDBOX_LIVE_URL=https://openvibe.live++CODES_SANDBOX_NETWORK_URL=https://openvibe.network++CODES_SANDBOX_CLIENT_ID=++CODES_SANDBOX_LIVE_STREAMS=++++# How often: the check-ins (every 30 s) and the self-test (every 10 minutes).++CODES_SANDBOX_ANNOUNCE_MS=30000++CODES_SANDBOX_SELFTEST_MS=600000nnew file mode 100644@@@ -0,0 +1,21 @@++-- phase: expand++-- Agent Streams S6 (design 2026-11-06 §1, §6): the chat plays sandbox workers. A worker (bin/openvibe-sandbox-worker.js)++-- checks in every 30 s with a signed heartbeat (POST /internal/sandbox-workers/heartbeat) carrying what its self-test++-- found: its isolation and every check. Codes reads this to say whether any healthy worker exists++-- (GET /internal/sandbox-workers/health) and, later, to pick one for a chat plays session.++--++-- No secret, path, address or person is stored: a worker's own name, version, the facts it reported and when.++-- Expand-only: a new table nothing older reads. Repeating this file is a no-op.++++CREATE TABLE IF NOT EXISTS codes_sandbox_workers (++ id text COLLATE "C" PRIMARY KEY, -- the worker's own name (sbw-<host>)++ version text NOT NULL DEFAULT '',++ ready boolean NOT NULL DEFAULT false, -- the worker's own verdict++ checks jsonb NOT NULL DEFAULT '{}'::jsonb, -- the self-test, check by check++ isolation jsonb NOT NULL DEFAULT '{}'::jsonb, -- namespaces, egress, credentials, limits++ capacity integer NOT NULL DEFAULT 0,++ active integer NOT NULL DEFAULT 0,++ last_signed_at bigint NOT NULL DEFAULT 0, -- the heartbeat's signed time: a replay is refused++ last_seen_at timestamptz NOT NULL DEFAULT now(),++ created_at timestamptz NOT NULL DEFAULT now()++);nnew file mode 100644@@@ -0,0 +1,110 @@++'use strict';++/**++ * The chat plays sandbox's browser, the worker's side: which headless Chromium the sandbox gets (bound read-only at++ * /opt/browser), and a small DevTools client over the pipe the supervisor relays (sandbox/supervisor.js). The browser++ * itself runs inside the sandbox, on the egress proxy, with a profile in the sandbox's own tmpfs home.++ *++ * findBrowser(env) → the executable's path or null: CODES_SANDBOX_BROWSER, the install script's copy, or Playwright's++ * chrome-headless-shell in this user's cache++ * createCdp(send) → { call(method, params, sessionId), onMessage(text), waitFor(method, sessionId, ms), close() }++ * openPage(cdp, url, { timeoutMs, screenshot }) → { ok, url, title, text, screenshot } | { ok: false, error }++ */++const fs = require('fs');++const os = require('os');++const path = require('path');++++const INSTALLED = '/opt/openvibe-codes-sandbox/browser';++++function newestHeadlessShell(base) {++ let dirs = [];++ try { dirs = fs.readdirSync(base).filter((d) => /^chromium_headless_shell-\d+$/.test(d)); } catch { return null; }++ dirs.sort((a, b) => Number(b.split('-')[1]) - Number(a.split('-')[1]));++ for (const d of dirs) {++ const exe = path.join(base, d, 'chrome-headless-shell-linux64', 'chrome-headless-shell');++ if (fs.existsSync(exe)) return exe;++ }++ return null;++}++++function findBrowser(env = process.env) {++ if (env.CODES_SANDBOX_BROWSER) return fs.existsSync(env.CODES_SANDBOX_BROWSER) ? env.CODES_SANDBOX_BROWSER : null;++ const installed = newestHeadlessShell(INSTALLED);++ if (installed) return installed;++ return newestHeadlessShell(env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), '.cache', 'ms-playwright'));++}++++function createCdp(send) {++ let nextId = 1;++ const pending = new Map();++ const waiters = [];++ let closed = null;++ return {++ call(method, params = {}, sessionId) {++ if (closed) return Promise.reject(new Error(`the browser is gone (${closed})`));++ const id = nextId++;++ send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));++ return new Promise((resolve, reject) => pending.set(id, { resolve, reject, method }));++ },++ waitFor(method, sessionId, ms) {++ return new Promise((resolve) => {++ const w = { method, sessionId, resolve, timer: setTimeout(() => { waiters.splice(waiters.indexOf(w), 1); resolve(null); }, ms) };++ waiters.push(w);++ });++ },++ /** One message from the browser (untrusted: it came out of the sandbox). */++ onMessage(text) {++ let m;++ try { m = JSON.parse(text); } catch { return; }++ if (!m || typeof m !== 'object') return;++ if (typeof m.id === 'number' && pending.has(m.id)) {++ const p = pending.get(m.id);++ pending.delete(m.id);++ if (m.error) p.reject(new Error(`${p.method}: ${String(m.error.message || 'failed').slice(0, 200)}`));++ else p.resolve(m.result || {});++ return;++ }++ if (typeof m.method === 'string') {++ for (const w of [...waiters]) {++ if (w.method === m.method && (!w.sessionId || w.sessionId === m.sessionId)) {++ clearTimeout(w.timer);++ waiters.splice(waiters.indexOf(w), 1);++ w.resolve(m.params || {});++ }++ }++ }++ },++ close(why = 'closed') {++ closed = why;++ for (const p of pending.values()) p.reject(new Error(`the browser is gone (${why})`));++ pending.clear();++ for (const w of waiters.splice(0)) { clearTimeout(w.timer); w.resolve(null); }++ },++ };++}++++/** Open a page in a new tab, wait for it to load, and read its title, text and a screenshot. */++async function openPage(cdp, url, { timeoutMs = 20_000, screenshot = true } = {}) {++ const { targetId } = await cdp.call('Target.createTarget', { url: 'about:blank' });++ try {++ const { sessionId } = await cdp.call('Target.attachToTarget', { targetId, flatten: true });++ await cdp.call('Page.enable', {}, sessionId);++ const loaded = cdp.waitFor('Page.loadEventFired', sessionId, timeoutMs);++ const nav = await cdp.call('Page.navigate', { url: String(url) }, sessionId);++ if (nav.errorText) return { ok: false, url, error: String(nav.errorText).slice(0, 200) };++ await loaded;++ const evaluate = async (expression) => {++ const r = await cdp.call('Runtime.evaluate', { expression, returnByValue: true }, sessionId);++ return r && r.result ? r.result.value : null;++ };++ const title = String((await evaluate('document.title')) || '').slice(0, 500);++ const text = String((await evaluate('document.body ? document.body.innerText : ""')) || '').slice(0, 20_000);++ const location = String((await evaluate('location.href')) || '').slice(0, 2000);++ let shot = null;++ if (screenshot) shot = (await cdp.call('Page.captureScreenshot', { format: 'png' }, sessionId)).data || null;++ return { ok: true, url: location || url, title, text, screenshot: shot };++ } finally {++ await cdp.call('Target.closeTarget', { targetId }).catch(() => {});++ }++}++++module.exports = { findBrowser, createCdp, openPage, INSTALLED };nnew file mode 100644@@@ -0,0 +1,138 @@++'use strict';++/**++ * What the chat plays sandbox is made of: the bubblewrap command line (sandbox/session.js runs it). Everything the++ * sandbox can see is named here, and nothing else of the host is there.++ *++ * namespaces user, pid, network, ipc, uts and cgroup of its own; no nested user namespace (--disable-userns); the++ * network namespace has only its own loopback: no route anywhere, the host's loopback included++ * identity uid/gid 1000 "agent" in a passwd of its own, hostname "sandbox"; every capability dropped, no new++ * privileges, a new session (no terminal to inject keys into), killed with the worker (--die-with-parent)++ * environment cleared (--clearenv) and set from an allowlist: PATH, HOME, the proxy, a few locale and terminal ones++ * files read-only: /usr (with /usr/local hidden), the /bin and /lib links, a short list of /etc entries a++ * program needs (certificates, fonts, the linker cache, editor and shell defaults), Node, the browser and++ * the supervisor (one file). Written by the worker for this sandbox: passwd, group, hosts, hostname,++ * resolv.conf, nsswitch.conf. Never /home, /root, /etc as a whole, /var, /run, /opt, /srv, /mnt, /media,++ * /sys or any device but bwrap's minimal /dev++ * writable size-limited tmpfs only: /workspace, /home/agent, /tmp and /dev/shm. They die with the sandbox: that is++ * the wipe. The root itself is read-only (--remount-ro /)++ * egress /run/ov/net, read-only: the directory of the worker's egress proxy socket (sandbox/egress-proxy.js)++ * seccomp sandbox/seccomp.js, when the architecture has a filter++ */++const fs = require('fs');++const path = require('path');++++const UID = 1000;++const GID = 1000;++const HOSTNAME = 'sandbox';++const HOME = '/home/agent';++const WORKSPACE = '/workspace';++const PROXY_PORT = 3128;++const NET_DIR = '/run/ov/net';++const SUPERVISOR = '/opt/ov/supervisor.js';++++// The /etc entries a program needs and that hold nothing of the host's: certificates, fonts, the dynamic linker's++// cache, editors' and shells' defaults. (/etc/ssl/private, ssh, passwd, shadow, the services' env files: never.)++const ETC_READ_ONLY = [++ 'ssl/certs', 'ssl/openssl.cnf', 'ca-certificates', 'ca-certificates.conf', 'pki/tls/certs', 'pki/ca-trust/extracted',++ 'fonts', 'ld.so.cache', 'ld.so.conf', 'ld.so.conf.d', 'localtime', 'timezone', 'alternatives', 'mime.types', 'magic',++ 'vim', 'vimrc', 'nanorc', 'bash.bashrc', 'inputrc', 'terminfo', 'protocols', 'services',++];++// The /usr-merged links (or directories on an older layout).++const ROOT_LINKS = ['bin', 'sbin', 'lib', 'lib32', 'lib64', 'libx32'];++++/** The files the worker writes for one sandbox (its own user list, host names and no DNS). */++function etcFiles({ shell = '/bin/sh' } = {}) {++ return {++ passwd: `agent:x:${UID}:${GID}:agent:${HOME}:${shell}\nnobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin\n`,++ group: `agent:x:${GID}:\nnogroup:x:65534:\n`,++ hosts: `127.0.0.1\tlocalhost ${HOSTNAME}\n::1\tlocalhost ip6-localhost ip6-loopback\n`,++ hostname: `${HOSTNAME}\n`,++ // No DNS inside: names are resolved by the egress proxy, after which the guard checks the answer.++ 'resolv.conf': '# No DNS in the sandbox: the egress proxy resolves names (http_proxy).\n',++ 'nsswitch.conf': 'passwd: files\ngroup: files\nshadow: files\nhosts: files\nnetworks: files\nprotocols: files\nservices: files\n',++ };++}++++/** The sandbox's whole environment: nothing of the worker's. */++function sandboxEnv({ nodeBin = null, browserExe = null, shell = '/bin/sh' } = {}) {++ const proxy = `http://127.0.0.1:${PROXY_PORT}`;++ return {++ PATH: [nodeBin, '/usr/bin', '/bin', '/usr/sbin', '/sbin'].filter(Boolean).join(':'),++ HOME, USER: 'agent', LOGNAME: 'agent', SHELL: shell, LANG: 'C.UTF-8', TERM: 'xterm-256color', TMPDIR: '/tmp',++ http_proxy: proxy, https_proxy: proxy, HTTP_PROXY: proxy, HTTPS_PROXY: proxy,++ no_proxy: 'localhost,127.0.0.1,::1', NO_PROXY: 'localhost,127.0.0.1,::1',++ npm_config_cache: `${HOME}/.npm`,++ OV_SANDBOX: '1', OV_SANDBOX_PROXY_PORT: String(PROXY_PORT), OV_SANDBOX_EGRESS_SOCKET: `${NET_DIR}/egress.sock`,++ OV_SANDBOX_WORKSPACE: WORKSPACE,++ ...(browserExe ? { OV_SANDBOX_BROWSER: browserExe } : {}),++ };++}++++const lstat = (p) => { try { return fs.lstatSync(p); } catch { return null; } };++++/**++ * The bwrap arguments for one sandbox.++ * spec: { rootfs, sessionEtc, netDir, supervisorPath, nodePath, browserPath, diskMb, shmMb, seccompFd,++ * disableUserns, hideUsrLocal }++ * → { args, env, inside: { node, browser } }++ */++function buildArgs(spec) {++ const rootfs = spec.rootfs || '/';++ const at = (p) => path.join(rootfs, p);++ const disk = Math.max(1, Math.round(Number(spec.diskMb))) * 1024 * 1024;++ const shm = Math.max(1, Math.round(Number(spec.shmMb || 64))) * 1024 * 1024;++ const args = [++ '--unshare-user', '--unshare-ipc', '--unshare-pid', '--unshare-net', '--unshare-uts', '--unshare-cgroup',++ ...(spec.disableUserns !== false ? ['--disable-userns'] : []),++ '--uid', String(UID), '--gid', String(GID), '--hostname', HOSTNAME,++ '--die-with-parent', '--new-session', '--cap-drop', 'ALL', '--clearenv',++ ];++++ // Node and the browser: from /usr as they are, or their own directory bound at /opt/node and /opt/browser.++ const node = fs.realpathSync(spec.nodePath || process.execPath);++ let nodeInside = node;++ let nodeBin = null;++ const binds = [];++ if (!node.startsWith('/usr/')) {++ const prefix = path.dirname(path.dirname(node));++ binds.push(['--ro-bind', prefix, '/opt/node']);++ nodeInside = path.join('/opt/node', path.relative(prefix, node));++ nodeBin = path.dirname(nodeInside);++ }++ let browserInside = null;++ if (spec.browserPath) {++ const exe = fs.realpathSync(spec.browserPath);++ binds.push(['--ro-bind', path.dirname(exe), '/opt/browser']);++ browserInside = path.join('/opt/browser', path.basename(exe));++ }++ const shell = lstat(at('bin/bash')) || lstat(at('usr/bin/bash')) ? '/bin/bash' : '/bin/sh';++ const env = sandboxEnv({ nodeBin, browserExe: browserInside, shell });++ for (const [k, v] of Object.entries(env)) args.push('--setenv', k, v);++++ args.push('--ro-bind', at('usr'), '/usr');++ if (spec.hideUsrLocal !== false && lstat(at('usr/local'))) args.push('--tmpfs', '/usr/local', '--remount-ro', '/usr/local');++ for (const name of ROOT_LINKS) {++ const st = lstat(at(name));++ if (!st) continue;++ if (st.isSymbolicLink()) args.push('--symlink', fs.readlinkSync(at(name)), `/${name}`);++ else if (st.isDirectory()) args.push('--ro-bind', at(name), `/${name}`);++ }++ args.push('--dir', '/etc');++ for (const name of ETC_READ_ONLY) if (lstat(at(`etc/${name}`))) args.push('--ro-bind', at(`etc/${name}`), `/etc/${name}`);++ for (const name of Object.keys(etcFiles())) args.push('--ro-bind', path.join(spec.sessionEtc, name), `/etc/${name}`);++++ args.push('--proc', '/proc', '--dev', '/dev');++ args.push('--perms', '1777', '--size', String(shm), '--tmpfs', '/dev/shm');++ args.push('--perms', '1777', '--size', String(disk), '--tmpfs', '/tmp');++ args.push('--dir', '/home', '--perms', '0700', '--size', String(disk), '--tmpfs', HOME);++ args.push('--perms', '0755', '--size', String(disk), '--tmpfs', WORKSPACE);++ args.push('--ro-bind', spec.netDir, NET_DIR);++ args.push('--ro-bind', spec.supervisorPath, SUPERVISOR);++ for (const b of binds) args.push(...b);++ args.push('--remount-ro', '/', '--chdir', WORKSPACE);++ if (spec.seccompFd != null) args.push('--seccomp', String(spec.seccompFd));++ args.push('--', nodeInside, SUPERVISOR);++ return { args, env, shell, inside: { node: nodeInside, browser: browserInside } };++}++++module.exports = { buildArgs, etcFiles, sandboxEnv, ETC_READ_ONLY, UID, GID, HOME, WORKSPACE, PROXY_PORT, NET_DIR, SUPERVISOR };nnew file mode 100644@@@ -0,0 +1,219 @@++'use strict';++/**++ * The chat plays sandbox's only way out (Agent Streams S6, design 2026-11-06 §1): an HTTP forward proxy that runs in++ * the worker, OUTSIDE the sandbox, on a Unix socket in a directory the sandbox sees read-only. The sandbox has no++ * network of its own (its own network namespace, a loopback and nothing else), so every byte that leaves it passes++ * here, and here it meets the egress guard (openvibe-shared/egress):++ *++ * - public http and https only: an absolute http:// request, or a CONNECT tunnel (https, and ws/wss the way browsers++ * send them), to port 80 or 443. No SMTP (25, 587, 465), no SSH, no database, no other port at all;++ * - never a private, loopback, link-local (cloud metadata), carrier-grade NAT, reserved or documentation address,++ * in any spelling, nor a name that can only mean a local network (localhost, *.local, *.internal, a bare word);++ * - never one of the host's own addresses, even a public one: the worker's machine is not "the web";++ * - checked AFTER DNS resolution: every answer must pass (one private answer among public ones refuses the name), and++ * the socket opens to the very address that was checked, so a second lookup cannot rebind it;++ * - checked on every redirect: the proxy never follows one. A 3xx goes back to the client as it came, and the++ * client's next request is a new request, checked again.++ *++ * A refusal is a 403 with `X-OV-Egress: refused; code=egress.…` and no socket is opened for it. Every decision (host,++ * port, address, allowed or the refusal's code — never a path, a header or a body) goes to onDecision for the++ * owner's log.++ *++ * const proxy = createEgressProxy({ lookup, dial, hostAddresses, ports, maxConnections, onDecision });++ * await proxy.listen('/run/…/net/egress.sock') or proxy.listen({ port: 0, host: '127.0.0.1' }) (tests)++ * await proxy.check(host, port) → { ok, host, port, address, family } | { ok: false, code, reason }++ * proxy.address(), await proxy.close()++ *++ * lookup (dns.lookup-shaped) and dial ({ host: address, port } → net.Socket) are injectable for the tests; the worker++ * uses the system resolver and net.connect.++ */++const dns = require('dns');++const fs = require('fs');++const http = require('http');++const net = require('net');++const os = require('os');++const egress = require('openvibe-shared/egress');++++const PORTS = Object.freeze([80, 443]);++// Hop-by-hop headers (RFC 9110 §7.6.1) and the proxy's own: never forwarded.++const HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'proxy-connection', 'te', 'trailer', 'upgrade']);++++/** The machine's own addresses (every interface), as the guard compares them. */++function defaultHostAddresses() {++ const out = new Set();++ for (const list of Object.values(os.networkInterfaces())) for (const a of list || []) out.add(a.address.split('%')[0].toLowerCase());++ return out;++}++++const defaultDial = ({ host, port }) => net.connect({ host, port });++++function createEgressProxy({++ lookup = dns.lookup, dial = defaultDial, hostAddresses = defaultHostAddresses, ports = PORTS,++ maxConnections = 64, connectTimeoutMs = 10_000, idleMs = 120_000, lookupTimeoutMs = 5_000,++ onDecision = () => {}, log = console,++} = {}) {++ const allowedPorts = new Set(ports.map(Number));++ const sockets = new Set();++ let server = null;++++ const refuse = (host, port, code, reason) => ({ ok: false, host, port, code, reason });++++ function resolveAll(host) {++ return new Promise((resolve) => {++ let settled = false;++ const done = (v) => { if (!settled) { settled = true; clearTimeout(timer); resolve(v); } };++ const timer = setTimeout(() => done({ error: 'ETIMEOUT' }), lookupTimeoutMs);++ try {++ lookup(host, { all: true, verbatim: true }, (err, addrs) => done(err ? { error: err.code || 'EDNS' } : { addrs: Array.isArray(addrs) ? addrs : [] }));++ } catch (err) { done({ error: err.code || 'EDNS' }); }++ });++ }++++ /** The egress guard for one host and port: where the socket may go, or why not. */++ async function check(rawHost, rawPort) {++ const port = Number(rawPort);++ const host = egress.normalizeHost(rawHost);++ if (!Number.isInteger(port) || !allowedPorts.has(port)) return refuse(host, rawPort, 'egress.port', `only ports ${[...allowedPorts].join(' and ')} leave the sandbox`);++ if (!host || host.length > 253 || /[\s/\\@]/.test(host)) return refuse(host, port, 'egress.bad_request', 'not a host name');++ let addrs;++ if (net.isIP(host)) addrs = [{ address: host, family: net.isIP(host) }];++ else {++ if (egress.isInternalName(host)) return refuse(host, port, 'egress.internal_name', `${host} can only name a local network`);++ const r = await resolveAll(host);++ if (r.error) return refuse(host, port, 'egress.dns', `${host} does not resolve`);++ addrs = r.addrs.filter((a) => a && net.isIP(String(a.address)));++ if (!addrs.length) return refuse(host, port, 'egress.dns', `${host} has no address`);++ }++ const mine = hostAddresses();++ for (const a of addrs) {++ const address = String(a.address).split('%')[0].toLowerCase();++ if (!egress.isPublicAddress(address)) return refuse(host, port, 'egress.private_address', `${host} is not a public address`);++ const v4 = net.isIPv6(address) ? egress.embeddedV4(address) : null;++ if (mine.has(address) || (v4 && mine.has(v4))) return refuse(host, port, 'egress.host_address', `${host} is this machine`);++ }++ const first = addrs[0];++ return { ok: true, host, port, address: String(first.address).split('%')[0], family: net.isIP(String(first.address).split('%')[0]) };++ }++++ function report(method, d) {++ try {++ onDecision({ at: new Date().toISOString(), method, allowed: d.ok === true, host: d.host, port: d.port, ...(d.ok ? { address: d.address } : { code: d.code }) });++ } catch { /* a logger never breaks the proxy */ }++ }++++ const refusalHead = (status, code, text) => {++ const body = `${text}\n`;++ return `HTTP/1.1 ${status} ${http.STATUS_CODES[status]}\r\nX-OV-Egress: refused; code=${code}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`;++ };++++ function deny(res, status, code, text) {++ if (res.headersSent) return res.destroy();++ res.writeHead(status, { 'x-ov-egress': `refused; code=${code}`, 'content-type': 'text/plain; charset=utf-8', connection: 'close' });++ res.end(`${text}\n`);++ }++++ async function onRequest(req, res) {++ if (req.socket.ovRefused) return;++ let url;++ try { url = new URL(req.url); } catch { return deny(res, 400, 'egress.bad_request', 'This is a forward proxy: send an absolute http:// URL, or CONNECT for https'); }++ if (url.protocol === 'https:') return deny(res, 400, 'egress.use_connect', 'Use CONNECT for https');++ if (url.protocol !== 'http:') return deny(res, 403, 'egress.scheme', 'Only http and https leave the sandbox');++ if (url.username || url.password) return deny(res, 403, 'egress.credentials', 'No credentials in a URL');++ const d = await check(url.hostname, url.port ? Number(url.port) : 80);++ report(req.method, d);++ if (!d.ok) return deny(res, 403, d.code, `Refused: ${d.reason}`);++++ const headers = {};++ for (let i = 0; i < req.rawHeaders.length; i += 2) {++ const k = req.rawHeaders[i];++ if (HOP.has(k.toLowerCase()) || k.toLowerCase() === 'host') continue;++ headers[k] = headers[k] === undefined ? req.rawHeaders[i + 1] : [].concat(headers[k], req.rawHeaders[i + 1]);++ }++ headers.Host = url.host; // the request target decides where it goes; Host follows it, never the other way++ headers.Connection = 'close';++ // No `agent` key: with agent: false Node makes a fresh Agent and IGNORES createConnection (it would dial++ // localhost:80). host and port name the checked address too, so even then nothing else is dialled.++ const upstream = http.request({++ host: d.address, port: d.port, method: req.method, path: url.pathname + url.search, headers, timeout: connectTimeoutMs,++ createConnection: () => dial({ host: d.address, port: d.port }),++ });++ upstream.on('response', (up) => {++ const out = [];++ for (let i = 0; i < up.rawHeaders.length; i += 2) if (!HOP.has(up.rawHeaders[i].toLowerCase())) out.push(up.rawHeaders[i], up.rawHeaders[i + 1]);++ res.writeHead(up.statusCode, up.statusMessage, out);++ up.pipe(res);++ });++ upstream.on('timeout', () => upstream.destroy(Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' })));++ upstream.on('error', () => deny(res, 502, 'egress.upstream', 'The site did not answer'));++ req.pipe(upstream);++ }++++ async function onConnect(req, socket, head) {++ socket.on('error', () => {});++ if (socket.ovRefused) return;++ const m = /^\[([0-9a-fA-F:.]+)\]:(\d{1,5})$/.exec(req.url) || /^([^\s:[\]/@]+):(\d{1,5})$/.exec(req.url);++ if (!m) return socket.end(refusalHead(400, 'egress.bad_request', 'CONNECT needs host:port'));++ const d = await check(m[1], Number(m[2]));++ report('CONNECT', d);++ if (!d.ok) return socket.end(refusalHead(403, d.code, `Refused: ${d.reason}`));++ const up = dial({ host: d.address, port: d.port });++ const timer = setTimeout(() => up.destroy(), connectTimeoutMs);++ up.once('connect', () => {++ clearTimeout(timer);++ socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');++ if (head && head.length) up.write(head);++ up.pipe(socket);++ socket.pipe(up);++ });++ up.on('error', () => { clearTimeout(timer); if (!socket.destroyed) socket.end(refusalHead(502, 'egress.upstream', 'The site did not answer')); });++ socket.on('close', () => up.destroy());++ up.on('close', () => socket.destroy());++ }++++ function onConnection(sock) {++ sock.on('error', () => {});++ if (sockets.size >= maxConnections) {++ sock.ovRefused = true;++ sock.end(refusalHead(503, 'egress.busy', 'Too many connections'));++ return;++ }++ sockets.add(sock);++ sock.setTimeout(idleMs, () => sock.destroy());++ sock.on('close', () => sockets.delete(sock));++ }++++ function build() {++ const s = http.createServer({ requestTimeout: 0, headersTimeout: 30_000, maxHeaderSize: 32 * 1024 });++ s.on('connection', onConnection);++ s.on('request', (req, res) => { onRequest(req, res).catch((e) => { log.error('[sandbox] egress proxy:', e && e.message); deny(res, 500, 'egress.error', 'Proxy error'); }); });++ s.on('connect', (req, socket, head) => { onConnect(req, socket, head).catch(() => socket.destroy()); });++ s.on('upgrade', (req, socket) => { socket.on('error', () => {}); socket.end(refusalHead(400, 'egress.use_connect', 'Use CONNECT for WebSockets')); });++ s.on('clientError', (err, socket) => { if (socket.writable && !socket.ovRefused) socket.end(refusalHead(400, 'egress.bad_request', 'Not an HTTP request')); else socket.destroy(); });++ return s;++ }++++ return {++ check,++ async listen(target) {++ server = build();++ if (typeof target === 'string') {++ try { fs.unlinkSync(target); } catch { /* none yet */ }++ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(target, resolve); });++ fs.chmodSync(target, 0o600);++ } else {++ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(target.port || 0, target.host || '127.0.0.1', resolve); });++ }++ return this;++ },++ address: () => server && server.address(),++ connections: () => sockets.size,++ async close() {++ if (!server) return;++ for (const s of sockets) s.destroy();++ await new Promise((resolve) => server.close(() => resolve()));++ server = null;++ },++ };++}++++module.exports = { createEgressProxy, PORTS, defaultHostAddresses };nnew file mode 100644@@@ -0,0 +1,188 @@++'use strict';++/**++ * The chat plays sandbox's limits: CPU, memory and process count come from a cgroup v2 of the sandbox's own (rlimits++ * cannot do it: in a user namespace RLIMIT_NPROC still counts every process of the host user, and RLIMIT_AS breaks a++ * browser). Disk is the size of the sandbox's tmpfs areas (sandbox/bwrap.js), charged to that cgroup's memory too;++ * the wall clock is the session's time box (sandbox/session.js).++ *++ * Two ways to get the cgroup, both unprivileged:++ *++ * cgroup the worker's systemd unit has Delegate=yes (deploy/sandbox/openvibe-codes-sandbox.service): the++ * worker owns its cgroup subtree. It moves itself into <unit>/worker, turns on the cpu, memory and pids++ * controllers for the children, and makes <unit>/sbx-<id> per sandbox. The sandbox's first process++ * writes itself into it before it executes bubblewrap, so nothing of the sandbox ever runs outside it.++ * systemd-user a workstation: `systemd-run --user --scope` makes a transient scope with MemoryMax, TasksMax and++ * CPUQuota per sandbox (the user's systemd delegates cpu, memory and pids).++ *++ * detectLimits({ mode: 'auto' | 'cgroup' | 'systemd-user' | 'none' }) → limiter { kind, ok, reason, prepare }++ * limiter.prepare(id, { memoryMb, diskMb, pids, cpuPercent }) → handle++ * handle.wrap(argv, env) → { argv, env } the command that starts the sandbox inside its cgroup++ * handle.attach(pid) learns the cgroup's path once the sandbox runs (systemd-user)++ * handle.path the cgroup's directory (null until known)++ * handle.kill() SIGKILL to everything in the cgroup (cgroup.kill)++ * await handle.remove() waits for it to be empty and removes it++ *++ * The memory limit is memory + the three tmpfs areas (workspace, home, /tmp): tmpfs pages are charged to the cgroup that++ * wrote them, so a full disk must not count against the processes' own memory.++ */++const fs = require('fs');++const path = require('path');++const { spawnSync } = require('child_process');++++const CONTROLLERS = ['cpu', 'memory', 'pids'];++const CPU_PERIOD = 100_000;++const MB = 1024 * 1024;++++const read = (f) => { try { return fs.readFileSync(f, 'utf8').trim(); } catch { return ''; } };++const write = (f, v) => fs.writeFileSync(f, String(v));++++/** The memory.max, pids.max and cpu.max values for a sandbox's limits. */++function values({ memoryMb, diskMb, pids, cpuPercent }) {++ const memory = Math.round((Number(memoryMb) + 3 * Number(diskMb)) * MB);++ const quota = Math.max(1000, Math.round(Number(cpuPercent) * CPU_PERIOD / 100));++ return { memory, pids: Math.max(1, Math.round(Number(pids))), cpu: `${quota} ${CPU_PERIOD}`, cpuPercent: Number(cpuPercent) };++}++++/** This process's own cgroup v2 path (the unified hierarchy's line in /proc/self/cgroup). */++function ownCgroup(root) {++ const line = read('/proc/self/cgroup').split('\n').find((l) => l.startsWith('0::'));++ return line ? path.join(root, line.slice(3)) : null;++}++++const missingControllers = (dir) => {++ const have = read(path.join(dir, 'cgroup.controllers')).split(/\s+/);++ return CONTROLLERS.filter((c) => !have.includes(c));++};++++async function waitEmpty(dir, ms = 5000) {++ const until = Date.now() + ms;++ while (Date.now() < until) {++ const events = read(path.join(dir, 'cgroup.events'));++ if (!events || /populated 0/.test(events)) return true;++ await new Promise((r) => setTimeout(r, 25));++ }++ return false;++}++++function killCgroup(dir) {++ if (!dir) return;++ try { write(path.join(dir, 'cgroup.kill'), '1'); return; } catch { /* before Linux 5.14: one by one */ }++ for (const pid of read(path.join(dir, 'cgroup.procs')).split('\n').filter(Boolean)) {++ try { process.kill(Number(pid), 'SIGKILL'); } catch { /* gone */ }++ }++}++++/** The delegated subtree of the worker's systemd unit (Delegate=yes). */++function tryDelegated(root) {++ let own = ownCgroup(root);++ if (!own) return { ok: false, reason: 'no cgroup v2 (unified hierarchy) for this process' };++ // A worker that already moved itself (a second detect in the same process) sits in <base>/worker.++ const base = path.basename(own) === 'worker' && fs.existsSync(path.join(path.dirname(own), 'cgroup.subtree_control')) ? path.dirname(own) : own;++ const missing = missingControllers(base);++ if (missing.length) return { ok: false, reason: `the worker's cgroup has no ${missing.join(', ')} controller (its systemd unit needs Delegate=yes)` };++ try {++ fs.accessSync(base, fs.constants.W_OK);++ fs.accessSync(path.join(base, 'cgroup.procs'), fs.constants.W_OK);++ fs.accessSync(path.join(base, 'cgroup.subtree_control'), fs.constants.W_OK);++ } catch {++ return { ok: false, reason: `the worker may not write its cgroup ${base} (its systemd unit needs Delegate=yes)` };++ }++ const others = read(path.join(base, 'cgroup.procs')).split('\n').filter(Boolean).map(Number).filter((p) => p !== process.pid);++ if (others.length) return { ok: false, reason: `the worker's cgroup ${base} holds other processes: it needs one of its own (Delegate=yes)` };++ const leaf = path.join(base, 'worker');++ try {++ if (own !== leaf) {++ fs.mkdirSync(leaf, { recursive: true });++ write(path.join(leaf, 'cgroup.procs'), process.pid);++ own = leaf;++ }++ const enabled = read(path.join(base, 'cgroup.subtree_control')).split(/\s+/);++ const todo = CONTROLLERS.filter((c) => !enabled.includes(c));++ if (todo.length) write(path.join(base, 'cgroup.subtree_control'), todo.map((c) => `+${c}`).join(' '));++ } catch (err) {++ return { ok: false, reason: `the worker could not set up its cgroup subtree: ${err.code || err.message}` };++ }++ return {++ kind: 'cgroup', ok: true, base,++ prepare(id, limits) {++ const v = values(limits);++ const dir = path.join(base, `sbx-${id}`);++ fs.mkdirSync(dir);++ write(path.join(dir, 'memory.max'), v.memory);++ try { write(path.join(dir, 'memory.swap.max'), 0); } catch { /* no swap accounting: no swap to limit */ }++ write(path.join(dir, 'pids.max'), v.pids);++ write(path.join(dir, 'cpu.max'), v.cpu);++ // An out-of-memory kills the process that asked, not the whole sandbox (the agent sees it and goes on).++ try { write(path.join(dir, 'memory.oom.group'), 0); } catch { /* older kernels: the default is 0 */ }++ return {++ path: dir,++ // The first process moves itself (`echo 0` is "the writer") into the cgroup, then becomes bubblewrap.++ wrap: (argv, env) => ({ argv: ['/bin/sh', '-c', 'echo 0 > "$0" && exec "$@"', path.join(dir, 'cgroup.procs'), ...argv], env }),++ attach() {},++ kill: () => killCgroup(dir),++ async remove() {++ if (!(await waitEmpty(dir))) { killCgroup(dir); await waitEmpty(dir); }++ try { fs.rmdirSync(dir); } catch (err) { if (err.code !== 'ENOENT') throw err; }++ },++ };++ },++ };++}++++/** A transient scope of the user's systemd per sandbox (a workstation; the worker's own unit uses the delegated way). */++function trySystemdUser(root) {++ const runtime = process.env.XDG_RUNTIME_DIR;++ if (!runtime || !fs.existsSync(path.join(runtime, 'systemd'))) return { ok: false, reason: 'no user systemd (XDG_RUNTIME_DIR/systemd)' };++ const uid = process.getuid();++ const manager = path.join(root, `user.slice/user-${uid}.slice/user@${uid}.service`);++ const missing = missingControllers(manager);++ if (!fs.existsSync(manager) || missing.length) return { ok: false, reason: `the user's systemd does not delegate ${missing.join(', ') || 'a cgroup'}` };++ const env = { PATH: '/usr/bin:/bin', XDG_RUNTIME_DIR: runtime, ...(process.env.DBUS_SESSION_BUS_ADDRESS ? { DBUS_SESSION_BUS_ADDRESS: process.env.DBUS_SESSION_BUS_ADDRESS } : {}) };++ const probe = spawnSync('systemd-run', ['--user', '--scope', '--quiet', '--collect', '-p', 'TasksMax=4', '--', '/bin/true'], { env, timeout: 10_000, encoding: 'utf8' });++ if (probe.error || probe.status !== 0) return { ok: false, reason: `systemd-run --user --scope failed: ${(probe.stderr || (probe.error && probe.error.code) || '').trim().slice(0, 200)}` };++ return {++ kind: 'systemd-user', ok: true,++ prepare(id, limits) {++ const v = values(limits);++ const unit = `ovsbx-${id}`;++ const handle = {++ path: null, unit,++ wrap: (argv, baseEnv) => ({++ argv: ['systemd-run', '--user', '--scope', '--quiet', '--collect', '--unit', unit,++ '-p', `MemoryMax=${v.memory}`, '-p', 'MemorySwapMax=0', '-p', `TasksMax=${v.pids}`, '-p', `CPUQuota=${v.cpuPercent}%`, '-p', 'OOMPolicy=continue', '--', ...argv],++ env: { ...baseEnv, ...env },++ }),++ // systemd-run --scope executes the command in place: the sandbox's first process is in the scope.++ attach(pid) {++ const line = read(`/proc/${pid}/cgroup`).split('\n').find((l) => l.startsWith('0::'));++ if (line && line.includes(unit)) handle.path = path.join(root, line.slice(3));++ },++ kill() {++ if (handle.path) return killCgroup(handle.path);++ spawnSync('systemctl', ['--user', 'kill', '--signal=SIGKILL', `${unit}.scope`], { env, timeout: 5000 });++ },++ async remove() {++ if (!handle.path) return;++ if (!(await waitEmpty(handle.path))) { handle.kill(); await waitEmpty(handle.path); }++ // systemd collects the scope once it is empty (--collect).++ const until = Date.now() + 5000;++ while (fs.existsSync(handle.path) && Date.now() < until) await new Promise((r) => setTimeout(r, 25));++ },++ };++ return handle;++ },++ };++}++++async function detectLimits({ mode = 'auto', cgroupRoot = '/sys/fs/cgroup' } = {}) {++ if (mode === 'none') return { kind: 'none', ok: false, reason: 'limits are turned off (CODES_SANDBOX_LIMITS=none)' };++ if (mode === 'cgroup') return { kind: 'cgroup', ...tryDelegated(cgroupRoot) };++ if (mode === 'systemd-user') return { kind: 'systemd-user', ...trySystemdUser(cgroupRoot) };++ const delegated = tryDelegated(cgroupRoot);++ if (delegated.ok) return delegated;++ const user = trySystemdUser(cgroupRoot);++ if (user.ok) return user;++ return { kind: 'none', ok: false, reason: `${delegated.reason}; ${user.reason}` };++}++++module.exports = { detectLimits, values, CONTROLLERS };nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++/**++ * The chat plays sandbox's seccomp filter: a classic BPF program bubblewrap loads (`--seccomp FD`) just before it++ * executes the sandbox's first process. Everything the sandbox runs inherits it and cannot remove it (no_new_privs).++ *++ * It is a deny list on top of the namespaces, not the boundary itself: the calls a build-and-browse agent never needs++ * and that are the kernel's usual attack surface or a way to reach something outside, answered EPERM:++ *++ * the kernel keyring (keyctl, add_key, request_key: a login's keys live there), ptrace and process_vm_* (reading++ * another process), namespaces and mounts (unshare, setns, mount, the new mount API, pivot_root), bpf, perf_event_open,++ * userfaultfd, io_uring, kernel modules and kexec, file handles (open_by_handle_at), syslog, swap, reboot, acct,++ * quotactl, and on x86-64 iopl/ioperm.++ *++ * Any other architecture's calls (i386 or x32 on an x86-64 kernel, which have their own numbers) kill the process: a++ * filter keyed on x86-64 numbers must not be bypassed by changing ABI.++ *++ * compile(arch = process.arch) → Buffer (struct sock_filter[]) or null when the architecture is not supported++ */++++// The numbers (asm/unistd_64.h on x86-64; asm-generic/unistd.h on arm64).++const SYSCALLS = {++ x64: {++ ptrace: 101, syslog: 103, vhangup: 153, pivot_root: 155, acct: 163, mount: 165, umount2: 166, swapon: 167, swapoff: 168,++ reboot: 169, iopl: 172, ioperm: 173, init_module: 175, delete_module: 176, quotactl: 179, lookup_dcookie: 212,++ kexec_load: 246, add_key: 248, request_key: 249, keyctl: 250, unshare: 272, perf_event_open: 298,++ name_to_handle_at: 303, open_by_handle_at: 304, setns: 308, process_vm_readv: 310, process_vm_writev: 311,++ finit_module: 313, kexec_file_load: 320, bpf: 321, userfaultfd: 323, io_uring_setup: 425, io_uring_enter: 426,++ io_uring_register: 427, open_tree: 428, move_mount: 429, fsopen: 430, fsconfig: 431, fsmount: 432, fspick: 433,++ mount_setattr: 442,++ },++ arm64: {++ lookup_dcookie: 18, umount2: 39, mount: 40, pivot_root: 41, vhangup: 58, quotactl: 60, acct: 89, unshare: 97,++ kexec_load: 104, init_module: 105, delete_module: 106, syslog: 116, ptrace: 117, reboot: 142, add_key: 217,++ request_key: 218, keyctl: 219, swapon: 224, swapoff: 225, perf_event_open: 241, open_by_handle_at: 265,++ name_to_handle_at: 264, setns: 268, process_vm_readv: 270, process_vm_writev: 271, finit_module: 273, bpf: 280,++ userfaultfd: 282, kexec_file_load: 294, io_uring_setup: 425, io_uring_enter: 426, io_uring_register: 427,++ open_tree: 428, move_mount: 429, fsopen: 430, fsconfig: 431, fsmount: 432, fspick: 433, mount_setattr: 442,++ },++};++const AUDIT_ARCH = { x64: 0xc000003e, arm64: 0xc00000b7 };++const X32_SYSCALL_BIT = 0x40000000;++++// Classic BPF (linux/filter.h, linux/seccomp.h).++const LD_W_ABS = 0x20; // BPF_LD | BPF_W | BPF_ABS++const JEQ_K = 0x15; // BPF_JMP | BPF_JEQ | BPF_K++const JGE_K = 0x35; // BPF_JMP | BPF_JGE | BPF_K++const RET_K = 0x06; // BPF_RET | BPF_K++const RET_ALLOW = 0x7fff0000;++const RET_KILL_PROCESS = 0x80000000;++const RET_ERRNO_EPERM = 0x00050000 | 1;++const OFF_NR = 0;++const OFF_ARCH = 4;++++function insn(code, jt, jf, k) {++ const b = Buffer.alloc(8);++ b.writeUInt16LE(code, 0);++ b.writeUInt8(jt, 2);++ b.writeUInt8(jf, 3);++ b.writeUInt32LE(k >>> 0, 4);++ return b;++}++++/** The denied calls of an architecture, by name. */++function denied(arch = process.arch) {++ return SYSCALLS[arch] ? Object.keys(SYSCALLS[arch]).sort() : [];++}++++/** The filter for an architecture, or null when there is none for it (the sandbox then runs without one, and says so). */++function compile(arch = process.arch) {++ const table = SYSCALLS[arch];++ if (!table) return null;++ const prog = [++ insn(LD_W_ABS, 0, 0, OFF_ARCH),++ insn(JEQ_K, 1, 0, AUDIT_ARCH[arch]),++ insn(RET_K, 0, 0, RET_KILL_PROCESS),++ insn(LD_W_ABS, 0, 0, OFF_NR),++ ];++ if (arch === 'x64') prog.push(insn(JGE_K, 0, 1, X32_SYSCALL_BIT), insn(RET_K, 0, 0, RET_ERRNO_EPERM));++ for (const nr of [...new Set(Object.values(table))].sort((a, b) => a - b)) {++ prog.push(insn(JEQ_K, 0, 1, nr), insn(RET_K, 0, 0, RET_ERRNO_EPERM));++ }++ prog.push(insn(RET_K, 0, 0, RET_ALLOW));++ return Buffer.concat(prog);++}++++module.exports = { compile, denied, SYSCALLS, AUDIT_ARCH };nnew file mode 100644@@@ -0,0 +1,90 @@++'use strict';++/**++ * The sandbox worker's self-test: before it says it is healthy, and every few minutes after, the worker makes a real++ * sandbox and tries from inside what the escape tests try (test/sandbox-escape.test.js, in short). What it finds is++ * what it reports to Codes and Live as its isolation; it is healthy only when every check passed.++ *++ * network_namespace the only interface is the sandbox's own loopback++ * no_direct_egress a TCP connection to a public address fails (no route): nothing leaves but by the proxy++ * egress_refuses_private through the proxy, the host's 127.0.0.1 and the cloud metadata address are refused (403)++ * clean_env the environment is the sandbox's own allowlist, nothing of the worker's++ * no_host_files the worker's home, its credentials, its runtime directory and /etc/openvibe are not there++ * no_capabilities no capability, no_new_privs set++ * seccomp the filter is on (where the architecture has one)++ * limits the sandbox's cgroup has the memory, pids and cpu limits the worker set++ * wipe after the wipe, no process of it is left and its directory and cgroup are gone++ *++ * No check needs the internet: the refusals are the proxy's own, before any connection.++ *++ * runSelfTest({ createSandbox, limiter, runtimeDir, nodePath, hidden, egress, log }) → { ok, checks, failed, at, isolation }++ */++const fs = require('fs');++const os = require('os');++const path = require('path');++++const ENV_ALLOWED = /^(PATH|HOME|USER|LOGNAME|SHELL|LANG|LC_ALL|TERM|TMPDIR|PWD|SHLVL|_|http_proxy|https_proxy|HTTP_PROXY|HTTPS_PROXY|no_proxy|NO_PROXY|npm_config_cache|OV_SANDBOX(_[A-Z_]+)?)$/;++const CHECKS = ['network_namespace', 'no_direct_egress', 'egress_refuses_private', 'clean_env', 'no_host_files', 'no_capabilities', 'seccomp', 'limits', 'wipe'];++++const PROBE = String.raw`'use strict';++const net = require('net'); const fs = require('fs');++const hidden = JSON.parse(process.argv[2] || '[]');++const PROXY = Number(process.env.OV_SANDBOX_PROXY_PORT || 3128);++const tcp = (host, port) => new Promise((r) => { const s = net.connect({ host, port }); const t = setTimeout(() => { s.destroy(); r('TIMEOUT'); }, 3000); s.on('connect', () => { clearTimeout(t); s.destroy(); r('CONNECTED'); }); s.on('error', (e) => { clearTimeout(t); r(e.code); }); });++const viaProxy = (url) => new Promise((r) => { const s = net.connect(PROXY, '127.0.0.1'); let b = ''; const t = setTimeout(() => { s.destroy(); r(b); }, 4000); s.on('connect', () => s.write('GET ' + url + ' HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n')); s.on('data', (d) => { b += d; }); s.on('close', () => { clearTimeout(t); r(b); }); s.on('error', () => {}); });++(async () => {++ const dev = fs.readFileSync('/proc/net/dev', 'utf8').split('\n').slice(2).map((l) => l.split(':')[0].trim()).filter(Boolean);++ const status = Object.fromEntries(fs.readFileSync('/proc/self/status', 'utf8').split('\n').map((l) => l.split(':\t')).filter((x) => x.length === 2));++ const out = {++ dev, direct: await tcp('1.1.1.1', 443),++ loop: (await viaProxy('http://127.0.0.1:9/')).split('\r\n').slice(0, 2).join('|'),++ meta: (await viaProxy('http://169.254.169.254/latest/meta-data/')).split('\r\n').slice(0, 2).join('|'),++ env: Object.keys(process.env), visible: hidden.filter((p) => { try { fs.lstatSync(p); return true; } catch { return false; } }),++ caps: ['CapInh', 'CapPrm', 'CapEff', 'CapBnd', 'CapAmb'].map((c) => status[c]), nnp: status.NoNewPrivs, seccomp: status.Seccomp,++ };++ process.stdout.write(JSON.stringify(out), () => process.exit(0));++})();++`;++++const refused = (head) => /^HTTP\/1\.1 403 /.test(head) && /X-OV-Egress: refused/i.test(head);++++async function runSelfTest({ createSandbox, limiter, runtimeDir, nodePath, hidden = [], egress = {}, seccompExpected = true, log = console }) {++ const checks = Object.fromEntries(CHECKS.map((c) => [c, false]));++ const at = new Date().toISOString();++ let sbx = null;++ let error = null;++ try {++ sbx = await createSandbox({++ runtimeDir, limiter: limiter && limiter.ok ? limiter : null, allowUnlimited: !(limiter && limiter.ok), egress, nodePath,++ limits: { memoryMb: 256, diskMb: 16, pids: 64, cpuPercent: 50 }, timeBoxMs: 60_000, id: `selftest-${Date.now().toString(36)}`, log,++ });++ const paths = [...new Set([os.homedir(), path.join(os.homedir(), '.ssh'), runtimeDir, sbx.dir, '/etc/openvibe', '/root', ...hidden].filter(Boolean))];++ await sbx.writeFile('/workspace/.selftest.js', PROBE);++ const r = await sbx.exec(['node', '/workspace/.selftest.js', JSON.stringify(paths)], { timeoutMs: 30_000 });++ if (r.code !== 0) throw new Error(`the probe failed (${r.code}/${r.signal}): ${r.stderr.slice(0, 200)}`);++ const p = JSON.parse(r.stdout);++ checks.network_namespace = Array.isArray(p.dev) && p.dev.length === 1 && p.dev[0] === 'lo';++ checks.no_direct_egress = ['ENETUNREACH', 'EHOSTUNREACH'].includes(p.direct);++ checks.egress_refuses_private = refused(String(p.loop).replace('|', '\r\n')) && refused(String(p.meta).replace('|', '\r\n'));++ checks.clean_env = Array.isArray(p.env) && p.env.every((k) => ENV_ALLOWED.test(k));++ checks.no_host_files = Array.isArray(p.visible) && p.visible.length === 0;++ checks.no_capabilities = p.caps.every((c) => c === '0000000000000000') && p.nnp === '1';++ checks.seccomp = seccompExpected ? p.seccomp === '2' : true;++ if (limiter && limiter.ok && sbx.cgroup) {++ const read = (f) => fs.readFileSync(path.join(sbx.cgroup, f), 'utf8').trim();++ checks.limits = Number(read('pids.max')) === 64 && Number(read('memory.max')) > 0 && /^\d+ \d+$/.test(read('cpu.max'));++ }++ } catch (err) {++ error = String(err.message).slice(0, 300);++ log.error('[sandbox] self-test:', error);++ }++ if (sbx) {++ const dir = sbx.dir;++ const cg = sbx.cgroup;++ const out = await sbx.wipe('selftest');++ checks.wipe = !out.leftover && !fs.existsSync(dir) && (!cg || !fs.existsSync(cg)) && sbx.state === 'wiped';++ }++ const failed = CHECKS.filter((c) => !checks[c]);++ return { ok: failed.length === 0, checks, failed, at, ...(error ? { error } : {}) };++}++++module.exports = { runSelfTest, CHECKS, ENV_ALLOWED };nnew file mode 100644@@@ -0,0 +1,343 @@++'use strict';++/**++ * One chat plays sandbox (Agent Streams S6, design 2026-11-06 §1, §3, §5): a fresh, empty, network-less box a++ * viewer's request runs in, and nothing of the host's.++ *++ * probeSupport({ bwrap }) → { ok, reason, version, seccomp, disableUserns } can this host make sandboxes?++ * createSandbox(opts) → a started sandbox:++ * sbx.exec(argv, { stdin, tty, cwd, env, timeoutMs, maxOutput, onOutput }) → { code, signal, stdout, stderr, timedOut, truncated }++ * sbx.writeFile(path, data) · sbx.readFile(path) → Buffer · sbx.list(path) → [{ name, type, size }] (workspace only)++ * sbx.openPage(url) → { ok, url, title, text, screenshot } (the browser)++ * sbx.wipe(reason) → { reason } Wipe now, the end of the session, or the time box++ * sbx.ended (Promise of { reason }), sbx.state ('running' | 'wiping' | 'wiped'), sbx.id, sbx.pid, sbx.dir, sbx.cgroup++ *++ * opts: runtimeDir (short: a socket path must fit 108 bytes), limits { memoryMb, diskMb, pids, cpuPercent }, timeBoxMs,++ * limiter (sandbox/limits.js; null only with allowUnlimited, for tests), egress { lookup, dial, hostAddresses }++ * (sandbox/egress-proxy.js), onEgress(decision), nodePath, browserPath, rootfs, bwrap, seccomp, log++ *++ * How it is made (sandbox/bwrap.js has the whole list): bubblewrap with its own user, pid, network, ipc, uts and cgroup++ * namespaces, a read-only root of /usr and a few /etc entries, size-limited tmpfs for /workspace, /home/agent and /tmp,++ * a cleared environment, no capabilities, the seccomp filter (sandbox/seccomp.js), in a cgroup of its own++ * (sandbox/limits.js). The only ways in or out are the supervisor's stdin/stdout (sandbox/supervisor.js) and the egress++ * proxy's socket, which the worker listens on in a directory the sandbox sees read-only.++ *++ * The wipe: SIGKILL to the cgroup and to bubblewrap (its pid namespace takes every process with it), then the proxy is++ * closed, the session's directory removed and the cgroup removed. The tmpfs areas were only ever in the sandbox's own++ * mount namespace: they are gone with it. Nothing of the sandbox is kept on the host.++ */++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const { spawn, spawnSync } = require('child_process');++const { buildArgs, etcFiles } = require('./bwrap');++const { compile } = require('./seccomp');++const { createEgressProxy } = require('./egress-proxy');++const { createCdp, openPage } = require('./browser');++++const SUPERVISOR = path.join(__dirname, 'supervisor.js');++const DEFAULT_LIMITS = Object.freeze({ memoryMb: 2048, diskMb: 512, pids: 256, cpuPercent: 100 });++const MAX_FRAME = 16 * 1024 * 1024;++const READY_MS = 20_000;++++const procState = (pid) => {++ try { const st = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); return st.slice(st.lastIndexOf(')') + 2, st.lastIndexOf(')') + 3); } catch { return null; }++};++const nsOf = (pid) => { try { return fs.readlinkSync(`/proc/${pid}/ns/pid`); } catch { return null; } };++++/** The first child of a process (bubblewrap's own child is the sandbox's pid namespace init). */++function firstChild(pid) {++ try {++ const kids = fs.readFileSync(`/proc/${pid}/task/${pid}/children`, 'utf8').trim().split(/\s+/).filter(Boolean).map(Number);++ if (kids.length) return kids[0];++ } catch { /* no CONFIG_PROC_CHILDREN: scan */ }++ for (const name of fs.readdirSync('/proc')) {++ if (!/^\d+$/.test(name)) continue;++ try {++ const st = fs.readFileSync(`/proc/${name}/stat`, 'utf8');++ if (Number(st.slice(st.lastIndexOf(')') + 2).split(' ')[1]) === pid) return Number(name);++ } catch { /* gone */ }++ }++ return null;++}++++/** Every live process (zombies excluded) in a pid namespace, seen from the host. */++function membersOf(ns) {++ const out = [];++ for (const name of fs.readdirSync('/proc')) {++ if (!/^\d+$/.test(name)) continue;++ if (nsOf(name) === ns && procState(name) !== 'Z') out.push(Number(name));++ }++ return out;++}++++/** Can this host make sandboxes? bubblewrap ≥ 0.8 and unprivileged user and network namespaces. */++async function probeSupport({ bwrap = process.env.CODES_SANDBOX_BWRAP || 'bwrap' } = {}) {++ const v = spawnSync(bwrap, ['--version'], { encoding: 'utf8', timeout: 5000, env: { PATH: '/usr/bin:/bin' } });++ if (v.error || v.status !== 0) return { ok: false, reason: `bubblewrap is not installed (${bwrap}: ${v.error ? v.error.code : v.status}); the worker's host needs it: apt install bubblewrap` };++ const version = (String(v.stdout).match(/(\d+)\.(\d+)\.(\d+)/) || [])[0] || '0.0.0';++ const [maj, min] = version.split('.').map(Number);++ if (maj === 0 && min < 8) return { ok: false, version, reason: `bubblewrap ${version} is too old: the sandbox needs 0.8 or newer (--disable-userns, --size)` };++ const t = spawnSync(bwrap, ['--unshare-all', '--unshare-user', '--disable-userns', '--die-with-parent', '--ro-bind', '/usr', '/usr',++ '--symlink', 'usr/bin', '/bin', '--symlink', 'usr/lib', '/lib', '--symlink', 'usr/lib64', '/lib64', '--proc', '/proc', '--dev', '/dev',++ '--', '/usr/bin/true'], { encoding: 'utf8', timeout: 10_000, env: { PATH: '/usr/bin:/bin' } });++ if (t.error || t.status !== 0) {++ const why = String(t.stderr || (t.error && t.error.code) || '').trim().split('\n').pop().slice(0, 200);++ return { ok: false, version, reason: `unprivileged user namespaces are not allowed here (${why}): Ubuntu needs kernel.apparmor_restrict_unprivileged_userns=0 or an AppArmor profile for bwrap` };++ }++ return { ok: true, version, seccomp: compile() !== null, disableUserns: true };++}++++function assertLimits(l) {++ const out = { ...DEFAULT_LIMITS, ...(l || {}) };++ for (const k of Object.keys(DEFAULT_LIMITS)) if (!(Number(out[k]) > 0)) throw new Error(`limit ${k} must be a positive number`);++ return out;++}++++async function createSandbox(opts = {}) {++ const {++ runtimeDir, timeBoxMs = 10 * 60_000, limiter = null, allowUnlimited = false, egress = {}, onEgress = () => {},++ nodePath = process.execPath, browserPath = null, rootfs = '/', bwrap = process.env.CODES_SANDBOX_BWRAP || 'bwrap',++ seccomp = true, log = console,++ } = opts;++ if (!runtimeDir) throw new Error('runtimeDir is required');++ const limits = assertLimits(opts.limits);++ if (!limiter && !allowUnlimited) throw new Error('no limiter: a sandbox never starts without its cgroup limits');++ if (limiter && !limiter.ok) throw new Error(`the limits are not available: ${limiter.reason}`);++ const id = opts.id || crypto.randomBytes(5).toString('hex');++ if (!/^[a-z0-9-]{4,32}$/.test(id)) throw new Error('bad sandbox id');++++ // The session's own directory: its /etc files and the proxy socket's directory. Owned by the worker, 0700.++ const dir = path.join(runtimeDir, `s-${id}`);++ fs.mkdirSync(dir, { mode: 0o700 });++ const etcDir = path.join(dir, 'etc');++ const netDir = path.join(dir, 'net');++ fs.mkdirSync(etcDir, { mode: 0o700 });++ fs.mkdirSync(netDir, { mode: 0o700 });++ const socketPath = path.join(netDir, 'egress.sock');++ if (Buffer.byteLength(socketPath) > 100) { fs.rmSync(dir, { recursive: true, force: true }); throw new Error(`runtimeDir is too long for a socket path: ${runtimeDir}`); }++++ let proxy = null;++ let handle = null;++ let seccompFd = null;++ let child = null;++ const cleanup = async () => {++ if (proxy) await proxy.close().catch(() => {});++ if (handle) await handle.remove().catch(() => {});++ if (seccompFd != null) { try { fs.closeSync(seccompFd); } catch { /* closed */ } seccompFd = null; }++ fs.rmSync(dir, { recursive: true, force: true });++ };++++ try {++ proxy = createEgressProxy({ ...egress, onDecision: (d) => { try { onEgress({ sandbox: id, ...d }); } catch { /* the log never breaks egress */ } }, log });++ await proxy.listen(socketPath);++++ const filter = seccomp ? compile() : null;++ if (filter) {++ const f = path.join(dir, 'seccomp.bpf');++ fs.writeFileSync(f, filter, { mode: 0o600 });++ seccompFd = fs.openSync(f, 'r');++ }++ const built = buildArgs({++ rootfs, sessionEtc: etcDir, netDir, supervisorPath: SUPERVISOR, nodePath, browserPath,++ diskMb: limits.diskMb, shmMb: Math.min(256, limits.diskMb), seccompFd: filter ? 3 : null,++ });++ for (const [name, text] of Object.entries(etcFiles({ shell: built.shell }))) fs.writeFileSync(path.join(etcDir, name), text, { mode: 0o644 });++++ // The worker's environment never reaches bubblewrap: PATH only (and the user bus for systemd-run).++ let argv = [bwrap, ...built.args];++ let env = { PATH: '/usr/bin:/bin' };++ if (limiter) {++ handle = limiter.prepare(id, limits);++ ({ argv, env } = handle.wrap(argv, env));++ }++ child = spawn(argv[0], argv.slice(1), { env, stdio: ['pipe', 'pipe', 'pipe', ...(filter ? [seccompFd] : [])], cwd: '/' });++ } catch (err) {++ if (child) { try { child.kill('SIGKILL'); } catch { /* gone */ } }++ await cleanup();++ throw err;++ }++++ const sbx = new Sandbox({ id, dir, child, proxy, handle, limits, timeBoxMs, cleanup, log, hasBrowser: !!browserPath });++ try {++ await sbx.ready();++ } catch (err) {++ await sbx.wipe('failed_to_start');++ throw err;++ }++ if (handle) handle.attach(child.pid);++ // The sandbox's pid namespace, from its init (bubblewrap's child): the wipe waits until nothing is left in it.++ const init = firstChild(child.pid);++ const ns = init ? nsOf(init) : null;++ if (ns && ns !== nsOf(process.pid)) sbx._pidns = ns;++ return sbx;++}++++class Sandbox {++ constructor({ id, dir, child, proxy, handle, limits, timeBoxMs, cleanup, log, hasBrowser }) {++ Object.assign(this, { id, dir, limits, log });++ this.pid = child.pid;++ this.state = 'running';++ this.startedAt = Date.now();++ this.endsAt = this.startedAt + timeBoxMs;++ this._child = child;++ this._proxy = proxy;++ this._handle = handle;++ this._cleanup = cleanup;++ this._hasBrowser = hasBrowser;++ this._nextId = 1;++ this._pending = new Map();++ this._stderr = '';++ this._cdp = null;++ this.ended = new Promise((resolve) => { this._resolveEnded = resolve; });++ this._exited = new Promise((resolve) => child.once('exit', (code, signal) => resolve({ code, signal })));++ this._readyWaiter = null;++++ let buf = '';++ child.stdout.setEncoding('utf8');++ child.stdout.on('data', (chunk) => {++ buf += chunk;++ if (buf.length > MAX_FRAME && buf.indexOf('\n') < 0) { this.wipe('protocol'); return; }++ let i;++ while ((i = buf.indexOf('\n')) >= 0) {++ const line = buf.slice(0, i);++ buf = buf.slice(i + 1);++ this._frame(line);++ }++ });++ child.stderr.setEncoding('utf8');++ child.stderr.on('data', (d) => { this._stderr = (this._stderr + d).slice(-4096); });++ for (const s of [child.stdin, child.stdout, child.stderr]) s.on('error', () => {});++ child.once('exit', () => { if (this.state === 'running') this.wipe('exited'); });++ this._timer = setTimeout(() => this.wipe('time_box'), timeBoxMs);++ if (this._timer.unref) this._timer.unref();++ }++++ get cgroup() { return this._handle ? this._handle.path : null; }++++ ready() {++ return new Promise((resolve, reject) => {++ const timer = setTimeout(() => reject(new Error(`the sandbox did not start: ${this._stderr.trim().slice(-300)}`)), READY_MS);++ this._readyWaiter = (frame) => { clearTimeout(timer); resolve(frame); };++ this._exited.then(({ code, signal }) => { clearTimeout(timer); reject(new Error(`the sandbox did not start (${code}/${signal}): ${this._stderr.trim().slice(-300)}`)); });++ });++ }++++ /** One line from inside. Untrusted: it is matched to a request of ours or dropped, never acted on. */++ _frame(line) {++ let f;++ try { f = JSON.parse(line); } catch { return; }++ if (!f || typeof f !== 'object') return;++ if (f.ev === 'ready' && this._readyWaiter) { const w = this._readyWaiter; this._readyWaiter = null; return w(f); }++ if (f.ev === 'cdp') { if (this._cdp && typeof f.message === 'string') this._cdp.onMessage(f.message); return; }++ if (f.ev === 'cdp-closed') { if (this._cdp) { this._cdp.close('exited'); this._cdp = null; } return; }++ const p = this._pending.get(f.id);++ if (!p) return;++ if (f.ev === 'out' && p.onOut) return p.onOut(f.fd === 2 ? 2 : 1, Buffer.from(String(f.data || ''), 'base64'));++ if (f.ev === 'exit' || f.ev === 'done') { this._pending.delete(f.id); p.resolve(f); }++ }++++ _request(msg, { onOut } = {}) {++ if (this.state !== 'running') return Promise.reject(new Error(`the sandbox is ${this.state === 'wiping' ? 'being wiped' : 'wiped'}`));++ const id = this._nextId++;++ return new Promise((resolve, reject) => {++ this._pending.set(id, { resolve, reject, onOut });++ this._child.stdin.write(`${JSON.stringify({ ...msg, id })}\n`);++ });++ }++++ /** The terminal: a command inside, with its output (capped) and how it ended. */++ async exec(argv, { stdin = null, tty = false, cwd, env, timeoutMs = 60_000, maxOutput = 1024 * 1024, onOutput } = {}) {++ if (!Array.isArray(argv) || !argv.length) throw new Error('argv is a non-empty array');++ const out = [[], []];++ const sizes = [0, 0];++ const onOut = (fd, buf) => {++ const k = fd === 2 ? 1 : 0;++ if (sizes[k] < maxOutput) { out[k].push(buf.subarray(0, maxOutput - sizes[k])); sizes[k] += buf.length; }++ if (onOutput) { try { onOutput(fd, buf); } catch { /* the caller's problem */ } }++ };++ const f = await this._request({++ op: 'exec', argv: argv.map(String), tty: !!tty, cwd, env, timeoutMs, maxOutput,++ ...(stdin != null ? { stdin: Buffer.from(stdin).toString('base64') } : {}),++ }, { onOut });++ return {++ code: typeof f.code === 'number' ? f.code : null, signal: typeof f.signal === 'string' ? f.signal : null,++ stdout: Buffer.concat(out[0]).toString('utf8'), stderr: Buffer.concat(out[1]).toString('utf8'),++ timedOut: f.timedOut === true, truncated: f.truncated === true, ...(f.error ? { error: String(f.error).slice(0, 200) } : {}),++ };++ }++++ async _file(msg) {++ const f = await this._request(msg);++ if (!f.ok) throw new Error(String(f.error || 'failed').slice(0, 200));++ return f;++ }++++ /** The editor: files in the workspace only (the supervisor refuses a path outside it). */++ async writeFile(p, data) { await this._file({ op: 'write', path: String(p), data: Buffer.from(data).toString('base64') }); }++ async readFile(p) { return Buffer.from(String((await this._file({ op: 'read', path: String(p) })).data || ''), 'base64'); }++ async list(p = '/workspace') {++ const f = await this._file({ op: 'list', path: String(p) });++ return (Array.isArray(f.entries) ? f.entries : []).map((e) => ({ name: String(e.name), type: String(e.type), size: e.size == null ? null : Number(e.size) }));++ }++++ /** The browser: headless Chromium inside, on the egress proxy. */++ async openPage(url, opts = {}) {++ if (!this._hasBrowser) throw new Error('this sandbox has no browser');++ if (!this._cdp) {++ const f = await this._request({ op: 'browser', action: 'start' });++ if (!f.ok) throw new Error(`the browser did not start: ${String(f.error || '').slice(0, 200)}`);++ this._cdp = createCdp((message) => { if (this.state === 'running') this._child.stdin.write(`${JSON.stringify({ op: 'cdp', message })}\n`); });++ // The browser answers once its pipe is open.++ const until = Date.now() + 15_000;++ for (;;) {++ try { await Promise.race([this._cdp.call('Browser.getVersion'), new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 2000))]); break; } catch (err) {++ if (Date.now() > until) throw new Error(`the browser did not answer: ${err.message}`);++ }++ }++ }++ try {++ return await openPage(this._cdp, url, opts);++ } catch (err) {++ return { ok: false, url, error: String(err.message).slice(0, 200) };++ }++ }++++ /** Wipe now (or the end, or the time box): everything of the sandbox goes, and nothing of it is kept. */++ wipe(reason = 'wipe_now') {++ if (this._wiping) return this._wiping;++ this.state = 'wiping';++ clearTimeout(this._timer);++ this._wiping = (async () => {++ if (this._cdp) { this._cdp.close(reason); this._cdp = null; }++ if (this._handle) { try { this._handle.kill(); } catch { /* gone */ } }++ try { this._child.kill('SIGKILL'); } catch { /* gone */ }++ await Promise.race([this._exited, new Promise((r) => setTimeout(r, 5000))]);++ // bubblewrap's death takes its pid namespace with it; make sure, and kill any straggler by hand.++ let leftover = [];++ if (this._pidns) {++ const until = Date.now() + 5000;++ for (;;) {++ leftover = membersOf(this._pidns);++ if (!leftover.length || Date.now() > until) break;++ for (const pid of leftover) { try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ } }++ await new Promise((r) => setTimeout(r, 25));++ }++ if (leftover.length) this.log.error(`[sandbox] ${this.id}: ${leftover.length} process(es) outlived the wipe`);++ }++ for (const p of this._pending.values()) p.reject(new Error(`the sandbox was wiped (${reason})`));++ this._pending.clear();++ try { await this._cleanup(); } catch (err) { this.log.error('[sandbox] cleanup:', err.message); }++ this.state = 'wiped';++ const out = { reason, ...(leftover.length ? { leftover: leftover.length } : {}) };++ this._resolveEnded(out);++ return out;++ })();++ return this._wiping;++ }++++ info() {++ return { id: this.id, state: this.state, started_at: new Date(this.startedAt).toISOString(), ends_at: new Date(this.endsAt).toISOString(), limits: this.limits };++ }++}++++module.exports = { probeSupport, createSandbox, DEFAULT_LIMITS, SUPERVISOR };nnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++/**++ * The sandbox worker's heartbeat signature to Codes (POST /internal/sandbox-workers/heartbeat): HMAC-SHA256 over++ * `<t>.<raw body>` with a secret only the worker and Codes hold (CODES_SANDBOX_WORKER_SECRET; a comma list on Codes'++ * side for a rotation). The secret never travels; t is the worker's clock in ms, so a stale or replayed heartbeat is++ * refused (Codes keeps the last t per worker and wants a newer one).++ *++ * sign(secret, body, t) → 'X-OV-Sandbox-Signature' header value: `t=<ms>,v1=<hex>`++ * verify({ secrets, header, body, now, toleranceMs }) → { ok: true, t } | { ok: false, code }++ */++const crypto = require('crypto');++++const HEADER = 'x-ov-sandbox-signature';++const TOLERANCE_MS = 60_000;++++const mac = (secret, t, body) => crypto.createHmac('sha256', String(secret)).update(`${t}.`).update(Buffer.isBuffer(body) ? body : Buffer.from(String(body))).digest('hex');++++function sign(secret, body, t = Date.now()) {++ if (!secret) throw new Error('no worker secret');++ return `t=${t},v1=${mac(secret, t, body)}`;++}++++function verify({ secrets = [], header, body, now = Date.now(), toleranceMs = TOLERANCE_MS }) {++ const list = [].concat(secrets).filter(Boolean);++ if (!list.length) return { ok: false, code: 'sandbox.not_configured' };++ const m = /^t=(\d{10,16}),v1=([0-9a-f]{64})$/.exec(String(header || '').trim());++ if (!m) return { ok: false, code: 'signature.missing' };++ const t = Number(m[1]);++ if (Math.abs(now - t) > toleranceMs) return { ok: false, code: 'signature.stale' };++ const given = Buffer.from(m[2], 'hex');++ const good = list.some((s) => {++ const want = Buffer.from(mac(s, t, body), 'hex');++ return want.length === given.length && crypto.timingSafeEqual(want, given);++ });++ return good ? { ok: true, t } : { ok: false, code: 'signature.invalid' };++}++++module.exports = { sign, verify, HEADER, TOLERANCE_MS };nnew file mode 100644@@@ -0,0 +1,233 @@++'use strict';++/**++ * The chat plays sandbox's supervisor: the first program INSIDE the sandbox (bubblewrap starts it as++ * `node /opt/ov/supervisor.js`; sandbox/bwrap.js). It is the agent's terminal, editor and browser launcher, and it++ * holds the sandbox's end of the egress proxy. It uses Node's own modules only: nothing else is mounted for it.++ *++ * It talks to the worker over its stdin and stdout only, one JSON object per line — never over a path the worker would++ * open, so nothing inside can point the worker at another socket. The worker treats every line from here as untrusted++ * data (anything inside could write one): it only ever shows them.++ *++ * in { id, op: 'exec', argv, cwd?, env?, stdin?, tty?, timeoutMs?, maxOutput? } run a command (the terminal)++ * { id, op: 'stdin', target, data?, end? } · { id, op: 'kill', target }++ * { id, op: 'write', path, data } · { id, op: 'read', path, max? } · { id, op: 'list', path } (the editor)++ * { id, op: 'browser', action: 'start' | 'stop' } · { op: 'cdp', message } (the browser)++ * out { ev: 'ready', pid, uid } · { id, ev: 'out', fd, data } · { id, ev: 'exit', code, signal, timedOut, truncated }++ * { id, ev: 'done', ok, …, error? } · { ev: 'cdp', message }++ *++ * data is base64. The egress relay: 127.0.0.1:OV_SANDBOX_PROXY_PORT inside → OV_SANDBOX_EGRESS_SOCKET (the worker's++ * proxy). The relay is a convenience: it adds no way out, the socket is the only one there is.++ */++const { spawn } = require('child_process');++const fs = require('fs');++const net = require('net');++const path = require('path');++++const WORKSPACE = process.env.OV_SANDBOX_WORKSPACE || '/workspace';++const PROXY_PORT = Number(process.env.OV_SANDBOX_PROXY_PORT || 3128);++const EGRESS_SOCKET = process.env.OV_SANDBOX_EGRESS_SOCKET || '/run/ov/net/egress.sock';++const BROWSER = process.env.OV_SANDBOX_BROWSER || '';++const MAX_LINE = 12 * 1024 * 1024;++const MAX_TIMEOUT_MS = 30 * 60_000;++const DEFAULT_OUTPUT = 1024 * 1024;++++const procs = new Map();++let browser = null;++++function send(frame) { process.stdout.write(`${JSON.stringify(frame)}\n`); }++const b64 = (buf) => Buffer.from(buf).toString('base64');++const fail = (id, error) => send({ id, ev: 'done', ok: false, error: String(error).slice(0, 300) });++++/** A path in the workspace, or an error: the editor works on the workspace only. */++function inWorkspace(p, { mustExist = false } = {}) {++ const abs = path.resolve(WORKSPACE, String(p == null ? '.' : p));++ const inside = (x) => x === WORKSPACE || x.startsWith(`${WORKSPACE}/`);++ if (!inside(abs)) throw new Error('outside the workspace');++ let real = abs;++ try { real = fs.realpathSync(abs); } catch (err) {++ if (mustExist) throw err;++ try { real = path.join(fs.realpathSync(path.dirname(abs)), path.basename(abs)); } catch { real = abs; }++ }++ if (!inside(real)) throw new Error('outside the workspace (a link points out of it)');++ return abs;++}++++const shq = (s) => `'${String(s).replace(/'/g, "'\\''")}'`;++++function exec(msg) {++ const id = msg.id;++ const argv = Array.isArray(msg.argv) ? msg.argv.map(String) : [];++ if (!argv.length || argv.some((a) => a.length > 65536)) return send({ id, ev: 'exit', code: 127, signal: null, error: 'bad argv' });++ let file = argv[0];++ let args = argv.slice(1);++ // A terminal device: util-linux script(1) gives the command a pty of the sandbox's own /dev/pts.++ if (msg.tty) { file = 'script'; args = ['-q', '-e', '-f', '-c', argv.map(shq).join(' '), '/dev/null']; }++ let cwd = WORKSPACE;++ try { if (msg.cwd) cwd = inWorkspace(msg.cwd, { mustExist: true }); } catch (err) { return send({ id, ev: 'exit', code: 127, signal: null, error: err.message }); }++ const env = { ...process.env };++ for (const [k, v] of Object.entries(msg.env || {})) if (/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(k) && !k.startsWith('OV_SANDBOX')) env[k] = String(v).slice(0, 8192);++ const max = Math.min(Number(msg.maxOutput) || DEFAULT_OUTPUT, 8 * DEFAULT_OUTPUT);++ const timeoutMs = Math.min(Math.max(100, Number(msg.timeoutMs) || 60_000), MAX_TIMEOUT_MS);++ let child;++ try {++ child = spawn(file, args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true });++ } catch (err) {++ return send({ id, ev: 'exit', code: 127, signal: null, error: err.code || err.message });++ }++ const state = { child, sent: 0, truncated: false, timedOut: false, finished: false };++ procs.set(id, state);++ const out = (fd) => (chunk) => {++ if (state.sent >= max) { state.truncated = true; return; }++ const part = chunk.length + state.sent > max ? chunk.subarray(0, max - state.sent) : chunk;++ if (part.length < chunk.length) state.truncated = true;++ state.sent += part.length;++ send({ id, ev: 'out', fd, data: b64(part) });++ };++ child.stdout.on('data', out(1));++ child.stderr.on('data', out(2));++ child.stdin.on('error', () => {});++ if (msg.stdin != null) child.stdin.write(Buffer.from(String(msg.stdin), 'base64'));++ if (msg.stdinOpen !== true) child.stdin.end();++ const killGroup = () => { try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { /* gone */ } } };++ const timer = setTimeout(() => { state.timedOut = true; killGroup(); }, timeoutMs);++ const finish = (code, signal, error) => {++ if (state.finished) return;++ state.finished = true;++ clearTimeout(timer);++ procs.delete(id);++ send({ id, ev: 'exit', code, signal, timedOut: state.timedOut, truncated: state.truncated, ...(error ? { error } : {}) });++ };++ child.on('error', (err) => finish(127, null, err.code || err.message));++ // A background process may keep the pipes open: the exit is the end, the rest of the output gets a moment.++ child.on('exit', (code, signal) => {++ const t = setTimeout(() => { child.stdout.destroy(); child.stderr.destroy(); finish(code, signal); }, 200);++ child.on('close', () => { clearTimeout(t); finish(code, signal); });++ });++}++++function file(msg) {++ const id = msg.id;++ try {++ if (msg.op === 'write') {++ const p = inWorkspace(msg.path);++ fs.mkdirSync(path.dirname(p), { recursive: true });++ const fd = fs.openSync(p, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW, 0o644);++ try { fs.writeSync(fd, Buffer.from(String(msg.data || ''), 'base64')); } finally { fs.closeSync(fd); }++ return send({ id, ev: 'done', ok: true });++ }++ if (msg.op === 'read') {++ const p = inWorkspace(msg.path, { mustExist: true });++ const max = Math.min(Number(msg.max) || 4 * 1024 * 1024, 8 * 1024 * 1024);++ const st = fs.statSync(p);++ if (!st.isFile()) throw new Error('not a file');++ const fd = fs.openSync(p, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);++ try {++ const buf = Buffer.alloc(Math.min(st.size, max));++ const n = fs.readSync(fd, buf, 0, buf.length, 0);++ return send({ id, ev: 'done', ok: true, data: b64(buf.subarray(0, n)), size: st.size, truncated: st.size > max });++ } finally { fs.closeSync(fd); }++ }++ if (msg.op === 'list') {++ const p = inWorkspace(msg.path, { mustExist: true });++ const entries = fs.readdirSync(p, { withFileTypes: true }).slice(0, 5000).map((e) => {++ let size = null;++ try { size = e.isFile() ? fs.statSync(path.join(p, e.name)).size : null; } catch { /* gone */ }++ return { name: e.name, type: e.isFile() ? 'file' : e.isDirectory() ? 'dir' : e.isSymbolicLink() ? 'link' : 'other', size };++ });++ return send({ id, ev: 'done', ok: true, entries });++ }++ } catch (err) {++ return fail(id, err.code && !/outside/.test(err.message) ? err.code : err.message);++ }++ return fail(id, 'unknown op');++}++++/** Headless Chromium inside the sandbox, on the egress proxy, driven over a DevTools pipe the worker relays. */++function browserOp(msg) {++ const id = msg.id;++ if (msg.action === 'stop') {++ if (browser) { try { process.kill(-browser.pid, 'SIGKILL'); } catch { /* gone */ } browser = null; }++ return send({ id, ev: 'done', ok: true });++ }++ if (msg.action !== 'start') return fail(id, 'unknown action');++ if (browser) return send({ id, ev: 'done', ok: true, already: true });++ if (!BROWSER) return fail(id, 'no browser in this sandbox');++ const args = [++ '--headless=new', '--no-sandbox', '--disable-gpu', '--no-first-run', '--no-default-browser-check',++ '--disable-background-networking', '--disable-component-update', '--disable-sync', '--disable-breakpad',++ '--disable-crash-reporter', '--disable-features=Translate,MediaRouter,OptimizationHints,DialMediaRouteProvider',++ `--proxy-server=http://127.0.0.1:${PROXY_PORT}`, '--user-data-dir=/home/agent/.browser', '--remote-debugging-pipe',++ '--window-size=1280,800', 'about:blank',++ ];++ let child;++ try {++ child = spawn(BROWSER, args, { cwd: WORKSPACE, env: process.env, stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'], detached: true });++ } catch (err) { return fail(id, err.code || err.message); }++ browser = child;++ child.stderr.on('data', () => {});++ let buf = '';++ child.stdio[4].on('data', (d) => {++ buf += d;++ let i;++ while ((i = buf.indexOf('\0')) >= 0) { send({ ev: 'cdp', message: buf.slice(0, i) }); buf = buf.slice(i + 1); }++ });++ child.stdio[3].on('error', () => {});++ child.on('exit', (code) => { if (browser === child) { browser = null; send({ ev: 'cdp-closed', code }); } });++ child.on('error', (err) => { if (browser === child) browser = null; fail(id, err.code || err.message); });++ child.on('spawn', () => send({ id, ev: 'done', ok: true }));++}++++function handle(msg) {++ if (!msg || typeof msg !== 'object') return;++ switch (msg.op) {++ case 'ping': return send({ id: msg.id, ev: 'done', ok: true, pid: process.pid, uid: process.getuid() });++ case 'exec': return exec(msg);++ case 'stdin': {++ const s = procs.get(msg.target);++ if (s) { if (msg.data) s.child.stdin.write(Buffer.from(String(msg.data), 'base64')); if (msg.end) s.child.stdin.end(); }++ return undefined;++ }++ case 'kill': {++ const s = procs.get(msg.target);++ if (s) { try { process.kill(-s.child.pid, 'SIGKILL'); } catch { /* gone */ } }++ return undefined;++ }++ case 'write': case 'read': case 'list': return file(msg);++ case 'browser': return browserOp(msg);++ case 'cdp': if (browser) browser.stdio[3].write(`${String(msg.message)}\0`); return undefined;++ default: return fail(msg.id, 'unknown op');++ }++}++++let inbuf = '';++process.stdin.setEncoding('utf8');++process.stdin.on('data', (chunk) => {++ inbuf += chunk;++ if (inbuf.length > MAX_LINE && inbuf.indexOf('\n') < 0) { inbuf = ''; return; }++ let i;++ while ((i = inbuf.indexOf('\n')) >= 0) {++ const line = inbuf.slice(0, i);++ inbuf = inbuf.slice(i + 1);++ let msg = null;++ try { msg = JSON.parse(line); } catch { continue; }++ try { handle(msg); } catch (err) { fail(msg && msg.id, err.message); }++ }++});++// The worker went away: so does everything here (bubblewrap's --die-with-parent does the rest).++process.stdin.on('end', () => process.exit(0));++process.on('uncaughtException', (err) => { try { send({ ev: 'error', error: String(err && err.message).slice(0, 300) }); } catch { /* stdout gone */ } });++++function ready() { send({ ev: 'ready', pid: process.pid, uid: process.getuid() }); }++if (PROXY_PORT > 0) {++ const relay = net.createServer((c) => {++ const up = net.connect(EGRESS_SOCKET);++ c.on('error', () => up.destroy());++ up.on('error', () => c.destroy());++ c.pipe(up);++ up.pipe(c);++ });++ relay.on('error', (err) => { send({ ev: 'error', error: `relay: ${err.code || err.message}` }); ready(); });++ relay.listen(PROXY_PORT, '127.0.0.1', ready);++} else {++ ready();++}nnew file mode 100644@@@ -0,0 +1,302 @@++'use strict';++/**++ * The chat plays sandbox worker (Agent Streams S6, design 2026-11-06 §1, §3, §5, §6): the process that makes the++ * sandboxes a viewer's request runs in, and says whether it can. It runs as its own unprivileged user under++ * deploy/sandbox/openvibe-codes-sandbox.service (bin/openvibe-sandbox-worker.js).++ *++ * health the self-test (sandbox/selftest.js) at boot and every CODES_SANDBOX_SELFTEST_MS: the worker is++ * ready only when every isolation check passed AND the cgroup limits are there++ * announce every CODES_SANDBOX_ANNOUNCE_MS (30 s):++ * Live POST {LIVE}/api/streams/agent/:id/plays/worker with the worker's own Network service token++ * (client credentials, audience openvibe.live, capability live.agent_sandbox.register), for++ * each stream it serves: CODES_SANDBOX_LIVE_STREAMS and the streams of its running sessions++ * Codes POST {CODES}/internal/sandbox-workers/heartbeat, HMAC-signed (sandbox/signature.js)++ * with the same body: the worker, ready, the checks, the isolation facts, capacity and what runs++ * local API HTTP on a Unix socket (CODES_SANDBOX_SOCKET, 0660: only the worker's user and group): Codes' runner++ * makes a session, runs the agent's commands, edits files, opens pages, and wipes it++ * GET /health POST /sessions { stream_id?, time_box_minutes? }++ * GET /sessions GET /sessions/:id (with its egress log)++ * POST /sessions/:id/exec { argv, stdin?, tty?, timeout_ms? }++ * GET|PUT /sessions/:id/files?path= GET /sessions/:id/list?path=++ * POST /sessions/:id/browser { url } POST /sessions/:id/wipe (Wipe now) DELETE /sessions/:id++ *++ * Its secrets (the Network client secret, the Codes heartbeat secret) come from systemd credentials or files, are read++ * once, and are never in the environment it hands anything: bubblewrap gets PATH only and the sandbox its own++ * allowlist. Nothing a sandbox says is a URL the worker fetches: every outbound request goes to a configured base.++ */++const crypto = require('crypto');++const fs = require('fs');++const http = require('http');++const os = require('os');++const path = require('path');++const { createSandbox: realCreateSandbox, probeSupport } = require('./session');++const { detectLimits } = require('./limits');++const { findBrowser } = require('./browser');++const { runSelfTest } = require('./selftest');++const signature = require('./signature');++const { PORTS } = require('./egress-proxy');++++const VERSION = require('../package.json').version;++const STREAM = /^[A-Za-z0-9_-]{1,64}$/;++const SESSION = /^[a-z0-9]{10}$/;++const MB = 1024 * 1024;++++const int = (v, def, min, max) => { const n = parseInt(v, 10); return Math.min(max, Math.max(min, Number.isFinite(n) ? n : def)); };++const trim = (s) => String(s || '').trim().replace(/\/+$/, '');++++/** A secret from the environment, a file, or a systemd credential (LoadCredential=name:…). */++function secret(env, name, credential) {++ if (env[`${name}_FILE`]) { try { return fs.readFileSync(env[`${name}_FILE`], 'utf8').trim(); } catch { return ''; } }++ if (env.CREDENTIALS_DIRECTORY && credential) { try { return fs.readFileSync(path.join(env.CREDENTIALS_DIRECTORY, credential), 'utf8').trim(); } catch { /* not given */ } }++ return String(env[name] || '').trim();++}++++function loadConfig(env = process.env) {++ const runtimeDir = env.CODES_SANDBOX_RUNTIME_DIR || env.RUNTIME_DIRECTORY++ || (env.XDG_RUNTIME_DIR ? path.join(env.XDG_RUNTIME_DIR, 'ovsbx') : path.join('/tmp', `ovsbx-${process.getuid()}`));++ return {++ workerId: String(env.CODES_SANDBOX_WORKER_ID || `sbw-${os.hostname()}`).replace(/[^A-Za-z0-9_.-]/g, '-').slice(0, 64),++ runtimeDir,++ socket: env.CODES_SANDBOX_SOCKET || path.join(runtimeDir, 'worker.sock'),++ socketGroupMode: 0o660,++ limitsMode: env.CODES_SANDBOX_LIMITS || 'auto',++ limits: {++ memoryMb: int(env.CODES_SANDBOX_MEMORY_MB, 2048, 128, 65536),++ diskMb: int(env.CODES_SANDBOX_DISK_MB, 512, 16, 65536),++ pids: int(env.CODES_SANDBOX_PIDS, 256, 32, 32768),++ cpuPercent: int(env.CODES_SANDBOX_CPU_PERCENT, 100, 10, 6400),++ },++ maxSessions: int(env.CODES_SANDBOX_MAX_SESSIONS, 2, 1, 64),++ // The owner's time box is 5 to 30 minutes (design §3); the worker's own ceiling is that, never more.++ timeBoxMinMinutes: 5,++ timeBoxMaxMinutes: int(env.CODES_SANDBOX_TIME_BOX_MAX_MIN, 30, 5, 30),++ nodePath: env.CODES_SANDBOX_NODE || process.execPath,++ browserPath: findBrowser(env),++ announceMs: int(env.CODES_SANDBOX_ANNOUNCE_MS, 30_000, 1000, 600_000),++ selfTestMs: int(env.CODES_SANDBOX_SELFTEST_MS, 10 * 60_000, 10_000, 24 * 3_600_000),++ codes: { url: trim(env.CODES_SANDBOX_CODES_URL || 'http://127.0.0.1:4900'), secret: secret(env, 'CODES_SANDBOX_WORKER_SECRET', 'codes-worker-secret') },++ live: {++ url: trim(env.CODES_SANDBOX_LIVE_URL || 'https://openvibe.live'),++ streams: String(env.CODES_SANDBOX_LIVE_STREAMS || '').split(',').map((s) => s.trim()).filter((s) => STREAM.test(s)),++ },++ network: {++ tokenUrl: `${trim(env.CODES_SANDBOX_NETWORK_URL || 'https://openvibe.network')}/oauth/token`,++ clientId: String(env.CODES_SANDBOX_CLIENT_ID || '').trim(),++ clientSecret: secret(env, 'CODES_SANDBOX_CLIENT_SECRET', 'network-client-secret'),++ audience: 'openvibe.live',++ scope: 'live.agent_sandbox.register',++ },++ hidden: [env.CREDENTIALS_DIRECTORY, env.CODES_SANDBOX_WORKER_SECRET_FILE, env.CODES_SANDBOX_CLIENT_SECRET_FILE].filter(Boolean),++ };++}++++/**++ * createWorker({ config, createSandbox, limiter, support, fetchImpl, tokens, log, now })++ * → { start(), stop(), health(), selfTest(), announce(), sessions, server }++ * createSandbox, limiter, support, fetchImpl and tokens are injectable (tests); start() detects them otherwise.++ */++function createWorker(opts = {}) {++ const config = opts.config || loadConfig();++ const log = opts.log || console;++ const fetchImpl = opts.fetchImpl || globalThis.fetch;++ const now = opts.now || (() => Date.now());++ const createSandbox = opts.createSandbox || realCreateSandbox;++ let limiter = opts.limiter || null;++ let support = opts.support || null;++ let tokens = opts.tokens || null;++ let lastSelfTest = null;++ const lastAnnounce = { codes: null, live: {} };++ const sessions = new Map();++ const timers = [];++ let server = null;++++ if (!tokens && config.network.clientId && config.network.clientSecret) {++ const { createServiceTokenClient } = require('openvibe-sdk/auth');++ tokens = createServiceTokenClient({ tokenUrl: config.network.tokenUrl, clientId: config.network.clientId, clientSecret: config.network.clientSecret, audience: config.network.audience, scope: config.network.scope, fetch: fetchImpl });++ }++++ /** What the worker can enforce, as it found it (never a secret, a path or an address). */++ function isolation() {++ const c = (lastSelfTest && lastSelfTest.checks) || {};++ return {++ sandbox: 'bubblewrap', bubblewrap: support && support.version || null,++ user_namespace: !!(support && support.ok), network_namespace: c.network_namespace === true,++ egress: { via: 'proxy', public_only: c.egress_refuses_private === true, ports: [...PORTS], direct: c.no_direct_egress === true ? 'none' : 'unknown' },++ host_credentials: c.clean_env === true && c.no_host_files === true ? 'none' : 'unknown',++ host_files: c.no_host_files === true ? 'none' : 'unknown',++ capabilities: c.no_capabilities === true ? 'none' : 'unknown',++ seccomp: c.seccomp === true,++ workspace: 'tmpfs, wiped with the sandbox',++ limits: {++ kind: limiter && limiter.ok ? limiter.kind : 'none', enforced: c.limits === true,++ memory_mb: config.limits.memoryMb, disk_mb: config.limits.diskMb, pids: config.limits.pids, cpu_percent: config.limits.cpuPercent,++ time_box_minutes: [config.timeBoxMinMinutes, config.timeBoxMaxMinutes],++ },++ };++ }++++ function health() {++ const ready = !!(support && support.ok && limiter && limiter.ok && lastSelfTest && lastSelfTest.ok);++ const reasons = [];++ if (!support || !support.ok) reasons.push(support ? support.reason : 'not started');++ if (!limiter || !limiter.ok) reasons.push(`limits: ${limiter ? limiter.reason : 'not detected'}`);++ if (lastSelfTest && !lastSelfTest.ok) reasons.push(`self-test failed: ${lastSelfTest.failed.join(', ')}`);++ return {++ worker_id: config.workerId, service: 'codes-sandbox', version: VERSION, ready, ...(reasons.length ? { reasons } : {}),++ checks: lastSelfTest ? lastSelfTest.checks : {}, checked_at: lastSelfTest ? lastSelfTest.at : null,++ isolation: isolation(), browser: !!config.browserPath,++ capacity: config.maxSessions, active: [...sessions.values()].filter((s) => s.sbx.state === 'running').length,++ at: new Date(now()).toISOString(),++ };++ }++++ async function selfTest() {++ if (!support || !support.ok) { lastSelfTest = { ok: false, checks: {}, failed: ['user_namespace'], at: new Date(now()).toISOString() }; return lastSelfTest; }++ lastSelfTest = await (opts.runSelfTest || runSelfTest)({++ createSandbox, limiter, runtimeDir: config.runtimeDir, nodePath: config.nodePath, hidden: config.hidden,++ seccompExpected: support.seccomp !== false, log,++ });++ if (!lastSelfTest.ok) log.error(`[sandbox] not ready: ${lastSelfTest.failed.join(', ')}${lastSelfTest.error ? ` (${lastSelfTest.error})` : ''}`);++ return lastSelfTest;++ }++++ async function post(url, body, headers) {++ const res = await fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body, signal: AbortSignal.timeout(10_000) });++ await res.text().catch(() => '');++ return res.status;++ }++++ /** One round of check-ins: Codes (signed) and Live (the worker's own service token), with the same facts. */++ async function announce() {++ const h = health();++ const body = JSON.stringify(h);++ if (config.codes.secret) {++ try {++ const status = await post(`${config.codes.url}/internal/sandbox-workers/heartbeat`, body, { [signature.HEADER]: signature.sign(config.codes.secret, body, now()) });++ lastAnnounce.codes = { status, at: new Date(now()).toISOString() };++ } catch (err) { lastAnnounce.codes = { error: err.code || err.name || 'failed', at: new Date(now()).toISOString() }; }++ }++ const streams = new Set(config.live.streams);++ for (const s of sessions.values()) if (s.stream && s.sbx.state === 'running') streams.add(s.stream);++ if (tokens && streams.size) {++ let token = null;++ try { token = await tokens.getToken(); } catch (err) { log.error('[sandbox] no Network service token:', err.code || err.message); }++ for (const stream of streams) {++ if (!token) { lastAnnounce.live[stream] = { error: 'no_token' }; continue; }++ try {++ const status = await post(`${config.live.url}/api/streams/agent/${encodeURIComponent(stream)}/plays/worker`, body, { authorization: `Bearer ${token}` });++ lastAnnounce.live[stream] = { status, at: new Date(now()).toISOString() };++ } catch (err) { lastAnnounce.live[stream] = { error: err.code || err.name || 'failed' }; }++ }++ }++ return { codes: lastAnnounce.codes, live: { ...lastAnnounce.live } };++ }++++ // ── Sessions ────────────────────────────────────────────++++ async function openSession({ stream_id: stream = null, time_box_minutes: minutes } = {}) {++ if (!health().ready) throw Object.assign(new Error('the worker is not ready'), { status: 503 });++ const running = [...sessions.values()].filter((s) => s.sbx.state === 'running').length;++ if (running >= config.maxSessions) throw Object.assign(new Error('no room: every sandbox is in use'), { status: 429 });++ if (stream != null && !STREAM.test(String(stream))) throw Object.assign(new Error('bad stream_id'), { status: 400 });++ const box = Math.min(config.timeBoxMaxMinutes, Math.max(config.timeBoxMinMinutes, Number(minutes) || 10));++ const id = crypto.randomBytes(5).toString('hex');++ const egressLog = [];++ const sbx = await createSandbox({++ id, runtimeDir: config.runtimeDir, limiter, limits: config.limits, timeBoxMs: box * 60_000, nodePath: config.nodePath,++ browserPath: config.browserPath, log,++ onEgress: (d) => { egressLog.push(d); if (egressLog.length > 500) egressLog.shift(); },++ });++ const entry = { id, sbx, stream: stream ? String(stream) : null, egressLog, created: now() };++ sessions.set(id, entry);++ sbx.ended.then(({ reason }) => { log.log(`[sandbox] ${id} ended: ${reason}`); setTimeout(() => sessions.delete(id), 60_000).unref(); });++ return entry;++ }++++ const view = (e) => ({ ...e.sbx.info(), stream_id: e.stream, egress: { allowed: e.egressLog.filter((d) => d.allowed).length, refused: e.egressLog.filter((d) => !d.allowed).length } });++++ // ── The local API (a Unix socket; the runner on this host is its only client) ──++++ function readBody(req, limit) {++ return new Promise((resolve, reject) => {++ const parts = [];++ let size = 0;++ req.on('data', (c) => { size += c.length; if (size > limit) { reject(Object.assign(new Error('too large'), { status: 413 })); req.destroy(); } else parts.push(c); });++ req.on('end', () => resolve(Buffer.concat(parts)));++ req.on('error', reject);++ });++ }++ const json = async (req) => { const b = await readBody(req, MB); if (!b.length) return {}; try { return JSON.parse(String(b)); } catch { throw Object.assign(new Error('bad JSON'), { status: 400 }); } };++ const send = (res, status, body) => { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(body)); };++++ async function route(req, res) {++ const url = new URL(req.url, 'http://worker');++ const parts = url.pathname.split('/').filter(Boolean);++ if (req.method === 'GET' && url.pathname === '/health') return send(res, 200, { ...health(), announce: lastAnnounce });++ if (parts[0] !== 'sessions') return send(res, 404, { error: 'not_found' });++ if (parts.length === 1) {++ if (req.method === 'GET') return send(res, 200, { sessions: [...sessions.values()].map(view) });++ if (req.method === 'POST') { const e = await openSession(await json(req)); return send(res, 201, view(e)); }++ return send(res, 405, { error: 'method' });++ }++ if (!SESSION.test(parts[1]) || !sessions.has(parts[1])) return send(res, 404, { error: 'no_session' });++ const e = sessions.get(parts[1]);++ const action = parts[2] || '';++ if (!action && req.method === 'GET') return send(res, 200, { ...view(e), egress_log: e.egressLog.slice(-100) });++ if ((!action && req.method === 'DELETE') || (action === 'wipe' && req.method === 'POST')) {++ const out = await e.sbx.wipe(action === 'wipe' ? 'wipe_now' : 'end');++ return send(res, 200, { id: e.id, state: e.sbx.state, ...out });++ }++ if (action === 'exec' && req.method === 'POST') {++ const b = await json(req);++ const r = await e.sbx.exec(b.argv, { stdin: b.stdin == null ? null : String(b.stdin), tty: b.tty === true, timeoutMs: Number(b.timeout_ms) || 60_000 });++ return send(res, 200, r);++ }++ if (action === 'files' && req.method === 'PUT') { await e.sbx.writeFile(url.searchParams.get('path'), await readBody(req, 8 * MB)); return send(res, 200, { ok: true }); }++ if (action === 'files' && req.method === 'GET') {++ const data = await e.sbx.readFile(url.searchParams.get('path'));++ res.writeHead(200, { 'content-type': 'application/octet-stream', 'cache-control': 'no-store' });++ return res.end(data);++ }++ if (action === 'list' && req.method === 'GET') return send(res, 200, { entries: await e.sbx.list(url.searchParams.get('path') || '/workspace') });++ if (action === 'browser' && req.method === 'POST') { const b = await json(req); return send(res, 200, await e.sbx.openPage(String(b.url || ''))); }++ return send(res, 404, { error: 'not_found' });++ }++++ async function start() {++ if (!support) support = await probeSupport();++ if (!support.ok) log.error(`[sandbox] cannot make sandboxes: ${support.reason}`);++ if (!limiter) limiter = await detectLimits({ mode: config.limitsMode });++ if (!limiter.ok) log.error(`[sandbox] no limits: ${limiter.reason}`);++ fs.mkdirSync(config.runtimeDir, { recursive: true, mode: 0o750 });++ // A worker that crashed left its sandboxes' directories (their processes died with it: --die-with-parent).++ for (const name of fs.readdirSync(config.runtimeDir)) if (/^s-[a-z0-9-]+$/.test(name)) fs.rmSync(path.join(config.runtimeDir, name), { recursive: true, force: true });++ if (limiter.ok && limiter.base) {++ for (const name of fs.readdirSync(limiter.base)) if (/^sbx-[a-z0-9-]+$/.test(name)) { try { fs.rmdirSync(path.join(limiter.base, name)); } catch { /* still busy: left alone */ } }++ }++ await selfTest();++ server = http.createServer((req, res) => {++ route(req, res).catch((err) => send(res, err.status || (/outside the workspace|not a file|ENOENT/.test(err.message) ? 400 : 500), { error: String(err.message).slice(0, 200) }));++ });++ try { fs.unlinkSync(config.socket); } catch { /* none */ }++ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(config.socket, resolve); });++ fs.chmodSync(config.socket, config.socketGroupMode);++ await announce();++ const a = setInterval(() => { announce().catch(() => {}); }, config.announceMs);++ const t = setInterval(() => { selfTest().catch(() => {}); }, config.selfTestMs);++ timers.push(a, t);++ log.log(`[sandbox] worker ${config.workerId}: ${health().ready ? 'ready' : 'NOT ready'} (limits: ${limiter.kind}, browser: ${config.browserPath ? 'yes' : 'no'})`);++ return health();++ }++++ async function stop() {++ for (const t of timers.splice(0)) clearInterval(t);++ await Promise.all([...sessions.values()].map((e) => e.sbx.wipe('worker_stopped').catch(() => {})));++ if (server) await new Promise((r) => server.close(() => r()));++ try { fs.unlinkSync(config.socket); } catch { /* gone */ }++ }++++ return { start, stop, health, selfTest, announce, openSession, sessions, config, get server() { return server; } };++}++++module.exports = { createWorker, loadConfig };@@@ -221,6 +221,8 @@ async function createApp(opts = {}) {ctx.sessionRetention = sessions.retention;app.use('/internal/events', consumer.router);}++ // ── Chat plays sandbox workers → Codes (Agent Streams S6; loopback only, signed heartbeats) ──++ app.use('/internal/sandbox-workers', require('./http/sandbox-workers').createSandboxWorkerRoutes({ store, config, log }));// ── Machine endpoints ───────────────────────────────────app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-codes', version: VERSION }));@@@ -179,6 +179,12 @@ function load(env = process.env) {},// The event stream of a task (GET /api/v1/tasks/:id/events): the heartbeat that keeps a connection alive.sse: { heartbeatMs: Math.max(1000, int(env.CODES_SSE_HEARTBEAT_MS, 15_000)) },++ // The chat plays sandbox workers (Agent Streams S6, sandbox/worker.js): the secret their heartbeats are signed++ // with (a comma list during a rotation), and how long a worker that stopped checking in still counts.++ sandbox: {++ workerSecrets: String(env.CODES_SANDBOX_WORKER_SECRET || '').split(',').map((s) => s.trim()).filter(Boolean),++ staleMs: Math.max(10_000, int(env.CODES_SANDBOX_STALE_MS, 90_000)),++ },};}nnew file mode 100644@@@ -0,0 +1,90 @@++'use strict';++/**++ * The chat plays sandbox workers Codes knows (Agent Streams S6, design 2026-11-06 §1, §6): each worker++ * (sandbox/worker.js) checks in every 30 s with what its self-test found, and Codes answers whether a healthy one exists.++ *++ * Codes does not take a worker's word for "ready": a worker is healthy only when it said ready, every required check++ * passed, its limits are enforced, and it checked in within staleMs. Anything else is not there.++ *++ * createSandboxWorkers({ store, config })++ * heartbeat(report, signedAt) → { ok, worker } | { ok: false, code } (the route verified the signature)++ * health() → { ready, workers: [{ id, healthy, version, last_seen_at, capacity, active, failing }], stale_after_ms }++ */++const REQUIRED = ['network_namespace', 'no_direct_egress', 'egress_refuses_private', 'clean_env', 'no_host_files', 'no_capabilities', 'seccomp', 'limits', 'wipe'];++const ID = /^[A-Za-z0-9_.-]{1,64}$/;++++const bool = (v) => v === true;++const small = (v, max) => Math.max(0, Math.min(max, Math.round(Number(v) || 0)));++++/** Only the facts, in their own shape: a worker cannot store anything else here. */++function cleanReport(r) {++ const checks = {};++ for (const c of REQUIRED) checks[c] = bool(r && r.checks && r.checks[c]);++ const iso = (r && r.isolation && typeof r.isolation === 'object') ? r.isolation : {};++ const limits = iso.limits && typeof iso.limits === 'object' ? iso.limits : {};++ const isolation = {++ sandbox: String(iso.sandbox || '').slice(0, 32), user_namespace: bool(iso.user_namespace), network_namespace: bool(iso.network_namespace),++ egress: { public_only: bool(iso.egress && iso.egress.public_only), ports: Array.isArray(iso.egress && iso.egress.ports) ? iso.egress.ports.map(Number).filter((p) => p > 0 && p < 65536).slice(0, 4) : [] },++ host_credentials: String(iso.host_credentials || 'unknown').slice(0, 16), host_files: String(iso.host_files || 'unknown').slice(0, 16),++ seccomp: bool(iso.seccomp),++ limits: {++ kind: String(limits.kind || 'none').slice(0, 16), enforced: bool(limits.enforced), memory_mb: small(limits.memory_mb, 1 << 20),++ disk_mb: small(limits.disk_mb, 1 << 20), pids: small(limits.pids, 1 << 20), cpu_percent: small(limits.cpu_percent, 1 << 16),++ },++ };++ return {++ id: String(r && r.worker_id || ''), version: String(r && r.version || '').slice(0, 32), ready: bool(r && r.ready), checks, isolation,++ capacity: small(r && r.capacity, 1000), active: small(r && r.active, 1000),++ };++}++++/** Why a reported worker is not healthy (empty when it is). */++function failing(row, { now, staleMs }) {++ const out = [];++ if (!row.ready) out.push('not_ready');++ for (const c of REQUIRED) if (!(row.checks && row.checks[c] === true)) out.push(c);++ if (!(row.isolation && row.isolation.limits && row.isolation.limits.enforced === true)) out.push('limits_not_enforced');++ if (!(row.isolation && row.isolation.network_namespace === true)) out.push('no_network_namespace');++ if (now - new Date(row.last_seen_at).getTime() > staleMs) out.push('stale');++ return [...new Set(out)];++}++++function createSandboxWorkers({ store, config }) {++ const db = store.db;++ const staleMs = (config.sandbox && config.sandbox.staleMs) || 90_000;++++ async function heartbeat(report, signedAt) {++ const r = cleanReport(report);++ if (!ID.test(r.id)) return { ok: false, code: 'sandbox.bad_worker' };++ // The signed time must move forward: the same heartbeat sent twice is refused.++ const row = await db.maybe(++ `INSERT INTO codes_sandbox_workers (id, version, ready, checks, isolation, capacity, active, last_signed_at, last_seen_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)++ ON CONFLICT (id) DO UPDATE SET version = EXCLUDED.version, ready = EXCLUDED.ready, checks = EXCLUDED.checks,++ isolation = EXCLUDED.isolation, capacity = EXCLUDED.capacity, active = EXCLUDED.active,++ last_signed_at = EXCLUDED.last_signed_at, last_seen_at = EXCLUDED.last_seen_at++ WHERE codes_sandbox_workers.last_signed_at < EXCLUDED.last_signed_at++ RETURNING id`,++ [r.id, r.version, r.ready, JSON.stringify(r.checks), JSON.stringify(r.isolation), r.capacity, r.active, signedAt, new Date(store.now()).toISOString()]);++ if (!row) return { ok: false, code: 'signature.replayed' };++ return { ok: true, worker: r.id };++ }++++ async function health() {++ const now = store.now();++ const rows = await db.many('SELECT * FROM codes_sandbox_workers ORDER BY id');++ const workers = rows.map((row) => {++ const f = failing(row, { now, staleMs });++ return {++ id: row.id, healthy: f.length === 0, version: row.version, last_seen_at: new Date(row.last_seen_at).toISOString(),++ capacity: Number(row.capacity), active: Number(row.active), room: Math.max(0, Number(row.capacity) - Number(row.active)),++ limits: row.isolation && row.isolation.limits ? row.isolation.limits.kind : 'none', failing: f,++ };++ });++ return { ready: workers.some((w) => w.healthy), workers, stale_after_ms: staleMs, checked_at: new Date(now).toISOString() };++ }++++ return { heartbeat, health };++}++++module.exports = { createSandboxWorkers, cleanReport, failing, REQUIRED };nnew file mode 100644@@@ -0,0 +1,56 @@++'use strict';++/**++ * The chat plays sandbox workers' internal routes (Agent Streams S6), under /internal/sandbox-workers. Loopback only:++ * nginx answers 404 for /internal/, and a request that came through a proxy is refused here too.++ *++ * POST /heartbeat a worker checks in (sandbox/worker.js, every 30 s). The body is signed with the worker secret++ * (CODES_SANDBOX_WORKER_SECRET; X-OV-Sandbox-Signature, sandbox/signature.js): unsigned, wrongly++ * signed, stale or replayed heartbeats are refused, and without a configured secret there is none.++ * GET /health is there a healthy worker? { ready, workers: [{ id, healthy, failing, … }] } — no secret, path++ * or address in it. Live's chat plays gate and Codes' runner read it.++ */++const express = require('express');++const signature = require('../../sandbox/signature');++const { createSandboxWorkers } = require('../domain/sandbox-workers');++++function createSandboxWorkerRoutes({ store, config, log = console }) {++ const workers = createSandboxWorkers({ store, config });++ const r = express.Router();++ const secrets = (config.sandbox && config.sandbox.workerSecrets) || [];++ const fail = (res, status, code, detail) => res.status(status).set('Cache-Control', 'no-store').json({ error: code, detail });++++ r.use((req, res, next) => {++ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) return fail(res, 403, 'codes.internal_only', 'internal route');++ return next();++ });++++ r.post('/heartbeat', express.raw({ type: () => true, limit: '64kb' }), async (req, res) => {++ if (!secrets.length) return fail(res, 503, 'sandbox.not_configured', 'no worker secret is configured (CODES_SANDBOX_WORKER_SECRET)');++ const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);++ const v = signature.verify({ secrets, header: req.get(signature.HEADER), body, now: store.now() });++ if (!v.ok) return fail(res, 401, v.code, 'the heartbeat is not signed with the worker secret');++ let report;++ try { report = JSON.parse(body.toString('utf8')); } catch { return fail(res, 400, 'sandbox.bad_report', 'not JSON'); }++ try {++ const out = await workers.heartbeat(report, v.t);++ if (!out.ok) return fail(res, out.code === 'signature.replayed' ? 409 : 400, out.code, 'heartbeat refused');++ return res.set('Cache-Control', 'no-store').json({ ok: true, worker: out.worker });++ } catch (err) {++ log.error('[Codes] sandbox heartbeat:', err.message);++ return fail(res, 500, 'internal.error', 'the heartbeat was not stored');++ }++ });++++ r.get('/health', async (_req, res) => {++ try {++ res.set('Cache-Control', 'no-store').json(await workers.health());++ } catch (err) {++ log.error('[Codes] sandbox health:', err.message);++ fail(res, 500, 'internal.error', 'health unavailable');++ }++ });++++ return r;++}++++module.exports = { createSandboxWorkerRoutes };nnew file mode 100644@@@ -0,0 +1,222 @@++'use strict';++/**++ * A fake public web for the chat plays sandbox tests (sandbox/*.js). Nothing here reaches the real network:++ *++ * - the resolver answers fixed names (origin.example is a public address, private.example a 10/8 one, …) and++ * hands numeric spellings (127.1, 2130706433) to the system resolver, which answers them without any network;++ * - dial() is the proxy's only way out: it connects the fake public addresses to a local origin server, and++ * records any other address it is asked for (a test fails if the proxy ever dials one: the guard must refuse++ * before a socket is opened);++ * - a canary server on the host's loopback and on every host address counts every connection it gets: none++ * may come from the sandbox;++ * - canaries: an environment variable, a token file and an abstract socket only the host has.++ *++ * const world = await startWorld(); … world.egress (for createSandbox) … await world.close();++ */++const crypto = require('crypto');++const dns = require('dns');++const fs = require('fs');++const http = require('http');++const net = require('net');++const os = require('os');++const path = require('path');++++const ORIGIN_IP = '93.184.215.14'; // public: the fake origin answers here++const SMTP_IP = '93.184.215.15'; // public: but only port 25/587/465 is ever asked of it++const SELF_IP = '93.184.215.99'; // public, and one of the host's own addresses in these tests++++const NAMES = {++ 'origin.example': [ORIGIN_IP],++ 'smtp.example': [SMTP_IP],++ 'self.example': [SELF_IP],++ 'private.example': ['10.0.0.5'],++ 'lan.example': ['192.168.1.20'],++ 'docker.example': ['172.17.0.1'],++ 'meta.example': ['169.254.169.254'],++ 'loop.example': ['127.0.0.1'],++ 'cgnat.example': ['100.64.0.7'],++ 'mixed.example': [ORIGIN_IP, '192.168.1.10'],++ 'v6loop.example': ['::1'],++ 'v6mapped.example': ['::ffff:127.0.0.1'],++};++++/** dns.lookup for the fake names; numeric spellings (127.1, 2130706433, 0x7f000001) go to the system resolver. */++function lookup(hostname, options, callback) {++ if (typeof options === 'function') { callback = options; options = {}; }++ const host = String(hostname).toLowerCase().replace(/\.$/, '');++ const fixed = NAMES[host];++ if (fixed) {++ const addrs = fixed.map((address) => ({ address, family: net.isIP(address) }));++ return process.nextTick(() => (options && options.all ? callback(null, addrs) : callback(null, addrs[0].address, addrs[0].family)));++ }++ if (/^[0-9a-fx.]+$/i.test(host)) return dns.lookup(host, options, callback);++ const err = new Error(`getaddrinfo ENOTFOUND ${host}`);++ err.code = 'ENOTFOUND';++ return process.nextTick(() => callback(err));++}++++/** The host's own addresses, plus SELF_IP so "the host's own public address" can be tested without one. */++function hostAddresses() {++ const out = new Set([SELF_IP]);++ for (const list of Object.values(os.networkInterfaces())) for (const a of list || []) out.add(a.address.split('%')[0]);++ return out;++}++++function hostLanAddresses() {++ const out = [];++ for (const list of Object.values(os.networkInterfaces())) for (const a of list || []) if (!a.internal && a.family === 'IPv4') out.push(a.address);++ return out;++}++++async function listen(server, ...args) {++ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(...args, () => resolve()); });++ return server;++}++++async function startWorld() {++ const canary = crypto.randomBytes(12).toString('hex');++ const hits = { canary: 0, abstract: 0, unexpectedDials: [] };++++ // The fake public origin: / answers, /redirect?to= redirects, /page has a title the browser test reads.++ const origin = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://origin.example');++ if (u.pathname === '/redirect') { res.writeHead(302, { location: u.searchParams.get('to') || '/' }); return res.end(); }++ if (u.pathname === '/page') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end('<!doctype html><title>Origin page</title><h1 id="h">origin-ok</h1>'); }++ res.writeHead(200, { 'content-type': 'text/plain' });++ res.end('origin-ok');++ });++ await listen(origin, 0, '127.0.0.1');++ // The fake public TLS port: a raw TCP echo of "ping" → "pong" (CONNECT tunnels carry bytes, the test needs no TLS).++ const tunnel = net.createServer((s) => s.on('data', (d) => { if (String(d).includes('ping')) s.end('pong'); }));++ await listen(tunnel, 0, '127.0.0.1');++++ // The canary: the host's loopback and every host address. Nothing inside the sandbox may ever reach it.++ const canaryServer = http.createServer((req, res) => { hits.canary++; res.end(`host-secret-${canary}`); });++ await listen(canaryServer, 0, '0.0.0.0');++ const canaryPort = canaryServer.address().port;++ // An abstract socket (X11 and D-Bus use these): it lives in the host's network namespace only.++ const abstractName = `ov-canary-${canary}`;++ const abstractServer = net.createServer((s) => { hits.abstract++; s.end(canary); });++ await listen(abstractServer, `\0${abstractName}`);++++ /** The proxy's only way out: the two fake public addresses, nothing else. */++ function dial({ host, port }) {++ if (host === ORIGIN_IP && port === 80) return net.connect(origin.address().port, '127.0.0.1');++ if (host === ORIGIN_IP && port === 443) return net.connect(tunnel.address().port, '127.0.0.1');++ hits.unexpectedDials.push(`${host}:${port}`);++ const s = new net.Socket();++ process.nextTick(() => s.destroy(Object.assign(new Error('unexpected dial in a test'), { code: 'ECONNREFUSED' })));++ return s;++ }++++ // The worker's token and the host's own secrets: a file in a directory only the host has, and an env variable.++ const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-sbx-secret-'));++ const tokenFile = path.join(secretDir, 'worker-secret');++ fs.writeFileSync(tokenFile, `token-${canary}`, { mode: 0o600 });++ process.env.OV_SANDBOX_TEST_CANARY = `env-${canary}`;++++ return {++ canary, hits, canaryPort, abstractName, tokenFile, secretDir,++ hostLan: hostLanAddresses(),++ egress: { lookup, dial, hostAddresses },++ async close() {++ for (const s of [origin, tunnel, canaryServer, abstractServer]) await new Promise((r) => s.close(() => r()));++ fs.rmSync(secretDir, { recursive: true, force: true });++ delete process.env.OV_SANDBOX_TEST_CANARY;++ },++ };++}++++/**++ * A short runtime directory: a Unix socket path must fit in 108 bytes, so the sessions live under /tmp (or++ * XDG_RUNTIME_DIR), never under a long working directory.++ */++function shortRuntimeDir() {++ const base = process.env.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR.length < 40 ? process.env.XDG_RUNTIME_DIR : '/tmp';++ return fs.mkdtempSync(path.join(base, 'ovsbx-'));++}++++/** The pids of a process and every descendant, read from /proc on the host. */++function processTree(rootPid) {++ const children = new Map();++ for (const name of fs.readdirSync('/proc')) {++ if (!/^\d+$/.test(name)) continue;++ try {++ const stat = fs.readFileSync(`/proc/${name}/stat`, 'utf8');++ const ppid = Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1]);++ if (!children.has(ppid)) children.set(ppid, []);++ children.get(ppid).push(Number(name));++ } catch { /* gone already */ }++ }++ const out = [];++ const walk = (pid) => { out.push(pid); for (const c of children.get(pid) || []) walk(c); };++ walk(rootPid);++ return out;++}++++/** Is a process still there? A zombie (killed, not yet reaped by whoever inherited it) is not. */++const alive = (pid) => {++ try {++ const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');++ return stat.slice(stat.lastIndexOf(')') + 2, stat.lastIndexOf(')') + 3) !== 'Z';++ } catch { return false; }++};++++/**++ * The probe that runs INSIDE the sandbox (written to /workspace/probe.js). It takes a list of attempts as JSON and++ * prints what happened to each one as JSON. It uses only Node's own modules.++ */++const PROBE = String.raw`'use strict';++const net = require('net'); const dgram = require('dgram'); const fs = require('fs'); const path = require('path');++const PROXY = Number(process.env.OV_SANDBOX_PROXY_PORT || 3128);++const plan = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));++fs.unlinkSync(process.argv[2]); // the plan names the canaries: it must not be what a search finds++const out = [];++const withTimeout = (p, ms = 4000) => Promise.race([p, new Promise((r) => setTimeout(() => r({ error: 'TIMEOUT' }), ms))]);++function tcp(host, port) { return new Promise((resolve) => { const s = net.connect({ host, port }); s.on('connect', () => { s.destroy(); resolve({ connected: true }); }); s.on('error', (e) => resolve({ error: e.code || e.message })); }); }++function udp(host, port) { return new Promise((resolve) => { const s = dgram.createSocket(host.includes(':') ? 'udp6' : 'udp4'); s.send(Buffer.from('x'), port, host, (e) => { s.close(); resolve(e ? { error: e.code || e.message } : { sent: true }); }); }); }++function rawToProxy(text) { return new Promise((resolve) => { const s = net.connect(PROXY, '127.0.0.1'); let buf = ''; s.on('connect', () => s.write(text)); s.on('data', (d) => { buf += d; }); s.on('end', () => resolve(parse(buf))); s.on('close', () => resolve(parse(buf))); s.on('error', (e) => resolve({ error: e.code || e.message })); setTimeout(() => s.destroy(), 3000); }); }++function parse(buf) { const [head, ...rest] = buf.split('\r\n\r\n'); const lines = head.split('\r\n'); const m = /^HTTP\/1\.[01] (\d{3})/.exec(lines[0] || ''); const headers = {}; for (const l of lines.slice(1)) { const i = l.indexOf(':'); if (i > 0) headers[l.slice(0, i).trim().toLowerCase()] = l.slice(i + 1).trim(); } let body = rest.join('\r\n\r\n'); if (/chunked/i.test(headers['transfer-encoding'] || '')) { let out = ''; let r = body; for (;;) { const i = r.indexOf('\r\n'); if (i < 0) break; const n = parseInt(r.slice(0, i), 16); if (!n) break; out += r.slice(i + 2, i + 2 + n); r = r.slice(i + 4 + n); } body = out; } return { status: m ? Number(m[1]) : null, headers, body: body.slice(0, 300) }; }++async function get(url, follow) { const hops = []; let target = url; for (let i = 0; i < 5; i++) { const host = target.replace(/^[a-z]+:\/\//i, '').split('/')[0]; const r = await rawToProxy('GET ' + target + ' HTTP/1.1\r\nHost: ' + host + '\r\nConnection: close\r\n\r\n'); hops.push({ url: target, status: r.status, egress: r.headers && r.headers['x-ov-egress'] }); if (follow && r.status >= 300 && r.status < 400 && r.headers.location) { target = r.headers.location; continue; } return { ...r, hops }; } return { error: 'too many hops', hops }; }++function connect(authority, payload) { return new Promise((resolve) => { const s = net.connect(PROXY, '127.0.0.1'); let buf = ''; let open = false; s.on('connect', () => s.write('CONNECT ' + authority + ' HTTP/1.1\r\nHost: ' + authority + '\r\n\r\n')); s.on('data', (d) => { buf += d; if (!open && buf.includes('\r\n\r\n')) { const r = parse(buf); if (r.status === 200) { open = true; buf = ''; s.write(payload || 'ping'); } else { s.destroy(); resolve(r); } } }); s.on('end', () => resolve(open ? { status: 200, reply: buf } : parse(buf))); s.on('error', (e) => resolve({ error: e.code || e.message })); setTimeout(() => { s.destroy(); resolve(open ? { status: 200, reply: buf } : parse(buf)); }, 3000); }); }++function walk(root, needles, found, depth = 0) { let ents; try { ents = fs.readdirSync(root, { withFileTypes: true }); } catch { return; } for (const e of ents) { const p = path.join(root, e.name); if (e.isDirectory() && depth < 8) walk(p, needles, found, depth + 1); else if (e.isFile()) { try { const st = fs.statSync(p); if (st.size > 4 * 1024 * 1024) continue; const t = fs.readFileSync(p, 'latin1'); for (const n of needles) if (t.includes(n)) found.push(p); } catch { /* unreadable */ } } } }++(async () => {++ for (const a of plan) {++ let r;++ try {++ if (a.kind === 'tcp') r = await withTimeout(tcp(a.host, a.port));++ else if (a.kind === 'udp') r = await withTimeout(udp(a.host, a.port));++ else if (a.kind === 'http') r = await withTimeout(get(a.url, a.follow), 8000);++ else if (a.kind === 'raw') r = await withTimeout(rawToProxy(a.request));++ else if (a.kind === 'connect') r = await withTimeout(connect(a.authority, a.payload));++ else if (a.kind === 'env') r = { env: process.env };++ else if (a.kind === 'exists') r = { exists: a.paths.filter((p) => { try { fs.lstatSync(p); return true; } catch { return false; } }) };++ else if (a.kind === 'readdir') r = { entries: (() => { try { return fs.readdirSync(a.path); } catch (e) { return 'ERR:' + e.code; } })() };++ else if (a.kind === 'read') r = (() => { try { return { text: fs.readFileSync(a.path, 'utf8') }; } catch (e) { return { error: e.code }; } })();++ else if (a.kind === 'write') r = (() => { try { fs.writeFileSync(a.path, 'x'); return { wrote: true }; } catch (e) { return { error: e.code }; } })();++ else if (a.kind === 'abstract') r = await withTimeout(new Promise((resolve) => { const s = net.connect('\0' + a.name); s.on('connect', () => { s.destroy(); resolve({ connected: true }); }); s.on('error', (e) => resolve({ error: e.code })); }));++ else if (a.kind === 'grep') { const found = []; for (const root of a.roots) walk(root, a.needles, found); for (const pid of fs.readdirSync('/proc').filter((n) => /^\d+$/.test(n))) for (const f of ['environ', 'cmdline']) { try { const t = fs.readFileSync('/proc/' + pid + '/' + f, 'latin1'); for (const n of a.needles) if (t.includes(n)) found.push('/proc/' + pid + '/' + f); } catch { /* not ours */ } } r = { found }; }++ else if (a.kind === 'fds') { const links = []; for (const pid of fs.readdirSync('/proc').filter((n) => /^\d+$/.test(n))) { let fds = []; try { fds = fs.readdirSync('/proc/' + pid + '/fd'); } catch { continue; } for (const fd of fds) { try { links.push(fs.readlinkSync('/proc/' + pid + '/fd/' + fd)); } catch { /* closed */ } } } r = { links }; }++ else if (a.kind === 'status') r = { status: fs.readFileSync('/proc/self/status', 'utf8').split('\n').filter((l) => /^(Cap(Inh|Prm|Eff|Bnd|Amb)|NoNewPrivs|Seccomp):/.test(l)) };++ else if (a.kind === 'interfaces') r = { dev: fs.readFileSync('/proc/net/dev', 'utf8').split('\n').slice(2).map((l) => l.split(':')[0].trim()).filter(Boolean) };++ else r = { error: 'unknown kind' };++ } catch (e) { r = { error: e.code || e.message }; }++ out.push({ ...a, result: r });++ }++ // Exit once the output is flushed: the fallback timers above must not hold the probe open.++ process.stdout.write(JSON.stringify(out), () => process.exit(0));++})();++`;++++/** Write the probe and the plan into the sandbox and run it; resolves the list of attempts with their results. */++async function probe(sbx, plan, { timeoutMs = 45_000 } = {}) {++ await sbx.writeFile('/workspace/.probe.js', PROBE);++ await sbx.writeFile('/workspace/.plan.json', JSON.stringify(plan));++ const r = await sbx.exec(['node', '/workspace/.probe.js', '/workspace/.plan.json'], { timeoutMs });++ if (r.code !== 0) throw new Error(`the probe failed (${r.code}/${r.signal}): ${String(r.stderr).slice(0, 800)}`);++ return JSON.parse(String(r.stdout));++}++++module.exports = {++ ORIGIN_IP, SMTP_IP, SELF_IP, NAMES, lookup, hostAddresses, startWorld, shortRuntimeDir, processTree, alive, probe, PROBE,++};nnew file mode 100644@@@ -0,0 +1,189 @@++'use strict';++/**++ * The chat plays sandbox's egress proxy (sandbox/egress-proxy.js), in-process: the sandbox's only way out. These run++ * everywhere, CI included (the same refusals are tried from inside a real sandbox in sandbox-escape.test.js).++ *++ * - the guard is openvibe-shared/egress: public unicast addresses only, checked AFTER DNS resolution (every++ * answer must pass), and the socket opens to the address that was checked (no second lookup, no rebinding);++ * - the host's own addresses are refused even when they are public;++ * - ports 80 and 443 only: no SMTP, no database, no SSH, nothing else;++ * - the proxy never follows a redirect: the client's next request is a new one, checked again;++ * - a refusal is a 403 with X-OV-Egress, reported to the worker, and no socket is ever opened for it.++ */++const assert = require('assert');++const http = require('http');++const net = require('net');++const { createEgressProxy } = require('../sandbox/egress-proxy');++const { startWorld, ORIGIN_IP, SELF_IP } = require('./helpers/sandbox-world');++++let failures = 0;++async function check(name, fn) {++ try { await fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }++}++++function dechunk(text) {++ let out = '';++ let rest = text;++ for (;;) {++ const i = rest.indexOf('\r\n');++ if (i < 0) return out;++ const n = parseInt(rest.slice(0, i), 16);++ if (!n) return out;++ out += rest.slice(i + 2, i + 2 + n);++ rest = rest.slice(i + 2 + n + 2);++ }++}++++function raw(port, text) {++ return new Promise((resolve) => {++ const s = net.connect(port, '127.0.0.1');++ let buf = '';++ s.on('connect', () => s.write(text));++ s.on('data', (d) => { buf += d; });++ const done = () => {++ const [head, ...rest] = buf.split('\r\n\r\n');++ const lines = head.split('\r\n');++ const m = /^HTTP\/1\.[01] (\d{3})/.exec(lines[0] || '');++ const headers = {};++ for (const l of lines.slice(1)) { const i = l.indexOf(':'); if (i > 0) headers[l.slice(0, i).trim().toLowerCase()] = l.slice(i + 1).trim(); }++ let body = rest.join('\r\n\r\n');++ if (/chunked/i.test(headers['transfer-encoding'] || '')) body = dechunk(body);++ resolve({ status: m ? Number(m[1]) : null, headers, body });++ };++ s.on('end', done);++ s.on('error', done);++ setTimeout(() => { s.destroy(); done(); }, 3000);++ });++}++const get = (port, url, host) => raw(port, `GET ${url} HTTP/1.1\r\nHost: ${host || new URL(url).host}\r\nConnection: close\r\n\r\n`);++const connect = (port, authority) => raw(port, `CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n\r\n`);++++(async () => {++ console.log('sandbox egress proxy');++ const world = await startWorld();++ const decisions = [];++ const proxy = createEgressProxy({ ...world.egress, onDecision: (d) => decisions.push(d) });++ await proxy.listen({ port: 0, host: '127.0.0.1' });++ const port = proxy.address().port;++++ await check('the guard: public addresses pass, every private, local and reserved one is refused', async () => {++ assert.deepStrictEqual(await proxy.check('origin.example', 80), { ok: true, host: 'origin.example', port: 80, address: ORIGIN_IP, family: 4 });++ const refused = {++ '127.0.0.1': 'egress.private_address', '10.1.2.3': 'egress.private_address', '192.168.0.1': 'egress.private_address',++ '172.16.0.1': 'egress.private_address', '172.31.0.1': 'egress.private_address', '169.254.169.254': 'egress.private_address',++ '100.64.0.1': 'egress.private_address', '0.0.0.0': 'egress.private_address', '::1': 'egress.private_address',++ '::ffff:10.0.0.1': 'egress.private_address', 'fd00::1': 'egress.private_address', 'fe80::1': 'egress.private_address',++ '64:ff9b::a00:1': 'egress.private_address', '2002:a00:1::': 'egress.private_address',++ localhost: 'egress.internal_name', 'db.internal': 'egress.internal_name', 'nas.local': 'egress.internal_name', intranet: 'egress.internal_name',++ 'private.example': 'egress.private_address', 'mixed.example': 'egress.private_address', 'meta.example': 'egress.private_address',++ 'v6mapped.example': 'egress.private_address', [SELF_IP]: 'egress.host_address', 'self.example': 'egress.host_address',++ 'nowhere.example': 'egress.dns',++ };++ for (const [host, code] of Object.entries(refused)) {++ const r = await proxy.check(host, 443);++ assert.strictEqual(r.ok, false, `${host} is refused`);++ assert.strictEqual(r.code, code, `${host}: ${r.code}`);++ }++ for (const p of [25, 587, 465, 22, 6379, 5432, 8080, 0, 65536, -1]) assert.strictEqual((await proxy.check('origin.example', p)).code, 'egress.port', `port ${p}`);++ });++++ await check('GET through the proxy: the public origin answers, and the request goes to the address that was checked', async () => {++ const r = await get(port, 'http://origin.example/');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body, 'origin-ok');++ const d = decisions.find((x) => x.host === 'origin.example' && x.allowed);++ assert.strictEqual(d.address, ORIGIN_IP);++ });++++ await check('GET refuses private targets in every spelling, with 403 and X-OV-Egress, and opens no socket', async () => {++ const urls = ['http://127.0.0.1/', `http://127.0.0.1:${world.canaryPort}/`, 'http://2130706433/', 'http://0x7f000001/', 'http://127.1/', 'http://[::1]/',++ 'http://[::ffff:127.0.0.1]/', 'http://localhost/', 'http://10.0.0.1/', 'http://192.168.1.1/', 'http://172.16.0.1/', 'http://169.254.169.254/latest/meta-data/',++ 'http://private.example/', 'http://mixed.example/', `http://${SELF_IP}/`, 'http://origin.example:25/', 'http://origin.example:8080/'];++ for (const url of urls) {++ const r = await get(port, url);++ assert.strictEqual(r.status, 403, `${url}: ${r.status}`);++ assert.match(String(r.headers['x-ov-egress']), /^refused; code=egress\./, `${url} says why`);++ }++ assert.strictEqual(world.hits.canary, 0);++ assert.deepStrictEqual(world.hits.unexpectedDials, []);++ });++++ await check('the Host header cannot steer the request: the request target decides, and Host is rewritten to it', async () => {++ const r = await get(port, 'http://origin.example/', `127.0.0.1:${world.canaryPort}`);++ assert.strictEqual(r.status, 200);++ const bad = await get(port, `http://127.0.0.1:${world.canaryPort}/`, 'origin.example');++ assert.strictEqual(bad.status, 403);++ assert.strictEqual(world.hits.canary, 0);++ });++++ await check('an origin-form request (the proxy used as a server) and a non-HTTP scheme are refused', async () => {++ assert.strictEqual((await raw(port, 'GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n')).status, 400);++ for (const u of ['ftp://origin.example/', 'gopher://origin.example:70/', 'file:///etc/passwd', 'ws://origin.example/']) {++ const r = await raw(port, `GET ${u} HTTP/1.1\r\nHost: origin.example\r\nConnection: close\r\n\r\n`);++ assert.ok(r.status === 403 || r.status === 400, `${u}: ${r.status}`);++ }++ });++++ await check('CONNECT: 443 and 80 to a public address tunnel; private, SMTP and other ports are refused', async () => {++ const ok = await new Promise((resolve) => {++ const s = net.connect(port, '127.0.0.1', () => s.write('CONNECT origin.example:443 HTTP/1.1\r\nHost: origin.example:443\r\n\r\n'));++ let buf = ''; let open = false;++ s.on('data', (d) => { buf += d; if (!open && buf.includes('\r\n\r\n')) { open = /^HTTP\/1\.1 200/.test(buf); buf = ''; s.write('ping'); } });++ s.on('end', () => resolve({ open, reply: buf }));++ setTimeout(() => { s.destroy(); resolve({ open, reply: buf }); }, 3000);++ });++ assert.deepStrictEqual(ok, { open: true, reply: 'pong' });++ for (const a of ['127.0.0.1:443', '10.0.0.1:443', '[::1]:443', 'private.example:443', 'meta.example:80', 'origin.example:25', 'origin.example:587',++ 'origin.example:465', 'smtp.example:25', 'origin.example:22', 'origin.example:6379', '2130706433:443', 'localhost:443', `${SELF_IP}:443`, 'origin.example', 'origin.example:abc']) {++ const r = await connect(port, a);++ assert.ok(r.status === 403 || r.status === 400, `CONNECT ${a}: ${r.status}`);++ }++ assert.deepStrictEqual(world.hits.unexpectedDials, []);++ });++++ await check('a redirect is passed back, never followed: the next hop is a new request, checked again', async () => {++ const first = await get(port, `http://origin.example/redirect?to=${encodeURIComponent('http://169.254.169.254/latest/meta-data/')}`);++ assert.strictEqual(first.status, 302);++ assert.strictEqual(first.headers.location, 'http://169.254.169.254/latest/meta-data/');++ const second = await get(port, first.headers.location);++ assert.strictEqual(second.status, 403);++ assert.deepStrictEqual(world.hits.unexpectedDials, []);++ });++++ await check('a real HTTP client through the proxy gets the same answers', async () => {++ const viaProxy = (url) => new Promise((resolve, reject) => {++ const u = new URL(url);++ http.get({ host: '127.0.0.1', port, path: url, headers: { host: u.host } }, (res) => {++ let body = ''; res.on('data', (d) => { body += d; }); res.on('end', () => resolve({ status: res.statusCode, body }));++ }).on('error', reject);++ });++ assert.deepStrictEqual(await viaProxy('http://origin.example/'), { status: 200, body: 'origin-ok' });++ assert.strictEqual((await viaProxy('http://10.0.0.1/')).status, 403);++ });++++ await check('every decision is reported (the worker logs them for the owner), refusals with their code', async () => {++ assert.ok(decisions.length > 20);++ for (const d of decisions) {++ assert.strictEqual(typeof d.allowed, 'boolean');++ if (!d.allowed) assert.match(d.code, /^egress\./);++ assert.ok(!('body' in d) && !('headers' in d), 'a decision carries no request content');++ }++ });++++ await check('the proxy holds a bounded number of connections', async () => {++ const small = createEgressProxy({ ...world.egress, maxConnections: 2 });++ await small.listen({ port: 0, host: '127.0.0.1' });++ const p = small.address().port;++ const held = [net.connect(p, '127.0.0.1'), net.connect(p, '127.0.0.1')];++ await new Promise((r) => setTimeout(r, 100));++ const third = await raw(p, 'GET http://origin.example/ HTTP/1.1\r\nHost: origin.example\r\nConnection: close\r\n\r\n');++ assert.strictEqual(third.status, 503);++ for (const s of held) s.destroy();++ await small.close();++ });++++ await proxy.close();++ await world.close();++ console.log(failures ? `\n${failures} failed` : '\nall passed');++ process.exit(failures ? 1 : 0);++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,239 @@++'use strict';++/**++ * The chat plays sandbox, from the inside (Agent Streams S6, design 2026-11-06 §1, §5 and §9). A viewer's request++ * is untrusted text, so every attempt below is made FROM INSIDE a real sandbox (bubblewrap, sandbox/session.js) by a++ * probe running as the agent would, and each one must be refused:++ *++ * - the host network: no interface but the sandbox's own loopback; no TCP or UDP to 127.0.0.1 (the host's), the++ * host's addresses, 10/8, 192.168/16, 172.16/12, 169.254.169.254 or even a public address, directly;++ * - through the egress proxy (the only way out): the same addresses in every spelling, a name that resolves to++ * one, a name with one private answer among public ones, an http redirect to one, SMTP (25, 587, 465) and any++ * port but 80 and 443; the public web still works (the positive control, so no refusal here is vacuous);++ * - the host's secrets: its environment, ~/.ssh, the worker's token (file and env), any /proc of the host, an++ * abstract socket, an inherited file descriptor; and the sandbox holds no capability and cannot write outside++ * its own workspace;++ * - Wipe now: every process of the sandbox is gone, the session's directory, proxy and cgroup are gone, and the++ * next sandbox starts with an empty workspace; the time box wipes on its own.++ *++ * It needs bubblewrap and unprivileged user namespaces. Where they are missing (a CI runner without bubblewrap, or++ * with kernel.apparmor_restrict_unprivileged_userns=1) it prints `sandbox escape: skipped (<why>)`; run it where they++ * are (the worker's host, or a workstation) to prove it: `ov test test/sandbox-escape.test.js`.++ */++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { startWorld, shortRuntimeDir, processTree, alive, probe, ORIGIN_IP, SELF_IP } = require('./helpers/sandbox-world');++++let failures = 0;++async function check(name, fn) {++ const t0 = Date.now();++ try { await fn(); console.log(' ✓', name, process.env.VERBOSE_TIMES ? `(${Date.now() - t0} ms)` : ''); } catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }++}++++const refusedByProxy = (r) => r && r.result && r.result.status === 403 && /refused/.test(String(r.result.headers && r.result.headers['x-ov-egress']));++const describe = (r) => JSON.stringify(r.result).slice(0, 300);++++(async () => {++ const { probeSupport, createSandbox } = require('../sandbox/session');++ const support = await probeSupport();++ if (!support.ok) {++ console.log(`sandbox escape: skipped (${support.reason})`);++ process.exit(0);++ }++ console.log(`sandbox escape (bubblewrap ${support.version}, seccomp ${support.seccomp ? 'on' : 'off'})`);++ const world = await startWorld();++ const runtimeDir = shortRuntimeDir();++ const egressLog = [];++ const open = (extra = {}) => createSandbox({++ runtimeDir, egress: world.egress, allowUnlimited: true, limiter: null,++ limits: { memoryMb: 512, diskMb: 64, pids: 128, cpuPercent: 100 }, timeBoxMs: 5 * 60_000,++ onEgress: (d) => egressLog.push(d), ...extra,++ });++ const sbx = await open();++ const hostHome = os.homedir();++++ await check('the sandbox has no network but its own loopback', async () => {++ const [r] = await probe(sbx, [{ kind: 'interfaces' }]);++ assert.deepStrictEqual(r.result.dev, ['lo'], `interfaces: ${describe(r)}`);++ });++++ await check('no direct TCP or UDP reaches the host, the private ranges, the metadata address or even the public web', async () => {++ const targets = [++ ['127.0.0.1', world.canaryPort], ...world.hostLan.map((ip) => [ip, world.canaryPort]),++ ['10.0.0.1', 80], ['192.168.1.1', 80], ['172.16.0.1', 80], ['172.17.0.1', 80], ['169.254.169.254', 80],++ ['100.64.0.1', 80], [ORIGIN_IP, 80], [ORIGIN_IP, 443], ['1.1.1.1', 25], ['8.8.8.8', 587], ['::1', world.canaryPort],++ ];++ const plan = targets.map(([host, port]) => ({ kind: 'tcp', host, port }))++ .concat([{ kind: 'udp', host: '8.8.8.8', port: 53 }, { kind: 'udp', host: '169.254.169.254', port: 53 }]);++ for (const r of await probe(sbx, plan)) {++ assert.ok(!r.result.connected && !r.result.sent, `${r.kind} ${r.host}:${r.port} must fail: ${describe(r)}`);++ if (r.host === '127.0.0.1' || r.host === '::1') assert.strictEqual(r.result.error, 'ECONNREFUSED', 'the loopback is the sandbox\'s own: nothing listens there');++ else assert.ok(['ENETUNREACH', 'EHOSTUNREACH', 'EADDRNOTAVAIL'].includes(r.result.error), `${r.host} has no route: ${describe(r)}`);++ }++ assert.strictEqual(world.hits.canary, 0, 'the host\'s canary server was never reached');++ });++++ await check('the proxy refuses 127.0.0.1, the host\'s own addresses and every private range, in every spelling', async () => {++ const urls = [++ `http://127.0.0.1:${world.canaryPort}/`, `http://127.0.0.1/`, `http://localhost:${world.canaryPort}/`, 'http://2130706433/',++ 'http://0x7f000001/', 'http://127.1/', 'http://[::1]/', 'http://[::ffff:127.0.0.1]/', 'http://0.0.0.0/',++ ...world.hostLan.map((ip) => `http://${ip}:${world.canaryPort}/`), `http://${SELF_IP}/`, 'http://self.example/',++ 'http://10.0.0.1/', 'http://10.255.255.254/', 'http://192.168.0.1/', 'http://192.168.1.1/', 'http://172.16.0.1/',++ 'http://172.31.255.254/', 'http://172.17.0.1/', 'http://169.254.169.254/latest/meta-data/iam/', 'http://100.64.0.1/',++ 'http://[fd00::1]/', 'http://[fe80::1]/', 'http://metadata.google.internal/', 'http://printer.local/',++ ];++ const results = await probe(sbx, urls.map((url) => ({ kind: 'http', url })));++ for (const r of results) assert.ok(refusedByProxy(r), `${r.url} must be refused by the egress proxy: ${describe(r)}`);++ assert.strictEqual(world.hits.canary, 0, 'the host\'s canary server was never reached');++ });++++ await check('a name that resolves to a private address is refused after resolution (and one private answer is enough)', async () => {++ const names = ['private.example', 'lan.example', 'docker.example', 'meta.example', 'loop.example', 'cgnat.example', 'mixed.example', 'v6loop.example', 'v6mapped.example'];++ const plan = names.flatMap((n) => [{ kind: 'http', url: `http://${n}/` }, { kind: 'connect', authority: `${n}:443` }]);++ for (const r of await probe(sbx, plan)) {++ if (r.kind === 'http') assert.ok(refusedByProxy(r), `${r.url}: ${describe(r)}`);++ else assert.strictEqual(r.result.status, 403, `CONNECT ${r.authority}: ${describe(r)}`);++ }++ });++++ await check('an http redirect to a private address is refused at the hop that follows it', async () => {++ const to = [`http://127.0.0.1:${world.canaryPort}/`, 'http://169.254.169.254/latest/meta-data/', 'http://private.example/', 'http://10.0.0.1:8080/'];++ const plan = to.map((t) => ({ kind: 'http', url: `http://origin.example/redirect?to=${encodeURIComponent(t)}`, follow: true }));++ for (const r of await probe(sbx, plan)) {++ const hops = r.result.hops || [];++ assert.strictEqual(hops[0] && hops[0].status, 302, `the public origin answered its redirect: ${describe(r)}`);++ assert.strictEqual(hops.length, 2, `the redirect was followed once: ${describe(r)}`);++ assert.ok(refusedByProxy(r), `the second hop is refused: ${describe(r)}`);++ }++ assert.strictEqual(world.hits.canary, 0);++ });++++ await check('SMTP (25, 587, 465) and every port but 80 and 443 are refused, with CONNECT and without', async () => {++ const ports = [25, 587, 465, 22, 21, 53, 3306, 5432, 6379, 8080, 9222, 11211];++ const plan = ports.flatMap((p) => [{ kind: 'connect', authority: `origin.example:${p}` }, { kind: 'http', url: `http://origin.example:${p}/` }])++ .concat([{ kind: 'connect', authority: 'smtp.example:25' }, { kind: 'raw', request: 'EHLO sandbox\r\n\r\n' }]);++ for (const r of await probe(sbx, plan)) {++ if (r.kind === 'raw') assert.ok(r.result.status === 400 || r.result.status === null, `a non-HTTP greeting gets nothing: ${describe(r)}`);++ else assert.strictEqual(r.result.status, 403, `${r.authority || r.url}: ${describe(r)}`);++ }++ });++++ await check('only http and https: no other scheme passes the proxy', async () => {++ const plan = ['ftp://origin.example/', 'gopher://origin.example/', 'file:///etc/passwd', 'ws://origin.example/'].map((u) => ({ kind: 'raw', request: `GET ${u} HTTP/1.1\r\nHost: origin.example\r\nConnection: close\r\n\r\n` }));++ for (const r of await probe(sbx, plan)) assert.ok(r.result.status === 403 || r.result.status === 400, `${r.request.split(' ')[1]}: ${describe(r)}`);++ });++++ await check('the positive control: the public web answers through the proxy, http and an https tunnel', async () => {++ const [a, b] = await probe(sbx, [{ kind: 'http', url: 'http://origin.example/' }, { kind: 'connect', authority: 'origin.example:443', payload: 'ping' }]);++ assert.strictEqual(a.result.status, 200, describe(a));++ assert.strictEqual(a.result.body, 'origin-ok');++ assert.strictEqual(b.result.status, 200, describe(b));++ assert.strictEqual(b.result.reply, 'pong', 'the tunnel carries bytes both ways');++ assert.deepStrictEqual(world.hits.unexpectedDials, [], 'the proxy never opened a socket to a refused address');++ assert.ok(egressLog.some((d) => d.allowed === false && d.code === 'egress.private_address'), 'refusals are reported to the worker');++ });++++ await check('the environment holds nothing of the host: no variable of it, no token, no canary', async () => {++ const [r] = await probe(sbx, [{ kind: 'env' }]);++ const env = r.result.env;++ const allowed = /^(PATH|HOME|USER|LOGNAME|SHELL|LANG|LC_ALL|TERM|TMPDIR|PWD|SHLVL|_|OLDPWD|http_proxy|https_proxy|HTTP_PROXY|HTTPS_PROXY|no_proxy|NO_PROXY|npm_config_cache|OV_SANDBOX(_[A-Z_]+)?)$/;++ for (const k of Object.keys(env)) assert.ok(allowed.test(k), `${k} is not a variable the sandbox gives: the host's environment leaked`);++ assert.ok(!JSON.stringify(env).includes(world.canary), 'no canary in the environment');++ assert.strictEqual(env.HOME, '/home/agent');++ });++++ await check('no host file is there: ~/.ssh, the home folder, the worker\'s token, /root, the runtime directory, D-Bus, Docker', async () => {++ const paths = [path.join(hostHome, '.ssh'), path.join(hostHome, '.ssh/id_ed25519'), path.join(hostHome, '.ssh/id_rsa'), hostHome,++ world.tokenFile, world.secretDir, '/root/.ssh', '/root', runtimeDir, sbx.dir, '/run/user', '/run/dbus/system_bus_socket',++ '/var/run/docker.sock', '/run/docker.sock', '/etc/openvibe', '/etc/shadow', '/etc/ssh', '/etc/ssl/private', '/opt/openvibe.codes',++ '/var/lib', '/sys/fs/cgroup', '/usr/local/bin'];++ const [r, home] = await probe(sbx, [{ kind: 'exists', paths }, { kind: 'readdir', path: '/home' }]);++ assert.deepStrictEqual(r.result.exists, [], `these host paths are visible inside: ${r.result.exists.join(', ')}`);++ assert.deepStrictEqual(home.result.entries, ['agent'], 'only the agent\'s own empty home is under /home');++ });++++ await check('the worker\'s token and the host\'s canaries are nowhere inside: files, /proc, the user list', async () => {++ const needles = [world.canary, path.basename(world.secretDir)];++ const [g, pw] = await probe(sbx, [{ kind: 'grep', needles, roots: ['/workspace', '/home', '/tmp', '/run', '/etc', '/opt/ov', '/dev/shm'] }, { kind: 'read', path: '/etc/passwd' }]);++ assert.deepStrictEqual(g.result.found, [], `a canary was found inside: ${g.result.found.join(', ')}`);++ const users = pw.result.text.trim().split('\n').map((l) => l.split(':')[0]).sort();++ assert.deepStrictEqual(users, ['agent', 'nobody'], 'the user list is the sandbox\'s own, not the host\'s');++ assert.ok(!pw.result.text.includes(os.userInfo().username + ':'), 'the host user is not named');++ });++++ await check('no host process, socket or file descriptor reaches in: an abstract socket and every open fd', async () => {++ const [a, f] = await probe(sbx, [{ kind: 'abstract', name: world.abstractName }, { kind: 'fds' }]);++ assert.strictEqual(a.result.error, 'ECONNREFUSED', `the host's abstract socket is unreachable: ${describe(a)}`);++ assert.strictEqual(world.hits.abstract, 0);++ const leaked = f.result.links.filter((l) => l.startsWith(hostHome) || l.includes(world.secretDir) || l.startsWith(runtimeDir) || l.includes('worker-secret'));++ assert.deepStrictEqual(leaked, [], 'no fd of the host is open inside');++ });++++ await check('no capability, no new privileges, the seccomp filter, and nothing writable outside the sandbox\'s own areas', async () => {++ const plan = [{ kind: 'status' }, ...['/usr/x', '/etc/x', '/opt/ov/x', '/run/ov/net/x', '/x', '/proc/sys/kernel/hostname'].map((p) => ({ kind: 'write', path: p })),++ ...['/workspace/ok', '/home/agent/ok', '/tmp/ok'].map((p) => ({ kind: 'write', path: p }))];++ const [s, ...writes] = await probe(sbx, plan);++ const status = Object.fromEntries(s.result.status.map((l) => l.split(':\t')));++ for (const c of ['CapInh', 'CapPrm', 'CapEff', 'CapBnd', 'CapAmb']) assert.strictEqual(status[c], '0000000000000000', `${c} is empty`);++ assert.strictEqual(status.NoNewPrivs, '1');++ if (support.seccomp) assert.strictEqual(status.Seccomp, '2', 'the seccomp filter is on');++ for (const w of writes) {++ const mine = /^\/(workspace|home\/agent|tmp)\//.test(w.path);++ if (mine) assert.strictEqual(w.result.wrote, true, `${w.path} is the sandbox's own`);++ else assert.ok(w.result.error, `${w.path} must not be writable: ${describe(w)}`);++ }++ });++++ await check('no nested namespace and no kernel keyring: unshare and keyctl are refused', async () => {++ const r = await sbx.exec(['sh', '-c', 'unshare -U true 2>/dev/null && echo unshared || echo refused'], { timeoutMs: 10_000 });++ assert.strictEqual(String(r.stdout).trim(), 'refused', 'a nested user namespace is refused');++ // The kernel keyring holds a login's keys; keyctl(KEYCTL_GET_KEYRING_ID, KEY_SPEC_SESSION_KEYRING) asks for it.++ const nr = { x64: 250, arm64: 219 }[process.arch];++ const py = await sbx.exec(['sh', '-c', 'command -v python3 || true'], { timeoutMs: 10_000 });++ if (support.seccomp && nr && String(py.stdout).trim()) {++ const k = await sbx.exec(['python3', '-c', `import ctypes,os;l=ctypes.CDLL(None,use_errno=True);r=l.syscall(${nr},0,-3,0);print(r, os.strerror(ctypes.get_errno()) if r<0 else "")`], { timeoutMs: 10_000 });++ assert.match(String(k.stdout).trim(), /^-1 Operation not permitted$/, `keyctl is refused by the seccomp filter: ${k.stdout}${k.stderr}`);++ } else {++ console.log(' (keyctl: no python3 inside or no seccomp filter; the keyring check needs both)');++ }++ });++++ await check('Wipe now: every process, the workspace, the directory, the proxy socket and the cgroup are gone', async () => {++ const w = await open();++ await w.writeFile('/workspace/left-behind.txt', `${world.canary}`);++ await w.exec(['sh', '-c', 'setsid sleep 300 >/dev/null 2>&1 & nohup sleep 301 >/dev/null 2>&1 & echo started'], { timeoutMs: 10_000 });++ const tree = processTree(w.pid);++ assert.ok(tree.length >= 4, `the sandbox has its processes on the host: ${tree.join(',')}`);++ const dir = w.dir;++ const cg = w.cgroup;++ assert.ok(fs.existsSync(path.join(dir, 'net', 'egress.sock')), 'the proxy socket is there while the sandbox runs');++ assert.ok(w._pidns, 'the worker knows the sandbox\'s pid namespace, so it can prove it empty');++ const out = await w.wipe('wipe_now');++ assert.deepStrictEqual(out, { reason: 'wipe_now' }, 'no process outlived the wipe');++ assert.strictEqual(w.state, 'wiped');++ for (const pid of tree) assert.ok(!alive(pid), `pid ${pid} of the sandbox is gone`);++ assert.ok(!fs.existsSync(dir), 'the session directory is gone');++ if (cg) assert.ok(!fs.existsSync(cg), 'the cgroup is gone');++ await assert.rejects(() => w.exec(['true']), /wiped|ended/, 'nothing runs in a wiped sandbox');++ const next = await open();++ const ls = await next.exec(['sh', '-c', 'ls -A /workspace /home/agent /tmp'], { timeoutMs: 10_000 });++ assert.strictEqual(String(ls.stdout).replace(/^\/[^\n]*:\n?/gm, '').trim(), '', `a fresh sandbox is empty: ${ls.stdout}`);++ await next.wipe('end');++ });++++ await check('the time box wipes the sandbox on its own', async () => {++ const t = await open({ timeBoxMs: 1500 });++ const tree = processTree(t.pid);++ const ended = await Promise.race([t.ended, new Promise((r) => setTimeout(() => r({ reason: 'still running' }), 6000))]);++ assert.strictEqual(ended.reason, 'time_box');++ for (const pid of tree) assert.ok(!alive(pid), `pid ${pid} is gone`);++ assert.ok(!fs.existsSync(t.dir));++ });++++ await sbx.wipe('end');++ await world.close();++ fs.rmSync(runtimeDir, { recursive: true, force: true });++ console.log(failures ? `\n${failures} failed` : '\nall passed');++ process.exit(failures ? 1 : 0);++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,96 @@++'use strict';++/**++ * The chat plays sandbox's limits (Agent Streams S6, design 2026-11-06 §1, §3): a request cannot take the worker's++ * host down with it. Each sandbox runs in a cgroup of its own (sandbox/limits.js): a delegated cgroup v2 subtree++ * under the worker's systemd unit (Delegate=yes), or a transient scope of the user's systemd on a workstation.++ *++ * - process count: pids.max holds a fork bomb;++ * - memory: memory.max (no swap) kills the process that asks for too much, and the sandbox goes on;++ * - disk: the workspace, the home folder and /tmp are size-limited tmpfs: a big write ends in ENOSPC;++ * - CPU: cpu.max is the quota the worker set;++ * - and the cgroup is removed with the sandbox.++ *++ * It needs bubblewrap and a cgroup the worker may create children in. Without them it prints++ * `sandbox limits: skipped (<why>)`. Locally: `ov test test/sandbox-limits.test.js` (a user systemd is enough), or++ * the production way, `systemd-run --user --scope -p Delegate=yes -E CODES_SANDBOX_LIMITS=cgroup node test/sandbox-limits.test.js`.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { startWorld, shortRuntimeDir } = require('./helpers/sandbox-world');++++let failures = 0;++async function check(name, fn) {++ try { await fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }++}++++(async () => {++ const { probeSupport, createSandbox } = require('../sandbox/session');++ const { detectLimits } = require('../sandbox/limits');++ const support = await probeSupport();++ if (!support.ok) { console.log(`sandbox limits: skipped (${support.reason})`); process.exit(0); }++ const limiter = await detectLimits({ mode: process.env.CODES_SANDBOX_LIMITS || 'auto' });++ if (!limiter.ok) { console.log(`sandbox limits: skipped (no cgroup to put a sandbox in: ${limiter.reason})`); process.exit(0); }++ console.log(`sandbox limits (${limiter.kind})`);++++ const world = await startWorld();++ const runtimeDir = shortRuntimeDir();++ const limits = { memoryMb: 160, diskMb: 32, pids: 64, cpuPercent: 50 };++ const sbx = await createSandbox({ runtimeDir, egress: world.egress, limiter, limits, timeBoxMs: 5 * 60_000 });++ const read = (f) => fs.readFileSync(path.join(sbx.cgroup, f), 'utf8').trim();++ const readMaybe = (f) => { try { return read(f); } catch { return ''; } };++++ await check('the sandbox runs in a cgroup of its own with the limits the worker set', async () => {++ assert.ok(sbx.cgroup && fs.existsSync(sbx.cgroup), `the cgroup is there: ${sbx.cgroup}`);++ const procs = read('cgroup.procs').split('\n').map(Number);++ assert.ok(procs.includes(sbx.pid), 'the sandbox\'s first process is in it');++ // The tmpfs areas are charged to the sandbox's memory too, so memory.max is memory + disk.++ assert.strictEqual(Number(read('memory.max')), (limits.memoryMb + 3 * limits.diskMb) * 1024 * 1024);++ assert.strictEqual(read('memory.swap.max'), '0');++ assert.strictEqual(Number(read('pids.max')), limits.pids);++ const [quota, period] = read('cpu.max').split(' ').map(Number);++ assert.strictEqual(Math.round((quota / period) * 100), limits.cpuPercent, `cpu.max: ${read('cpu.max')}`);++ });++++ await check('a fork bomb stops at pids.max', async () => {++ const r = await sbx.exec(['sh', '-c', 'i=0; while [ $i -lt 100 ]; do i=$((i+1)); sleep 3 & done; wait'], { timeoutMs: 30_000 });++ // The kernel's own count of refused forks, not the shell's (dash gives up at the first "Cannot fork").++ const refused = Number((/^max (\d+)/m.exec(readMaybe('pids.events')) || [])[1]);++ assert.ok(refused > 0, `the cgroup refused forks (pids.events: ${readMaybe('pids.events')})`);++ assert.match(String(r.stderr), /Cannot fork|Resource temporarily unavailable/, 'the sandbox saw the refusal');++ const peak = readMaybe('pids.peak');++ if (peak) assert.ok(Number(peak) <= limits.pids, `the cgroup never held more than ${limits.pids} (peak ${peak})`);++ // The sleeps that did start finish on their own; the next checks need the room back.++ const until = Date.now() + 10_000;++ while (Number(readMaybe('pids.current')) > 16 && Date.now() < until) await new Promise((res) => setTimeout(res, 100));++ assert.ok(Number(readMaybe('pids.current')) <= 16, `the sandbox drained (${readMaybe('pids.current')})`);++ });++++ await check('memory.max kills the process that asks for too much, and the sandbox goes on', async () => {++ const r = await sbx.exec(['node', '-e', 'const a=[];for(let i=0;i<80;i++){a.push(Buffer.alloc(8*1024*1024,1))}console.log("allocated")'], { timeoutMs: 30_000 });++ assert.ok(r.signal === 'SIGKILL' || r.code === 137, `the allocation was killed: ${r.code}/${r.signal} ${r.stdout}`);++ assert.ok(!String(r.stdout).includes('allocated'));++ const after = await sbx.exec(['sh', '-c', 'echo still-here'], { timeoutMs: 10_000 });++ assert.strictEqual(String(after.stdout).trim(), 'still-here', 'the sandbox survives its own out-of-memory');++ });++++ await check('the disk is a size-limited tmpfs: the workspace, home and /tmp end in ENOSPC', async () => {++ for (const dir of ['/workspace', '/home/agent', '/tmp']) {++ const r = await sbx.exec(['sh', '-c', `head -c ${(limits.diskMb + 8) * 1024 * 1024} /dev/zero > ${dir}/big; echo $?; rm -f ${dir}/big`], { timeoutMs: 30_000 });++ assert.match(String(r.stderr), /No space left on device/, `${dir}: ${r.stderr}`);++ }++ });++++ await check('the cgroup is removed with the sandbox', async () => {++ const cg = sbx.cgroup;++ await sbx.wipe('end');++ const gone = Date.now() + 5000;++ while (fs.existsSync(cg) && Date.now() < gone) await new Promise((r) => setTimeout(r, 100));++ assert.ok(!fs.existsSync(cg), `${cg} is gone`);++ });++++ await world.close();++ fs.rmSync(runtimeDir, { recursive: true, force: true });++ console.log(failures ? `\n${failures} failed` : '\nall passed');++ process.exit(failures ? 1 : 0);++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,118 @@++'use strict';++/**++ * Codes' side of the chat plays sandbox workers (Agent Streams S6): /internal/sandbox-workers.++ *++ * - a heartbeat must be signed with the worker secret: unsigned, wrongly signed, stale and replayed ones are refused,++ * and without a configured secret nothing can check in;++ * - a request that came through a proxy is refused (the routes are loopback only);++ * - health says ready only for a worker that said ready, passed every check, enforces its limits and checked in++ * recently; a worker that stopped checking in, or failed a check, is not there;++ * - nothing but the facts is stored, and the health answer holds no secret.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const signature = require('../sandbox/signature');++const { REQUIRED } = require('../server/domain/sandbox-workers');++++const SECRET = 'sandbox-worker-test-secret-0123456789abcdef';++++function report(over = {}) {++ return {++ worker_id: 'sbw-test', service: 'codes-sandbox', version: '0.7.0', ready: true,++ checks: Object.fromEntries(REQUIRED.map((c) => [c, true])),++ isolation: { sandbox: 'bubblewrap', user_namespace: true, network_namespace: true, egress: { via: 'proxy', public_only: true, ports: [80, 443] }, host_credentials: 'none', host_files: 'none', seccomp: true, limits: { kind: 'cgroup', enforced: true, memory_mb: 2048, disk_mb: 512, pids: 256, cpu_percent: 100 } },++ capacity: 2, active: 0, at: new Date().toISOString(),++ ...over,++ };++}++++(async () => {++ const t = await boot({ env: { CODES_SANDBOX_WORKER_SECRET: `old-secret-${'x'.repeat(20)},${SECRET}` } });++ const beat = (body, { secret = SECRET, at = Date.now(), headers = {} } = {}) => {++ const raw = JSON.stringify(body);++ return t.get('/internal/sandbox-workers/heartbeat', { method: 'POST', body: raw, headers: { 'content-type': 'application/json', [signature.HEADER]: signature.sign(secret, raw, at), ...headers } });++ };++ const health = async () => (await t.get('/internal/sandbox-workers/health')).json();++++ await check('no worker yet: health says not ready', async () => {++ const h = await health();++ assert.strictEqual(h.ready, false);++ assert.deepStrictEqual(h.workers, []);++ });++++ await check('an unsigned, wrongly signed or stale heartbeat is refused', async () => {++ const raw = JSON.stringify(report());++ const unsigned = await t.get('/internal/sandbox-workers/heartbeat', { method: 'POST', body: raw, headers: { 'content-type': 'application/json' } });++ assert.strictEqual(unsigned.status, 401);++ assert.strictEqual((await beat(report(), { secret: 'not-the-secret' })).status, 401);++ assert.strictEqual((await beat(report(), { at: Date.now() - 5 * 60_000 })).status, 401, 'stale');++ // The signature covers the body: the same signature on another body is refused.++ const sig = signature.sign(SECRET, raw);++ const tampered = await t.get('/internal/sandbox-workers/heartbeat', { method: 'POST', body: raw.replace('"active":0', '"active":1'), headers: { 'content-type': 'application/json', [signature.HEADER]: sig } });++ assert.strictEqual(tampered.status, 401);++ assert.strictEqual((await health()).ready, false);++ });++++ await check('through a proxy, the internal routes answer nothing', async () => {++ assert.strictEqual((await beat(report(), { headers: { 'x-forwarded-for': '203.0.113.9' } })).status, 403);++ assert.strictEqual((await t.get('/internal/sandbox-workers/health', { headers: { 'cf-connecting-ip': '203.0.113.9' } })).status, 403);++ });++++ await check('a signed heartbeat from a healthy worker makes health ready (either secret of a rotation)', async () => {++ const at = Date.now();++ const r = await beat(report(), { at });++ assert.strictEqual(r.status, 200, r.text);++ const h = await health();++ assert.strictEqual(h.ready, true);++ assert.deepStrictEqual(h.workers.map((w) => [w.id, w.healthy, w.room, w.limits]), [['sbw-test', true, 2, 'cgroup']]);++ const replay = await beat(report(), { at });++ assert.strictEqual(replay.status, 409, 'the same signed time again is a replay');++ assert.strictEqual((await beat(report(), { at: at + 1, secret: `old-secret-${'x'.repeat(20)}` })).status, 200, 'the old secret of a rotation still works');++ });++++ await check('a worker that failed a check, does not enforce limits or said not ready is not healthy', async () => {++ const at = Date.now() + 10;++ for (const [n, bad] of [++ [1, report({ checks: { ...report().checks, egress_refuses_private: false } })],++ [2, report({ isolation: { ...report().isolation, limits: { kind: 'none', enforced: false } } })],++ [3, report({ ready: false })],++ [4, report({ isolation: { ...report().isolation, network_namespace: false } })],++ ]) {++ assert.strictEqual((await beat(bad, { at: at + n })).status, 200);++ const h = await health();++ assert.strictEqual(h.ready, false, `case ${n}`);++ assert.ok(h.workers[0].failing.length, `case ${n} says why`);++ }++ assert.strictEqual((await beat(report(), { at: at + 10 })).status, 200);++ assert.strictEqual((await health()).ready, true);++ });++++ await check('a worker that stopped checking in is not there', async () => {++ await t.ctx.store.db.exec("UPDATE codes_sandbox_workers SET last_seen_at = now() - interval '10 minutes'");++ const h = await health();++ assert.strictEqual(h.ready, false);++ assert.deepStrictEqual(h.workers[0].failing, ['stale']);++ });++++ await check('only the facts are stored, and health holds no secret', async () => {++ await beat(report({ secret: SECRET, endpoint: '/run/x.sock', isolation: { ...report().isolation, extra: 'http://10.0.0.1/' } }), { at: Date.now() + 100 });++ const dump = await t.dbDump();++ assert.ok(!dump.includes(SECRET), 'the secret is not stored');++ assert.ok(!dump.includes('/run/x.sock') && !dump.includes('10.0.0.1'), 'nothing but the facts is stored');++ const text = (await t.get('/internal/sandbox-workers/health')).text;++ assert.ok(!text.includes(SECRET) && !text.includes('old-secret'));++ });++++ await t.close();++++ await check('without a configured secret no worker can check in', async () => {++ const u = await boot();++ const raw = JSON.stringify(report());++ const r = await u.get('/internal/sandbox-workers/heartbeat', { method: 'POST', body: raw, headers: { 'content-type': 'application/json', [signature.HEADER]: signature.sign(SECRET, raw) } });++ assert.strictEqual(r.status, 503);++ assert.strictEqual((await u.get('/internal/sandbox-workers/health')).json().ready, false);++ await u.close();++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,92 @@++'use strict';++/**++ * The agent's tools in the chat plays sandbox (Agent Streams S6, design 2026-11-06 §1): a terminal, an editor and a++ * browser, all inside the sandbox (sandbox/session.js, sandbox/supervisor.js).++ *++ * - the terminal runs as the sandbox's own user in /workspace, takes input, and can have a terminal device;++ * - the editor reads, writes and lists files in the workspace only (a path outside it is refused);++ * - the browser (headless Chromium, started inside) reaches the public web through the egress proxy, and a page++ * that points at the host or a private address gets nothing from it.++ *++ * It needs bubblewrap (and, for the browser, a headless Chromium: CODES_SANDBOX_BROWSER, or Playwright's++ * chrome-headless-shell). Without them it prints `sandbox tools: skipped (<why>)`.++ */++const assert = require('assert');++const fs = require('fs');++const { startWorld, shortRuntimeDir } = require('./helpers/sandbox-world');++++let failures = 0;++async function check(name, fn) {++ try { await fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }++}++++(async () => {++ const { probeSupport, createSandbox } = require('../sandbox/session');++ const { findBrowser } = require('../sandbox/browser');++ const support = await probeSupport();++ if (!support.ok) { console.log(`sandbox tools: skipped (${support.reason})`); process.exit(0); }++ const browserPath = findBrowser();++ console.log(`sandbox tools (browser: ${browserPath || 'none'})`);++++ const world = await startWorld();++ const runtimeDir = shortRuntimeDir();++ const sbx = await createSandbox({++ runtimeDir, egress: world.egress, allowUnlimited: true, limiter: null, browserPath,++ limits: { memoryMb: 1024, diskMb: 128, pids: 256, cpuPercent: 100 }, timeBoxMs: 5 * 60_000,++ });++++ await check('the terminal: the sandbox\'s own user, in /workspace, with input', async () => {++ const who = await sbx.exec(['sh', '-c', 'id -un; id -u; pwd; uname -n'], { timeoutMs: 10_000 });++ assert.deepStrictEqual(String(who.stdout).trim().split('\n'), ['agent', '1000', '/workspace', 'sandbox']);++ const shell = await sbx.exec(['sh', '-s'], { stdin: 'mkdir -p snake && cd snake && echo "<canvas></canvas>" > index.html && ls\n', timeoutMs: 10_000 });++ assert.strictEqual(String(shell.stdout).trim(), 'index.html');++ const failed = await sbx.exec(['sh', '-c', 'echo oops >&2; exit 3'], { timeoutMs: 10_000 });++ assert.strictEqual(failed.code, 3);++ assert.strictEqual(String(failed.stderr).trim(), 'oops');++ });++++ await check('the terminal can have a terminal device, and a command that runs too long is stopped', async () => {++ const tty = await sbx.exec(['tty'], { tty: true, timeoutMs: 10_000 });++ assert.match(String(tty.stdout), /\/dev\/pts\/\d+/, `a pty: ${tty.stdout}${tty.stderr}`);++ const slow = await sbx.exec(['sleep', '30'], { timeoutMs: 500 });++ assert.strictEqual(slow.signal, 'SIGKILL');++ assert.strictEqual(slow.timedOut, true);++ });++++ await check('the editor: read, write and list in the workspace; nothing outside it', async () => {++ await sbx.writeFile('/workspace/snake/index.html', '<canvas></canvas>\n');++ await sbx.writeFile('/workspace/snake/game.js', 'let score = 0;\n');++ assert.strictEqual(String(await sbx.readFile('/workspace/snake/game.js')), 'let score = 0;\n');++ assert.strictEqual(String(await sbx.readFile('snake/game.js')), 'let score = 0;\n', 'a relative path is in the workspace');++ const names = (await sbx.list('/workspace/snake')).map((e) => `${e.name}:${e.type}`).sort();++ assert.deepStrictEqual(names, ['game.js:file', 'index.html:file']);++ for (const p of ['/etc/passwd', '/workspace/../etc/passwd', '../etc/hosts', '/proc/self/environ', '/opt/ov/supervisor.js']) {++ await assert.rejects(() => sbx.readFile(p), /outside the workspace/, `${p} is refused`);++ }++ await assert.rejects(() => sbx.writeFile('/workspace/../tmp/x', 'x'), /outside the workspace/);++ const editors = await sbx.exec(['sh', '-c', 'for e in vim vi nano; do command -v $e; done'], { timeoutMs: 10_000 });++ assert.ok(String(editors.stdout).trim(), 'a terminal editor is there too');++ });++++ await check('the browser opens the public web through the proxy, and nothing of the host', async () => {++ if (!browserPath) { console.log('sandbox tools: skipped (no headless Chromium: set CODES_SANDBOX_BROWSER or install Playwright\'s chrome-headless-shell)'); return; }++ const page = await sbx.openPage('http://origin.example/page');++ assert.strictEqual(page.ok, true, JSON.stringify(page).slice(0, 300));++ assert.strictEqual(page.title, 'Origin page');++ assert.match(page.text, /origin-ok/);++ assert.ok(page.screenshot && page.screenshot.length > 100, 'a screenshot of the page');++ for (const url of [`http://127.0.0.1:${world.canaryPort}/`, `http://localhost:${world.canaryPort}/`, 'http://169.254.169.254/latest/meta-data/', 'http://private.example/',++ ...world.hostLan.map((ip) => `http://${ip}:${world.canaryPort}/`), `https://origin.example:${world.canaryPort}/`]) {++ const r = await sbx.openPage(url);++ assert.ok(!String(r.text || '').includes(world.canary), `${url} showed the host's canary`);++ }++ assert.strictEqual(world.hits.canary, 0, 'the host\'s canary server was never reached by the browser');++ assert.deepStrictEqual(world.hits.unexpectedDials, []);++ });++++ await sbx.wipe('end');++ await world.close();++ fs.rmSync(runtimeDir, { recursive: true, force: true });++ console.log(failures ? `\n${failures} failed` : '\nall passed');++ process.exit(failures ? 1 : 0);++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,318 @@++'use strict';++/**++ * The chat plays sandbox worker's parts that need no bubblewrap (these run everywhere, CI included; the real sandbox++ * is tried in sandbox-escape, sandbox-limits and sandbox-tools):++ *++ * - the bubblewrap command line: every namespace, nothing of the host but the read-only list, nothing writable but++ * the tmpfs areas, a cleared environment;++ * - the seccomp program: the architecture check first, the denied calls, nothing for an unknown architecture;++ * - the heartbeat signature;++ * - the supervisor's protocol (run here without a sandbox): the terminal, the editor confined to the workspace,++ * time-outs and capped output;++ * - the worker: ready only with the self-test and the limits; its check-ins to Codes (signed) and Live (its own++ * service token, every stream it serves) carry the isolation facts and no secret; its local API; the time box++ * bounds; and no room means no new sandbox.++ */++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const os = require('os');++const path = require('path');++const { spawn } = require('child_process');++++let failures = 0;++async function check(name, fn) {++ try { await fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }++}++++(async () => {++ console.log('sandbox worker');++ const { buildArgs, etcFiles } = require('../sandbox/bwrap');++ const seccomp = require('../sandbox/seccomp');++ const signature = require('../sandbox/signature');++ const { values } = require('../sandbox/limits');++ const { createWorker, loadConfig } = require('../sandbox/worker');++ const tmp = fs.mkdtempSync(path.join('/tmp', 'ovsbx-unit-'));++++ await check('bubblewrap: every namespace, no capability, a cleared environment, killed with the worker', () => {++ const { args } = buildArgs({ sessionEtc: tmp, netDir: tmp, supervisorPath: path.join(__dirname, '..', 'sandbox', 'supervisor.js'), diskMb: 64, seccompFd: 3 });++ for (const a of ['--unshare-user', '--unshare-pid', '--unshare-net', '--unshare-ipc', '--unshare-uts', '--unshare-cgroup', '--disable-userns',++ '--die-with-parent', '--new-session', '--clearenv']) assert.ok(args.includes(a), `${a} is there`);++ assert.ok(!args.includes('--share-net'), 'the host network is never shared');++ assert.deepStrictEqual(args.slice(args.indexOf('--cap-drop'), args.indexOf('--cap-drop') + 2), ['--cap-drop', 'ALL']);++ assert.deepStrictEqual(args.slice(args.indexOf('--seccomp'), args.indexOf('--seccomp') + 2), ['--seccomp', '3']);++ assert.ok(args.indexOf('--remount-ro') >= 0 && args.includes('/'), 'the root is read-only');++ });++++ await check('bubblewrap: nothing of the host but the read-only list; writable areas are tmpfs only', () => {++ const { args, env } = buildArgs({ sessionEtc: tmp, netDir: tmp, supervisorPath: path.join(__dirname, '..', 'sandbox', 'supervisor.js'), diskMb: 64 });++ const binds = [];++ for (let i = 0; i < args.length; i++) {++ if (['--bind', '--dev-bind', '--bind-try', '--dev-bind-try'].includes(args[i])) assert.fail(`a writable bind: ${args.slice(i, i + 3).join(' ')}`);++ if (args[i] === '--ro-bind' || args[i] === '--ro-bind-try') binds.push([args[i + 1], args[i + 2]]);++ }++ const forbidden = ['/', '/home', '/root', '/etc', '/var', '/run', '/opt', '/srv', '/mnt', '/media', '/sys', '/proc', '/dev', os.homedir(), '/etc/ssl', '/etc/ssl/private', '/etc/openvibe', '/etc/shadow', '/etc/passwd', '/etc/ssh'];++ for (const [src] of binds) assert.ok(!forbidden.includes(src), `${src} is never bound`);++ const allowedTargets = /^(\/usr|\/etc\/.+|\/run\/ov\/net|\/opt\/ov\/supervisor\.js|\/opt\/node|\/opt\/browser|\/(bin|sbin|lib|lib32|lib64|libx32))$/;++ for (const [, dest] of binds) assert.match(dest, allowedTargets, `${dest} is on the list`);++ const tmpfs = args.map((a, i) => (a === '--tmpfs' ? args[i + 1] : null)).filter(Boolean).sort();++ assert.deepStrictEqual(tmpfs.filter((p) => p !== '/usr/local'), ['/dev/shm', '/home/agent', '/tmp', '/workspace']);++ for (const p of ['/tmp', '/home/agent', '/workspace']) {++ const i = args.findIndex((a, k) => a === '--tmpfs' && args[k + 1] === p);++ assert.deepStrictEqual(args.slice(i - 2, i), ['--size', String(64 * 1024 * 1024)], `${p} is size-limited`);++ }++ assert.deepStrictEqual(Object.keys(env).filter((k) => /SECRET|TOKEN|KEY|PASS|DATABASE|AWS|GITHUB/i.test(k)), [], 'no secret-shaped variable');++ assert.strictEqual(env.HOME, '/home/agent');++ assert.strictEqual(env.https_proxy, 'http://127.0.0.1:3128');++ });++++ await check('the sandbox\'s own /etc: its user list and no DNS', () => {++ const f = etcFiles();++ assert.deepStrictEqual(f.passwd.trim().split('\n').map((l) => l.split(':')[0]), ['agent', 'nobody']);++ assert.ok(!/nameserver/.test(f['resolv.conf']), 'no nameserver: names resolve at the proxy');++ });++++ await check('seccomp: the architecture is checked first, the denied calls answer EPERM, an unknown one has no filter', () => {++ for (const arch of ['x64', 'arm64']) {++ const prog = seccomp.compile(arch);++ assert.strictEqual(prog.length % 8, 0);++ assert.strictEqual(prog.readUInt16LE(0), 0x20, 'it loads the architecture first');++ assert.strictEqual(prog.readUInt32LE(4), 4);++ assert.strictEqual(prog.readUInt32LE(12), seccomp.AUDIT_ARCH[arch]);++ assert.strictEqual(prog.readUInt32LE(20), 0x80000000, 'another architecture kills the process');++ assert.strictEqual(prog.readUInt32LE(prog.length - 4), 0x7fff0000, 'the rest is allowed');++ for (const name of ['keyctl', 'add_key', 'ptrace', 'unshare', 'setns', 'mount', 'bpf', 'io_uring_setup', 'perf_event_open']) assert.ok(seccomp.denied(arch).includes(name), `${arch} denies ${name}`);++ }++ assert.strictEqual(seccomp.compile('ia32'), null);++ const header = '/usr/include/x86_64-linux-gnu/asm/unistd_64.h';++ if (fs.existsSync(header)) {++ const nr = Object.fromEntries([...fs.readFileSync(header, 'utf8').matchAll(/#define __NR_(\w+)\s+(\d+)/g)].map((m) => [m[1], Number(m[2])]));++ for (const [name, n] of Object.entries(seccomp.SYSCALLS.x64)) assert.strictEqual(nr[name], n, `x86-64 ${name} is ${nr[name]}`);++ }++ });++++ await check('the heartbeat signature: the secret, the body and the time all count', () => {++ const body = '{"worker_id":"sbw-x"}';++ const h = signature.sign('s3cret', body, 1_700_000_000_000);++ assert.deepStrictEqual(signature.verify({ secrets: ['s3cret'], header: h, body, now: 1_700_000_000_500 }), { ok: true, t: 1_700_000_000_000 });++ assert.strictEqual(signature.verify({ secrets: ['other'], header: h, body, now: 1_700_000_000_500 }).code, 'signature.invalid');++ assert.strictEqual(signature.verify({ secrets: ['s3cret'], header: h, body: `${body} `, now: 1_700_000_000_500 }).code, 'signature.invalid');++ assert.strictEqual(signature.verify({ secrets: ['s3cret'], header: h, body, now: 1_700_000_100_000 }).code, 'signature.stale');++ assert.strictEqual(signature.verify({ secrets: [], header: h, body }).code, 'sandbox.not_configured');++ assert.strictEqual(signature.verify({ secrets: ['s3cret'], header: 'v1=abc', body }).code, 'signature.missing');++ });++++ await check('the cgroup values: memory counts the tmpfs areas, cpu is a quota of 100 ms', () => {++ assert.deepStrictEqual(values({ memoryMb: 1024, diskMb: 256, pids: 128, cpuPercent: 150 }), { memory: (1024 + 768) * 1024 * 1024, pids: 128, cpu: '150000 100000', cpuPercent: 150 });++ });++++ // ── The supervisor, run here without a sandbox (its protocol, not its isolation) ──++ const ws = path.join(tmp, 'workspace');++ fs.mkdirSync(ws);++ fs.writeFileSync(path.join(tmp, 'outside.txt'), 'outside');++ fs.symlinkSync(path.join(tmp, 'outside.txt'), path.join(ws, 'link-out'));++ const sup = spawn(process.execPath, [path.join(__dirname, '..', 'sandbox', 'supervisor.js')], {++ env: { PATH: process.env.PATH, OV_SANDBOX_WORKSPACE: ws, OV_SANDBOX_PROXY_PORT: '0' }, stdio: ['pipe', 'pipe', 'pipe'],++ });++ const frames = [];++ const waiters = [];++ let sbuf = '';++ sup.stdout.on('data', (d) => {++ sbuf += d;++ let i;++ while ((i = sbuf.indexOf('\n')) >= 0) {++ const f = JSON.parse(sbuf.slice(0, i));++ sbuf = sbuf.slice(i + 1);++ frames.push(f);++ for (const w of [...waiters]) if (w.test(f)) { waiters.splice(waiters.indexOf(w), 1); w.resolve(f); }++ }++ });++ const waitFrame = (test) => new Promise((resolve) => { const hit = frames.find(test); if (hit) resolve(hit); else waiters.push({ test, resolve }); });++ let seq = 0;++ const ask = async (msg) => {++ const id = ++seq;++ sup.stdin.write(`${JSON.stringify({ ...msg, id })}\n`);++ const end = await waitFrame((f) => f.id === id && (f.ev === 'exit' || f.ev === 'done'));++ const out = Buffer.concat(frames.filter((f) => f.id === id && f.ev === 'out' && f.fd === 1).map((f) => Buffer.from(f.data, 'base64'))).toString();++ return { ...end, out };++ };++ await waitFrame((f) => f.ev === 'ready');++++ await check('the supervisor: a command runs in the workspace, with input, and its exit code comes back', async () => {++ const r = await ask({ op: 'exec', argv: ['sh', '-c', 'pwd; cat; exit 4'], stdin: Buffer.from('hello').toString('base64') });++ assert.strictEqual(r.code, 4);++ assert.strictEqual(r.out, `${fs.realpathSync(ws)}\nhello`);++ });++++ await check('the supervisor: a command that runs too long is killed, and its output is capped', async () => {++ const slow = await ask({ op: 'exec', argv: ['sleep', '20'], timeoutMs: 300 });++ assert.strictEqual(slow.signal, 'SIGKILL');++ assert.strictEqual(slow.timedOut, true);++ const big = await ask({ op: 'exec', argv: ['sh', '-c', 'head -c 300000 /dev/zero'], maxOutput: 1000 });++ assert.strictEqual(big.out.length, 1000);++ assert.strictEqual(big.truncated, true);++ });++++ await check('the supervisor: the editor writes, reads and lists the workspace, and refuses anything outside it', async () => {++ assert.strictEqual((await ask({ op: 'write', path: 'game/index.html', data: Buffer.from('<canvas>').toString('base64') })).ok, true);++ const r = await ask({ op: 'read', path: '/workspace/game/index.html'.replace('/workspace', ws) });++ assert.strictEqual(Buffer.from(r.data, 'base64').toString(), '<canvas>');++ const l = await ask({ op: 'list', path: 'game' });++ assert.deepStrictEqual(l.entries.map((e) => e.name), ['index.html']);++ for (const p of ['../outside.txt', '/etc/passwd', 'link-out', `${ws}/../outside.txt`]) {++ const x = await ask({ op: 'read', path: p });++ assert.strictEqual(x.ok, false, `${p} is refused`);++ assert.match(x.error, /outside the workspace/);++ }++ const w = await ask({ op: 'write', path: 'link-out', data: Buffer.from('x').toString('base64') });++ assert.strictEqual(w.ok, false, 'a write through a link out of the workspace is refused');++ assert.strictEqual(fs.readFileSync(path.join(tmp, 'outside.txt'), 'utf8'), 'outside');++ assert.strictEqual((await ask({ op: 'exec', argv: ['true'], cwd: '/etc' })).code, 127, 'a working directory outside the workspace is refused');++ });++++ await check('the supervisor: an unknown request is an error, garbage is ignored, and the process lives on', async () => {++ sup.stdin.write('not json\n{"x":1}\n');++ assert.strictEqual((await ask({ op: 'format-disk' })).ok, false);++ assert.strictEqual((await ask({ op: 'ping' })).ok, true);++ });++ sup.stdin.end();++++ // ── The worker, with a fake sandbox and fake neighbours ──++ const calls = [];++ const fakeFetch = async (url, init) => { calls.push({ url, init }); return { status: 200, text: async () => '' }; };++ const CODES_SECRET = 'codes-secret-for-the-test-0123456789';++ const fakeSandboxes = [];++ const fakeCreate = async (o) => {++ const sbx = {++ id: o.id, state: 'running', opts: o,++ info: () => ({ id: o.id, state: sbx.state, ends_at: new Date(Date.now() + o.timeBoxMs).toISOString() }),++ wipe: async (reason) => { sbx.state = 'wiped'; endResolve({ reason }); return { reason }; },++ exec: async (argv) => ({ code: 0, signal: null, stdout: argv.join(' '), stderr: '' }),++ };++ let endResolve;++ sbx.ended = new Promise((r) => { endResolve = r; });++ fakeSandboxes.push(sbx);++ return sbx;++ };++ const config = {++ ...loadConfig({ CODES_SANDBOX_RUNTIME_DIR: tmp, CODES_SANDBOX_LIVE_STREAMS: 'ags_owner1,bad stream!,ags_owner2', CODES_SANDBOX_LIVE_URL: 'http://live.test', CODES_SANDBOX_CODES_URL: 'http://codes.test', CODES_SANDBOX_WORKER_SECRET: CODES_SECRET, CODES_SANDBOX_MAX_SESSIONS: '2' }),++ socket: path.join(tmp, 'w.sock'),++ };++ const passed = { ok: true, checks: Object.fromEntries(require('../sandbox/selftest').CHECKS.map((c) => [c, true])), failed: [], at: new Date().toISOString() };++ const make = (over = {}) => createWorker({++ config, createSandbox: fakeCreate, fetchImpl: fakeFetch, log: { log() {}, error() {} },++ support: { ok: true, version: '0.9.0', seccomp: true }, limiter: { ok: true, kind: 'cgroup' },++ runSelfTest: async () => passed, tokens: { getToken: async () => 'svc-token-for-live' }, ...over,++ });++++ await check('the worker is ready only with the self-test passed and the limits there', async () => {++ const w = make();++ await w.selfTest();++ assert.strictEqual(w.health().ready, true);++ const noLimits = make({ limiter: { ok: false, kind: 'none', reason: 'no cgroup' } });++ await noLimits.selfTest();++ assert.strictEqual(noLimits.health().ready, false);++ assert.ok(noLimits.health().reasons.some((r) => /limits/.test(r)));++ const failing = make({ runSelfTest: async () => ({ ...passed, ok: false, failed: ['egress_refuses_private'], checks: { ...passed.checks, egress_refuses_private: false } }) });++ await failing.selfTest();++ assert.strictEqual(failing.health().ready, false);++ assert.strictEqual(failing.health().isolation.egress.public_only, false);++ await assert.rejects(() => failing.openSession({}), /not ready/, 'a worker that is not ready makes no sandbox');++ });++++ await check('the check-ins: Codes signed, Live with the worker\'s own token for every stream it serves, the facts and no secret', async () => {++ const w = make();++ await w.selfTest();++ calls.length = 0;++ await w.openSession({ stream_id: 'ags_session3' });++ await w.announce();++ const codes = calls.filter((c) => c.url === 'http://codes.test/internal/sandbox-workers/heartbeat');++ assert.strictEqual(codes.length, 1);++ assert.strictEqual(signature.verify({ secrets: [CODES_SECRET], header: codes[0].init.headers[signature.HEADER], body: codes[0].init.body }).ok, true);++ const live = calls.filter((c) => c.url.startsWith('http://live.test/')).map((c) => c.url).sort();++ assert.deepStrictEqual(live, ['http://live.test/api/streams/agent/ags_owner1/plays/worker', 'http://live.test/api/streams/agent/ags_owner2/plays/worker', 'http://live.test/api/streams/agent/ags_session3/plays/worker']);++ for (const c of calls.filter((x) => x.url.startsWith('http://live.test/'))) assert.strictEqual(c.init.headers.authorization, 'Bearer svc-token-for-live');++ const body = JSON.parse(calls[0].init.body);++ assert.strictEqual(body.ready, true);++ assert.strictEqual(body.isolation.network_namespace, true);++ assert.strictEqual(body.isolation.host_credentials, 'none');++ assert.strictEqual(body.isolation.limits.enforced, true);++ for (const c of calls) {++ assert.ok(!String(c.init.body).includes(CODES_SECRET), 'the Codes secret never travels');++ assert.ok(!String(c.init.body).includes(tmp), 'no path of the host');++ assert.ok(!/svc-token/.test(String(c.init.body)), 'no token in a body');++ }++ });++++ await check('no service token, no Live check-in; no Codes secret, no heartbeat', async () => {++ const w = make({ tokens: null, config: { ...config, codes: { url: 'http://codes.test', secret: '' } } });++ await w.selfTest();++ calls.length = 0;++ await w.announce();++ assert.deepStrictEqual(calls, []);++ });++++ await check('a session\'s time box is the owner\'s 5 to 30 minutes, and no room means no new sandbox', async () => {++ const w = make();++ await w.selfTest();++ fakeSandboxes.length = 0;++ const a = await w.openSession({ time_box_minutes: 90 });++ const b = await w.openSession({ time_box_minutes: 1 });++ assert.strictEqual(fakeSandboxes[0].opts.timeBoxMs, 30 * 60_000);++ assert.strictEqual(fakeSandboxes[1].opts.timeBoxMs, 5 * 60_000);++ await assert.rejects(() => w.openSession({}), /no room/);++ await assert.rejects(() => w.openSession({ stream_id: '../x' }), /no room|bad stream/);++ await a.sbx.wipe('end');++ await w.openSession({});++ await b.sbx.wipe('end');++ });++++ await check('the local API: a Unix socket only the worker\'s user and group may use; health, sessions, Wipe now', async () => {++ const w = make();++ fs.mkdirSync(path.join(tmp, 's-stale0001', 'net'), { recursive: true });++ await w.start();++ try {++ assert.ok(!fs.existsSync(path.join(tmp, 's-stale0001')), 'a crashed worker\'s leftover sandbox directory is swept at start');++ assert.strictEqual(fs.statSync(config.socket).mode & 0o777, 0o660);++ const req = (method, p, body) => new Promise((resolve, reject) => {++ const r = http.request({ socketPath: config.socket, method, path: p, headers: { 'content-type': 'application/json' } }, (res) => {++ let t = ''; res.on('data', (d) => { t += d; }); res.on('end', () => resolve({ status: res.statusCode, json: t ? JSON.parse(t) : null }));++ });++ r.on('error', reject);++ r.end(body ? JSON.stringify(body) : undefined);++ });++ const h = await req('GET', '/health');++ assert.strictEqual(h.json.ready, true);++ const s = await req('POST', '/sessions', { stream_id: 'ags_x', time_box_minutes: 10 });++ assert.strictEqual(s.status, 201);++ const e = await req('POST', `/sessions/${s.json.id}/exec`, { argv: ['echo', 'hi'] });++ assert.strictEqual(e.json.stdout, 'echo hi');++ const wiped = await req('POST', `/sessions/${s.json.id}/wipe`);++ assert.strictEqual(wiped.json.reason, 'wipe_now');++ assert.strictEqual(wiped.json.state, 'wiped');++ assert.strictEqual((await req('GET', '/sessions/zzzzzzzzzz')).status, 404);++ assert.strictEqual((await req('GET', '/sessions/../../etc')).status, 404);++ } finally { await w.stop(); }++ assert.ok(!fs.existsSync(config.socket), 'the socket is removed when the worker stops');++ });++++ await check('the command line: --check prints the health and fails when the worker would not be ready (no limits here)', async () => {++ const run = spawn(process.execPath, [path.join(__dirname, '..', 'bin', 'openvibe-sandbox-worker.js'), '--check'], {++ env: { PATH: process.env.PATH, CODES_SANDBOX_RUNTIME_DIR: path.join(tmp, 'check'), CODES_SANDBOX_LIMITS: 'none', CODES_SANDBOX_WORKER_SECRET: 'not-printed-0123456789' },++ stdio: ['ignore', 'pipe', 'pipe'],++ });++ let out = '';++ run.stdout.on('data', (d) => { out += d; });++ run.stderr.on('data', () => {});++ const code = await new Promise((r) => run.on('exit', r));++ assert.strictEqual(code, 1, 'not ready without limits');++ const h = JSON.parse(out);++ assert.strictEqual(h.ready, false);++ assert.ok(h.reasons.some((r) => /limits/.test(r)), h.reasons.join('; '));++ assert.ok(!out.includes('not-printed-0123456789'), 'the secret is not printed');++ });++++ fs.rmSync(tmp, { recursive: true, force: true });++ console.log(failures ? `\n${failures} failed` : '\nall passed');++ process.exit(failures ? 1 : 0);++})().catch((e) => { console.error(e); process.exit(1); });
@@ -42,3 +42,10 @@ OV_OAUTH_CLIENT_SECRET=
@@ -42,3 +42,10 @@ OV_OAUTH_CLIENT_SECRET=
# Know (the shared lessons store): on a Node, the portal's know/lessons.js. Its path is configuration, never a
# Know (the shared lessons store): on a Node, the portal's know/lessons.js. Its path is configuration, never a
# hard-coded home; without it a job gets no lessons. Never a secret.
# hard-coded home; without it a job gets no lessons. Never a secret.
#CODES_KNOW_LESSONS=/home/openvibe/agents/know/lessons.js
#CODES_KNOW_LESSONS=/home/openvibe/agents/know/lessons.js
+
+# Chat plays sandbox workers (Agent Streams S6): the secret a worker signs its heartbeat with
+# (POST /internal/sandbox-workers/heartbeat), a comma list during a rotation, and how long a worker that stopped
+# checking in still counts. Without a secret no worker can check in and /internal/sandbox-workers/health says not ready.
+# The worker's own settings (CODES_SANDBOX_* on its host) are in deploy/sandbox/sandbox-worker.env.example.
+#CODES_SANDBOX_WORKER_SECRET=
+#CODES_SANDBOX_STALE_MS=90000
new file mode 100755
new file mode 100755
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * The chat plays sandbox worker (sandbox/worker.js). Run by deploy/sandbox/openvibe-codes-sandbox.service as its own
+ * unprivileged user; on a workstation: `node bin/openvibe-sandbox-worker.js` (a user systemd gives it its limits).
+ *
+ * openvibe-sandbox-worker run: self-test, the local API, the check-ins every 30 s
+ * openvibe-sandbox-worker --check the self-test once, print the health and exit 0 (ready) or 1
+ */
+const { createWorker, loadConfig } = require('../sandbox/worker');
+
+(async () => {
+ const config = loadConfig(process.env);
+ // The secrets are read; nothing the worker starts gets this environment, but drop them from it anyway.
+ for (const k of ['CODES_SANDBOX_WORKER_SECRET', 'CODES_SANDBOX_CLIENT_SECRET']) delete process.env[k];
+ const worker = createWorker({ config });
+ if (process.argv.includes('--check')) {
+ const { probeSupport } = require('../sandbox/session');
+ const { detectLimits } = require('../sandbox/limits');
+ const check = createWorker({ config, support: await probeSupport(), limiter: await detectLimits({ mode: config.limitsMode }) });
+ require('fs').mkdirSync(config.runtimeDir, { recursive: true, mode: 0o750 });
+ await check.selfTest();
+ const h = check.health();
+ process.stdout.write(`${JSON.stringify(h, null, 2)}\n`);
+ process.exit(h.ready ? 0 : 1);
+ }
+ await worker.start();
+ const stop = async (sig) => { console.log(`[sandbox] ${sig}: wiping every sandbox`); await worker.stop(); process.exit(0); };
+ process.on('SIGTERM', () => stop('SIGTERM'));
+ process.on('SIGINT', () => stop('SIGINT'));
+})().catch((err) => { console.error('[sandbox] the worker failed:', err && err.message); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+# /etc/apparmor.d/openvibe-codes-sandbox-bwrap — only on a host where Ubuntu restricts unprivileged user namespaces
+# (kernel.apparmor_restrict_unprivileged_userns=1, the default since 23.10/24.04). It lets /usr/bin/bwrap, and nothing
+# else, create the user namespace a sandbox starts in; bubblewrap itself drops every capability and turns nested user
+# namespaces off (--disable-userns) before anything of the sandbox runs. install.sh installs it only when needed and
+# never changes the sysctl.
+abi <abi/4.0>,
+include <tunables/global>
+
+profile openvibe-codes-sandbox-bwrap /usr/bin/bwrap flags=(unconfined) {
+ userns,
+
+ include if exists <local/openvibe-codes-sandbox-bwrap>
+}
new file mode 100755
new file mode 100755
@@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
+#!/bin/sh
+# Install the chat plays sandbox worker (Agent Streams S6) on the host that runs OpenVibe.Codes. Run as root, from the
+# Codes release (/opt/openvibe.codes): `sudo deploy/sandbox/install.sh [--with-browser] [--start]`. Idempotent: it
+# never overwrites a secret or an env file that exists, never changes a sysctl, and starts nothing without --start.
+#
+# What the worker needs from the host:
+# 1. bubblewrap >= 0.8 (apt install bubblewrap) and unprivileged user namespaces. On Ubuntu 23.10+ with
+# kernel.apparmor_restrict_unprivileged_userns=1 an AppArmor profile lets /usr/bin/bwrap (only it) create one.
+# 2. cgroup v2 (the unified hierarchy): the unit has Delegate=cpu memory pids, one child cgroup per sandbox.
+# 3. Node 22+ at /usr/bin/node (bound read-only into each sandbox for its supervisor) and util-linux script(1).
+# 4. Its own system user, openvibe-sandbox, with no home and no login; Codes' user joins its group for the local socket.
+# 5. Two secrets as systemd credentials in /etc/openvibe/codes-sandbox/ (root, 0600):
+# codes-worker-secret generated here; the same value goes into /etc/openvibe/codes.env as
+# CODES_SANDBOX_WORKER_SECRET (then restart openvibe-codes)
+# network-client-secret the worker's own Network OAuth client (its service identity, holding the grant
+# live.agent_sandbox.register); created by the owner on OpenVibe.Network, not here
+# 6. Optional (--with-browser): Playwright's headless Chromium in /opt/openvibe-codes-sandbox/browser.
+set -eu
+
+CODES_DIR=${CODES_DIR:-/opt/openvibe.codes}
+CODES_USER=${CODES_USER:-ubuntu}
+SANDBOX_USER=openvibe-sandbox
+SECRETS=/etc/openvibe/codes-sandbox
+ENV_FILE=/etc/openvibe/codes-sandbox.env
+BROWSER_DIR=/opt/openvibe-codes-sandbox/browser
+PLAYWRIGHT_VERSION=${PLAYWRIGHT_VERSION:-1.62.1}
+WITH_BROWSER=0
+START=0
+for arg in "$@"; do
+ case "$arg" in
+ --with-browser) WITH_BROWSER=1 ;;
+ --start) START=1 ;;
+ *) echo "usage: $0 [--with-browser] [--start]" >&2; exit 2 ;;
+ esac
+done
+[ "$(id -u)" -eq 0 ] || { echo "run as root" >&2; exit 1; }
+say() { printf '[sandbox-install] %s\n' "$*"; }
+here=$(cd "$(dirname "$0")" && pwd)
+
+# 1. bubblewrap and script(1)
+if ! command -v bwrap >/dev/null 2>&1; then say "installing bubblewrap"; apt-get install -y bubblewrap; fi
+command -v script >/dev/null 2>&1 || apt-get install -y bsdutils
+ver=$(bwrap --version | sed -n 's/.* \([0-9]*\)\.\([0-9]*\)\..*/\1 \2/p')
+# shellcheck disable=SC2086 # split "major minor" on purpose
+set -- $ver
+if [ "${1:-0}" -eq 0 ] && [ "${2:-0}" -lt 8 ]; then echo "bubblewrap $(bwrap --version) is too old: 0.8 or newer" >&2; exit 1; fi
+if [ -u "$(command -v bwrap)" ]; then echo "bwrap is setuid here; the worker expects the unprivileged build (NoNewPrivileges=yes)" >&2; exit 1; fi
+
+# 2. cgroup v2
+[ -f /sys/fs/cgroup/cgroup.controllers ] || { echo "no cgroup v2 unified hierarchy: the sandboxes' limits need it" >&2; exit 1; }
+
+# 3. Node
+node_ok=$(/usr/bin/node -e 'process.stdout.write(String(Number(process.versions.node.split(".")[0]) >= 22))' 2>/dev/null || echo false)
+[ "$node_ok" = true ] || { echo "/usr/bin/node 22+ is required" >&2; exit 1; }
+
+# 4. The user, and Codes' user in its group (the local API socket is 0660)
+if ! id "$SANDBOX_USER" >/dev/null 2>&1; then
+ say "creating the system user $SANDBOX_USER"
+ useradd --system --user-group --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin "$SANDBOX_USER"
+fi
+if id "$CODES_USER" >/dev/null 2>&1; then usermod -aG "$SANDBOX_USER" "$CODES_USER"; fi
+
+# 1b. Unprivileged user namespaces: allowed, or allowed for bwrap by its own AppArmor profile (never the sysctl)
+restrict=$(sysctl -n kernel.apparmor_restrict_unprivileged_userns 2>/dev/null || echo 0)
+if [ "$restrict" = 1 ]; then
+ say "Ubuntu restricts unprivileged user namespaces: installing the AppArmor profile for /usr/bin/bwrap"
+ install -m 0644 "$here/apparmor-bwrap" /etc/apparmor.d/openvibe-codes-sandbox-bwrap
+ apparmor_parser -r /etc/apparmor.d/openvibe-codes-sandbox-bwrap
+fi
+if [ "$(sysctl -n kernel.unprivileged_userns_clone 2>/dev/null || echo 1)" = 0 ]; then
+ echo "kernel.unprivileged_userns_clone=0: unprivileged user namespaces are off on this host; the worker cannot run" >&2
+ exit 1
+fi
+if ! runuser -u "$SANDBOX_USER" -- bwrap --unshare-all --unshare-user --disable-userns --ro-bind /usr /usr \
+ --symlink usr/bin /bin --symlink usr/lib /lib --symlink usr/lib64 /lib64 --proc /proc --dev /dev -- /usr/bin/true; then
+ echo "bubblewrap cannot make a sandbox as $SANDBOX_USER here" >&2
+ exit 1
+fi
+
+# 5. Secrets (credentials) and the env file
+install -d -m 0700 -o root -g root "$SECRETS"
+if [ ! -s "$SECRETS/codes-worker-secret" ]; then
+ say "generating the Codes heartbeat secret ($SECRETS/codes-worker-secret)"
+ (umask 077; head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$SECRETS/codes-worker-secret")
+ say "put the same value in /etc/openvibe/codes.env as CODES_SANDBOX_WORKER_SECRET (sudo cat $SECRETS/codes-worker-secret), then restart openvibe-codes"
+fi
+if [ ! -s "$SECRETS/network-client-secret" ]; then
+ (umask 077; : > "$SECRETS/network-client-secret")
+ say "the worker's Network client secret is empty: write it to $SECRETS/network-client-secret and its client id to $ENV_FILE (CODES_SANDBOX_CLIENT_ID); until then it does not check in with Live"
+fi
+if [ ! -f "$ENV_FILE" ]; then
+ install -m 0640 -o root -g "$SANDBOX_USER" "$here/sandbox-worker.env.example" "$ENV_FILE"
+ say "wrote $ENV_FILE from the example: set CODES_SANDBOX_WORKER_ID, CODES_SANDBOX_CLIENT_ID and CODES_SANDBOX_LIVE_STREAMS"
+fi
+
+# 6. The browser (optional)
+if [ "$WITH_BROWSER" = 1 ]; then
+ say "installing Playwright $PLAYWRIGHT_VERSION's headless Chromium into $BROWSER_DIR"
+ install -d -m 0755 "$BROWSER_DIR"
+ PLAYWRIGHT_BROWSERS_PATH="$BROWSER_DIR" npx --yes "playwright@$PLAYWRIGHT_VERSION" install --with-deps --only-shell chromium
+ chmod -R a+rX "$BROWSER_DIR"
+fi
+
+# The unit
+install -m 0644 "$here/openvibe-codes-sandbox.service" /etc/systemd/system/openvibe-codes-sandbox.service
+systemctl daemon-reload
+say "installed openvibe-codes-sandbox.service"
+
+# The self-test, the way the unit runs it (its user, its delegated cgroup, its hardening): ready or why not.
+say "self-test:"
+systemd-run --wait --pipe --quiet --uid="$SANDBOX_USER" --gid="$SANDBOX_USER" -p Delegate=yes -p NoNewPrivileges=yes \
+ -p ProtectSystem=strict -p ProtectHome=yes -p PrivateTmp=yes -p RuntimeDirectory=openvibe-codes-sandbox-check \
+ -E CODES_SANDBOX_LIMITS=cgroup -E PATH=/usr/bin:/bin -E CODES_SANDBOX_RUNTIME_DIR=/run/openvibe-codes-sandbox-check \
+ --working-directory="$CODES_DIR" -- /usr/bin/node "$CODES_DIR/bin/openvibe-sandbox-worker.js" --check \
+ || { echo "the self-test failed: the worker would report itself not ready (see above)" >&2; exit 1; }
+
+if [ "$START" = 1 ]; then
+ systemctl enable --now openvibe-codes-sandbox.service
+ say "started; journalctl -u openvibe-codes-sandbox -f"
+else
+ say "not started (pass --start, or: systemctl enable --now openvibe-codes-sandbox)"
+fi
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+[Unit]
+Description=OpenVibe.Codes chat plays sandbox worker (Agent Streams S6): bubblewrap sandboxes for viewers' requests
+# The worker checks in with Codes (loopback) and Live; it waits for neither and says "not ready" until its own
+# self-test passed. deploy/sandbox/install.sh installs this unit; nothing starts it until the owner does.
+After=network-online.target openvibe-codes.service
+Wants=network-online.target
+
+[Service]
+Type=simple
+# Its own unprivileged user: no login, no home, no other service's files. Codes' user is in its group, for the
+# local API socket (0660) in the runtime directory.
+User=openvibe-sandbox
+Group=openvibe-sandbox
+WorkingDirectory=/opt/openvibe.codes
+EnvironmentFile=-/etc/openvibe/codes-sandbox.env
+Environment=NODE_ENV=production
+Environment=PATH=/usr/bin:/bin
+# The limits come from this unit's own cgroup subtree: one child cgroup per sandbox with cpu.max, memory.max and
+# pids.max (sandbox/limits.js). Without the delegation the worker reports itself not ready.
+Environment=CODES_SANDBOX_LIMITS=cgroup
+Delegate=cpu memory pids
+# Every sandbox of the worker, together: a ceiling over the per-sandbox limits (2 sandboxes of 2 GiB + 3 × 512 MiB).
+MemoryMax=10G
+TasksMax=2048
+ExecStart=/usr/bin/node /opt/openvibe.codes/bin/openvibe-sandbox-worker.js
+# A short, private runtime directory: each sandbox's egress socket path must fit in 108 bytes.
+RuntimeDirectory=openvibe-codes-sandbox
+RuntimeDirectoryMode=0750
+# Secrets as systemd credentials, never in the environment (sandbox/worker.js reads $CREDENTIALS_DIRECTORY).
+LoadCredential=codes-worker-secret:/etc/openvibe/codes-sandbox/codes-worker-secret
+LoadCredential=network-client-secret:/etc/openvibe/codes-sandbox/network-client-secret
+Restart=always
+RestartSec=5
+# Stopping the worker wipes every sandbox: SIGTERM first (the worker wipes them), then the whole cgroup.
+KillMode=control-group
+KillSignal=SIGTERM
+TimeoutStopSec=20
+
+# Hardening that leaves bubblewrap's unprivileged user namespaces working. The sandboxes inherit this view of the
+# machine, so even a bind the worker never makes could not reach /home, /root or the services' files.
+NoNewPrivileges=yes
+ProtectSystem=strict
+ProtectHome=yes
+PrivateTmp=yes
+PrivateDevices=yes
+ProtectKernelModules=yes
+ProtectClock=yes
+LockPersonality=yes
+SystemCallArchitectures=native
+RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
+RestrictSUIDSGID=yes
+RestrictRealtime=yes
+UMask=0077
+# Not set on purpose: RestrictNamespaces (bubblewrap needs user, mount, pid, net, ipc, uts and cgroup namespaces),
+# ProtectControlGroups (the worker writes its delegated subtree), PrivateUsers (bubblewrap maps its own user), and
+# ProtectKernelTunables / ProtectKernelLogs: they mount over parts of /proc, and the kernel then refuses the fresh
+# /proc a sandbox's pid namespace needs ("Can't mount proc on /newroot/proc: Operation not permitted"). The sandbox's
+# own /proc shows only its own processes, and it has no capability to write /proc/sys.
+
+[Install]
+WantedBy=multi-user.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+# /etc/openvibe/codes-sandbox.env — the chat plays sandbox worker (sandbox/worker.js). No secret goes here: the two
+# secrets are systemd credentials in /etc/openvibe/codes-sandbox/ (see install.sh).
+
+# The worker's name in Codes and Live.
+CODES_SANDBOX_WORKER_ID=sbw-ovhost
+
+# Per sandbox: memory (MiB; the tmpfs areas are added on top), each tmpfs area's size (MiB: /workspace, /home/agent,
+# /tmp), processes and threads, CPU (100 = one core). Sandboxes at once.
+CODES_SANDBOX_MEMORY_MB=2048
+CODES_SANDBOX_DISK_MB=512
+CODES_SANDBOX_PIDS=256
+CODES_SANDBOX_CPU_PERCENT=100
+CODES_SANDBOX_MAX_SESSIONS=2
+# The longest time box a session may have (the owner sets 5 to 30 minutes).
+CODES_SANDBOX_TIME_BOX_MAX_MIN=30
+
+# Node for the supervisor inside the sandbox (bound read-only), and the headless Chromium install.sh put in place.
+CODES_SANDBOX_NODE=/usr/bin/node
+#CODES_SANDBOX_BROWSER=/opt/openvibe-codes-sandbox/browser/chromium_headless_shell-NNNN/chrome-headless-shell-linux64/chrome-headless-shell
+
+# Codes: where the signed heartbeat goes (loopback). The secret is the credential codes-worker-secret, and the same
+# value is CODES_SANDBOX_WORKER_SECRET in /etc/openvibe/codes.env.
+CODES_SANDBOX_CODES_URL=http://127.0.0.1:4900
+
+# Live: the check-in (POST /api/streams/agent/:id/plays/worker) with the worker's own Network service token
+# (client credentials; audience openvibe.live; capability live.agent_sandbox.register). The client secret is the
+# credential network-client-secret. CODES_SANDBOX_LIVE_STREAMS: the agent streams this worker serves (comma list);
+# a running session's own stream is added for its lifetime.
+CODES_SANDBOX_LIVE_URL=https://openvibe.live
+CODES_SANDBOX_NETWORK_URL=https://openvibe.network
+CODES_SANDBOX_CLIENT_ID=
+CODES_SANDBOX_LIVE_STREAMS=
+
+# How often: the check-ins (every 30 s) and the self-test (every 10 minutes).
+CODES_SANDBOX_ANNOUNCE_MS=30000
+CODES_SANDBOX_SELFTEST_MS=600000
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+-- phase: expand
+-- Agent Streams S6 (design 2026-11-06 §1, §6): the chat plays sandbox workers. A worker (bin/openvibe-sandbox-worker.js)
+-- checks in every 30 s with a signed heartbeat (POST /internal/sandbox-workers/heartbeat) carrying what its self-test
+-- found: its isolation and every check. Codes reads this to say whether any healthy worker exists
+-- (GET /internal/sandbox-workers/health) and, later, to pick one for a chat plays session.
+--
+-- No secret, path, address or person is stored: a worker's own name, version, the facts it reported and when.
+-- Expand-only: a new table nothing older reads. Repeating this file is a no-op.
+
+CREATE TABLE IF NOT EXISTS codes_sandbox_workers (
+ id text COLLATE "C" PRIMARY KEY, -- the worker's own name (sbw-<host>)
+ version text NOT NULL DEFAULT '',
+ ready boolean NOT NULL DEFAULT false, -- the worker's own verdict
+ checks jsonb NOT NULL DEFAULT '{}'::jsonb, -- the self-test, check by check
+ isolation jsonb NOT NULL DEFAULT '{}'::jsonb, -- namespaces, egress, credentials, limits
+ capacity integer NOT NULL DEFAULT 0,
+ active integer NOT NULL DEFAULT 0,
+ last_signed_at bigint NOT NULL DEFAULT 0, -- the heartbeat's signed time: a replay is refused
+ last_seen_at timestamptz NOT NULL DEFAULT now(),
+ created_at timestamptz NOT NULL DEFAULT now()
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -0,0 +1,110 @@
+'use strict';
+/**
+ * The chat plays sandbox's browser, the worker's side: which headless Chromium the sandbox gets (bound read-only at
+ * /opt/browser), and a small DevTools client over the pipe the supervisor relays (sandbox/supervisor.js). The browser
+ * itself runs inside the sandbox, on the egress proxy, with a profile in the sandbox's own tmpfs home.
+ *
+ * findBrowser(env) → the executable's path or null: CODES_SANDBOX_BROWSER, the install script's copy, or Playwright's
+ * chrome-headless-shell in this user's cache
+ * createCdp(send) → { call(method, params, sessionId), onMessage(text), waitFor(method, sessionId, ms), close() }
+ * openPage(cdp, url, { timeoutMs, screenshot }) → { ok, url, title, text, screenshot } | { ok: false, error }
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const INSTALLED = '/opt/openvibe-codes-sandbox/browser';
+
+function newestHeadlessShell(base) {
+ let dirs = [];
+ try { dirs = fs.readdirSync(base).filter((d) => /^chromium_headless_shell-\d+$/.test(d)); } catch { return null; }
+ dirs.sort((a, b) => Number(b.split('-')[1]) - Number(a.split('-')[1]));
+ for (const d of dirs) {
+ const exe = path.join(base, d, 'chrome-headless-shell-linux64', 'chrome-headless-shell');
+ if (fs.existsSync(exe)) return exe;
+ }
+ return null;
+}
+
+function findBrowser(env = process.env) {
+ if (env.CODES_SANDBOX_BROWSER) return fs.existsSync(env.CODES_SANDBOX_BROWSER) ? env.CODES_SANDBOX_BROWSER : null;
+ const installed = newestHeadlessShell(INSTALLED);
+ if (installed) return installed;
+ return newestHeadlessShell(env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), '.cache', 'ms-playwright'));
+}
+
+function createCdp(send) {
+ let nextId = 1;
+ const pending = new Map();
+ const waiters = [];
+ let closed = null;
+ return {
+ call(method, params = {}, sessionId) {
+ if (closed) return Promise.reject(new Error(`the browser is gone (${closed})`));
+ const id = nextId++;
+ send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
+ return new Promise((resolve, reject) => pending.set(id, { resolve, reject, method }));
+ },
+ waitFor(method, sessionId, ms) {
+ return new Promise((resolve) => {
+ const w = { method, sessionId, resolve, timer: setTimeout(() => { waiters.splice(waiters.indexOf(w), 1); resolve(null); }, ms) };
+ waiters.push(w);
+ });
+ },
+ /** One message from the browser (untrusted: it came out of the sandbox). */
+ onMessage(text) {
+ let m;
+ try { m = JSON.parse(text); } catch { return; }
+ if (!m || typeof m !== 'object') return;
+ if (typeof m.id === 'number' && pending.has(m.id)) {
+ const p = pending.get(m.id);
+ pending.delete(m.id);
+ if (m.error) p.reject(new Error(`${p.method}: ${String(m.error.message || 'failed').slice(0, 200)}`));
+ else p.resolve(m.result || {});
+ return;
+ }
+ if (typeof m.method === 'string') {
+ for (const w of [...waiters]) {
+ if (w.method === m.method && (!w.sessionId || w.sessionId === m.sessionId)) {
+ clearTimeout(w.timer);
+ waiters.splice(waiters.indexOf(w), 1);
+ w.resolve(m.params || {});
+ }
+ }
+ }
+ },
+ close(why = 'closed') {
+ closed = why;
+ for (const p of pending.values()) p.reject(new Error(`the browser is gone (${why})`));
+ pending.clear();
+ for (const w of waiters.splice(0)) { clearTimeout(w.timer); w.resolve(null); }
+ },
+ };
+}
+
+/** Open a page in a new tab, wait for it to load, and read its title, text and a screenshot. */
+async function openPage(cdp, url, { timeoutMs = 20_000, screenshot = true } = {}) {
+ const { targetId } = await cdp.call('Target.createTarget', { url: 'about:blank' });
+ try {
+ const { sessionId } = await cdp.call('Target.attachToTarget', { targetId, flatten: true });
+ await cdp.call('Page.enable', {}, sessionId);
+ const loaded = cdp.waitFor('Page.loadEventFired', sessionId, timeoutMs);
+ const nav = await cdp.call('Page.navigate', { url: String(url) }, sessionId);
+ if (nav.errorText) return { ok: false, url, error: String(nav.errorText).slice(0, 200) };
+ await loaded;
+ const evaluate = async (expression) => {
+ const r = await cdp.call('Runtime.evaluate', { expression, returnByValue: true }, sessionId);
+ return r && r.result ? r.result.value : null;
+ };
+ const title = String((await evaluate('document.title')) || '').slice(0, 500);
+ const text = String((await evaluate('document.body ? document.body.innerText : ""')) || '').slice(0, 20_000);
+ const location = String((await evaluate('location.href')) || '').slice(0, 2000);
+ let shot = null;
+ if (screenshot) shot = (await cdp.call('Page.captureScreenshot', { format: 'png' }, sessionId)).data || null;
+ return { ok: true, url: location || url, title, text, screenshot: shot };
+ } finally {
+ await cdp.call('Target.closeTarget', { targetId }).catch(() => {});
+ }
+}
+
+module.exports = { findBrowser, createCdp, openPage, INSTALLED };
new file mode 100644
new file mode 100644
@@ -0,0 +1,138 @@
@@ -0,0 +1,138 @@
+'use strict';
+/**
+ * What the chat plays sandbox is made of: the bubblewrap command line (sandbox/session.js runs it). Everything the
+ * sandbox can see is named here, and nothing else of the host is there.
+ *
+ * namespaces user, pid, network, ipc, uts and cgroup of its own; no nested user namespace (--disable-userns); the
+ * network namespace has only its own loopback: no route anywhere, the host's loopback included
+ * identity uid/gid 1000 "agent" in a passwd of its own, hostname "sandbox"; every capability dropped, no new
+ * privileges, a new session (no terminal to inject keys into), killed with the worker (--die-with-parent)
+ * environment cleared (--clearenv) and set from an allowlist: PATH, HOME, the proxy, a few locale and terminal ones
+ * files read-only: /usr (with /usr/local hidden), the /bin and /lib links, a short list of /etc entries a
+ * program needs (certificates, fonts, the linker cache, editor and shell defaults), Node, the browser and
+ * the supervisor (one file). Written by the worker for this sandbox: passwd, group, hosts, hostname,
+ * resolv.conf, nsswitch.conf. Never /home, /root, /etc as a whole, /var, /run, /opt, /srv, /mnt, /media,
+ * /sys or any device but bwrap's minimal /dev
+ * writable size-limited tmpfs only: /workspace, /home/agent, /tmp and /dev/shm. They die with the sandbox: that is
+ * the wipe. The root itself is read-only (--remount-ro /)
+ * egress /run/ov/net, read-only: the directory of the worker's egress proxy socket (sandbox/egress-proxy.js)
+ * seccomp sandbox/seccomp.js, when the architecture has a filter
+ */
+const fs = require('fs');
+const path = require('path');
+
+const UID = 1000;
+const GID = 1000;
+const HOSTNAME = 'sandbox';
+const HOME = '/home/agent';
+const WORKSPACE = '/workspace';
+const PROXY_PORT = 3128;
+const NET_DIR = '/run/ov/net';
+const SUPERVISOR = '/opt/ov/supervisor.js';
+
+// The /etc entries a program needs and that hold nothing of the host's: certificates, fonts, the dynamic linker's
+// cache, editors' and shells' defaults. (/etc/ssl/private, ssh, passwd, shadow, the services' env files: never.)
+const ETC_READ_ONLY = [
+ 'ssl/certs', 'ssl/openssl.cnf', 'ca-certificates', 'ca-certificates.conf', 'pki/tls/certs', 'pki/ca-trust/extracted',
+ 'fonts', 'ld.so.cache', 'ld.so.conf', 'ld.so.conf.d', 'localtime', 'timezone', 'alternatives', 'mime.types', 'magic',
+ 'vim', 'vimrc', 'nanorc', 'bash.bashrc', 'inputrc', 'terminfo', 'protocols', 'services',
+];
+// The /usr-merged links (or directories on an older layout).
+const ROOT_LINKS = ['bin', 'sbin', 'lib', 'lib32', 'lib64', 'libx32'];
+
+/** The files the worker writes for one sandbox (its own user list, host names and no DNS). */
+function etcFiles({ shell = '/bin/sh' } = {}) {
+ return {
+ passwd: `agent:x:${UID}:${GID}:agent:${HOME}:${shell}\nnobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin\n`,
+ group: `agent:x:${GID}:\nnogroup:x:65534:\n`,
+ hosts: `127.0.0.1\tlocalhost ${HOSTNAME}\n::1\tlocalhost ip6-localhost ip6-loopback\n`,
+ hostname: `${HOSTNAME}\n`,
+ // No DNS inside: names are resolved by the egress proxy, after which the guard checks the answer.
+ 'resolv.conf': '# No DNS in the sandbox: the egress proxy resolves names (http_proxy).\n',
+ 'nsswitch.conf': 'passwd: files\ngroup: files\nshadow: files\nhosts: files\nnetworks: files\nprotocols: files\nservices: files\n',
+ };
+}
+
+/** The sandbox's whole environment: nothing of the worker's. */
+function sandboxEnv({ nodeBin = null, browserExe = null, shell = '/bin/sh' } = {}) {
+ const proxy = `http://127.0.0.1:${PROXY_PORT}`;
+ return {
+ PATH: [nodeBin, '/usr/bin', '/bin', '/usr/sbin', '/sbin'].filter(Boolean).join(':'),
+ HOME, USER: 'agent', LOGNAME: 'agent', SHELL: shell, LANG: 'C.UTF-8', TERM: 'xterm-256color', TMPDIR: '/tmp',
+ http_proxy: proxy, https_proxy: proxy, HTTP_PROXY: proxy, HTTPS_PROXY: proxy,
+ no_proxy: 'localhost,127.0.0.1,::1', NO_PROXY: 'localhost,127.0.0.1,::1',
+ npm_config_cache: `${HOME}/.npm`,
+ OV_SANDBOX: '1', OV_SANDBOX_PROXY_PORT: String(PROXY_PORT), OV_SANDBOX_EGRESS_SOCKET: `${NET_DIR}/egress.sock`,
+ OV_SANDBOX_WORKSPACE: WORKSPACE,
+ ...(browserExe ? { OV_SANDBOX_BROWSER: browserExe } : {}),
+ };
+}
+
+const lstat = (p) => { try { return fs.lstatSync(p); } catch { return null; } };
+
+/**
+ * The bwrap arguments for one sandbox.
+ * spec: { rootfs, sessionEtc, netDir, supervisorPath, nodePath, browserPath, diskMb, shmMb, seccompFd,
+ * disableUserns, hideUsrLocal }
+ * → { args, env, inside: { node, browser } }
+ */
+function buildArgs(spec) {
+ const rootfs = spec.rootfs || '/';
+ const at = (p) => path.join(rootfs, p);
+ const disk = Math.max(1, Math.round(Number(spec.diskMb))) * 1024 * 1024;
+ const shm = Math.max(1, Math.round(Number(spec.shmMb || 64))) * 1024 * 1024;
+ const args = [
+ '--unshare-user', '--unshare-ipc', '--unshare-pid', '--unshare-net', '--unshare-uts', '--unshare-cgroup',
+ ...(spec.disableUserns !== false ? ['--disable-userns'] : []),
+ '--uid', String(UID), '--gid', String(GID), '--hostname', HOSTNAME,
+ '--die-with-parent', '--new-session', '--cap-drop', 'ALL', '--clearenv',
+ ];
+
+ // Node and the browser: from /usr as they are, or their own directory bound at /opt/node and /opt/browser.
+ const node = fs.realpathSync(spec.nodePath || process.execPath);
+ let nodeInside = node;
+ let nodeBin = null;
+ const binds = [];
+ if (!node.startsWith('/usr/')) {
+ const prefix = path.dirname(path.dirname(node));
+ binds.push(['--ro-bind', prefix, '/opt/node']);
+ nodeInside = path.join('/opt/node', path.relative(prefix, node));
+ nodeBin = path.dirname(nodeInside);
+ }
+ let browserInside = null;
+ if (spec.browserPath) {
+ const exe = fs.realpathSync(spec.browserPath);
+ binds.push(['--ro-bind', path.dirname(exe), '/opt/browser']);
+ browserInside = path.join('/opt/browser', path.basename(exe));
+ }
+ const shell = lstat(at('bin/bash')) || lstat(at('usr/bin/bash')) ? '/bin/bash' : '/bin/sh';
+ const env = sandboxEnv({ nodeBin, browserExe: browserInside, shell });
+ for (const [k, v] of Object.entries(env)) args.push('--setenv', k, v);
+
+ args.push('--ro-bind', at('usr'), '/usr');
+ if (spec.hideUsrLocal !== false && lstat(at('usr/local'))) args.push('--tmpfs', '/usr/local', '--remount-ro', '/usr/local');
+ for (const name of ROOT_LINKS) {
+ const st = lstat(at(name));
+ if (!st) continue;
+ if (st.isSymbolicLink()) args.push('--symlink', fs.readlinkSync(at(name)), `/${name}`);
+ else if (st.isDirectory()) args.push('--ro-bind', at(name), `/${name}`);
+ }
+ args.push('--dir', '/etc');
+ for (const name of ETC_READ_ONLY) if (lstat(at(`etc/${name}`))) args.push('--ro-bind', at(`etc/${name}`), `/etc/${name}`);
+ for (const name of Object.keys(etcFiles())) args.push('--ro-bind', path.join(spec.sessionEtc, name), `/etc/${name}`);
+
+ args.push('--proc', '/proc', '--dev', '/dev');
+ args.push('--perms', '1777', '--size', String(shm), '--tmpfs', '/dev/shm');
+ args.push('--perms', '1777', '--size', String(disk), '--tmpfs', '/tmp');
+ args.push('--dir', '/home', '--perms', '0700', '--size', String(disk), '--tmpfs', HOME);
+ args.push('--perms', '0755', '--size', String(disk), '--tmpfs', WORKSPACE);
+ args.push('--ro-bind', spec.netDir, NET_DIR);
+ args.push('--ro-bind', spec.supervisorPath, SUPERVISOR);
+ for (const b of binds) args.push(...b);
+ args.push('--remount-ro', '/', '--chdir', WORKSPACE);
+ if (spec.seccompFd != null) args.push('--seccomp', String(spec.seccompFd));
+ args.push('--', nodeInside, SUPERVISOR);
+ return { args, env, shell, inside: { node: nodeInside, browser: browserInside } };
+}
+
+module.exports = { buildArgs, etcFiles, sandboxEnv, ETC_READ_ONLY, UID, GID, HOME, WORKSPACE, PROXY_PORT, NET_DIR, SUPERVISOR };
new file mode 100644
new file mode 100644
@@ -0,0 +1,219 @@
@@ -0,0 +1,219 @@
+'use strict';
+/**
+ * The chat plays sandbox's only way out (Agent Streams S6, design 2026-11-06 §1): an HTTP forward proxy that runs in
+ * the worker, OUTSIDE the sandbox, on a Unix socket in a directory the sandbox sees read-only. The sandbox has no
+ * network of its own (its own network namespace, a loopback and nothing else), so every byte that leaves it passes
+ * here, and here it meets the egress guard (openvibe-shared/egress):
+ *
+ * - public http and https only: an absolute http:// request, or a CONNECT tunnel (https, and ws/wss the way browsers
+ * send them), to port 80 or 443. No SMTP (25, 587, 465), no SSH, no database, no other port at all;
+ * - never a private, loopback, link-local (cloud metadata), carrier-grade NAT, reserved or documentation address,
+ * in any spelling, nor a name that can only mean a local network (localhost, *.local, *.internal, a bare word);
+ * - never one of the host's own addresses, even a public one: the worker's machine is not "the web";
+ * - checked AFTER DNS resolution: every answer must pass (one private answer among public ones refuses the name), and
+ * the socket opens to the very address that was checked, so a second lookup cannot rebind it;
+ * - checked on every redirect: the proxy never follows one. A 3xx goes back to the client as it came, and the
+ * client's next request is a new request, checked again.
+ *
+ * A refusal is a 403 with `X-OV-Egress: refused; code=egress.…` and no socket is opened for it. Every decision (host,
+ * port, address, allowed or the refusal's code — never a path, a header or a body) goes to onDecision for the
+ * owner's log.
+ *
+ * const proxy = createEgressProxy({ lookup, dial, hostAddresses, ports, maxConnections, onDecision });
+ * await proxy.listen('/run/…/net/egress.sock') or proxy.listen({ port: 0, host: '127.0.0.1' }) (tests)
+ * await proxy.check(host, port) → { ok, host, port, address, family } | { ok: false, code, reason }
+ * proxy.address(), await proxy.close()
+ *
+ * lookup (dns.lookup-shaped) and dial ({ host: address, port } → net.Socket) are injectable for the tests; the worker
+ * uses the system resolver and net.connect.
+ */
+const dns = require('dns');
+const fs = require('fs');
+const http = require('http');
+const net = require('net');
+const os = require('os');
+const egress = require('openvibe-shared/egress');
+
+const PORTS = Object.freeze([80, 443]);
+// Hop-by-hop headers (RFC 9110 §7.6.1) and the proxy's own: never forwarded.
+const HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'proxy-connection', 'te', 'trailer', 'upgrade']);
+
+/** The machine's own addresses (every interface), as the guard compares them. */
+function defaultHostAddresses() {
+ const out = new Set();
+ for (const list of Object.values(os.networkInterfaces())) for (const a of list || []) out.add(a.address.split('%')[0].toLowerCase());
+ return out;
+}
+
+const defaultDial = ({ host, port }) => net.connect({ host, port });
+
+function createEgressProxy({
+ lookup = dns.lookup, dial = defaultDial, hostAddresses = defaultHostAddresses, ports = PORTS,
+ maxConnections = 64, connectTimeoutMs = 10_000, idleMs = 120_000, lookupTimeoutMs = 5_000,
+ onDecision = () => {}, log = console,
+} = {}) {
+ const allowedPorts = new Set(ports.map(Number));
+ const sockets = new Set();
+ let server = null;
+
+ const refuse = (host, port, code, reason) => ({ ok: false, host, port, code, reason });
+
+ function resolveAll(host) {
+ return new Promise((resolve) => {
+ let settled = false;
+ const done = (v) => { if (!settled) { settled = true; clearTimeout(timer); resolve(v); } };
+ const timer = setTimeout(() => done({ error: 'ETIMEOUT' }), lookupTimeoutMs);
+ try {
+ lookup(host, { all: true, verbatim: true }, (err, addrs) => done(err ? { error: err.code || 'EDNS' } : { addrs: Array.isArray(addrs) ? addrs : [] }));
+ } catch (err) { done({ error: err.code || 'EDNS' }); }
+ });
+ }
+
+ /** The egress guard for one host and port: where the socket may go, or why not. */
+ async function check(rawHost, rawPort) {
+ const port = Number(rawPort);
+ const host = egress.normalizeHost(rawHost);
+ if (!Number.isInteger(port) || !allowedPorts.has(port)) return refuse(host, rawPort, 'egress.port', `only ports ${[...allowedPorts].join(' and ')} leave the sandbox`);
+ if (!host || host.length > 253 || /[\s/\\@]/.test(host)) return refuse(host, port, 'egress.bad_request', 'not a host name');
+ let addrs;
+ if (net.isIP(host)) addrs = [{ address: host, family: net.isIP(host) }];
+ else {
+ if (egress.isInternalName(host)) return refuse(host, port, 'egress.internal_name', `${host} can only name a local network`);
+ const r = await resolveAll(host);
+ if (r.error) return refuse(host, port, 'egress.dns', `${host} does not resolve`);
+ addrs = r.addrs.filter((a) => a && net.isIP(String(a.address)));
+ if (!addrs.length) return refuse(host, port, 'egress.dns', `${host} has no address`);
+ }
+ const mine = hostAddresses();
+ for (const a of addrs) {
+ const address = String(a.address).split('%')[0].toLowerCase();
+ if (!egress.isPublicAddress(address)) return refuse(host, port, 'egress.private_address', `${host} is not a public address`);
+ const v4 = net.isIPv6(address) ? egress.embeddedV4(address) : null;
+ if (mine.has(address) || (v4 && mine.has(v4))) return refuse(host, port, 'egress.host_address', `${host} is this machine`);
+ }
+ const first = addrs[0];
+ return { ok: true, host, port, address: String(first.address).split('%')[0], family: net.isIP(String(first.address).split('%')[0]) };
+ }
+
+ function report(method, d) {
+ try {
+ onDecision({ at: new Date().toISOString(), method, allowed: d.ok === true, host: d.host, port: d.port, ...(d.ok ? { address: d.address } : { code: d.code }) });
+ } catch { /* a logger never breaks the proxy */ }
+ }
+
+ const refusalHead = (status, code, text) => {
+ const body = `${text}\n`;
+ return `HTTP/1.1 ${status} ${http.STATUS_CODES[status]}\r\nX-OV-Egress: refused; code=${code}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`;
+ };
+
+ function deny(res, status, code, text) {
+ if (res.headersSent) return res.destroy();
+ res.writeHead(status, { 'x-ov-egress': `refused; code=${code}`, 'content-type': 'text/plain; charset=utf-8', connection: 'close' });
+ res.end(`${text}\n`);
+ }
+
+ async function onRequest(req, res) {
+ if (req.socket.ovRefused) return;
+ let url;
+ try { url = new URL(req.url); } catch { return deny(res, 400, 'egress.bad_request', 'This is a forward proxy: send an absolute http:// URL, or CONNECT for https'); }
+ if (url.protocol === 'https:') return deny(res, 400, 'egress.use_connect', 'Use CONNECT for https');
+ if (url.protocol !== 'http:') return deny(res, 403, 'egress.scheme', 'Only http and https leave the sandbox');
+ if (url.username || url.password) return deny(res, 403, 'egress.credentials', 'No credentials in a URL');
+ const d = await check(url.hostname, url.port ? Number(url.port) : 80);
+ report(req.method, d);
+ if (!d.ok) return deny(res, 403, d.code, `Refused: ${d.reason}`);
+
+ const headers = {};
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
+ const k = req.rawHeaders[i];
+ if (HOP.has(k.toLowerCase()) || k.toLowerCase() === 'host') continue;
+ headers[k] = headers[k] === undefined ? req.rawHeaders[i + 1] : [].concat(headers[k], req.rawHeaders[i + 1]);
+ }
+ headers.Host = url.host; // the request target decides where it goes; Host follows it, never the other way
+ headers.Connection = 'close';
+ // No `agent` key: with agent: false Node makes a fresh Agent and IGNORES createConnection (it would dial
+ // localhost:80). host and port name the checked address too, so even then nothing else is dialled.
+ const upstream = http.request({
+ host: d.address, port: d.port, method: req.method, path: url.pathname + url.search, headers, timeout: connectTimeoutMs,
+ createConnection: () => dial({ host: d.address, port: d.port }),
+ });
+ upstream.on('response', (up) => {
+ const out = [];
+ for (let i = 0; i < up.rawHeaders.length; i += 2) if (!HOP.has(up.rawHeaders[i].toLowerCase())) out.push(up.rawHeaders[i], up.rawHeaders[i + 1]);
+ res.writeHead(up.statusCode, up.statusMessage, out);
+ up.pipe(res);
+ });
+ upstream.on('timeout', () => upstream.destroy(Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' })));
+ upstream.on('error', () => deny(res, 502, 'egress.upstream', 'The site did not answer'));
+ req.pipe(upstream);
+ }
+
+ async function onConnect(req, socket, head) {
+ socket.on('error', () => {});
+ if (socket.ovRefused) return;
+ const m = /^\[([0-9a-fA-F:.]+)\]:(\d{1,5})$/.exec(req.url) || /^([^\s:[\]/@]+):(\d{1,5})$/.exec(req.url);
+ if (!m) return socket.end(refusalHead(400, 'egress.bad_request', 'CONNECT needs host:port'));
+ const d = await check(m[1], Number(m[2]));
+ report('CONNECT', d);
+ if (!d.ok) return socket.end(refusalHead(403, d.code, `Refused: ${d.reason}`));
+ const up = dial({ host: d.address, port: d.port });
+ const timer = setTimeout(() => up.destroy(), connectTimeoutMs);
+ up.once('connect', () => {
+ clearTimeout(timer);
+ socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
+ if (head && head.length) up.write(head);
+ up.pipe(socket);
+ socket.pipe(up);
+ });
+ up.on('error', () => { clearTimeout(timer); if (!socket.destroyed) socket.end(refusalHead(502, 'egress.upstream', 'The site did not answer')); });
+ socket.on('close', () => up.destroy());
+ up.on('close', () => socket.destroy());
+ }
+
+ function onConnection(sock) {
+ sock.on('error', () => {});
+ if (sockets.size >= maxConnections) {
+ sock.ovRefused = true;
+ sock.end(refusalHead(503, 'egress.busy', 'Too many connections'));
+ return;
+ }
+ sockets.add(sock);
+ sock.setTimeout(idleMs, () => sock.destroy());
+ sock.on('close', () => sockets.delete(sock));
+ }
+
+ function build() {
+ const s = http.createServer({ requestTimeout: 0, headersTimeout: 30_000, maxHeaderSize: 32 * 1024 });
+ s.on('connection', onConnection);
+ s.on('request', (req, res) => { onRequest(req, res).catch((e) => { log.error('[sandbox] egress proxy:', e && e.message); deny(res, 500, 'egress.error', 'Proxy error'); }); });
+ s.on('connect', (req, socket, head) => { onConnect(req, socket, head).catch(() => socket.destroy()); });
+ s.on('upgrade', (req, socket) => { socket.on('error', () => {}); socket.end(refusalHead(400, 'egress.use_connect', 'Use CONNECT for WebSockets')); });
+ s.on('clientError', (err, socket) => { if (socket.writable && !socket.ovRefused) socket.end(refusalHead(400, 'egress.bad_request', 'Not an HTTP request')); else socket.destroy(); });
+ return s;
+ }
+
+ return {
+ check,
+ async listen(target) {
+ server = build();
+ if (typeof target === 'string') {
+ try { fs.unlinkSync(target); } catch { /* none yet */ }
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(target, resolve); });
+ fs.chmodSync(target, 0o600);
+ } else {
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(target.port || 0, target.host || '127.0.0.1', resolve); });
+ }
+ return this;
+ },
+ address: () => server && server.address(),
+ connections: () => sockets.size,
+ async close() {
+ if (!server) return;
+ for (const s of sockets) s.destroy();
+ await new Promise((resolve) => server.close(() => resolve()));
+ server = null;
+ },
+ };
+}
+
+module.exports = { createEgressProxy, PORTS, defaultHostAddresses };
new file mode 100644
new file mode 100644
@@ -0,0 +1,188 @@
@@ -0,0 +1,188 @@
+'use strict';
+/**
+ * The chat plays sandbox's limits: CPU, memory and process count come from a cgroup v2 of the sandbox's own (rlimits
+ * cannot do it: in a user namespace RLIMIT_NPROC still counts every process of the host user, and RLIMIT_AS breaks a
+ * browser). Disk is the size of the sandbox's tmpfs areas (sandbox/bwrap.js), charged to that cgroup's memory too;
+ * the wall clock is the session's time box (sandbox/session.js).
+ *
+ * Two ways to get the cgroup, both unprivileged:
+ *
+ * cgroup the worker's systemd unit has Delegate=yes (deploy/sandbox/openvibe-codes-sandbox.service): the
+ * worker owns its cgroup subtree. It moves itself into <unit>/worker, turns on the cpu, memory and pids
+ * controllers for the children, and makes <unit>/sbx-<id> per sandbox. The sandbox's first process
+ * writes itself into it before it executes bubblewrap, so nothing of the sandbox ever runs outside it.
+ * systemd-user a workstation: `systemd-run --user --scope` makes a transient scope with MemoryMax, TasksMax and
+ * CPUQuota per sandbox (the user's systemd delegates cpu, memory and pids).
+ *
+ * detectLimits({ mode: 'auto' | 'cgroup' | 'systemd-user' | 'none' }) → limiter { kind, ok, reason, prepare }
+ * limiter.prepare(id, { memoryMb, diskMb, pids, cpuPercent }) → handle
+ * handle.wrap(argv, env) → { argv, env } the command that starts the sandbox inside its cgroup
+ * handle.attach(pid) learns the cgroup's path once the sandbox runs (systemd-user)
+ * handle.path the cgroup's directory (null until known)
+ * handle.kill() SIGKILL to everything in the cgroup (cgroup.kill)
+ * await handle.remove() waits for it to be empty and removes it
+ *
+ * The memory limit is memory + the three tmpfs areas (workspace, home, /tmp): tmpfs pages are charged to the cgroup that
+ * wrote them, so a full disk must not count against the processes' own memory.
+ */
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+const CONTROLLERS = ['cpu', 'memory', 'pids'];
+const CPU_PERIOD = 100_000;
+const MB = 1024 * 1024;
+
+const read = (f) => { try { return fs.readFileSync(f, 'utf8').trim(); } catch { return ''; } };
+const write = (f, v) => fs.writeFileSync(f, String(v));
+
+/** The memory.max, pids.max and cpu.max values for a sandbox's limits. */
+function values({ memoryMb, diskMb, pids, cpuPercent }) {
+ const memory = Math.round((Number(memoryMb) + 3 * Number(diskMb)) * MB);
+ const quota = Math.max(1000, Math.round(Number(cpuPercent) * CPU_PERIOD / 100));
+ return { memory, pids: Math.max(1, Math.round(Number(pids))), cpu: `${quota} ${CPU_PERIOD}`, cpuPercent: Number(cpuPercent) };
+}
+
+/** This process's own cgroup v2 path (the unified hierarchy's line in /proc/self/cgroup). */
+function ownCgroup(root) {
+ const line = read('/proc/self/cgroup').split('\n').find((l) => l.startsWith('0::'));
+ return line ? path.join(root, line.slice(3)) : null;
+}
+
+const missingControllers = (dir) => {
+ const have = read(path.join(dir, 'cgroup.controllers')).split(/\s+/);
+ return CONTROLLERS.filter((c) => !have.includes(c));
+};
+
+async function waitEmpty(dir, ms = 5000) {
+ const until = Date.now() + ms;
+ while (Date.now() < until) {
+ const events = read(path.join(dir, 'cgroup.events'));
+ if (!events || /populated 0/.test(events)) return true;
+ await new Promise((r) => setTimeout(r, 25));
+ }
+ return false;
+}
+
+function killCgroup(dir) {
+ if (!dir) return;
+ try { write(path.join(dir, 'cgroup.kill'), '1'); return; } catch { /* before Linux 5.14: one by one */ }
+ for (const pid of read(path.join(dir, 'cgroup.procs')).split('\n').filter(Boolean)) {
+ try { process.kill(Number(pid), 'SIGKILL'); } catch { /* gone */ }
+ }
+}
+
+/** The delegated subtree of the worker's systemd unit (Delegate=yes). */
+function tryDelegated(root) {
+ let own = ownCgroup(root);
+ if (!own) return { ok: false, reason: 'no cgroup v2 (unified hierarchy) for this process' };
+ // A worker that already moved itself (a second detect in the same process) sits in <base>/worker.
+ const base = path.basename(own) === 'worker' && fs.existsSync(path.join(path.dirname(own), 'cgroup.subtree_control')) ? path.dirname(own) : own;
+ const missing = missingControllers(base);
+ if (missing.length) return { ok: false, reason: `the worker's cgroup has no ${missing.join(', ')} controller (its systemd unit needs Delegate=yes)` };
+ try {
+ fs.accessSync(base, fs.constants.W_OK);
+ fs.accessSync(path.join(base, 'cgroup.procs'), fs.constants.W_OK);
+ fs.accessSync(path.join(base, 'cgroup.subtree_control'), fs.constants.W_OK);
+ } catch {
+ return { ok: false, reason: `the worker may not write its cgroup ${base} (its systemd unit needs Delegate=yes)` };
+ }
+ const others = read(path.join(base, 'cgroup.procs')).split('\n').filter(Boolean).map(Number).filter((p) => p !== process.pid);
+ if (others.length) return { ok: false, reason: `the worker's cgroup ${base} holds other processes: it needs one of its own (Delegate=yes)` };
+ const leaf = path.join(base, 'worker');
+ try {
+ if (own !== leaf) {
+ fs.mkdirSync(leaf, { recursive: true });
+ write(path.join(leaf, 'cgroup.procs'), process.pid);
+ own = leaf;
+ }
+ const enabled = read(path.join(base, 'cgroup.subtree_control')).split(/\s+/);
+ const todo = CONTROLLERS.filter((c) => !enabled.includes(c));
+ if (todo.length) write(path.join(base, 'cgroup.subtree_control'), todo.map((c) => `+${c}`).join(' '));
+ } catch (err) {
+ return { ok: false, reason: `the worker could not set up its cgroup subtree: ${err.code || err.message}` };
+ }
+ return {
+ kind: 'cgroup', ok: true, base,
+ prepare(id, limits) {
+ const v = values(limits);
+ const dir = path.join(base, `sbx-${id}`);
+ fs.mkdirSync(dir);
+ write(path.join(dir, 'memory.max'), v.memory);
+ try { write(path.join(dir, 'memory.swap.max'), 0); } catch { /* no swap accounting: no swap to limit */ }
+ write(path.join(dir, 'pids.max'), v.pids);
+ write(path.join(dir, 'cpu.max'), v.cpu);
+ // An out-of-memory kills the process that asked, not the whole sandbox (the agent sees it and goes on).
+ try { write(path.join(dir, 'memory.oom.group'), 0); } catch { /* older kernels: the default is 0 */ }
+ return {
+ path: dir,
+ // The first process moves itself (`echo 0` is "the writer") into the cgroup, then becomes bubblewrap.
+ wrap: (argv, env) => ({ argv: ['/bin/sh', '-c', 'echo 0 > "$0" && exec "$@"', path.join(dir, 'cgroup.procs'), ...argv], env }),
+ attach() {},
+ kill: () => killCgroup(dir),
+ async remove() {
+ if (!(await waitEmpty(dir))) { killCgroup(dir); await waitEmpty(dir); }
+ try { fs.rmdirSync(dir); } catch (err) { if (err.code !== 'ENOENT') throw err; }
+ },
+ };
+ },
+ };
+}
+
+/** A transient scope of the user's systemd per sandbox (a workstation; the worker's own unit uses the delegated way). */
+function trySystemdUser(root) {
+ const runtime = process.env.XDG_RUNTIME_DIR;
+ if (!runtime || !fs.existsSync(path.join(runtime, 'systemd'))) return { ok: false, reason: 'no user systemd (XDG_RUNTIME_DIR/systemd)' };
+ const uid = process.getuid();
+ const manager = path.join(root, `user.slice/user-${uid}.slice/user@${uid}.service`);
+ const missing = missingControllers(manager);
+ if (!fs.existsSync(manager) || missing.length) return { ok: false, reason: `the user's systemd does not delegate ${missing.join(', ') || 'a cgroup'}` };
+ const env = { PATH: '/usr/bin:/bin', XDG_RUNTIME_DIR: runtime, ...(process.env.DBUS_SESSION_BUS_ADDRESS ? { DBUS_SESSION_BUS_ADDRESS: process.env.DBUS_SESSION_BUS_ADDRESS } : {}) };
+ const probe = spawnSync('systemd-run', ['--user', '--scope', '--quiet', '--collect', '-p', 'TasksMax=4', '--', '/bin/true'], { env, timeout: 10_000, encoding: 'utf8' });
+ if (probe.error || probe.status !== 0) return { ok: false, reason: `systemd-run --user --scope failed: ${(probe.stderr || (probe.error && probe.error.code) || '').trim().slice(0, 200)}` };
+ return {
+ kind: 'systemd-user', ok: true,
+ prepare(id, limits) {
+ const v = values(limits);
+ const unit = `ovsbx-${id}`;
+ const handle = {
+ path: null, unit,
+ wrap: (argv, baseEnv) => ({
+ argv: ['systemd-run', '--user', '--scope', '--quiet', '--collect', '--unit', unit,
+ '-p', `MemoryMax=${v.memory}`, '-p', 'MemorySwapMax=0', '-p', `TasksMax=${v.pids}`, '-p', `CPUQuota=${v.cpuPercent}%`, '-p', 'OOMPolicy=continue', '--', ...argv],
+ env: { ...baseEnv, ...env },
+ }),
+ // systemd-run --scope executes the command in place: the sandbox's first process is in the scope.
+ attach(pid) {
+ const line = read(`/proc/${pid}/cgroup`).split('\n').find((l) => l.startsWith('0::'));
+ if (line && line.includes(unit)) handle.path = path.join(root, line.slice(3));
+ },
+ kill() {
+ if (handle.path) return killCgroup(handle.path);
+ spawnSync('systemctl', ['--user', 'kill', '--signal=SIGKILL', `${unit}.scope`], { env, timeout: 5000 });
+ },
+ async remove() {
+ if (!handle.path) return;
+ if (!(await waitEmpty(handle.path))) { handle.kill(); await waitEmpty(handle.path); }
+ // systemd collects the scope once it is empty (--collect).
+ const until = Date.now() + 5000;
+ while (fs.existsSync(handle.path) && Date.now() < until) await new Promise((r) => setTimeout(r, 25));
+ },
+ };
+ return handle;
+ },
+ };
+}
+
+async function detectLimits({ mode = 'auto', cgroupRoot = '/sys/fs/cgroup' } = {}) {
+ if (mode === 'none') return { kind: 'none', ok: false, reason: 'limits are turned off (CODES_SANDBOX_LIMITS=none)' };
+ if (mode === 'cgroup') return { kind: 'cgroup', ...tryDelegated(cgroupRoot) };
+ if (mode === 'systemd-user') return { kind: 'systemd-user', ...trySystemdUser(cgroupRoot) };
+ const delegated = tryDelegated(cgroupRoot);
+ if (delegated.ok) return delegated;
+ const user = trySystemdUser(cgroupRoot);
+ if (user.ok) return user;
+ return { kind: 'none', ok: false, reason: `${delegated.reason}; ${user.reason}` };
+}
+
+module.exports = { detectLimits, values, CONTROLLERS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+/**
+ * The chat plays sandbox's seccomp filter: a classic BPF program bubblewrap loads (`--seccomp FD`) just before it
+ * executes the sandbox's first process. Everything the sandbox runs inherits it and cannot remove it (no_new_privs).
+ *
+ * It is a deny list on top of the namespaces, not the boundary itself: the calls a build-and-browse agent never needs
+ * and that are the kernel's usual attack surface or a way to reach something outside, answered EPERM:
+ *
+ * the kernel keyring (keyctl, add_key, request_key: a login's keys live there), ptrace and process_vm_* (reading
+ * another process), namespaces and mounts (unshare, setns, mount, the new mount API, pivot_root), bpf, perf_event_open,
+ * userfaultfd, io_uring, kernel modules and kexec, file handles (open_by_handle_at), syslog, swap, reboot, acct,
+ * quotactl, and on x86-64 iopl/ioperm.
+ *
+ * Any other architecture's calls (i386 or x32 on an x86-64 kernel, which have their own numbers) kill the process: a
+ * filter keyed on x86-64 numbers must not be bypassed by changing ABI.
+ *
+ * compile(arch = process.arch) → Buffer (struct sock_filter[]) or null when the architecture is not supported
+ */
+
+// The numbers (asm/unistd_64.h on x86-64; asm-generic/unistd.h on arm64).
+const SYSCALLS = {
+ x64: {
+ ptrace: 101, syslog: 103, vhangup: 153, pivot_root: 155, acct: 163, mount: 165, umount2: 166, swapon: 167, swapoff: 168,
+ reboot: 169, iopl: 172, ioperm: 173, init_module: 175, delete_module: 176, quotactl: 179, lookup_dcookie: 212,
+ kexec_load: 246, add_key: 248, request_key: 249, keyctl: 250, unshare: 272, perf_event_open: 298,
+ name_to_handle_at: 303, open_by_handle_at: 304, setns: 308, process_vm_readv: 310, process_vm_writev: 311,
+ finit_module: 313, kexec_file_load: 320, bpf: 321, userfaultfd: 323, io_uring_setup: 425, io_uring_enter: 426,
+ io_uring_register: 427, open_tree: 428, move_mount: 429, fsopen: 430, fsconfig: 431, fsmount: 432, fspick: 433,
+ mount_setattr: 442,
+ },
+ arm64: {
+ lookup_dcookie: 18, umount2: 39, mount: 40, pivot_root: 41, vhangup: 58, quotactl: 60, acct: 89, unshare: 97,
+ kexec_load: 104, init_module: 105, delete_module: 106, syslog: 116, ptrace: 117, reboot: 142, add_key: 217,
+ request_key: 218, keyctl: 219, swapon: 224, swapoff: 225, perf_event_open: 241, open_by_handle_at: 265,
+ name_to_handle_at: 264, setns: 268, process_vm_readv: 270, process_vm_writev: 271, finit_module: 273, bpf: 280,
+ userfaultfd: 282, kexec_file_load: 294, io_uring_setup: 425, io_uring_enter: 426, io_uring_register: 427,
+ open_tree: 428, move_mount: 429, fsopen: 430, fsconfig: 431, fsmount: 432, fspick: 433, mount_setattr: 442,
+ },
+};
+const AUDIT_ARCH = { x64: 0xc000003e, arm64: 0xc00000b7 };
+const X32_SYSCALL_BIT = 0x40000000;
+
+// Classic BPF (linux/filter.h, linux/seccomp.h).
+const LD_W_ABS = 0x20; // BPF_LD | BPF_W | BPF_ABS
+const JEQ_K = 0x15; // BPF_JMP | BPF_JEQ | BPF_K
+const JGE_K = 0x35; // BPF_JMP | BPF_JGE | BPF_K
+const RET_K = 0x06; // BPF_RET | BPF_K
+const RET_ALLOW = 0x7fff0000;
+const RET_KILL_PROCESS = 0x80000000;
+const RET_ERRNO_EPERM = 0x00050000 | 1;
+const OFF_NR = 0;
+const OFF_ARCH = 4;
+
+function insn(code, jt, jf, k) {
+ const b = Buffer.alloc(8);
+ b.writeUInt16LE(code, 0);
+ b.writeUInt8(jt, 2);
+ b.writeUInt8(jf, 3);
+ b.writeUInt32LE(k >>> 0, 4);
+ return b;
+}
+
+/** The denied calls of an architecture, by name. */
+function denied(arch = process.arch) {
+ return SYSCALLS[arch] ? Object.keys(SYSCALLS[arch]).sort() : [];
+}
+
+/** The filter for an architecture, or null when there is none for it (the sandbox then runs without one, and says so). */
+function compile(arch = process.arch) {
+ const table = SYSCALLS[arch];
+ if (!table) return null;
+ const prog = [
+ insn(LD_W_ABS, 0, 0, OFF_ARCH),
+ insn(JEQ_K, 1, 0, AUDIT_ARCH[arch]),
+ insn(RET_K, 0, 0, RET_KILL_PROCESS),
+ insn(LD_W_ABS, 0, 0, OFF_NR),
+ ];
+ if (arch === 'x64') prog.push(insn(JGE_K, 0, 1, X32_SYSCALL_BIT), insn(RET_K, 0, 0, RET_ERRNO_EPERM));
+ for (const nr of [...new Set(Object.values(table))].sort((a, b) => a - b)) {
+ prog.push(insn(JEQ_K, 0, 1, nr), insn(RET_K, 0, 0, RET_ERRNO_EPERM));
+ }
+ prog.push(insn(RET_K, 0, 0, RET_ALLOW));
+ return Buffer.concat(prog);
+}
+
+module.exports = { compile, denied, SYSCALLS, AUDIT_ARCH };
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
+'use strict';
+/**
+ * The sandbox worker's self-test: before it says it is healthy, and every few minutes after, the worker makes a real
+ * sandbox and tries from inside what the escape tests try (test/sandbox-escape.test.js, in short). What it finds is
+ * what it reports to Codes and Live as its isolation; it is healthy only when every check passed.
+ *
+ * network_namespace the only interface is the sandbox's own loopback
+ * no_direct_egress a TCP connection to a public address fails (no route): nothing leaves but by the proxy
+ * egress_refuses_private through the proxy, the host's 127.0.0.1 and the cloud metadata address are refused (403)
+ * clean_env the environment is the sandbox's own allowlist, nothing of the worker's
+ * no_host_files the worker's home, its credentials, its runtime directory and /etc/openvibe are not there
+ * no_capabilities no capability, no_new_privs set
+ * seccomp the filter is on (where the architecture has one)
+ * limits the sandbox's cgroup has the memory, pids and cpu limits the worker set
+ * wipe after the wipe, no process of it is left and its directory and cgroup are gone
+ *
+ * No check needs the internet: the refusals are the proxy's own, before any connection.
+ *
+ * runSelfTest({ createSandbox, limiter, runtimeDir, nodePath, hidden, egress, log }) → { ok, checks, failed, at, isolation }
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const ENV_ALLOWED = /^(PATH|HOME|USER|LOGNAME|SHELL|LANG|LC_ALL|TERM|TMPDIR|PWD|SHLVL|_|http_proxy|https_proxy|HTTP_PROXY|HTTPS_PROXY|no_proxy|NO_PROXY|npm_config_cache|OV_SANDBOX(_[A-Z_]+)?)$/;
+const CHECKS = ['network_namespace', 'no_direct_egress', 'egress_refuses_private', 'clean_env', 'no_host_files', 'no_capabilities', 'seccomp', 'limits', 'wipe'];
+
+const PROBE = String.raw`'use strict';
+const net = require('net'); const fs = require('fs');
+const hidden = JSON.parse(process.argv[2] || '[]');
+const PROXY = Number(process.env.OV_SANDBOX_PROXY_PORT || 3128);
+const tcp = (host, port) => new Promise((r) => { const s = net.connect({ host, port }); const t = setTimeout(() => { s.destroy(); r('TIMEOUT'); }, 3000); s.on('connect', () => { clearTimeout(t); s.destroy(); r('CONNECTED'); }); s.on('error', (e) => { clearTimeout(t); r(e.code); }); });
+const viaProxy = (url) => new Promise((r) => { const s = net.connect(PROXY, '127.0.0.1'); let b = ''; const t = setTimeout(() => { s.destroy(); r(b); }, 4000); s.on('connect', () => s.write('GET ' + url + ' HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n')); s.on('data', (d) => { b += d; }); s.on('close', () => { clearTimeout(t); r(b); }); s.on('error', () => {}); });
+(async () => {
+ const dev = fs.readFileSync('/proc/net/dev', 'utf8').split('\n').slice(2).map((l) => l.split(':')[0].trim()).filter(Boolean);
+ const status = Object.fromEntries(fs.readFileSync('/proc/self/status', 'utf8').split('\n').map((l) => l.split(':\t')).filter((x) => x.length === 2));
+ const out = {
+ dev, direct: await tcp('1.1.1.1', 443),
+ loop: (await viaProxy('http://127.0.0.1:9/')).split('\r\n').slice(0, 2).join('|'),
+ meta: (await viaProxy('http://169.254.169.254/latest/meta-data/')).split('\r\n').slice(0, 2).join('|'),
+ env: Object.keys(process.env), visible: hidden.filter((p) => { try { fs.lstatSync(p); return true; } catch { return false; } }),
+ caps: ['CapInh', 'CapPrm', 'CapEff', 'CapBnd', 'CapAmb'].map((c) => status[c]), nnp: status.NoNewPrivs, seccomp: status.Seccomp,
+ };
+ process.stdout.write(JSON.stringify(out), () => process.exit(0));
+})();
+`;
+
+const refused = (head) => /^HTTP\/1\.1 403 /.test(head) && /X-OV-Egress: refused/i.test(head);
+
+async function runSelfTest({ createSandbox, limiter, runtimeDir, nodePath, hidden = [], egress = {}, seccompExpected = true, log = console }) {
+ const checks = Object.fromEntries(CHECKS.map((c) => [c, false]));
+ const at = new Date().toISOString();
+ let sbx = null;
+ let error = null;
+ try {
+ sbx = await createSandbox({
+ runtimeDir, limiter: limiter && limiter.ok ? limiter : null, allowUnlimited: !(limiter && limiter.ok), egress, nodePath,
+ limits: { memoryMb: 256, diskMb: 16, pids: 64, cpuPercent: 50 }, timeBoxMs: 60_000, id: `selftest-${Date.now().toString(36)}`, log,
+ });
+ const paths = [...new Set([os.homedir(), path.join(os.homedir(), '.ssh'), runtimeDir, sbx.dir, '/etc/openvibe', '/root', ...hidden].filter(Boolean))];
+ await sbx.writeFile('/workspace/.selftest.js', PROBE);
+ const r = await sbx.exec(['node', '/workspace/.selftest.js', JSON.stringify(paths)], { timeoutMs: 30_000 });
+ if (r.code !== 0) throw new Error(`the probe failed (${r.code}/${r.signal}): ${r.stderr.slice(0, 200)}`);
+ const p = JSON.parse(r.stdout);
+ checks.network_namespace = Array.isArray(p.dev) && p.dev.length === 1 && p.dev[0] === 'lo';
+ checks.no_direct_egress = ['ENETUNREACH', 'EHOSTUNREACH'].includes(p.direct);
+ checks.egress_refuses_private = refused(String(p.loop).replace('|', '\r\n')) && refused(String(p.meta).replace('|', '\r\n'));
+ checks.clean_env = Array.isArray(p.env) && p.env.every((k) => ENV_ALLOWED.test(k));
+ checks.no_host_files = Array.isArray(p.visible) && p.visible.length === 0;
+ checks.no_capabilities = p.caps.every((c) => c === '0000000000000000') && p.nnp === '1';
+ checks.seccomp = seccompExpected ? p.seccomp === '2' : true;
+ if (limiter && limiter.ok && sbx.cgroup) {
+ const read = (f) => fs.readFileSync(path.join(sbx.cgroup, f), 'utf8').trim();
+ checks.limits = Number(read('pids.max')) === 64 && Number(read('memory.max')) > 0 && /^\d+ \d+$/.test(read('cpu.max'));
+ }
+ } catch (err) {
+ error = String(err.message).slice(0, 300);
+ log.error('[sandbox] self-test:', error);
+ }
+ if (sbx) {
+ const dir = sbx.dir;
+ const cg = sbx.cgroup;
+ const out = await sbx.wipe('selftest');
+ checks.wipe = !out.leftover && !fs.existsSync(dir) && (!cg || !fs.existsSync(cg)) && sbx.state === 'wiped';
+ }
+ const failed = CHECKS.filter((c) => !checks[c]);
+ return { ok: failed.length === 0, checks, failed, at, ...(error ? { error } : {}) };
+}
+
+module.exports = { runSelfTest, CHECKS, ENV_ALLOWED };
new file mode 100644
new file mode 100644
@@ -0,0 +1,343 @@
@@ -0,0 +1,343 @@
+'use strict';
+/**
+ * One chat plays sandbox (Agent Streams S6, design 2026-11-06 §1, §3, §5): a fresh, empty, network-less box a
+ * viewer's request runs in, and nothing of the host's.
+ *
+ * probeSupport({ bwrap }) → { ok, reason, version, seccomp, disableUserns } can this host make sandboxes?
+ * createSandbox(opts) → a started sandbox:
+ * sbx.exec(argv, { stdin, tty, cwd, env, timeoutMs, maxOutput, onOutput }) → { code, signal, stdout, stderr, timedOut, truncated }
+ * sbx.writeFile(path, data) · sbx.readFile(path) → Buffer · sbx.list(path) → [{ name, type, size }] (workspace only)
+ * sbx.openPage(url) → { ok, url, title, text, screenshot } (the browser)
+ * sbx.wipe(reason) → { reason } Wipe now, the end of the session, or the time box
+ * sbx.ended (Promise of { reason }), sbx.state ('running' | 'wiping' | 'wiped'), sbx.id, sbx.pid, sbx.dir, sbx.cgroup
+ *
+ * opts: runtimeDir (short: a socket path must fit 108 bytes), limits { memoryMb, diskMb, pids, cpuPercent }, timeBoxMs,
+ * limiter (sandbox/limits.js; null only with allowUnlimited, for tests), egress { lookup, dial, hostAddresses }
+ * (sandbox/egress-proxy.js), onEgress(decision), nodePath, browserPath, rootfs, bwrap, seccomp, log
+ *
+ * How it is made (sandbox/bwrap.js has the whole list): bubblewrap with its own user, pid, network, ipc, uts and cgroup
+ * namespaces, a read-only root of /usr and a few /etc entries, size-limited tmpfs for /workspace, /home/agent and /tmp,
+ * a cleared environment, no capabilities, the seccomp filter (sandbox/seccomp.js), in a cgroup of its own
+ * (sandbox/limits.js). The only ways in or out are the supervisor's stdin/stdout (sandbox/supervisor.js) and the egress
+ * proxy's socket, which the worker listens on in a directory the sandbox sees read-only.
+ *
+ * The wipe: SIGKILL to the cgroup and to bubblewrap (its pid namespace takes every process with it), then the proxy is
+ * closed, the session's directory removed and the cgroup removed. The tmpfs areas were only ever in the sandbox's own
+ * mount namespace: they are gone with it. Nothing of the sandbox is kept on the host.
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const { spawn, spawnSync } = require('child_process');
+const { buildArgs, etcFiles } = require('./bwrap');
+const { compile } = require('./seccomp');
+const { createEgressProxy } = require('./egress-proxy');
+const { createCdp, openPage } = require('./browser');
+
+const SUPERVISOR = path.join(__dirname, 'supervisor.js');
+const DEFAULT_LIMITS = Object.freeze({ memoryMb: 2048, diskMb: 512, pids: 256, cpuPercent: 100 });
+const MAX_FRAME = 16 * 1024 * 1024;
+const READY_MS = 20_000;
+
+const procState = (pid) => {
+ try { const st = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); return st.slice(st.lastIndexOf(')') + 2, st.lastIndexOf(')') + 3); } catch { return null; }
+};
+const nsOf = (pid) => { try { return fs.readlinkSync(`/proc/${pid}/ns/pid`); } catch { return null; } };
+
+/** The first child of a process (bubblewrap's own child is the sandbox's pid namespace init). */
+function firstChild(pid) {
+ try {
+ const kids = fs.readFileSync(`/proc/${pid}/task/${pid}/children`, 'utf8').trim().split(/\s+/).filter(Boolean).map(Number);
+ if (kids.length) return kids[0];
+ } catch { /* no CONFIG_PROC_CHILDREN: scan */ }
+ for (const name of fs.readdirSync('/proc')) {
+ if (!/^\d+$/.test(name)) continue;
+ try {
+ const st = fs.readFileSync(`/proc/${name}/stat`, 'utf8');
+ if (Number(st.slice(st.lastIndexOf(')') + 2).split(' ')[1]) === pid) return Number(name);
+ } catch { /* gone */ }
+ }
+ return null;
+}
+
+/** Every live process (zombies excluded) in a pid namespace, seen from the host. */
+function membersOf(ns) {
+ const out = [];
+ for (const name of fs.readdirSync('/proc')) {
+ if (!/^\d+$/.test(name)) continue;
+ if (nsOf(name) === ns && procState(name) !== 'Z') out.push(Number(name));
+ }
+ return out;
+}
+
+/** Can this host make sandboxes? bubblewrap ≥ 0.8 and unprivileged user and network namespaces. */
+async function probeSupport({ bwrap = process.env.CODES_SANDBOX_BWRAP || 'bwrap' } = {}) {
+ const v = spawnSync(bwrap, ['--version'], { encoding: 'utf8', timeout: 5000, env: { PATH: '/usr/bin:/bin' } });
+ if (v.error || v.status !== 0) return { ok: false, reason: `bubblewrap is not installed (${bwrap}: ${v.error ? v.error.code : v.status}); the worker's host needs it: apt install bubblewrap` };
+ const version = (String(v.stdout).match(/(\d+)\.(\d+)\.(\d+)/) || [])[0] || '0.0.0';
+ const [maj, min] = version.split('.').map(Number);
+ if (maj === 0 && min < 8) return { ok: false, version, reason: `bubblewrap ${version} is too old: the sandbox needs 0.8 or newer (--disable-userns, --size)` };
+ const t = spawnSync(bwrap, ['--unshare-all', '--unshare-user', '--disable-userns', '--die-with-parent', '--ro-bind', '/usr', '/usr',
+ '--symlink', 'usr/bin', '/bin', '--symlink', 'usr/lib', '/lib', '--symlink', 'usr/lib64', '/lib64', '--proc', '/proc', '--dev', '/dev',
+ '--', '/usr/bin/true'], { encoding: 'utf8', timeout: 10_000, env: { PATH: '/usr/bin:/bin' } });
+ if (t.error || t.status !== 0) {
+ const why = String(t.stderr || (t.error && t.error.code) || '').trim().split('\n').pop().slice(0, 200);
+ return { ok: false, version, reason: `unprivileged user namespaces are not allowed here (${why}): Ubuntu needs kernel.apparmor_restrict_unprivileged_userns=0 or an AppArmor profile for bwrap` };
+ }
+ return { ok: true, version, seccomp: compile() !== null, disableUserns: true };
+}
+
+function assertLimits(l) {
+ const out = { ...DEFAULT_LIMITS, ...(l || {}) };
+ for (const k of Object.keys(DEFAULT_LIMITS)) if (!(Number(out[k]) > 0)) throw new Error(`limit ${k} must be a positive number`);
+ return out;
+}
+
+async function createSandbox(opts = {}) {
+ const {
+ runtimeDir, timeBoxMs = 10 * 60_000, limiter = null, allowUnlimited = false, egress = {}, onEgress = () => {},
+ nodePath = process.execPath, browserPath = null, rootfs = '/', bwrap = process.env.CODES_SANDBOX_BWRAP || 'bwrap',
+ seccomp = true, log = console,
+ } = opts;
+ if (!runtimeDir) throw new Error('runtimeDir is required');
+ const limits = assertLimits(opts.limits);
+ if (!limiter && !allowUnlimited) throw new Error('no limiter: a sandbox never starts without its cgroup limits');
+ if (limiter && !limiter.ok) throw new Error(`the limits are not available: ${limiter.reason}`);
+ const id = opts.id || crypto.randomBytes(5).toString('hex');
+ if (!/^[a-z0-9-]{4,32}$/.test(id)) throw new Error('bad sandbox id');
+
+ // The session's own directory: its /etc files and the proxy socket's directory. Owned by the worker, 0700.
+ const dir = path.join(runtimeDir, `s-${id}`);
+ fs.mkdirSync(dir, { mode: 0o700 });
+ const etcDir = path.join(dir, 'etc');
+ const netDir = path.join(dir, 'net');
+ fs.mkdirSync(etcDir, { mode: 0o700 });
+ fs.mkdirSync(netDir, { mode: 0o700 });
+ const socketPath = path.join(netDir, 'egress.sock');
+ if (Buffer.byteLength(socketPath) > 100) { fs.rmSync(dir, { recursive: true, force: true }); throw new Error(`runtimeDir is too long for a socket path: ${runtimeDir}`); }
+
+ let proxy = null;
+ let handle = null;
+ let seccompFd = null;
+ let child = null;
+ const cleanup = async () => {
+ if (proxy) await proxy.close().catch(() => {});
+ if (handle) await handle.remove().catch(() => {});
+ if (seccompFd != null) { try { fs.closeSync(seccompFd); } catch { /* closed */ } seccompFd = null; }
+ fs.rmSync(dir, { recursive: true, force: true });
+ };
+
+ try {
+ proxy = createEgressProxy({ ...egress, onDecision: (d) => { try { onEgress({ sandbox: id, ...d }); } catch { /* the log never breaks egress */ } }, log });
+ await proxy.listen(socketPath);
+
+ const filter = seccomp ? compile() : null;
+ if (filter) {
+ const f = path.join(dir, 'seccomp.bpf');
+ fs.writeFileSync(f, filter, { mode: 0o600 });
+ seccompFd = fs.openSync(f, 'r');
+ }
+ const built = buildArgs({
+ rootfs, sessionEtc: etcDir, netDir, supervisorPath: SUPERVISOR, nodePath, browserPath,
+ diskMb: limits.diskMb, shmMb: Math.min(256, limits.diskMb), seccompFd: filter ? 3 : null,
+ });
+ for (const [name, text] of Object.entries(etcFiles({ shell: built.shell }))) fs.writeFileSync(path.join(etcDir, name), text, { mode: 0o644 });
+
+ // The worker's environment never reaches bubblewrap: PATH only (and the user bus for systemd-run).
+ let argv = [bwrap, ...built.args];
+ let env = { PATH: '/usr/bin:/bin' };
+ if (limiter) {
+ handle = limiter.prepare(id, limits);
+ ({ argv, env } = handle.wrap(argv, env));
+ }
+ child = spawn(argv[0], argv.slice(1), { env, stdio: ['pipe', 'pipe', 'pipe', ...(filter ? [seccompFd] : [])], cwd: '/' });
+ } catch (err) {
+ if (child) { try { child.kill('SIGKILL'); } catch { /* gone */ } }
+ await cleanup();
+ throw err;
+ }
+
+ const sbx = new Sandbox({ id, dir, child, proxy, handle, limits, timeBoxMs, cleanup, log, hasBrowser: !!browserPath });
+ try {
+ await sbx.ready();
+ } catch (err) {
+ await sbx.wipe('failed_to_start');
+ throw err;
+ }
+ if (handle) handle.attach(child.pid);
+ // The sandbox's pid namespace, from its init (bubblewrap's child): the wipe waits until nothing is left in it.
+ const init = firstChild(child.pid);
+ const ns = init ? nsOf(init) : null;
+ if (ns && ns !== nsOf(process.pid)) sbx._pidns = ns;
+ return sbx;
+}
+
+class Sandbox {
+ constructor({ id, dir, child, proxy, handle, limits, timeBoxMs, cleanup, log, hasBrowser }) {
+ Object.assign(this, { id, dir, limits, log });
+ this.pid = child.pid;
+ this.state = 'running';
+ this.startedAt = Date.now();
+ this.endsAt = this.startedAt + timeBoxMs;
+ this._child = child;
+ this._proxy = proxy;
+ this._handle = handle;
+ this._cleanup = cleanup;
+ this._hasBrowser = hasBrowser;
+ this._nextId = 1;
+ this._pending = new Map();
+ this._stderr = '';
+ this._cdp = null;
+ this.ended = new Promise((resolve) => { this._resolveEnded = resolve; });
+ this._exited = new Promise((resolve) => child.once('exit', (code, signal) => resolve({ code, signal })));
+ this._readyWaiter = null;
+
+ let buf = '';
+ child.stdout.setEncoding('utf8');
+ child.stdout.on('data', (chunk) => {
+ buf += chunk;
+ if (buf.length > MAX_FRAME && buf.indexOf('\n') < 0) { this.wipe('protocol'); return; }
+ let i;
+ while ((i = buf.indexOf('\n')) >= 0) {
+ const line = buf.slice(0, i);
+ buf = buf.slice(i + 1);
+ this._frame(line);
+ }
+ });
+ child.stderr.setEncoding('utf8');
+ child.stderr.on('data', (d) => { this._stderr = (this._stderr + d).slice(-4096); });
+ for (const s of [child.stdin, child.stdout, child.stderr]) s.on('error', () => {});
+ child.once('exit', () => { if (this.state === 'running') this.wipe('exited'); });
+ this._timer = setTimeout(() => this.wipe('time_box'), timeBoxMs);
+ if (this._timer.unref) this._timer.unref();
+ }
+
+ get cgroup() { return this._handle ? this._handle.path : null; }
+
+ ready() {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error(`the sandbox did not start: ${this._stderr.trim().slice(-300)}`)), READY_MS);
+ this._readyWaiter = (frame) => { clearTimeout(timer); resolve(frame); };
+ this._exited.then(({ code, signal }) => { clearTimeout(timer); reject(new Error(`the sandbox did not start (${code}/${signal}): ${this._stderr.trim().slice(-300)}`)); });
+ });
+ }
+
+ /** One line from inside. Untrusted: it is matched to a request of ours or dropped, never acted on. */
+ _frame(line) {
+ let f;
+ try { f = JSON.parse(line); } catch { return; }
+ if (!f || typeof f !== 'object') return;
+ if (f.ev === 'ready' && this._readyWaiter) { const w = this._readyWaiter; this._readyWaiter = null; return w(f); }
+ if (f.ev === 'cdp') { if (this._cdp && typeof f.message === 'string') this._cdp.onMessage(f.message); return; }
+ if (f.ev === 'cdp-closed') { if (this._cdp) { this._cdp.close('exited'); this._cdp = null; } return; }
+ const p = this._pending.get(f.id);
+ if (!p) return;
+ if (f.ev === 'out' && p.onOut) return p.onOut(f.fd === 2 ? 2 : 1, Buffer.from(String(f.data || ''), 'base64'));
+ if (f.ev === 'exit' || f.ev === 'done') { this._pending.delete(f.id); p.resolve(f); }
+ }
+
+ _request(msg, { onOut } = {}) {
+ if (this.state !== 'running') return Promise.reject(new Error(`the sandbox is ${this.state === 'wiping' ? 'being wiped' : 'wiped'}`));
+ const id = this._nextId++;
+ return new Promise((resolve, reject) => {
+ this._pending.set(id, { resolve, reject, onOut });
+ this._child.stdin.write(`${JSON.stringify({ ...msg, id })}\n`);
+ });
+ }
+
+ /** The terminal: a command inside, with its output (capped) and how it ended. */
+ async exec(argv, { stdin = null, tty = false, cwd, env, timeoutMs = 60_000, maxOutput = 1024 * 1024, onOutput } = {}) {
+ if (!Array.isArray(argv) || !argv.length) throw new Error('argv is a non-empty array');
+ const out = [[], []];
+ const sizes = [0, 0];
+ const onOut = (fd, buf) => {
+ const k = fd === 2 ? 1 : 0;
+ if (sizes[k] < maxOutput) { out[k].push(buf.subarray(0, maxOutput - sizes[k])); sizes[k] += buf.length; }
+ if (onOutput) { try { onOutput(fd, buf); } catch { /* the caller's problem */ } }
+ };
+ const f = await this._request({
+ op: 'exec', argv: argv.map(String), tty: !!tty, cwd, env, timeoutMs, maxOutput,
+ ...(stdin != null ? { stdin: Buffer.from(stdin).toString('base64') } : {}),
+ }, { onOut });
+ return {
+ code: typeof f.code === 'number' ? f.code : null, signal: typeof f.signal === 'string' ? f.signal : null,
+ stdout: Buffer.concat(out[0]).toString('utf8'), stderr: Buffer.concat(out[1]).toString('utf8'),
+ timedOut: f.timedOut === true, truncated: f.truncated === true, ...(f.error ? { error: String(f.error).slice(0, 200) } : {}),
+ };
+ }
+
+ async _file(msg) {
+ const f = await this._request(msg);
+ if (!f.ok) throw new Error(String(f.error || 'failed').slice(0, 200));
+ return f;
+ }
+
+ /** The editor: files in the workspace only (the supervisor refuses a path outside it). */
+ async writeFile(p, data) { await this._file({ op: 'write', path: String(p), data: Buffer.from(data).toString('base64') }); }
+ async readFile(p) { return Buffer.from(String((await this._file({ op: 'read', path: String(p) })).data || ''), 'base64'); }
+ async list(p = '/workspace') {
+ const f = await this._file({ op: 'list', path: String(p) });
+ return (Array.isArray(f.entries) ? f.entries : []).map((e) => ({ name: String(e.name), type: String(e.type), size: e.size == null ? null : Number(e.size) }));
+ }
+
+ /** The browser: headless Chromium inside, on the egress proxy. */
+ async openPage(url, opts = {}) {
+ if (!this._hasBrowser) throw new Error('this sandbox has no browser');
+ if (!this._cdp) {
+ const f = await this._request({ op: 'browser', action: 'start' });
+ if (!f.ok) throw new Error(`the browser did not start: ${String(f.error || '').slice(0, 200)}`);
+ this._cdp = createCdp((message) => { if (this.state === 'running') this._child.stdin.write(`${JSON.stringify({ op: 'cdp', message })}\n`); });
+ // The browser answers once its pipe is open.
+ const until = Date.now() + 15_000;
+ for (;;) {
+ try { await Promise.race([this._cdp.call('Browser.getVersion'), new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 2000))]); break; } catch (err) {
+ if (Date.now() > until) throw new Error(`the browser did not answer: ${err.message}`);
+ }
+ }
+ }
+ try {
+ return await openPage(this._cdp, url, opts);
+ } catch (err) {
+ return { ok: false, url, error: String(err.message).slice(0, 200) };
+ }
+ }
+
+ /** Wipe now (or the end, or the time box): everything of the sandbox goes, and nothing of it is kept. */
+ wipe(reason = 'wipe_now') {
+ if (this._wiping) return this._wiping;
+ this.state = 'wiping';
+ clearTimeout(this._timer);
+ this._wiping = (async () => {
+ if (this._cdp) { this._cdp.close(reason); this._cdp = null; }
+ if (this._handle) { try { this._handle.kill(); } catch { /* gone */ } }
+ try { this._child.kill('SIGKILL'); } catch { /* gone */ }
+ await Promise.race([this._exited, new Promise((r) => setTimeout(r, 5000))]);
+ // bubblewrap's death takes its pid namespace with it; make sure, and kill any straggler by hand.
+ let leftover = [];
+ if (this._pidns) {
+ const until = Date.now() + 5000;
+ for (;;) {
+ leftover = membersOf(this._pidns);
+ if (!leftover.length || Date.now() > until) break;
+ for (const pid of leftover) { try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ } }
+ await new Promise((r) => setTimeout(r, 25));
+ }
+ if (leftover.length) this.log.error(`[sandbox] ${this.id}: ${leftover.length} process(es) outlived the wipe`);
+ }
+ for (const p of this._pending.values()) p.reject(new Error(`the sandbox was wiped (${reason})`));
+ this._pending.clear();
+ try { await this._cleanup(); } catch (err) { this.log.error('[sandbox] cleanup:', err.message); }
+ this.state = 'wiped';
+ const out = { reason, ...(leftover.length ? { leftover: leftover.length } : {}) };
+ this._resolveEnded(out);
+ return out;
+ })();
+ return this._wiping;
+ }
+
+ info() {
+ return { id: this.id, state: this.state, started_at: new Date(this.startedAt).toISOString(), ends_at: new Date(this.endsAt).toISOString(), limits: this.limits };
+ }
+}
+
+module.exports = { probeSupport, createSandbox, DEFAULT_LIMITS, SUPERVISOR };
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+/**
+ * The sandbox worker's heartbeat signature to Codes (POST /internal/sandbox-workers/heartbeat): HMAC-SHA256 over
+ * `<t>.<raw body>` with a secret only the worker and Codes hold (CODES_SANDBOX_WORKER_SECRET; a comma list on Codes'
+ * side for a rotation). The secret never travels; t is the worker's clock in ms, so a stale or replayed heartbeat is
+ * refused (Codes keeps the last t per worker and wants a newer one).
+ *
+ * sign(secret, body, t) → 'X-OV-Sandbox-Signature' header value: `t=<ms>,v1=<hex>`
+ * verify({ secrets, header, body, now, toleranceMs }) → { ok: true, t } | { ok: false, code }
+ */
+const crypto = require('crypto');
+
+const HEADER = 'x-ov-sandbox-signature';
+const TOLERANCE_MS = 60_000;
+
+const mac = (secret, t, body) => crypto.createHmac('sha256', String(secret)).update(`${t}.`).update(Buffer.isBuffer(body) ? body : Buffer.from(String(body))).digest('hex');
+
+function sign(secret, body, t = Date.now()) {
+ if (!secret) throw new Error('no worker secret');
+ return `t=${t},v1=${mac(secret, t, body)}`;
+}
+
+function verify({ secrets = [], header, body, now = Date.now(), toleranceMs = TOLERANCE_MS }) {
+ const list = [].concat(secrets).filter(Boolean);
+ if (!list.length) return { ok: false, code: 'sandbox.not_configured' };
+ const m = /^t=(\d{10,16}),v1=([0-9a-f]{64})$/.exec(String(header || '').trim());
+ if (!m) return { ok: false, code: 'signature.missing' };
+ const t = Number(m[1]);
+ if (Math.abs(now - t) > toleranceMs) return { ok: false, code: 'signature.stale' };
+ const given = Buffer.from(m[2], 'hex');
+ const good = list.some((s) => {
+ const want = Buffer.from(mac(s, t, body), 'hex');
+ return want.length === given.length && crypto.timingSafeEqual(want, given);
+ });
+ return good ? { ok: true, t } : { ok: false, code: 'signature.invalid' };
+}
+
+module.exports = { sign, verify, HEADER, TOLERANCE_MS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,233 @@
@@ -0,0 +1,233 @@
+'use strict';
+/**
+ * The chat plays sandbox's supervisor: the first program INSIDE the sandbox (bubblewrap starts it as
+ * `node /opt/ov/supervisor.js`; sandbox/bwrap.js). It is the agent's terminal, editor and browser launcher, and it
+ * holds the sandbox's end of the egress proxy. It uses Node's own modules only: nothing else is mounted for it.
+ *
+ * It talks to the worker over its stdin and stdout only, one JSON object per line — never over a path the worker would
+ * open, so nothing inside can point the worker at another socket. The worker treats every line from here as untrusted
+ * data (anything inside could write one): it only ever shows them.
+ *
+ * in { id, op: 'exec', argv, cwd?, env?, stdin?, tty?, timeoutMs?, maxOutput? } run a command (the terminal)
+ * { id, op: 'stdin', target, data?, end? } · { id, op: 'kill', target }
+ * { id, op: 'write', path, data } · { id, op: 'read', path, max? } · { id, op: 'list', path } (the editor)
+ * { id, op: 'browser', action: 'start' | 'stop' } · { op: 'cdp', message } (the browser)
+ * out { ev: 'ready', pid, uid } · { id, ev: 'out', fd, data } · { id, ev: 'exit', code, signal, timedOut, truncated }
+ * { id, ev: 'done', ok, …, error? } · { ev: 'cdp', message }
+ *
+ * data is base64. The egress relay: 127.0.0.1:OV_SANDBOX_PROXY_PORT inside → OV_SANDBOX_EGRESS_SOCKET (the worker's
+ * proxy). The relay is a convenience: it adds no way out, the socket is the only one there is.
+ */
+const { spawn } = require('child_process');
+const fs = require('fs');
+const net = require('net');
+const path = require('path');
+
+const WORKSPACE = process.env.OV_SANDBOX_WORKSPACE || '/workspace';
+const PROXY_PORT = Number(process.env.OV_SANDBOX_PROXY_PORT || 3128);
+const EGRESS_SOCKET = process.env.OV_SANDBOX_EGRESS_SOCKET || '/run/ov/net/egress.sock';
+const BROWSER = process.env.OV_SANDBOX_BROWSER || '';
+const MAX_LINE = 12 * 1024 * 1024;
+const MAX_TIMEOUT_MS = 30 * 60_000;
+const DEFAULT_OUTPUT = 1024 * 1024;
+
+const procs = new Map();
+let browser = null;
+
+function send(frame) { process.stdout.write(`${JSON.stringify(frame)}\n`); }
+const b64 = (buf) => Buffer.from(buf).toString('base64');
+const fail = (id, error) => send({ id, ev: 'done', ok: false, error: String(error).slice(0, 300) });
+
+/** A path in the workspace, or an error: the editor works on the workspace only. */
+function inWorkspace(p, { mustExist = false } = {}) {
+ const abs = path.resolve(WORKSPACE, String(p == null ? '.' : p));
+ const inside = (x) => x === WORKSPACE || x.startsWith(`${WORKSPACE}/`);
+ if (!inside(abs)) throw new Error('outside the workspace');
+ let real = abs;
+ try { real = fs.realpathSync(abs); } catch (err) {
+ if (mustExist) throw err;
+ try { real = path.join(fs.realpathSync(path.dirname(abs)), path.basename(abs)); } catch { real = abs; }
+ }
+ if (!inside(real)) throw new Error('outside the workspace (a link points out of it)');
+ return abs;
+}
+
+const shq = (s) => `'${String(s).replace(/'/g, "'\\''")}'`;
+
+function exec(msg) {
+ const id = msg.id;
+ const argv = Array.isArray(msg.argv) ? msg.argv.map(String) : [];
+ if (!argv.length || argv.some((a) => a.length > 65536)) return send({ id, ev: 'exit', code: 127, signal: null, error: 'bad argv' });
+ let file = argv[0];
+ let args = argv.slice(1);
+ // A terminal device: util-linux script(1) gives the command a pty of the sandbox's own /dev/pts.
+ if (msg.tty) { file = 'script'; args = ['-q', '-e', '-f', '-c', argv.map(shq).join(' '), '/dev/null']; }
+ let cwd = WORKSPACE;
+ try { if (msg.cwd) cwd = inWorkspace(msg.cwd, { mustExist: true }); } catch (err) { return send({ id, ev: 'exit', code: 127, signal: null, error: err.message }); }
+ const env = { ...process.env };
+ for (const [k, v] of Object.entries(msg.env || {})) if (/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(k) && !k.startsWith('OV_SANDBOX')) env[k] = String(v).slice(0, 8192);
+ const max = Math.min(Number(msg.maxOutput) || DEFAULT_OUTPUT, 8 * DEFAULT_OUTPUT);
+ const timeoutMs = Math.min(Math.max(100, Number(msg.timeoutMs) || 60_000), MAX_TIMEOUT_MS);
+ let child;
+ try {
+ child = spawn(file, args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true });
+ } catch (err) {
+ return send({ id, ev: 'exit', code: 127, signal: null, error: err.code || err.message });
+ }
+ const state = { child, sent: 0, truncated: false, timedOut: false, finished: false };
+ procs.set(id, state);
+ const out = (fd) => (chunk) => {
+ if (state.sent >= max) { state.truncated = true; return; }
+ const part = chunk.length + state.sent > max ? chunk.subarray(0, max - state.sent) : chunk;
+ if (part.length < chunk.length) state.truncated = true;
+ state.sent += part.length;
+ send({ id, ev: 'out', fd, data: b64(part) });
+ };
+ child.stdout.on('data', out(1));
+ child.stderr.on('data', out(2));
+ child.stdin.on('error', () => {});
+ if (msg.stdin != null) child.stdin.write(Buffer.from(String(msg.stdin), 'base64'));
+ if (msg.stdinOpen !== true) child.stdin.end();
+ const killGroup = () => { try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { /* gone */ } } };
+ const timer = setTimeout(() => { state.timedOut = true; killGroup(); }, timeoutMs);
+ const finish = (code, signal, error) => {
+ if (state.finished) return;
+ state.finished = true;
+ clearTimeout(timer);
+ procs.delete(id);
+ send({ id, ev: 'exit', code, signal, timedOut: state.timedOut, truncated: state.truncated, ...(error ? { error } : {}) });
+ };
+ child.on('error', (err) => finish(127, null, err.code || err.message));
+ // A background process may keep the pipes open: the exit is the end, the rest of the output gets a moment.
+ child.on('exit', (code, signal) => {
+ const t = setTimeout(() => { child.stdout.destroy(); child.stderr.destroy(); finish(code, signal); }, 200);
+ child.on('close', () => { clearTimeout(t); finish(code, signal); });
+ });
+}
+
+function file(msg) {
+ const id = msg.id;
+ try {
+ if (msg.op === 'write') {
+ const p = inWorkspace(msg.path);
+ fs.mkdirSync(path.dirname(p), { recursive: true });
+ const fd = fs.openSync(p, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW, 0o644);
+ try { fs.writeSync(fd, Buffer.from(String(msg.data || ''), 'base64')); } finally { fs.closeSync(fd); }
+ return send({ id, ev: 'done', ok: true });
+ }
+ if (msg.op === 'read') {
+ const p = inWorkspace(msg.path, { mustExist: true });
+ const max = Math.min(Number(msg.max) || 4 * 1024 * 1024, 8 * 1024 * 1024);
+ const st = fs.statSync(p);
+ if (!st.isFile()) throw new Error('not a file');
+ const fd = fs.openSync(p, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
+ try {
+ const buf = Buffer.alloc(Math.min(st.size, max));
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
+ return send({ id, ev: 'done', ok: true, data: b64(buf.subarray(0, n)), size: st.size, truncated: st.size > max });
+ } finally { fs.closeSync(fd); }
+ }
+ if (msg.op === 'list') {
+ const p = inWorkspace(msg.path, { mustExist: true });
+ const entries = fs.readdirSync(p, { withFileTypes: true }).slice(0, 5000).map((e) => {
+ let size = null;
+ try { size = e.isFile() ? fs.statSync(path.join(p, e.name)).size : null; } catch { /* gone */ }
+ return { name: e.name, type: e.isFile() ? 'file' : e.isDirectory() ? 'dir' : e.isSymbolicLink() ? 'link' : 'other', size };
+ });
+ return send({ id, ev: 'done', ok: true, entries });
+ }
+ } catch (err) {
+ return fail(id, err.code && !/outside/.test(err.message) ? err.code : err.message);
+ }
+ return fail(id, 'unknown op');
+}
+
+/** Headless Chromium inside the sandbox, on the egress proxy, driven over a DevTools pipe the worker relays. */
+function browserOp(msg) {
+ const id = msg.id;
+ if (msg.action === 'stop') {
+ if (browser) { try { process.kill(-browser.pid, 'SIGKILL'); } catch { /* gone */ } browser = null; }
+ return send({ id, ev: 'done', ok: true });
+ }
+ if (msg.action !== 'start') return fail(id, 'unknown action');
+ if (browser) return send({ id, ev: 'done', ok: true, already: true });
+ if (!BROWSER) return fail(id, 'no browser in this sandbox');
+ const args = [
+ '--headless=new', '--no-sandbox', '--disable-gpu', '--no-first-run', '--no-default-browser-check',
+ '--disable-background-networking', '--disable-component-update', '--disable-sync', '--disable-breakpad',
+ '--disable-crash-reporter', '--disable-features=Translate,MediaRouter,OptimizationHints,DialMediaRouteProvider',
+ `--proxy-server=http://127.0.0.1:${PROXY_PORT}`, '--user-data-dir=/home/agent/.browser', '--remote-debugging-pipe',
+ '--window-size=1280,800', 'about:blank',
+ ];
+ let child;
+ try {
+ child = spawn(BROWSER, args, { cwd: WORKSPACE, env: process.env, stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'], detached: true });
+ } catch (err) { return fail(id, err.code || err.message); }
+ browser = child;
+ child.stderr.on('data', () => {});
+ let buf = '';
+ child.stdio[4].on('data', (d) => {
+ buf += d;
+ let i;
+ while ((i = buf.indexOf('\0')) >= 0) { send({ ev: 'cdp', message: buf.slice(0, i) }); buf = buf.slice(i + 1); }
+ });
+ child.stdio[3].on('error', () => {});
+ child.on('exit', (code) => { if (browser === child) { browser = null; send({ ev: 'cdp-closed', code }); } });
+ child.on('error', (err) => { if (browser === child) browser = null; fail(id, err.code || err.message); });
+ child.on('spawn', () => send({ id, ev: 'done', ok: true }));
+}
+
+function handle(msg) {
+ if (!msg || typeof msg !== 'object') return;
+ switch (msg.op) {
+ case 'ping': return send({ id: msg.id, ev: 'done', ok: true, pid: process.pid, uid: process.getuid() });
+ case 'exec': return exec(msg);
+ case 'stdin': {
+ const s = procs.get(msg.target);
+ if (s) { if (msg.data) s.child.stdin.write(Buffer.from(String(msg.data), 'base64')); if (msg.end) s.child.stdin.end(); }
+ return undefined;
+ }
+ case 'kill': {
+ const s = procs.get(msg.target);
+ if (s) { try { process.kill(-s.child.pid, 'SIGKILL'); } catch { /* gone */ } }
+ return undefined;
+ }
+ case 'write': case 'read': case 'list': return file(msg);
+ case 'browser': return browserOp(msg);
+ case 'cdp': if (browser) browser.stdio[3].write(`${String(msg.message)}\0`); return undefined;
+ default: return fail(msg.id, 'unknown op');
+ }
+}
+
+let inbuf = '';
+process.stdin.setEncoding('utf8');
+process.stdin.on('data', (chunk) => {
+ inbuf += chunk;
+ if (inbuf.length > MAX_LINE && inbuf.indexOf('\n') < 0) { inbuf = ''; return; }
+ let i;
+ while ((i = inbuf.indexOf('\n')) >= 0) {
+ const line = inbuf.slice(0, i);
+ inbuf = inbuf.slice(i + 1);
+ let msg = null;
+ try { msg = JSON.parse(line); } catch { continue; }
+ try { handle(msg); } catch (err) { fail(msg && msg.id, err.message); }
+ }
+});
+// The worker went away: so does everything here (bubblewrap's --die-with-parent does the rest).
+process.stdin.on('end', () => process.exit(0));
+process.on('uncaughtException', (err) => { try { send({ ev: 'error', error: String(err && err.message).slice(0, 300) }); } catch { /* stdout gone */ } });
+
+function ready() { send({ ev: 'ready', pid: process.pid, uid: process.getuid() }); }
+if (PROXY_PORT > 0) {
+ const relay = net.createServer((c) => {
+ const up = net.connect(EGRESS_SOCKET);
+ c.on('error', () => up.destroy());
+ up.on('error', () => c.destroy());
+ c.pipe(up);
+ up.pipe(c);
+ });
+ relay.on('error', (err) => { send({ ev: 'error', error: `relay: ${err.code || err.message}` }); ready(); });
+ relay.listen(PROXY_PORT, '127.0.0.1', ready);
+} else {
+ ready();
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,302 @@
@@ -0,0 +1,302 @@
+'use strict';
+/**
+ * The chat plays sandbox worker (Agent Streams S6, design 2026-11-06 §1, §3, §5, §6): the process that makes the
+ * sandboxes a viewer's request runs in, and says whether it can. It runs as its own unprivileged user under
+ * deploy/sandbox/openvibe-codes-sandbox.service (bin/openvibe-sandbox-worker.js).
+ *
+ * health the self-test (sandbox/selftest.js) at boot and every CODES_SANDBOX_SELFTEST_MS: the worker is
+ * ready only when every isolation check passed AND the cgroup limits are there
+ * announce every CODES_SANDBOX_ANNOUNCE_MS (30 s):
+ * Live POST {LIVE}/api/streams/agent/:id/plays/worker with the worker's own Network service token
+ * (client credentials, audience openvibe.live, capability live.agent_sandbox.register), for
+ * each stream it serves: CODES_SANDBOX_LIVE_STREAMS and the streams of its running sessions
+ * Codes POST {CODES}/internal/sandbox-workers/heartbeat, HMAC-signed (sandbox/signature.js)
+ * with the same body: the worker, ready, the checks, the isolation facts, capacity and what runs
+ * local API HTTP on a Unix socket (CODES_SANDBOX_SOCKET, 0660: only the worker's user and group): Codes' runner
+ * makes a session, runs the agent's commands, edits files, opens pages, and wipes it
+ * GET /health POST /sessions { stream_id?, time_box_minutes? }
+ * GET /sessions GET /sessions/:id (with its egress log)
+ * POST /sessions/:id/exec { argv, stdin?, tty?, timeout_ms? }
+ * GET|PUT /sessions/:id/files?path= GET /sessions/:id/list?path=
+ * POST /sessions/:id/browser { url } POST /sessions/:id/wipe (Wipe now) DELETE /sessions/:id
+ *
+ * Its secrets (the Network client secret, the Codes heartbeat secret) come from systemd credentials or files, are read
+ * once, and are never in the environment it hands anything: bubblewrap gets PATH only and the sandbox its own
+ * allowlist. Nothing a sandbox says is a URL the worker fetches: every outbound request goes to a configured base.
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const http = require('http');
+const os = require('os');
+const path = require('path');
+const { createSandbox: realCreateSandbox, probeSupport } = require('./session');
+const { detectLimits } = require('./limits');
+const { findBrowser } = require('./browser');
+const { runSelfTest } = require('./selftest');
+const signature = require('./signature');
+const { PORTS } = require('./egress-proxy');
+
+const VERSION = require('../package.json').version;
+const STREAM = /^[A-Za-z0-9_-]{1,64}$/;
+const SESSION = /^[a-z0-9]{10}$/;
+const MB = 1024 * 1024;
+
+const int = (v, def, min, max) => { const n = parseInt(v, 10); return Math.min(max, Math.max(min, Number.isFinite(n) ? n : def)); };
+const trim = (s) => String(s || '').trim().replace(/\/+$/, '');
+
+/** A secret from the environment, a file, or a systemd credential (LoadCredential=name:…). */
+function secret(env, name, credential) {
+ if (env[`${name}_FILE`]) { try { return fs.readFileSync(env[`${name}_FILE`], 'utf8').trim(); } catch { return ''; } }
+ if (env.CREDENTIALS_DIRECTORY && credential) { try { return fs.readFileSync(path.join(env.CREDENTIALS_DIRECTORY, credential), 'utf8').trim(); } catch { /* not given */ } }
+ return String(env[name] || '').trim();
+}
+
+function loadConfig(env = process.env) {
+ const runtimeDir = env.CODES_SANDBOX_RUNTIME_DIR || env.RUNTIME_DIRECTORY
+ || (env.XDG_RUNTIME_DIR ? path.join(env.XDG_RUNTIME_DIR, 'ovsbx') : path.join('/tmp', `ovsbx-${process.getuid()}`));
+ return {
+ workerId: String(env.CODES_SANDBOX_WORKER_ID || `sbw-${os.hostname()}`).replace(/[^A-Za-z0-9_.-]/g, '-').slice(0, 64),
+ runtimeDir,
+ socket: env.CODES_SANDBOX_SOCKET || path.join(runtimeDir, 'worker.sock'),
+ socketGroupMode: 0o660,
+ limitsMode: env.CODES_SANDBOX_LIMITS || 'auto',
+ limits: {
+ memoryMb: int(env.CODES_SANDBOX_MEMORY_MB, 2048, 128, 65536),
+ diskMb: int(env.CODES_SANDBOX_DISK_MB, 512, 16, 65536),
+ pids: int(env.CODES_SANDBOX_PIDS, 256, 32, 32768),
+ cpuPercent: int(env.CODES_SANDBOX_CPU_PERCENT, 100, 10, 6400),
+ },
+ maxSessions: int(env.CODES_SANDBOX_MAX_SESSIONS, 2, 1, 64),
+ // The owner's time box is 5 to 30 minutes (design §3); the worker's own ceiling is that, never more.
+ timeBoxMinMinutes: 5,
+ timeBoxMaxMinutes: int(env.CODES_SANDBOX_TIME_BOX_MAX_MIN, 30, 5, 30),
+ nodePath: env.CODES_SANDBOX_NODE || process.execPath,
+ browserPath: findBrowser(env),
+ announceMs: int(env.CODES_SANDBOX_ANNOUNCE_MS, 30_000, 1000, 600_000),
+ selfTestMs: int(env.CODES_SANDBOX_SELFTEST_MS, 10 * 60_000, 10_000, 24 * 3_600_000),
+ codes: { url: trim(env.CODES_SANDBOX_CODES_URL || 'http://127.0.0.1:4900'), secret: secret(env, 'CODES_SANDBOX_WORKER_SECRET', 'codes-worker-secret') },
+ live: {
+ url: trim(env.CODES_SANDBOX_LIVE_URL || 'https://openvibe.live'),
+ streams: String(env.CODES_SANDBOX_LIVE_STREAMS || '').split(',').map((s) => s.trim()).filter((s) => STREAM.test(s)),
+ },
+ network: {
+ tokenUrl: `${trim(env.CODES_SANDBOX_NETWORK_URL || 'https://openvibe.network')}/oauth/token`,
+ clientId: String(env.CODES_SANDBOX_CLIENT_ID || '').trim(),
+ clientSecret: secret(env, 'CODES_SANDBOX_CLIENT_SECRET', 'network-client-secret'),
+ audience: 'openvibe.live',
+ scope: 'live.agent_sandbox.register',
+ },
+ hidden: [env.CREDENTIALS_DIRECTORY, env.CODES_SANDBOX_WORKER_SECRET_FILE, env.CODES_SANDBOX_CLIENT_SECRET_FILE].filter(Boolean),
+ };
+}
+
+/**
+ * createWorker({ config, createSandbox, limiter, support, fetchImpl, tokens, log, now })
+ * → { start(), stop(), health(), selfTest(), announce(), sessions, server }
+ * createSandbox, limiter, support, fetchImpl and tokens are injectable (tests); start() detects them otherwise.
+ */
+function createWorker(opts = {}) {
+ const config = opts.config || loadConfig();
+ const log = opts.log || console;
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
+ const now = opts.now || (() => Date.now());
+ const createSandbox = opts.createSandbox || realCreateSandbox;
+ let limiter = opts.limiter || null;
+ let support = opts.support || null;
+ let tokens = opts.tokens || null;
+ let lastSelfTest = null;
+ const lastAnnounce = { codes: null, live: {} };
+ const sessions = new Map();
+ const timers = [];
+ let server = null;
+
+ if (!tokens && config.network.clientId && config.network.clientSecret) {
+ const { createServiceTokenClient } = require('openvibe-sdk/auth');
+ tokens = createServiceTokenClient({ tokenUrl: config.network.tokenUrl, clientId: config.network.clientId, clientSecret: config.network.clientSecret, audience: config.network.audience, scope: config.network.scope, fetch: fetchImpl });
+ }
+
+ /** What the worker can enforce, as it found it (never a secret, a path or an address). */
+ function isolation() {
+ const c = (lastSelfTest && lastSelfTest.checks) || {};
+ return {
+ sandbox: 'bubblewrap', bubblewrap: support && support.version || null,
+ user_namespace: !!(support && support.ok), network_namespace: c.network_namespace === true,
+ egress: { via: 'proxy', public_only: c.egress_refuses_private === true, ports: [...PORTS], direct: c.no_direct_egress === true ? 'none' : 'unknown' },
+ host_credentials: c.clean_env === true && c.no_host_files === true ? 'none' : 'unknown',
+ host_files: c.no_host_files === true ? 'none' : 'unknown',
+ capabilities: c.no_capabilities === true ? 'none' : 'unknown',
+ seccomp: c.seccomp === true,
+ workspace: 'tmpfs, wiped with the sandbox',
+ limits: {
+ kind: limiter && limiter.ok ? limiter.kind : 'none', enforced: c.limits === true,
+ memory_mb: config.limits.memoryMb, disk_mb: config.limits.diskMb, pids: config.limits.pids, cpu_percent: config.limits.cpuPercent,
+ time_box_minutes: [config.timeBoxMinMinutes, config.timeBoxMaxMinutes],
+ },
+ };
+ }
+
+ function health() {
+ const ready = !!(support && support.ok && limiter && limiter.ok && lastSelfTest && lastSelfTest.ok);
+ const reasons = [];
+ if (!support || !support.ok) reasons.push(support ? support.reason : 'not started');
+ if (!limiter || !limiter.ok) reasons.push(`limits: ${limiter ? limiter.reason : 'not detected'}`);
+ if (lastSelfTest && !lastSelfTest.ok) reasons.push(`self-test failed: ${lastSelfTest.failed.join(', ')}`);
+ return {
+ worker_id: config.workerId, service: 'codes-sandbox', version: VERSION, ready, ...(reasons.length ? { reasons } : {}),
+ checks: lastSelfTest ? lastSelfTest.checks : {}, checked_at: lastSelfTest ? lastSelfTest.at : null,
+ isolation: isolation(), browser: !!config.browserPath,
+ capacity: config.maxSessions, active: [...sessions.values()].filter((s) => s.sbx.state === 'running').length,
+ at: new Date(now()).toISOString(),
+ };
+ }
+
+ async function selfTest() {
+ if (!support || !support.ok) { lastSelfTest = { ok: false, checks: {}, failed: ['user_namespace'], at: new Date(now()).toISOString() }; return lastSelfTest; }
+ lastSelfTest = await (opts.runSelfTest || runSelfTest)({
+ createSandbox, limiter, runtimeDir: config.runtimeDir, nodePath: config.nodePath, hidden: config.hidden,
+ seccompExpected: support.seccomp !== false, log,
+ });
+ if (!lastSelfTest.ok) log.error(`[sandbox] not ready: ${lastSelfTest.failed.join(', ')}${lastSelfTest.error ? ` (${lastSelfTest.error})` : ''}`);
+ return lastSelfTest;
+ }
+
+ async function post(url, body, headers) {
+ const res = await fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body, signal: AbortSignal.timeout(10_000) });
+ await res.text().catch(() => '');
+ return res.status;
+ }
+
+ /** One round of check-ins: Codes (signed) and Live (the worker's own service token), with the same facts. */
+ async function announce() {
+ const h = health();
+ const body = JSON.stringify(h);
+ if (config.codes.secret) {
+ try {
+ const status = await post(`${config.codes.url}/internal/sandbox-workers/heartbeat`, body, { [signature.HEADER]: signature.sign(config.codes.secret, body, now()) });
+ lastAnnounce.codes = { status, at: new Date(now()).toISOString() };
+ } catch (err) { lastAnnounce.codes = { error: err.code || err.name || 'failed', at: new Date(now()).toISOString() }; }
+ }
+ const streams = new Set(config.live.streams);
+ for (const s of sessions.values()) if (s.stream && s.sbx.state === 'running') streams.add(s.stream);
+ if (tokens && streams.size) {
+ let token = null;
+ try { token = await tokens.getToken(); } catch (err) { log.error('[sandbox] no Network service token:', err.code || err.message); }
+ for (const stream of streams) {
+ if (!token) { lastAnnounce.live[stream] = { error: 'no_token' }; continue; }
+ try {
+ const status = await post(`${config.live.url}/api/streams/agent/${encodeURIComponent(stream)}/plays/worker`, body, { authorization: `Bearer ${token}` });
+ lastAnnounce.live[stream] = { status, at: new Date(now()).toISOString() };
+ } catch (err) { lastAnnounce.live[stream] = { error: err.code || err.name || 'failed' }; }
+ }
+ }
+ return { codes: lastAnnounce.codes, live: { ...lastAnnounce.live } };
+ }
+
+ // ── Sessions ────────────────────────────────────────────
+
+ async function openSession({ stream_id: stream = null, time_box_minutes: minutes } = {}) {
+ if (!health().ready) throw Object.assign(new Error('the worker is not ready'), { status: 503 });
+ const running = [...sessions.values()].filter((s) => s.sbx.state === 'running').length;
+ if (running >= config.maxSessions) throw Object.assign(new Error('no room: every sandbox is in use'), { status: 429 });
+ if (stream != null && !STREAM.test(String(stream))) throw Object.assign(new Error('bad stream_id'), { status: 400 });
+ const box = Math.min(config.timeBoxMaxMinutes, Math.max(config.timeBoxMinMinutes, Number(minutes) || 10));
+ const id = crypto.randomBytes(5).toString('hex');
+ const egressLog = [];
+ const sbx = await createSandbox({
+ id, runtimeDir: config.runtimeDir, limiter, limits: config.limits, timeBoxMs: box * 60_000, nodePath: config.nodePath,
+ browserPath: config.browserPath, log,
+ onEgress: (d) => { egressLog.push(d); if (egressLog.length > 500) egressLog.shift(); },
+ });
+ const entry = { id, sbx, stream: stream ? String(stream) : null, egressLog, created: now() };
+ sessions.set(id, entry);
+ sbx.ended.then(({ reason }) => { log.log(`[sandbox] ${id} ended: ${reason}`); setTimeout(() => sessions.delete(id), 60_000).unref(); });
+ return entry;
+ }
+
+ const view = (e) => ({ ...e.sbx.info(), stream_id: e.stream, egress: { allowed: e.egressLog.filter((d) => d.allowed).length, refused: e.egressLog.filter((d) => !d.allowed).length } });
+
+ // ── The local API (a Unix socket; the runner on this host is its only client) ──
+
+ function readBody(req, limit) {
+ return new Promise((resolve, reject) => {
+ const parts = [];
+ let size = 0;
+ req.on('data', (c) => { size += c.length; if (size > limit) { reject(Object.assign(new Error('too large'), { status: 413 })); req.destroy(); } else parts.push(c); });
+ req.on('end', () => resolve(Buffer.concat(parts)));
+ req.on('error', reject);
+ });
+ }
+ const json = async (req) => { const b = await readBody(req, MB); if (!b.length) return {}; try { return JSON.parse(String(b)); } catch { throw Object.assign(new Error('bad JSON'), { status: 400 }); } };
+ const send = (res, status, body) => { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(body)); };
+
+ async function route(req, res) {
+ const url = new URL(req.url, 'http://worker');
+ const parts = url.pathname.split('/').filter(Boolean);
+ if (req.method === 'GET' && url.pathname === '/health') return send(res, 200, { ...health(), announce: lastAnnounce });
+ if (parts[0] !== 'sessions') return send(res, 404, { error: 'not_found' });
+ if (parts.length === 1) {
+ if (req.method === 'GET') return send(res, 200, { sessions: [...sessions.values()].map(view) });
+ if (req.method === 'POST') { const e = await openSession(await json(req)); return send(res, 201, view(e)); }
+ return send(res, 405, { error: 'method' });
+ }
+ if (!SESSION.test(parts[1]) || !sessions.has(parts[1])) return send(res, 404, { error: 'no_session' });
+ const e = sessions.get(parts[1]);
+ const action = parts[2] || '';
+ if (!action && req.method === 'GET') return send(res, 200, { ...view(e), egress_log: e.egressLog.slice(-100) });
+ if ((!action && req.method === 'DELETE') || (action === 'wipe' && req.method === 'POST')) {
+ const out = await e.sbx.wipe(action === 'wipe' ? 'wipe_now' : 'end');
+ return send(res, 200, { id: e.id, state: e.sbx.state, ...out });
+ }
+ if (action === 'exec' && req.method === 'POST') {
+ const b = await json(req);
+ const r = await e.sbx.exec(b.argv, { stdin: b.stdin == null ? null : String(b.stdin), tty: b.tty === true, timeoutMs: Number(b.timeout_ms) || 60_000 });
+ return send(res, 200, r);
+ }
+ if (action === 'files' && req.method === 'PUT') { await e.sbx.writeFile(url.searchParams.get('path'), await readBody(req, 8 * MB)); return send(res, 200, { ok: true }); }
+ if (action === 'files' && req.method === 'GET') {
+ const data = await e.sbx.readFile(url.searchParams.get('path'));
+ res.writeHead(200, { 'content-type': 'application/octet-stream', 'cache-control': 'no-store' });
+ return res.end(data);
+ }
+ if (action === 'list' && req.method === 'GET') return send(res, 200, { entries: await e.sbx.list(url.searchParams.get('path') || '/workspace') });
+ if (action === 'browser' && req.method === 'POST') { const b = await json(req); return send(res, 200, await e.sbx.openPage(String(b.url || ''))); }
+ return send(res, 404, { error: 'not_found' });
+ }
+
+ async function start() {
+ if (!support) support = await probeSupport();
+ if (!support.ok) log.error(`[sandbox] cannot make sandboxes: ${support.reason}`);
+ if (!limiter) limiter = await detectLimits({ mode: config.limitsMode });
+ if (!limiter.ok) log.error(`[sandbox] no limits: ${limiter.reason}`);
+ fs.mkdirSync(config.runtimeDir, { recursive: true, mode: 0o750 });
+ // A worker that crashed left its sandboxes' directories (their processes died with it: --die-with-parent).
+ for (const name of fs.readdirSync(config.runtimeDir)) if (/^s-[a-z0-9-]+$/.test(name)) fs.rmSync(path.join(config.runtimeDir, name), { recursive: true, force: true });
+ if (limiter.ok && limiter.base) {
+ for (const name of fs.readdirSync(limiter.base)) if (/^sbx-[a-z0-9-]+$/.test(name)) { try { fs.rmdirSync(path.join(limiter.base, name)); } catch { /* still busy: left alone */ } }
+ }
+ await selfTest();
+ server = http.createServer((req, res) => {
+ route(req, res).catch((err) => send(res, err.status || (/outside the workspace|not a file|ENOENT/.test(err.message) ? 400 : 500), { error: String(err.message).slice(0, 200) }));
+ });
+ try { fs.unlinkSync(config.socket); } catch { /* none */ }
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(config.socket, resolve); });
+ fs.chmodSync(config.socket, config.socketGroupMode);
+ await announce();
+ const a = setInterval(() => { announce().catch(() => {}); }, config.announceMs);
+ const t = setInterval(() => { selfTest().catch(() => {}); }, config.selfTestMs);
+ timers.push(a, t);
+ log.log(`[sandbox] worker ${config.workerId}: ${health().ready ? 'ready' : 'NOT ready'} (limits: ${limiter.kind}, browser: ${config.browserPath ? 'yes' : 'no'})`);
+ return health();
+ }
+
+ async function stop() {
+ for (const t of timers.splice(0)) clearInterval(t);
+ await Promise.all([...sessions.values()].map((e) => e.sbx.wipe('worker_stopped').catch(() => {})));
+ if (server) await new Promise((r) => server.close(() => r()));
+ try { fs.unlinkSync(config.socket); } catch { /* gone */ }
+ }
+
+ return { start, stop, health, selfTest, announce, openSession, sessions, config, get server() { return server; } };
+}
+
+module.exports = { createWorker, loadConfig };
@@ -221,6 +221,8 @@ async function createApp(opts = {}) {
@@ -221,6 +221,8 @@ async function createApp(opts = {}) {
ctx.sessionRetention = sessions.retention;
ctx.sessionRetention = sessions.retention;
app.use('/internal/events', consumer.router);
app.use('/internal/events', consumer.router);
}
}
+ // ── Chat plays sandbox workers → Codes (Agent Streams S6; loopback only, signed heartbeats) ──
+ app.use('/internal/sandbox-workers', require('./http/sandbox-workers').createSandboxWorkerRoutes({ store, config, log }));
// ── Machine endpoints ───────────────────────────────────
// ── Machine endpoints ───────────────────────────────────
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-codes', version: VERSION }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-codes', version: VERSION }));
@@ -179,6 +179,12 @@ function load(env = process.env) {
@@ -179,6 +179,12 @@ function load(env = process.env) {
},
},
// The event stream of a task (GET /api/v1/tasks/:id/events): the heartbeat that keeps a connection alive.
// The event stream of a task (GET /api/v1/tasks/:id/events): the heartbeat that keeps a connection alive.
sse: { heartbeatMs: Math.max(1000, int(env.CODES_SSE_HEARTBEAT_MS, 15_000)) },
sse: { heartbeatMs: Math.max(1000, int(env.CODES_SSE_HEARTBEAT_MS, 15_000)) },
+ // The chat plays sandbox workers (Agent Streams S6, sandbox/worker.js): the secret their heartbeats are signed
+ // with (a comma list during a rotation), and how long a worker that stopped checking in still counts.
+ sandbox: {
+ workerSecrets: String(env.CODES_SANDBOX_WORKER_SECRET || '').split(',').map((s) => s.trim()).filter(Boolean),
+ staleMs: Math.max(10_000, int(env.CODES_SANDBOX_STALE_MS, 90_000)),
+ },
};
};
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
+'use strict';
+/**
+ * The chat plays sandbox workers Codes knows (Agent Streams S6, design 2026-11-06 §1, §6): each worker
+ * (sandbox/worker.js) checks in every 30 s with what its self-test found, and Codes answers whether a healthy one exists.
+ *
+ * Codes does not take a worker's word for "ready": a worker is healthy only when it said ready, every required check
+ * passed, its limits are enforced, and it checked in within staleMs. Anything else is not there.
+ *
+ * createSandboxWorkers({ store, config })
+ * heartbeat(report, signedAt) → { ok, worker } | { ok: false, code } (the route verified the signature)
+ * health() → { ready, workers: [{ id, healthy, version, last_seen_at, capacity, active, failing }], stale_after_ms }
+ */
+const REQUIRED = ['network_namespace', 'no_direct_egress', 'egress_refuses_private', 'clean_env', 'no_host_files', 'no_capabilities', 'seccomp', 'limits', 'wipe'];
+const ID = /^[A-Za-z0-9_.-]{1,64}$/;
+
+const bool = (v) => v === true;
+const small = (v, max) => Math.max(0, Math.min(max, Math.round(Number(v) || 0)));
+
+/** Only the facts, in their own shape: a worker cannot store anything else here. */
+function cleanReport(r) {
+ const checks = {};
+ for (const c of REQUIRED) checks[c] = bool(r && r.checks && r.checks[c]);
+ const iso = (r && r.isolation && typeof r.isolation === 'object') ? r.isolation : {};
+ const limits = iso.limits && typeof iso.limits === 'object' ? iso.limits : {};
+ const isolation = {
+ sandbox: String(iso.sandbox || '').slice(0, 32), user_namespace: bool(iso.user_namespace), network_namespace: bool(iso.network_namespace),
+ egress: { public_only: bool(iso.egress && iso.egress.public_only), ports: Array.isArray(iso.egress && iso.egress.ports) ? iso.egress.ports.map(Number).filter((p) => p > 0 && p < 65536).slice(0, 4) : [] },
+ host_credentials: String(iso.host_credentials || 'unknown').slice(0, 16), host_files: String(iso.host_files || 'unknown').slice(0, 16),
+ seccomp: bool(iso.seccomp),
+ limits: {
+ kind: String(limits.kind || 'none').slice(0, 16), enforced: bool(limits.enforced), memory_mb: small(limits.memory_mb, 1 << 20),
+ disk_mb: small(limits.disk_mb, 1 << 20), pids: small(limits.pids, 1 << 20), cpu_percent: small(limits.cpu_percent, 1 << 16),
+ },
+ };
+ return {
+ id: String(r && r.worker_id || ''), version: String(r && r.version || '').slice(0, 32), ready: bool(r && r.ready), checks, isolation,
+ capacity: small(r && r.capacity, 1000), active: small(r && r.active, 1000),
+ };
+}
+
+/** Why a reported worker is not healthy (empty when it is). */
+function failing(row, { now, staleMs }) {
+ const out = [];
+ if (!row.ready) out.push('not_ready');
+ for (const c of REQUIRED) if (!(row.checks && row.checks[c] === true)) out.push(c);
+ if (!(row.isolation && row.isolation.limits && row.isolation.limits.enforced === true)) out.push('limits_not_enforced');
+ if (!(row.isolation && row.isolation.network_namespace === true)) out.push('no_network_namespace');
+ if (now - new Date(row.last_seen_at).getTime() > staleMs) out.push('stale');
+ return [...new Set(out)];
+}
+
+function createSandboxWorkers({ store, config }) {
+ const db = store.db;
+ const staleMs = (config.sandbox && config.sandbox.staleMs) || 90_000;
+
+ async function heartbeat(report, signedAt) {
+ const r = cleanReport(report);
+ if (!ID.test(r.id)) return { ok: false, code: 'sandbox.bad_worker' };
+ // The signed time must move forward: the same heartbeat sent twice is refused.
+ const row = await db.maybe(
+ `INSERT INTO codes_sandbox_workers (id, version, ready, checks, isolation, capacity, active, last_signed_at, last_seen_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+ ON CONFLICT (id) DO UPDATE SET version = EXCLUDED.version, ready = EXCLUDED.ready, checks = EXCLUDED.checks,
+ isolation = EXCLUDED.isolation, capacity = EXCLUDED.capacity, active = EXCLUDED.active,
+ last_signed_at = EXCLUDED.last_signed_at, last_seen_at = EXCLUDED.last_seen_at
+ WHERE codes_sandbox_workers.last_signed_at < EXCLUDED.last_signed_at
+ RETURNING id`,
+ [r.id, r.version, r.ready, JSON.stringify(r.checks), JSON.stringify(r.isolation), r.capacity, r.active, signedAt, new Date(store.now()).toISOString()]);
+ if (!row) return { ok: false, code: 'signature.replayed' };
+ return { ok: true, worker: r.id };
+ }
+
+ async function health() {
+ const now = store.now();
+ const rows = await db.many('SELECT * FROM codes_sandbox_workers ORDER BY id');
+ const workers = rows.map((row) => {
+ const f = failing(row, { now, staleMs });
+ return {
+ id: row.id, healthy: f.length === 0, version: row.version, last_seen_at: new Date(row.last_seen_at).toISOString(),
+ capacity: Number(row.capacity), active: Number(row.active), room: Math.max(0, Number(row.capacity) - Number(row.active)),
+ limits: row.isolation && row.isolation.limits ? row.isolation.limits.kind : 'none', failing: f,
+ };
+ });
+ return { ready: workers.some((w) => w.healthy), workers, stale_after_ms: staleMs, checked_at: new Date(now).toISOString() };
+ }
+
+ return { heartbeat, health };
+}
+
+module.exports = { createSandboxWorkers, cleanReport, failing, REQUIRED };
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+'use strict';
+/**
+ * The chat plays sandbox workers' internal routes (Agent Streams S6), under /internal/sandbox-workers. Loopback only:
+ * nginx answers 404 for /internal/, and a request that came through a proxy is refused here too.
+ *
+ * POST /heartbeat a worker checks in (sandbox/worker.js, every 30 s). The body is signed with the worker secret
+ * (CODES_SANDBOX_WORKER_SECRET; X-OV-Sandbox-Signature, sandbox/signature.js): unsigned, wrongly
+ * signed, stale or replayed heartbeats are refused, and without a configured secret there is none.
+ * GET /health is there a healthy worker? { ready, workers: [{ id, healthy, failing, … }] } — no secret, path
+ * or address in it. Live's chat plays gate and Codes' runner read it.
+ */
+const express = require('express');
+const signature = require('../../sandbox/signature');
+const { createSandboxWorkers } = require('../domain/sandbox-workers');
+
+function createSandboxWorkerRoutes({ store, config, log = console }) {
+ const workers = createSandboxWorkers({ store, config });
+ const r = express.Router();
+ const secrets = (config.sandbox && config.sandbox.workerSecrets) || [];
+ const fail = (res, status, code, detail) => res.status(status).set('Cache-Control', 'no-store').json({ error: code, detail });
+
+ r.use((req, res, next) => {
+ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) return fail(res, 403, 'codes.internal_only', 'internal route');
+ return next();
+ });
+
+ r.post('/heartbeat', express.raw({ type: () => true, limit: '64kb' }), async (req, res) => {
+ if (!secrets.length) return fail(res, 503, 'sandbox.not_configured', 'no worker secret is configured (CODES_SANDBOX_WORKER_SECRET)');
+ const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
+ const v = signature.verify({ secrets, header: req.get(signature.HEADER), body, now: store.now() });
+ if (!v.ok) return fail(res, 401, v.code, 'the heartbeat is not signed with the worker secret');
+ let report;
+ try { report = JSON.parse(body.toString('utf8')); } catch { return fail(res, 400, 'sandbox.bad_report', 'not JSON'); }
+ try {
+ const out = await workers.heartbeat(report, v.t);
+ if (!out.ok) return fail(res, out.code === 'signature.replayed' ? 409 : 400, out.code, 'heartbeat refused');
+ return res.set('Cache-Control', 'no-store').json({ ok: true, worker: out.worker });
+ } catch (err) {
+ log.error('[Codes] sandbox heartbeat:', err.message);
+ return fail(res, 500, 'internal.error', 'the heartbeat was not stored');
+ }
+ });
+
+ r.get('/health', async (_req, res) => {
+ try {
+ res.set('Cache-Control', 'no-store').json(await workers.health());
+ } catch (err) {
+ log.error('[Codes] sandbox health:', err.message);
+ fail(res, 500, 'internal.error', 'health unavailable');
+ }
+ });
+
+ return r;
+}
+
+module.exports = { createSandboxWorkerRoutes };
new file mode 100644
new file mode 100644
@@ -0,0 +1,222 @@
@@ -0,0 +1,222 @@
+'use strict';
+/**
+ * A fake public web for the chat plays sandbox tests (sandbox/*.js). Nothing here reaches the real network:
+ *
+ * - the resolver answers fixed names (origin.example is a public address, private.example a 10/8 one, …) and
+ * hands numeric spellings (127.1, 2130706433) to the system resolver, which answers them without any network;
+ * - dial() is the proxy's only way out: it connects the fake public addresses to a local origin server, and
+ * records any other address it is asked for (a test fails if the proxy ever dials one: the guard must refuse
+ * before a socket is opened);
+ * - a canary server on the host's loopback and on every host address counts every connection it gets: none
+ * may come from the sandbox;
+ * - canaries: an environment variable, a token file and an abstract socket only the host has.
+ *
+ * const world = await startWorld(); … world.egress (for createSandbox) … await world.close();
+ */
+const crypto = require('crypto');
+const dns = require('dns');
+const fs = require('fs');
+const http = require('http');
+const net = require('net');
+const os = require('os');
+const path = require('path');
+
+const ORIGIN_IP = '93.184.215.14'; // public: the fake origin answers here
+const SMTP_IP = '93.184.215.15'; // public: but only port 25/587/465 is ever asked of it
+const SELF_IP = '93.184.215.99'; // public, and one of the host's own addresses in these tests
+
+const NAMES = {
+ 'origin.example': [ORIGIN_IP],
+ 'smtp.example': [SMTP_IP],
+ 'self.example': [SELF_IP],
+ 'private.example': ['10.0.0.5'],
+ 'lan.example': ['192.168.1.20'],
+ 'docker.example': ['172.17.0.1'],
+ 'meta.example': ['169.254.169.254'],
+ 'loop.example': ['127.0.0.1'],
+ 'cgnat.example': ['100.64.0.7'],
+ 'mixed.example': [ORIGIN_IP, '192.168.1.10'],
+ 'v6loop.example': ['::1'],
+ 'v6mapped.example': ['::ffff:127.0.0.1'],
+};
+
+/** dns.lookup for the fake names; numeric spellings (127.1, 2130706433, 0x7f000001) go to the system resolver. */
+function lookup(hostname, options, callback) {
+ if (typeof options === 'function') { callback = options; options = {}; }
+ const host = String(hostname).toLowerCase().replace(/\.$/, '');
+ const fixed = NAMES[host];
+ if (fixed) {
+ const addrs = fixed.map((address) => ({ address, family: net.isIP(address) }));
+ return process.nextTick(() => (options && options.all ? callback(null, addrs) : callback(null, addrs[0].address, addrs[0].family)));
+ }
+ if (/^[0-9a-fx.]+$/i.test(host)) return dns.lookup(host, options, callback);
+ const err = new Error(`getaddrinfo ENOTFOUND ${host}`);
+ err.code = 'ENOTFOUND';
+ return process.nextTick(() => callback(err));
+}
+
+/** The host's own addresses, plus SELF_IP so "the host's own public address" can be tested without one. */
+function hostAddresses() {
+ const out = new Set([SELF_IP]);
+ for (const list of Object.values(os.networkInterfaces())) for (const a of list || []) out.add(a.address.split('%')[0]);
+ return out;
+}
+
+function hostLanAddresses() {
+ const out = [];
+ for (const list of Object.values(os.networkInterfaces())) for (const a of list || []) if (!a.internal && a.family === 'IPv4') out.push(a.address);
+ return out;
+}
+
+async function listen(server, ...args) {
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(...args, () => resolve()); });
+ return server;
+}
+
+async function startWorld() {
+ const canary = crypto.randomBytes(12).toString('hex');
+ const hits = { canary: 0, abstract: 0, unexpectedDials: [] };
+
+ // The fake public origin: / answers, /redirect?to= redirects, /page has a title the browser test reads.
+ const origin = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://origin.example');
+ if (u.pathname === '/redirect') { res.writeHead(302, { location: u.searchParams.get('to') || '/' }); return res.end(); }
+ if (u.pathname === '/page') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end('<!doctype html><title>Origin page</title><h1 id="h">origin-ok</h1>'); }
+ res.writeHead(200, { 'content-type': 'text/plain' });
+ res.end('origin-ok');
+ });
+ await listen(origin, 0, '127.0.0.1');
+ // The fake public TLS port: a raw TCP echo of "ping" → "pong" (CONNECT tunnels carry bytes, the test needs no TLS).
+ const tunnel = net.createServer((s) => s.on('data', (d) => { if (String(d).includes('ping')) s.end('pong'); }));
+ await listen(tunnel, 0, '127.0.0.1');
+
+ // The canary: the host's loopback and every host address. Nothing inside the sandbox may ever reach it.
+ const canaryServer = http.createServer((req, res) => { hits.canary++; res.end(`host-secret-${canary}`); });
+ await listen(canaryServer, 0, '0.0.0.0');
+ const canaryPort = canaryServer.address().port;
+ // An abstract socket (X11 and D-Bus use these): it lives in the host's network namespace only.
+ const abstractName = `ov-canary-${canary}`;
+ const abstractServer = net.createServer((s) => { hits.abstract++; s.end(canary); });
+ await listen(abstractServer, `\0${abstractName}`);
+
+ /** The proxy's only way out: the two fake public addresses, nothing else. */
+ function dial({ host, port }) {
+ if (host === ORIGIN_IP && port === 80) return net.connect(origin.address().port, '127.0.0.1');
+ if (host === ORIGIN_IP && port === 443) return net.connect(tunnel.address().port, '127.0.0.1');
+ hits.unexpectedDials.push(`${host}:${port}`);
+ const s = new net.Socket();
+ process.nextTick(() => s.destroy(Object.assign(new Error('unexpected dial in a test'), { code: 'ECONNREFUSED' })));
+ return s;
+ }
+
+ // The worker's token and the host's own secrets: a file in a directory only the host has, and an env variable.
+ const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-sbx-secret-'));
+ const tokenFile = path.join(secretDir, 'worker-secret');
+ fs.writeFileSync(tokenFile, `token-${canary}`, { mode: 0o600 });
+ process.env.OV_SANDBOX_TEST_CANARY = `env-${canary}`;
+
+ return {
+ canary, hits, canaryPort, abstractName, tokenFile, secretDir,
+ hostLan: hostLanAddresses(),
+ egress: { lookup, dial, hostAddresses },
+ async close() {
+ for (const s of [origin, tunnel, canaryServer, abstractServer]) await new Promise((r) => s.close(() => r()));
+ fs.rmSync(secretDir, { recursive: true, force: true });
+ delete process.env.OV_SANDBOX_TEST_CANARY;
+ },
+ };
+}
+
+/**
+ * A short runtime directory: a Unix socket path must fit in 108 bytes, so the sessions live under /tmp (or
+ * XDG_RUNTIME_DIR), never under a long working directory.
+ */
+function shortRuntimeDir() {
+ const base = process.env.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR.length < 40 ? process.env.XDG_RUNTIME_DIR : '/tmp';
+ return fs.mkdtempSync(path.join(base, 'ovsbx-'));
+}
+
+/** The pids of a process and every descendant, read from /proc on the host. */
+function processTree(rootPid) {
+ const children = new Map();
+ for (const name of fs.readdirSync('/proc')) {
+ if (!/^\d+$/.test(name)) continue;
+ try {
+ const stat = fs.readFileSync(`/proc/${name}/stat`, 'utf8');
+ const ppid = Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1]);
+ if (!children.has(ppid)) children.set(ppid, []);
+ children.get(ppid).push(Number(name));
+ } catch { /* gone already */ }
+ }
+ const out = [];
+ const walk = (pid) => { out.push(pid); for (const c of children.get(pid) || []) walk(c); };
+ walk(rootPid);
+ return out;
+}
+
+/** Is a process still there? A zombie (killed, not yet reaped by whoever inherited it) is not. */
+const alive = (pid) => {
+ try {
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
+ return stat.slice(stat.lastIndexOf(')') + 2, stat.lastIndexOf(')') + 3) !== 'Z';
+ } catch { return false; }
+};
+
+/**
+ * The probe that runs INSIDE the sandbox (written to /workspace/probe.js). It takes a list of attempts as JSON and
+ * prints what happened to each one as JSON. It uses only Node's own modules.
+ */
+const PROBE = String.raw`'use strict';
+const net = require('net'); const dgram = require('dgram'); const fs = require('fs'); const path = require('path');
+const PROXY = Number(process.env.OV_SANDBOX_PROXY_PORT || 3128);
+const plan = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
+fs.unlinkSync(process.argv[2]); // the plan names the canaries: it must not be what a search finds
+const out = [];
+const withTimeout = (p, ms = 4000) => Promise.race([p, new Promise((r) => setTimeout(() => r({ error: 'TIMEOUT' }), ms))]);
+function tcp(host, port) { return new Promise((resolve) => { const s = net.connect({ host, port }); s.on('connect', () => { s.destroy(); resolve({ connected: true }); }); s.on('error', (e) => resolve({ error: e.code || e.message })); }); }
+function udp(host, port) { return new Promise((resolve) => { const s = dgram.createSocket(host.includes(':') ? 'udp6' : 'udp4'); s.send(Buffer.from('x'), port, host, (e) => { s.close(); resolve(e ? { error: e.code || e.message } : { sent: true }); }); }); }
+function rawToProxy(text) { return new Promise((resolve) => { const s = net.connect(PROXY, '127.0.0.1'); let buf = ''; s.on('connect', () => s.write(text)); s.on('data', (d) => { buf += d; }); s.on('end', () => resolve(parse(buf))); s.on('close', () => resolve(parse(buf))); s.on('error', (e) => resolve({ error: e.code || e.message })); setTimeout(() => s.destroy(), 3000); }); }
+function parse(buf) { const [head, ...rest] = buf.split('\r\n\r\n'); const lines = head.split('\r\n'); const m = /^HTTP\/1\.[01] (\d{3})/.exec(lines[0] || ''); const headers = {}; for (const l of lines.slice(1)) { const i = l.indexOf(':'); if (i > 0) headers[l.slice(0, i).trim().toLowerCase()] = l.slice(i + 1).trim(); } let body = rest.join('\r\n\r\n'); if (/chunked/i.test(headers['transfer-encoding'] || '')) { let out = ''; let r = body; for (;;) { const i = r.indexOf('\r\n'); if (i < 0) break; const n = parseInt(r.slice(0, i), 16); if (!n) break; out += r.slice(i + 2, i + 2 + n); r = r.slice(i + 4 + n); } body = out; } return { status: m ? Number(m[1]) : null, headers, body: body.slice(0, 300) }; }
+async function get(url, follow) { const hops = []; let target = url; for (let i = 0; i < 5; i++) { const host = target.replace(/^[a-z]+:\/\//i, '').split('/')[0]; const r = await rawToProxy('GET ' + target + ' HTTP/1.1\r\nHost: ' + host + '\r\nConnection: close\r\n\r\n'); hops.push({ url: target, status: r.status, egress: r.headers && r.headers['x-ov-egress'] }); if (follow && r.status >= 300 && r.status < 400 && r.headers.location) { target = r.headers.location; continue; } return { ...r, hops }; } return { error: 'too many hops', hops }; }
+function connect(authority, payload) { return new Promise((resolve) => { const s = net.connect(PROXY, '127.0.0.1'); let buf = ''; let open = false; s.on('connect', () => s.write('CONNECT ' + authority + ' HTTP/1.1\r\nHost: ' + authority + '\r\n\r\n')); s.on('data', (d) => { buf += d; if (!open && buf.includes('\r\n\r\n')) { const r = parse(buf); if (r.status === 200) { open = true; buf = ''; s.write(payload || 'ping'); } else { s.destroy(); resolve(r); } } }); s.on('end', () => resolve(open ? { status: 200, reply: buf } : parse(buf))); s.on('error', (e) => resolve({ error: e.code || e.message })); setTimeout(() => { s.destroy(); resolve(open ? { status: 200, reply: buf } : parse(buf)); }, 3000); }); }
+function walk(root, needles, found, depth = 0) { let ents; try { ents = fs.readdirSync(root, { withFileTypes: true }); } catch { return; } for (const e of ents) { const p = path.join(root, e.name); if (e.isDirectory() && depth < 8) walk(p, needles, found, depth + 1); else if (e.isFile()) { try { const st = fs.statSync(p); if (st.size > 4 * 1024 * 1024) continue; const t = fs.readFileSync(p, 'latin1'); for (const n of needles) if (t.includes(n)) found.push(p); } catch { /* unreadable */ } } } }
+(async () => {
+ for (const a of plan) {
+ let r;
+ try {
+ if (a.kind === 'tcp') r = await withTimeout(tcp(a.host, a.port));
+ else if (a.kind === 'udp') r = await withTimeout(udp(a.host, a.port));
+ else if (a.kind === 'http') r = await withTimeout(get(a.url, a.follow), 8000);
+ else if (a.kind === 'raw') r = await withTimeout(rawToProxy(a.request));
+ else if (a.kind === 'connect') r = await withTimeout(connect(a.authority, a.payload));
+ else if (a.kind === 'env') r = { env: process.env };
+ else if (a.kind === 'exists') r = { exists: a.paths.filter((p) => { try { fs.lstatSync(p); return true; } catch { return false; } }) };
+ else if (a.kind === 'readdir') r = { entries: (() => { try { return fs.readdirSync(a.path); } catch (e) { return 'ERR:' + e.code; } })() };
+ else if (a.kind === 'read') r = (() => { try { return { text: fs.readFileSync(a.path, 'utf8') }; } catch (e) { return { error: e.code }; } })();
+ else if (a.kind === 'write') r = (() => { try { fs.writeFileSync(a.path, 'x'); return { wrote: true }; } catch (e) { return { error: e.code }; } })();
+ else if (a.kind === 'abstract') r = await withTimeout(new Promise((resolve) => { const s = net.connect('\0' + a.name); s.on('connect', () => { s.destroy(); resolve({ connected: true }); }); s.on('error', (e) => resolve({ error: e.code })); }));
+ else if (a.kind === 'grep') { const found = []; for (const root of a.roots) walk(root, a.needles, found); for (const pid of fs.readdirSync('/proc').filter((n) => /^\d+$/.test(n))) for (const f of ['environ', 'cmdline']) { try { const t = fs.readFileSync('/proc/' + pid + '/' + f, 'latin1'); for (const n of a.needles) if (t.includes(n)) found.push('/proc/' + pid + '/' + f); } catch { /* not ours */ } } r = { found }; }
+ else if (a.kind === 'fds') { const links = []; for (const pid of fs.readdirSync('/proc').filter((n) => /^\d+$/.test(n))) { let fds = []; try { fds = fs.readdirSync('/proc/' + pid + '/fd'); } catch { continue; } for (const fd of fds) { try { links.push(fs.readlinkSync('/proc/' + pid + '/fd/' + fd)); } catch { /* closed */ } } } r = { links }; }
+ else if (a.kind === 'status') r = { status: fs.readFileSync('/proc/self/status', 'utf8').split('\n').filter((l) => /^(Cap(Inh|Prm|Eff|Bnd|Amb)|NoNewPrivs|Seccomp):/.test(l)) };
+ else if (a.kind === 'interfaces') r = { dev: fs.readFileSync('/proc/net/dev', 'utf8').split('\n').slice(2).map((l) => l.split(':')[0].trim()).filter(Boolean) };
+ else r = { error: 'unknown kind' };
+ } catch (e) { r = { error: e.code || e.message }; }
+ out.push({ ...a, result: r });
+ }
+ // Exit once the output is flushed: the fallback timers above must not hold the probe open.
+ process.stdout.write(JSON.stringify(out), () => process.exit(0));
+})();
+`;
+
+/** Write the probe and the plan into the sandbox and run it; resolves the list of attempts with their results. */
+async function probe(sbx, plan, { timeoutMs = 45_000 } = {}) {
+ await sbx.writeFile('/workspace/.probe.js', PROBE);
+ await sbx.writeFile('/workspace/.plan.json', JSON.stringify(plan));
+ const r = await sbx.exec(['node', '/workspace/.probe.js', '/workspace/.plan.json'], { timeoutMs });
+ if (r.code !== 0) throw new Error(`the probe failed (${r.code}/${r.signal}): ${String(r.stderr).slice(0, 800)}`);
+ return JSON.parse(String(r.stdout));
+}
+
+module.exports = {
+ ORIGIN_IP, SMTP_IP, SELF_IP, NAMES, lookup, hostAddresses, startWorld, shortRuntimeDir, processTree, alive, probe, PROBE,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,189 @@
@@ -0,0 +1,189 @@
+'use strict';
+/**
+ * The chat plays sandbox's egress proxy (sandbox/egress-proxy.js), in-process: the sandbox's only way out. These run
+ * everywhere, CI included (the same refusals are tried from inside a real sandbox in sandbox-escape.test.js).
+ *
+ * - the guard is openvibe-shared/egress: public unicast addresses only, checked AFTER DNS resolution (every
+ * answer must pass), and the socket opens to the address that was checked (no second lookup, no rebinding);
+ * - the host's own addresses are refused even when they are public;
+ * - ports 80 and 443 only: no SMTP, no database, no SSH, nothing else;
+ * - the proxy never follows a redirect: the client's next request is a new one, checked again;
+ * - a refusal is a 403 with X-OV-Egress, reported to the worker, and no socket is ever opened for it.
+ */
+const assert = require('assert');
+const http = require('http');
+const net = require('net');
+const { createEgressProxy } = require('../sandbox/egress-proxy');
+const { startWorld, ORIGIN_IP, SELF_IP } = require('./helpers/sandbox-world');
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }
+}
+
+function dechunk(text) {
+ let out = '';
+ let rest = text;
+ for (;;) {
+ const i = rest.indexOf('\r\n');
+ if (i < 0) return out;
+ const n = parseInt(rest.slice(0, i), 16);
+ if (!n) return out;
+ out += rest.slice(i + 2, i + 2 + n);
+ rest = rest.slice(i + 2 + n + 2);
+ }
+}
+
+function raw(port, text) {
+ return new Promise((resolve) => {
+ const s = net.connect(port, '127.0.0.1');
+ let buf = '';
+ s.on('connect', () => s.write(text));
+ s.on('data', (d) => { buf += d; });
+ const done = () => {
+ const [head, ...rest] = buf.split('\r\n\r\n');
+ const lines = head.split('\r\n');
+ const m = /^HTTP\/1\.[01] (\d{3})/.exec(lines[0] || '');
+ const headers = {};
+ for (const l of lines.slice(1)) { const i = l.indexOf(':'); if (i > 0) headers[l.slice(0, i).trim().toLowerCase()] = l.slice(i + 1).trim(); }
+ let body = rest.join('\r\n\r\n');
+ if (/chunked/i.test(headers['transfer-encoding'] || '')) body = dechunk(body);
+ resolve({ status: m ? Number(m[1]) : null, headers, body });
+ };
+ s.on('end', done);
+ s.on('error', done);
+ setTimeout(() => { s.destroy(); done(); }, 3000);
+ });
+}
+const get = (port, url, host) => raw(port, `GET ${url} HTTP/1.1\r\nHost: ${host || new URL(url).host}\r\nConnection: close\r\n\r\n`);
+const connect = (port, authority) => raw(port, `CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n\r\n`);
+
+(async () => {
+ console.log('sandbox egress proxy');
+ const world = await startWorld();
+ const decisions = [];
+ const proxy = createEgressProxy({ ...world.egress, onDecision: (d) => decisions.push(d) });
+ await proxy.listen({ port: 0, host: '127.0.0.1' });
+ const port = proxy.address().port;
+
+ await check('the guard: public addresses pass, every private, local and reserved one is refused', async () => {
+ assert.deepStrictEqual(await proxy.check('origin.example', 80), { ok: true, host: 'origin.example', port: 80, address: ORIGIN_IP, family: 4 });
+ const refused = {
+ '127.0.0.1': 'egress.private_address', '10.1.2.3': 'egress.private_address', '192.168.0.1': 'egress.private_address',
+ '172.16.0.1': 'egress.private_address', '172.31.0.1': 'egress.private_address', '169.254.169.254': 'egress.private_address',
+ '100.64.0.1': 'egress.private_address', '0.0.0.0': 'egress.private_address', '::1': 'egress.private_address',
+ '::ffff:10.0.0.1': 'egress.private_address', 'fd00::1': 'egress.private_address', 'fe80::1': 'egress.private_address',
+ '64:ff9b::a00:1': 'egress.private_address', '2002:a00:1::': 'egress.private_address',
+ localhost: 'egress.internal_name', 'db.internal': 'egress.internal_name', 'nas.local': 'egress.internal_name', intranet: 'egress.internal_name',
+ 'private.example': 'egress.private_address', 'mixed.example': 'egress.private_address', 'meta.example': 'egress.private_address',
+ 'v6mapped.example': 'egress.private_address', [SELF_IP]: 'egress.host_address', 'self.example': 'egress.host_address',
+ 'nowhere.example': 'egress.dns',
+ };
+ for (const [host, code] of Object.entries(refused)) {
+ const r = await proxy.check(host, 443);
+ assert.strictEqual(r.ok, false, `${host} is refused`);
+ assert.strictEqual(r.code, code, `${host}: ${r.code}`);
+ }
+ for (const p of [25, 587, 465, 22, 6379, 5432, 8080, 0, 65536, -1]) assert.strictEqual((await proxy.check('origin.example', p)).code, 'egress.port', `port ${p}`);
+ });
+
+ await check('GET through the proxy: the public origin answers, and the request goes to the address that was checked', async () => {
+ const r = await get(port, 'http://origin.example/');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body, 'origin-ok');
+ const d = decisions.find((x) => x.host === 'origin.example' && x.allowed);
+ assert.strictEqual(d.address, ORIGIN_IP);
+ });
+
+ await check('GET refuses private targets in every spelling, with 403 and X-OV-Egress, and opens no socket', async () => {
+ const urls = ['http://127.0.0.1/', `http://127.0.0.1:${world.canaryPort}/`, 'http://2130706433/', 'http://0x7f000001/', 'http://127.1/', 'http://[::1]/',
+ 'http://[::ffff:127.0.0.1]/', 'http://localhost/', 'http://10.0.0.1/', 'http://192.168.1.1/', 'http://172.16.0.1/', 'http://169.254.169.254/latest/meta-data/',
+ 'http://private.example/', 'http://mixed.example/', `http://${SELF_IP}/`, 'http://origin.example:25/', 'http://origin.example:8080/'];
+ for (const url of urls) {
+ const r = await get(port, url);
+ assert.strictEqual(r.status, 403, `${url}: ${r.status}`);
+ assert.match(String(r.headers['x-ov-egress']), /^refused; code=egress\./, `${url} says why`);
+ }
+ assert.strictEqual(world.hits.canary, 0);
+ assert.deepStrictEqual(world.hits.unexpectedDials, []);
+ });
+
+ await check('the Host header cannot steer the request: the request target decides, and Host is rewritten to it', async () => {
+ const r = await get(port, 'http://origin.example/', `127.0.0.1:${world.canaryPort}`);
+ assert.strictEqual(r.status, 200);
+ const bad = await get(port, `http://127.0.0.1:${world.canaryPort}/`, 'origin.example');
+ assert.strictEqual(bad.status, 403);
+ assert.strictEqual(world.hits.canary, 0);
+ });
+
+ await check('an origin-form request (the proxy used as a server) and a non-HTTP scheme are refused', async () => {
+ assert.strictEqual((await raw(port, 'GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n')).status, 400);
+ for (const u of ['ftp://origin.example/', 'gopher://origin.example:70/', 'file:///etc/passwd', 'ws://origin.example/']) {
+ const r = await raw(port, `GET ${u} HTTP/1.1\r\nHost: origin.example\r\nConnection: close\r\n\r\n`);
+ assert.ok(r.status === 403 || r.status === 400, `${u}: ${r.status}`);
+ }
+ });
+
+ await check('CONNECT: 443 and 80 to a public address tunnel; private, SMTP and other ports are refused', async () => {
+ const ok = await new Promise((resolve) => {
+ const s = net.connect(port, '127.0.0.1', () => s.write('CONNECT origin.example:443 HTTP/1.1\r\nHost: origin.example:443\r\n\r\n'));
+ let buf = ''; let open = false;
+ s.on('data', (d) => { buf += d; if (!open && buf.includes('\r\n\r\n')) { open = /^HTTP\/1\.1 200/.test(buf); buf = ''; s.write('ping'); } });
+ s.on('end', () => resolve({ open, reply: buf }));
+ setTimeout(() => { s.destroy(); resolve({ open, reply: buf }); }, 3000);
+ });
+ assert.deepStrictEqual(ok, { open: true, reply: 'pong' });
+ for (const a of ['127.0.0.1:443', '10.0.0.1:443', '[::1]:443', 'private.example:443', 'meta.example:80', 'origin.example:25', 'origin.example:587',
+ 'origin.example:465', 'smtp.example:25', 'origin.example:22', 'origin.example:6379', '2130706433:443', 'localhost:443', `${SELF_IP}:443`, 'origin.example', 'origin.example:abc']) {
+ const r = await connect(port, a);
+ assert.ok(r.status === 403 || r.status === 400, `CONNECT ${a}: ${r.status}`);
+ }
+ assert.deepStrictEqual(world.hits.unexpectedDials, []);
+ });
+
+ await check('a redirect is passed back, never followed: the next hop is a new request, checked again', async () => {
+ const first = await get(port, `http://origin.example/redirect?to=${encodeURIComponent('http://169.254.169.254/latest/meta-data/')}`);
+ assert.strictEqual(first.status, 302);
+ assert.strictEqual(first.headers.location, 'http://169.254.169.254/latest/meta-data/');
+ const second = await get(port, first.headers.location);
+ assert.strictEqual(second.status, 403);
+ assert.deepStrictEqual(world.hits.unexpectedDials, []);
+ });
+
+ await check('a real HTTP client through the proxy gets the same answers', async () => {
+ const viaProxy = (url) => new Promise((resolve, reject) => {
+ const u = new URL(url);
+ http.get({ host: '127.0.0.1', port, path: url, headers: { host: u.host } }, (res) => {
+ let body = ''; res.on('data', (d) => { body += d; }); res.on('end', () => resolve({ status: res.statusCode, body }));
+ }).on('error', reject);
+ });
+ assert.deepStrictEqual(await viaProxy('http://origin.example/'), { status: 200, body: 'origin-ok' });
+ assert.strictEqual((await viaProxy('http://10.0.0.1/')).status, 403);
+ });
+
+ await check('every decision is reported (the worker logs them for the owner), refusals with their code', async () => {
+ assert.ok(decisions.length > 20);
+ for (const d of decisions) {
+ assert.strictEqual(typeof d.allowed, 'boolean');
+ if (!d.allowed) assert.match(d.code, /^egress\./);
+ assert.ok(!('body' in d) && !('headers' in d), 'a decision carries no request content');
+ }
+ });
+
+ await check('the proxy holds a bounded number of connections', async () => {
+ const small = createEgressProxy({ ...world.egress, maxConnections: 2 });
+ await small.listen({ port: 0, host: '127.0.0.1' });
+ const p = small.address().port;
+ const held = [net.connect(p, '127.0.0.1'), net.connect(p, '127.0.0.1')];
+ await new Promise((r) => setTimeout(r, 100));
+ const third = await raw(p, 'GET http://origin.example/ HTTP/1.1\r\nHost: origin.example\r\nConnection: close\r\n\r\n');
+ assert.strictEqual(third.status, 503);
+ for (const s of held) s.destroy();
+ await small.close();
+ });
+
+ await proxy.close();
+ await world.close();
+ console.log(failures ? `\n${failures} failed` : '\nall passed');
+ process.exit(failures ? 1 : 0);
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,239 @@
@@ -0,0 +1,239 @@
+'use strict';
+/**
+ * The chat plays sandbox, from the inside (Agent Streams S6, design 2026-11-06 §1, §5 and §9). A viewer's request
+ * is untrusted text, so every attempt below is made FROM INSIDE a real sandbox (bubblewrap, sandbox/session.js) by a
+ * probe running as the agent would, and each one must be refused:
+ *
+ * - the host network: no interface but the sandbox's own loopback; no TCP or UDP to 127.0.0.1 (the host's), the
+ * host's addresses, 10/8, 192.168/16, 172.16/12, 169.254.169.254 or even a public address, directly;
+ * - through the egress proxy (the only way out): the same addresses in every spelling, a name that resolves to
+ * one, a name with one private answer among public ones, an http redirect to one, SMTP (25, 587, 465) and any
+ * port but 80 and 443; the public web still works (the positive control, so no refusal here is vacuous);
+ * - the host's secrets: its environment, ~/.ssh, the worker's token (file and env), any /proc of the host, an
+ * abstract socket, an inherited file descriptor; and the sandbox holds no capability and cannot write outside
+ * its own workspace;
+ * - Wipe now: every process of the sandbox is gone, the session's directory, proxy and cgroup are gone, and the
+ * next sandbox starts with an empty workspace; the time box wipes on its own.
+ *
+ * It needs bubblewrap and unprivileged user namespaces. Where they are missing (a CI runner without bubblewrap, or
+ * with kernel.apparmor_restrict_unprivileged_userns=1) it prints `sandbox escape: skipped (<why>)`; run it where they
+ * are (the worker's host, or a workstation) to prove it: `ov test test/sandbox-escape.test.js`.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { startWorld, shortRuntimeDir, processTree, alive, probe, ORIGIN_IP, SELF_IP } = require('./helpers/sandbox-world');
+
+let failures = 0;
+async function check(name, fn) {
+ const t0 = Date.now();
+ try { await fn(); console.log(' ✓', name, process.env.VERBOSE_TIMES ? `(${Date.now() - t0} ms)` : ''); } catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }
+}
+
+const refusedByProxy = (r) => r && r.result && r.result.status === 403 && /refused/.test(String(r.result.headers && r.result.headers['x-ov-egress']));
+const describe = (r) => JSON.stringify(r.result).slice(0, 300);
+
+(async () => {
+ const { probeSupport, createSandbox } = require('../sandbox/session');
+ const support = await probeSupport();
+ if (!support.ok) {
+ console.log(`sandbox escape: skipped (${support.reason})`);
+ process.exit(0);
+ }
+ console.log(`sandbox escape (bubblewrap ${support.version}, seccomp ${support.seccomp ? 'on' : 'off'})`);
+ const world = await startWorld();
+ const runtimeDir = shortRuntimeDir();
+ const egressLog = [];
+ const open = (extra = {}) => createSandbox({
+ runtimeDir, egress: world.egress, allowUnlimited: true, limiter: null,
+ limits: { memoryMb: 512, diskMb: 64, pids: 128, cpuPercent: 100 }, timeBoxMs: 5 * 60_000,
+ onEgress: (d) => egressLog.push(d), ...extra,
+ });
+ const sbx = await open();
+ const hostHome = os.homedir();
+
+ await check('the sandbox has no network but its own loopback', async () => {
+ const [r] = await probe(sbx, [{ kind: 'interfaces' }]);
+ assert.deepStrictEqual(r.result.dev, ['lo'], `interfaces: ${describe(r)}`);
+ });
+
+ await check('no direct TCP or UDP reaches the host, the private ranges, the metadata address or even the public web', async () => {
+ const targets = [
+ ['127.0.0.1', world.canaryPort], ...world.hostLan.map((ip) => [ip, world.canaryPort]),
+ ['10.0.0.1', 80], ['192.168.1.1', 80], ['172.16.0.1', 80], ['172.17.0.1', 80], ['169.254.169.254', 80],
+ ['100.64.0.1', 80], [ORIGIN_IP, 80], [ORIGIN_IP, 443], ['1.1.1.1', 25], ['8.8.8.8', 587], ['::1', world.canaryPort],
+ ];
+ const plan = targets.map(([host, port]) => ({ kind: 'tcp', host, port }))
+ .concat([{ kind: 'udp', host: '8.8.8.8', port: 53 }, { kind: 'udp', host: '169.254.169.254', port: 53 }]);
+ for (const r of await probe(sbx, plan)) {
+ assert.ok(!r.result.connected && !r.result.sent, `${r.kind} ${r.host}:${r.port} must fail: ${describe(r)}`);
+ if (r.host === '127.0.0.1' || r.host === '::1') assert.strictEqual(r.result.error, 'ECONNREFUSED', 'the loopback is the sandbox\'s own: nothing listens there');
+ else assert.ok(['ENETUNREACH', 'EHOSTUNREACH', 'EADDRNOTAVAIL'].includes(r.result.error), `${r.host} has no route: ${describe(r)}`);
+ }
+ assert.strictEqual(world.hits.canary, 0, 'the host\'s canary server was never reached');
+ });
+
+ await check('the proxy refuses 127.0.0.1, the host\'s own addresses and every private range, in every spelling', async () => {
+ const urls = [
+ `http://127.0.0.1:${world.canaryPort}/`, `http://127.0.0.1/`, `http://localhost:${world.canaryPort}/`, 'http://2130706433/',
+ 'http://0x7f000001/', 'http://127.1/', 'http://[::1]/', 'http://[::ffff:127.0.0.1]/', 'http://0.0.0.0/',
+ ...world.hostLan.map((ip) => `http://${ip}:${world.canaryPort}/`), `http://${SELF_IP}/`, 'http://self.example/',
+ 'http://10.0.0.1/', 'http://10.255.255.254/', 'http://192.168.0.1/', 'http://192.168.1.1/', 'http://172.16.0.1/',
+ 'http://172.31.255.254/', 'http://172.17.0.1/', 'http://169.254.169.254/latest/meta-data/iam/', 'http://100.64.0.1/',
+ 'http://[fd00::1]/', 'http://[fe80::1]/', 'http://metadata.google.internal/', 'http://printer.local/',
+ ];
+ const results = await probe(sbx, urls.map((url) => ({ kind: 'http', url })));
+ for (const r of results) assert.ok(refusedByProxy(r), `${r.url} must be refused by the egress proxy: ${describe(r)}`);
+ assert.strictEqual(world.hits.canary, 0, 'the host\'s canary server was never reached');
+ });
+
+ await check('a name that resolves to a private address is refused after resolution (and one private answer is enough)', async () => {
+ const names = ['private.example', 'lan.example', 'docker.example', 'meta.example', 'loop.example', 'cgnat.example', 'mixed.example', 'v6loop.example', 'v6mapped.example'];
+ const plan = names.flatMap((n) => [{ kind: 'http', url: `http://${n}/` }, { kind: 'connect', authority: `${n}:443` }]);
+ for (const r of await probe(sbx, plan)) {
+ if (r.kind === 'http') assert.ok(refusedByProxy(r), `${r.url}: ${describe(r)}`);
+ else assert.strictEqual(r.result.status, 403, `CONNECT ${r.authority}: ${describe(r)}`);
+ }
+ });
+
+ await check('an http redirect to a private address is refused at the hop that follows it', async () => {
+ const to = [`http://127.0.0.1:${world.canaryPort}/`, 'http://169.254.169.254/latest/meta-data/', 'http://private.example/', 'http://10.0.0.1:8080/'];
+ const plan = to.map((t) => ({ kind: 'http', url: `http://origin.example/redirect?to=${encodeURIComponent(t)}`, follow: true }));
+ for (const r of await probe(sbx, plan)) {
+ const hops = r.result.hops || [];
+ assert.strictEqual(hops[0] && hops[0].status, 302, `the public origin answered its redirect: ${describe(r)}`);
+ assert.strictEqual(hops.length, 2, `the redirect was followed once: ${describe(r)}`);
+ assert.ok(refusedByProxy(r), `the second hop is refused: ${describe(r)}`);
+ }
+ assert.strictEqual(world.hits.canary, 0);
+ });
+
+ await check('SMTP (25, 587, 465) and every port but 80 and 443 are refused, with CONNECT and without', async () => {
+ const ports = [25, 587, 465, 22, 21, 53, 3306, 5432, 6379, 8080, 9222, 11211];
+ const plan = ports.flatMap((p) => [{ kind: 'connect', authority: `origin.example:${p}` }, { kind: 'http', url: `http://origin.example:${p}/` }])
+ .concat([{ kind: 'connect', authority: 'smtp.example:25' }, { kind: 'raw', request: 'EHLO sandbox\r\n\r\n' }]);
+ for (const r of await probe(sbx, plan)) {
+ if (r.kind === 'raw') assert.ok(r.result.status === 400 || r.result.status === null, `a non-HTTP greeting gets nothing: ${describe(r)}`);
+ else assert.strictEqual(r.result.status, 403, `${r.authority || r.url}: ${describe(r)}`);
+ }
+ });
+
+ await check('only http and https: no other scheme passes the proxy', async () => {
+ const plan = ['ftp://origin.example/', 'gopher://origin.example/', 'file:///etc/passwd', 'ws://origin.example/'].map((u) => ({ kind: 'raw', request: `GET ${u} HTTP/1.1\r\nHost: origin.example\r\nConnection: close\r\n\r\n` }));
+ for (const r of await probe(sbx, plan)) assert.ok(r.result.status === 403 || r.result.status === 400, `${r.request.split(' ')[1]}: ${describe(r)}`);
+ });
+
+ await check('the positive control: the public web answers through the proxy, http and an https tunnel', async () => {
+ const [a, b] = await probe(sbx, [{ kind: 'http', url: 'http://origin.example/' }, { kind: 'connect', authority: 'origin.example:443', payload: 'ping' }]);
+ assert.strictEqual(a.result.status, 200, describe(a));
+ assert.strictEqual(a.result.body, 'origin-ok');
+ assert.strictEqual(b.result.status, 200, describe(b));
+ assert.strictEqual(b.result.reply, 'pong', 'the tunnel carries bytes both ways');
+ assert.deepStrictEqual(world.hits.unexpectedDials, [], 'the proxy never opened a socket to a refused address');
+ assert.ok(egressLog.some((d) => d.allowed === false && d.code === 'egress.private_address'), 'refusals are reported to the worker');
+ });
+
+ await check('the environment holds nothing of the host: no variable of it, no token, no canary', async () => {
+ const [r] = await probe(sbx, [{ kind: 'env' }]);
+ const env = r.result.env;
+ const allowed = /^(PATH|HOME|USER|LOGNAME|SHELL|LANG|LC_ALL|TERM|TMPDIR|PWD|SHLVL|_|OLDPWD|http_proxy|https_proxy|HTTP_PROXY|HTTPS_PROXY|no_proxy|NO_PROXY|npm_config_cache|OV_SANDBOX(_[A-Z_]+)?)$/;
+ for (const k of Object.keys(env)) assert.ok(allowed.test(k), `${k} is not a variable the sandbox gives: the host's environment leaked`);
+ assert.ok(!JSON.stringify(env).includes(world.canary), 'no canary in the environment');
+ assert.strictEqual(env.HOME, '/home/agent');
+ });
+
+ await check('no host file is there: ~/.ssh, the home folder, the worker\'s token, /root, the runtime directory, D-Bus, Docker', async () => {
+ const paths = [path.join(hostHome, '.ssh'), path.join(hostHome, '.ssh/id_ed25519'), path.join(hostHome, '.ssh/id_rsa'), hostHome,
+ world.tokenFile, world.secretDir, '/root/.ssh', '/root', runtimeDir, sbx.dir, '/run/user', '/run/dbus/system_bus_socket',
+ '/var/run/docker.sock', '/run/docker.sock', '/etc/openvibe', '/etc/shadow', '/etc/ssh', '/etc/ssl/private', '/opt/openvibe.codes',
+ '/var/lib', '/sys/fs/cgroup', '/usr/local/bin'];
+ const [r, home] = await probe(sbx, [{ kind: 'exists', paths }, { kind: 'readdir', path: '/home' }]);
+ assert.deepStrictEqual(r.result.exists, [], `these host paths are visible inside: ${r.result.exists.join(', ')}`);
+ assert.deepStrictEqual(home.result.entries, ['agent'], 'only the agent\'s own empty home is under /home');
+ });
+
+ await check('the worker\'s token and the host\'s canaries are nowhere inside: files, /proc, the user list', async () => {
+ const needles = [world.canary, path.basename(world.secretDir)];
+ const [g, pw] = await probe(sbx, [{ kind: 'grep', needles, roots: ['/workspace', '/home', '/tmp', '/run', '/etc', '/opt/ov', '/dev/shm'] }, { kind: 'read', path: '/etc/passwd' }]);
+ assert.deepStrictEqual(g.result.found, [], `a canary was found inside: ${g.result.found.join(', ')}`);
+ const users = pw.result.text.trim().split('\n').map((l) => l.split(':')[0]).sort();
+ assert.deepStrictEqual(users, ['agent', 'nobody'], 'the user list is the sandbox\'s own, not the host\'s');
+ assert.ok(!pw.result.text.includes(os.userInfo().username + ':'), 'the host user is not named');
+ });
+
+ await check('no host process, socket or file descriptor reaches in: an abstract socket and every open fd', async () => {
+ const [a, f] = await probe(sbx, [{ kind: 'abstract', name: world.abstractName }, { kind: 'fds' }]);
+ assert.strictEqual(a.result.error, 'ECONNREFUSED', `the host's abstract socket is unreachable: ${describe(a)}`);
+ assert.strictEqual(world.hits.abstract, 0);
+ const leaked = f.result.links.filter((l) => l.startsWith(hostHome) || l.includes(world.secretDir) || l.startsWith(runtimeDir) || l.includes('worker-secret'));
+ assert.deepStrictEqual(leaked, [], 'no fd of the host is open inside');
+ });
+
+ await check('no capability, no new privileges, the seccomp filter, and nothing writable outside the sandbox\'s own areas', async () => {
+ const plan = [{ kind: 'status' }, ...['/usr/x', '/etc/x', '/opt/ov/x', '/run/ov/net/x', '/x', '/proc/sys/kernel/hostname'].map((p) => ({ kind: 'write', path: p })),
+ ...['/workspace/ok', '/home/agent/ok', '/tmp/ok'].map((p) => ({ kind: 'write', path: p }))];
+ const [s, ...writes] = await probe(sbx, plan);
+ const status = Object.fromEntries(s.result.status.map((l) => l.split(':\t')));
+ for (const c of ['CapInh', 'CapPrm', 'CapEff', 'CapBnd', 'CapAmb']) assert.strictEqual(status[c], '0000000000000000', `${c} is empty`);
+ assert.strictEqual(status.NoNewPrivs, '1');
+ if (support.seccomp) assert.strictEqual(status.Seccomp, '2', 'the seccomp filter is on');
+ for (const w of writes) {
+ const mine = /^\/(workspace|home\/agent|tmp)\//.test(w.path);
+ if (mine) assert.strictEqual(w.result.wrote, true, `${w.path} is the sandbox's own`);
+ else assert.ok(w.result.error, `${w.path} must not be writable: ${describe(w)}`);
+ }
+ });
+
+ await check('no nested namespace and no kernel keyring: unshare and keyctl are refused', async () => {
+ const r = await sbx.exec(['sh', '-c', 'unshare -U true 2>/dev/null && echo unshared || echo refused'], { timeoutMs: 10_000 });
+ assert.strictEqual(String(r.stdout).trim(), 'refused', 'a nested user namespace is refused');
+ // The kernel keyring holds a login's keys; keyctl(KEYCTL_GET_KEYRING_ID, KEY_SPEC_SESSION_KEYRING) asks for it.
+ const nr = { x64: 250, arm64: 219 }[process.arch];
+ const py = await sbx.exec(['sh', '-c', 'command -v python3 || true'], { timeoutMs: 10_000 });
+ if (support.seccomp && nr && String(py.stdout).trim()) {
+ const k = await sbx.exec(['python3', '-c', `import ctypes,os;l=ctypes.CDLL(None,use_errno=True);r=l.syscall(${nr},0,-3,0);print(r, os.strerror(ctypes.get_errno()) if r<0 else "")`], { timeoutMs: 10_000 });
+ assert.match(String(k.stdout).trim(), /^-1 Operation not permitted$/, `keyctl is refused by the seccomp filter: ${k.stdout}${k.stderr}`);
+ } else {
+ console.log(' (keyctl: no python3 inside or no seccomp filter; the keyring check needs both)');
+ }
+ });
+
+ await check('Wipe now: every process, the workspace, the directory, the proxy socket and the cgroup are gone', async () => {
+ const w = await open();
+ await w.writeFile('/workspace/left-behind.txt', `${world.canary}`);
+ await w.exec(['sh', '-c', 'setsid sleep 300 >/dev/null 2>&1 & nohup sleep 301 >/dev/null 2>&1 & echo started'], { timeoutMs: 10_000 });
+ const tree = processTree(w.pid);
+ assert.ok(tree.length >= 4, `the sandbox has its processes on the host: ${tree.join(',')}`);
+ const dir = w.dir;
+ const cg = w.cgroup;
+ assert.ok(fs.existsSync(path.join(dir, 'net', 'egress.sock')), 'the proxy socket is there while the sandbox runs');
+ assert.ok(w._pidns, 'the worker knows the sandbox\'s pid namespace, so it can prove it empty');
+ const out = await w.wipe('wipe_now');
+ assert.deepStrictEqual(out, { reason: 'wipe_now' }, 'no process outlived the wipe');
+ assert.strictEqual(w.state, 'wiped');
+ for (const pid of tree) assert.ok(!alive(pid), `pid ${pid} of the sandbox is gone`);
+ assert.ok(!fs.existsSync(dir), 'the session directory is gone');
+ if (cg) assert.ok(!fs.existsSync(cg), 'the cgroup is gone');
+ await assert.rejects(() => w.exec(['true']), /wiped|ended/, 'nothing runs in a wiped sandbox');
+ const next = await open();
+ const ls = await next.exec(['sh', '-c', 'ls -A /workspace /home/agent /tmp'], { timeoutMs: 10_000 });
+ assert.strictEqual(String(ls.stdout).replace(/^\/[^\n]*:\n?/gm, '').trim(), '', `a fresh sandbox is empty: ${ls.stdout}`);
+ await next.wipe('end');
+ });
+
+ await check('the time box wipes the sandbox on its own', async () => {
+ const t = await open({ timeBoxMs: 1500 });
+ const tree = processTree(t.pid);
+ const ended = await Promise.race([t.ended, new Promise((r) => setTimeout(() => r({ reason: 'still running' }), 6000))]);
+ assert.strictEqual(ended.reason, 'time_box');
+ for (const pid of tree) assert.ok(!alive(pid), `pid ${pid} is gone`);
+ assert.ok(!fs.existsSync(t.dir));
+ });
+
+ await sbx.wipe('end');
+ await world.close();
+ fs.rmSync(runtimeDir, { recursive: true, force: true });
+ console.log(failures ? `\n${failures} failed` : '\nall passed');
+ process.exit(failures ? 1 : 0);
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
+'use strict';
+/**
+ * The chat plays sandbox's limits (Agent Streams S6, design 2026-11-06 §1, §3): a request cannot take the worker's
+ * host down with it. Each sandbox runs in a cgroup of its own (sandbox/limits.js): a delegated cgroup v2 subtree
+ * under the worker's systemd unit (Delegate=yes), or a transient scope of the user's systemd on a workstation.
+ *
+ * - process count: pids.max holds a fork bomb;
+ * - memory: memory.max (no swap) kills the process that asks for too much, and the sandbox goes on;
+ * - disk: the workspace, the home folder and /tmp are size-limited tmpfs: a big write ends in ENOSPC;
+ * - CPU: cpu.max is the quota the worker set;
+ * - and the cgroup is removed with the sandbox.
+ *
+ * It needs bubblewrap and a cgroup the worker may create children in. Without them it prints
+ * `sandbox limits: skipped (<why>)`. Locally: `ov test test/sandbox-limits.test.js` (a user systemd is enough), or
+ * the production way, `systemd-run --user --scope -p Delegate=yes -E CODES_SANDBOX_LIMITS=cgroup node test/sandbox-limits.test.js`.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { startWorld, shortRuntimeDir } = require('./helpers/sandbox-world');
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }
+}
+
+(async () => {
+ const { probeSupport, createSandbox } = require('../sandbox/session');
+ const { detectLimits } = require('../sandbox/limits');
+ const support = await probeSupport();
+ if (!support.ok) { console.log(`sandbox limits: skipped (${support.reason})`); process.exit(0); }
+ const limiter = await detectLimits({ mode: process.env.CODES_SANDBOX_LIMITS || 'auto' });
+ if (!limiter.ok) { console.log(`sandbox limits: skipped (no cgroup to put a sandbox in: ${limiter.reason})`); process.exit(0); }
+ console.log(`sandbox limits (${limiter.kind})`);
+
+ const world = await startWorld();
+ const runtimeDir = shortRuntimeDir();
+ const limits = { memoryMb: 160, diskMb: 32, pids: 64, cpuPercent: 50 };
+ const sbx = await createSandbox({ runtimeDir, egress: world.egress, limiter, limits, timeBoxMs: 5 * 60_000 });
+ const read = (f) => fs.readFileSync(path.join(sbx.cgroup, f), 'utf8').trim();
+ const readMaybe = (f) => { try { return read(f); } catch { return ''; } };
+
+ await check('the sandbox runs in a cgroup of its own with the limits the worker set', async () => {
+ assert.ok(sbx.cgroup && fs.existsSync(sbx.cgroup), `the cgroup is there: ${sbx.cgroup}`);
+ const procs = read('cgroup.procs').split('\n').map(Number);
+ assert.ok(procs.includes(sbx.pid), 'the sandbox\'s first process is in it');
+ // The tmpfs areas are charged to the sandbox's memory too, so memory.max is memory + disk.
+ assert.strictEqual(Number(read('memory.max')), (limits.memoryMb + 3 * limits.diskMb) * 1024 * 1024);
+ assert.strictEqual(read('memory.swap.max'), '0');
+ assert.strictEqual(Number(read('pids.max')), limits.pids);
+ const [quota, period] = read('cpu.max').split(' ').map(Number);
+ assert.strictEqual(Math.round((quota / period) * 100), limits.cpuPercent, `cpu.max: ${read('cpu.max')}`);
+ });
+
+ await check('a fork bomb stops at pids.max', async () => {
+ const r = await sbx.exec(['sh', '-c', 'i=0; while [ $i -lt 100 ]; do i=$((i+1)); sleep 3 & done; wait'], { timeoutMs: 30_000 });
+ // The kernel's own count of refused forks, not the shell's (dash gives up at the first "Cannot fork").
+ const refused = Number((/^max (\d+)/m.exec(readMaybe('pids.events')) || [])[1]);
+ assert.ok(refused > 0, `the cgroup refused forks (pids.events: ${readMaybe('pids.events')})`);
+ assert.match(String(r.stderr), /Cannot fork|Resource temporarily unavailable/, 'the sandbox saw the refusal');
+ const peak = readMaybe('pids.peak');
+ if (peak) assert.ok(Number(peak) <= limits.pids, `the cgroup never held more than ${limits.pids} (peak ${peak})`);
+ // The sleeps that did start finish on their own; the next checks need the room back.
+ const until = Date.now() + 10_000;
+ while (Number(readMaybe('pids.current')) > 16 && Date.now() < until) await new Promise((res) => setTimeout(res, 100));
+ assert.ok(Number(readMaybe('pids.current')) <= 16, `the sandbox drained (${readMaybe('pids.current')})`);
+ });
+
+ await check('memory.max kills the process that asks for too much, and the sandbox goes on', async () => {
+ const r = await sbx.exec(['node', '-e', 'const a=[];for(let i=0;i<80;i++){a.push(Buffer.alloc(8*1024*1024,1))}console.log("allocated")'], { timeoutMs: 30_000 });
+ assert.ok(r.signal === 'SIGKILL' || r.code === 137, `the allocation was killed: ${r.code}/${r.signal} ${r.stdout}`);
+ assert.ok(!String(r.stdout).includes('allocated'));
+ const after = await sbx.exec(['sh', '-c', 'echo still-here'], { timeoutMs: 10_000 });
+ assert.strictEqual(String(after.stdout).trim(), 'still-here', 'the sandbox survives its own out-of-memory');
+ });
+
+ await check('the disk is a size-limited tmpfs: the workspace, home and /tmp end in ENOSPC', async () => {
+ for (const dir of ['/workspace', '/home/agent', '/tmp']) {
+ const r = await sbx.exec(['sh', '-c', `head -c ${(limits.diskMb + 8) * 1024 * 1024} /dev/zero > ${dir}/big; echo $?; rm -f ${dir}/big`], { timeoutMs: 30_000 });
+ assert.match(String(r.stderr), /No space left on device/, `${dir}: ${r.stderr}`);
+ }
+ });
+
+ await check('the cgroup is removed with the sandbox', async () => {
+ const cg = sbx.cgroup;
+ await sbx.wipe('end');
+ const gone = Date.now() + 5000;
+ while (fs.existsSync(cg) && Date.now() < gone) await new Promise((r) => setTimeout(r, 100));
+ assert.ok(!fs.existsSync(cg), `${cg} is gone`);
+ });
+
+ await world.close();
+ fs.rmSync(runtimeDir, { recursive: true, force: true });
+ console.log(failures ? `\n${failures} failed` : '\nall passed');
+ process.exit(failures ? 1 : 0);
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
+'use strict';
+/**
+ * Codes' side of the chat plays sandbox workers (Agent Streams S6): /internal/sandbox-workers.
+ *
+ * - a heartbeat must be signed with the worker secret: unsigned, wrongly signed, stale and replayed ones are refused,
+ * and without a configured secret nothing can check in;
+ * - a request that came through a proxy is refused (the routes are loopback only);
+ * - health says ready only for a worker that said ready, passed every check, enforces its limits and checked in
+ * recently; a worker that stopped checking in, or failed a check, is not there;
+ * - nothing but the facts is stored, and the health answer holds no secret.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const signature = require('../sandbox/signature');
+const { REQUIRED } = require('../server/domain/sandbox-workers');
+
+const SECRET = 'sandbox-worker-test-secret-0123456789abcdef';
+
+function report(over = {}) {
+ return {
+ worker_id: 'sbw-test', service: 'codes-sandbox', version: '0.7.0', ready: true,
+ checks: Object.fromEntries(REQUIRED.map((c) => [c, true])),
+ isolation: { sandbox: 'bubblewrap', user_namespace: true, network_namespace: true, egress: { via: 'proxy', public_only: true, ports: [80, 443] }, host_credentials: 'none', host_files: 'none', seccomp: true, limits: { kind: 'cgroup', enforced: true, memory_mb: 2048, disk_mb: 512, pids: 256, cpu_percent: 100 } },
+ capacity: 2, active: 0, at: new Date().toISOString(),
+ ...over,
+ };
+}
+
+(async () => {
+ const t = await boot({ env: { CODES_SANDBOX_WORKER_SECRET: `old-secret-${'x'.repeat(20)},${SECRET}` } });
+ const beat = (body, { secret = SECRET, at = Date.now(), headers = {} } = {}) => {
+ const raw = JSON.stringify(body);
+ return t.get('/internal/sandbox-workers/heartbeat', { method: 'POST', body: raw, headers: { 'content-type': 'application/json', [signature.HEADER]: signature.sign(secret, raw, at), ...headers } });
+ };
+ const health = async () => (await t.get('/internal/sandbox-workers/health')).json();
+
+ await check('no worker yet: health says not ready', async () => {
+ const h = await health();
+ assert.strictEqual(h.ready, false);
+ assert.deepStrictEqual(h.workers, []);
+ });
+
+ await check('an unsigned, wrongly signed or stale heartbeat is refused', async () => {
+ const raw = JSON.stringify(report());
+ const unsigned = await t.get('/internal/sandbox-workers/heartbeat', { method: 'POST', body: raw, headers: { 'content-type': 'application/json' } });
+ assert.strictEqual(unsigned.status, 401);
+ assert.strictEqual((await beat(report(), { secret: 'not-the-secret' })).status, 401);
+ assert.strictEqual((await beat(report(), { at: Date.now() - 5 * 60_000 })).status, 401, 'stale');
+ // The signature covers the body: the same signature on another body is refused.
+ const sig = signature.sign(SECRET, raw);
+ const tampered = await t.get('/internal/sandbox-workers/heartbeat', { method: 'POST', body: raw.replace('"active":0', '"active":1'), headers: { 'content-type': 'application/json', [signature.HEADER]: sig } });
+ assert.strictEqual(tampered.status, 401);
+ assert.strictEqual((await health()).ready, false);
+ });
+
+ await check('through a proxy, the internal routes answer nothing', async () => {
+ assert.strictEqual((await beat(report(), { headers: { 'x-forwarded-for': '203.0.113.9' } })).status, 403);
+ assert.strictEqual((await t.get('/internal/sandbox-workers/health', { headers: { 'cf-connecting-ip': '203.0.113.9' } })).status, 403);
+ });
+
+ await check('a signed heartbeat from a healthy worker makes health ready (either secret of a rotation)', async () => {
+ const at = Date.now();
+ const r = await beat(report(), { at });
+ assert.strictEqual(r.status, 200, r.text);
+ const h = await health();
+ assert.strictEqual(h.ready, true);
+ assert.deepStrictEqual(h.workers.map((w) => [w.id, w.healthy, w.room, w.limits]), [['sbw-test', true, 2, 'cgroup']]);
+ const replay = await beat(report(), { at });
+ assert.strictEqual(replay.status, 409, 'the same signed time again is a replay');
+ assert.strictEqual((await beat(report(), { at: at + 1, secret: `old-secret-${'x'.repeat(20)}` })).status, 200, 'the old secret of a rotation still works');
+ });
+
+ await check('a worker that failed a check, does not enforce limits or said not ready is not healthy', async () => {
+ const at = Date.now() + 10;
+ for (const [n, bad] of [
+ [1, report({ checks: { ...report().checks, egress_refuses_private: false } })],
+ [2, report({ isolation: { ...report().isolation, limits: { kind: 'none', enforced: false } } })],
+ [3, report({ ready: false })],
+ [4, report({ isolation: { ...report().isolation, network_namespace: false } })],
+ ]) {
+ assert.strictEqual((await beat(bad, { at: at + n })).status, 200);
+ const h = await health();
+ assert.strictEqual(h.ready, false, `case ${n}`);
+ assert.ok(h.workers[0].failing.length, `case ${n} says why`);
+ }
+ assert.strictEqual((await beat(report(), { at: at + 10 })).status, 200);
+ assert.strictEqual((await health()).ready, true);
+ });
+
+ await check('a worker that stopped checking in is not there', async () => {
+ await t.ctx.store.db.exec("UPDATE codes_sandbox_workers SET last_seen_at = now() - interval '10 minutes'");
+ const h = await health();
+ assert.strictEqual(h.ready, false);
+ assert.deepStrictEqual(h.workers[0].failing, ['stale']);
+ });
+
+ await check('only the facts are stored, and health holds no secret', async () => {
+ await beat(report({ secret: SECRET, endpoint: '/run/x.sock', isolation: { ...report().isolation, extra: 'http://10.0.0.1/' } }), { at: Date.now() + 100 });
+ const dump = await t.dbDump();
+ assert.ok(!dump.includes(SECRET), 'the secret is not stored');
+ assert.ok(!dump.includes('/run/x.sock') && !dump.includes('10.0.0.1'), 'nothing but the facts is stored');
+ const text = (await t.get('/internal/sandbox-workers/health')).text;
+ assert.ok(!text.includes(SECRET) && !text.includes('old-secret'));
+ });
+
+ await t.close();
+
+ await check('without a configured secret no worker can check in', async () => {
+ const u = await boot();
+ const raw = JSON.stringify(report());
+ const r = await u.get('/internal/sandbox-workers/heartbeat', { method: 'POST', body: raw, headers: { 'content-type': 'application/json', [signature.HEADER]: signature.sign(SECRET, raw) } });
+ assert.strictEqual(r.status, 503);
+ assert.strictEqual((await u.get('/internal/sandbox-workers/health')).json().ready, false);
+ await u.close();
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
+'use strict';
+/**
+ * The agent's tools in the chat plays sandbox (Agent Streams S6, design 2026-11-06 §1): a terminal, an editor and a
+ * browser, all inside the sandbox (sandbox/session.js, sandbox/supervisor.js).
+ *
+ * - the terminal runs as the sandbox's own user in /workspace, takes input, and can have a terminal device;
+ * - the editor reads, writes and lists files in the workspace only (a path outside it is refused);
+ * - the browser (headless Chromium, started inside) reaches the public web through the egress proxy, and a page
+ * that points at the host or a private address gets nothing from it.
+ *
+ * It needs bubblewrap (and, for the browser, a headless Chromium: CODES_SANDBOX_BROWSER, or Playwright's
+ * chrome-headless-shell). Without them it prints `sandbox tools: skipped (<why>)`.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const { startWorld, shortRuntimeDir } = require('./helpers/sandbox-world');
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }
+}
+
+(async () => {
+ const { probeSupport, createSandbox } = require('../sandbox/session');
+ const { findBrowser } = require('../sandbox/browser');
+ const support = await probeSupport();
+ if (!support.ok) { console.log(`sandbox tools: skipped (${support.reason})`); process.exit(0); }
+ const browserPath = findBrowser();
+ console.log(`sandbox tools (browser: ${browserPath || 'none'})`);
+
+ const world = await startWorld();
+ const runtimeDir = shortRuntimeDir();
+ const sbx = await createSandbox({
+ runtimeDir, egress: world.egress, allowUnlimited: true, limiter: null, browserPath,
+ limits: { memoryMb: 1024, diskMb: 128, pids: 256, cpuPercent: 100 }, timeBoxMs: 5 * 60_000,
+ });
+
+ await check('the terminal: the sandbox\'s own user, in /workspace, with input', async () => {
+ const who = await sbx.exec(['sh', '-c', 'id -un; id -u; pwd; uname -n'], { timeoutMs: 10_000 });
+ assert.deepStrictEqual(String(who.stdout).trim().split('\n'), ['agent', '1000', '/workspace', 'sandbox']);
+ const shell = await sbx.exec(['sh', '-s'], { stdin: 'mkdir -p snake && cd snake && echo "<canvas></canvas>" > index.html && ls\n', timeoutMs: 10_000 });
+ assert.strictEqual(String(shell.stdout).trim(), 'index.html');
+ const failed = await sbx.exec(['sh', '-c', 'echo oops >&2; exit 3'], { timeoutMs: 10_000 });
+ assert.strictEqual(failed.code, 3);
+ assert.strictEqual(String(failed.stderr).trim(), 'oops');
+ });
+
+ await check('the terminal can have a terminal device, and a command that runs too long is stopped', async () => {
+ const tty = await sbx.exec(['tty'], { tty: true, timeoutMs: 10_000 });
+ assert.match(String(tty.stdout), /\/dev\/pts\/\d+/, `a pty: ${tty.stdout}${tty.stderr}`);
+ const slow = await sbx.exec(['sleep', '30'], { timeoutMs: 500 });
+ assert.strictEqual(slow.signal, 'SIGKILL');
+ assert.strictEqual(slow.timedOut, true);
+ });
+
+ await check('the editor: read, write and list in the workspace; nothing outside it', async () => {
+ await sbx.writeFile('/workspace/snake/index.html', '<canvas></canvas>\n');
+ await sbx.writeFile('/workspace/snake/game.js', 'let score = 0;\n');
+ assert.strictEqual(String(await sbx.readFile('/workspace/snake/game.js')), 'let score = 0;\n');
+ assert.strictEqual(String(await sbx.readFile('snake/game.js')), 'let score = 0;\n', 'a relative path is in the workspace');
+ const names = (await sbx.list('/workspace/snake')).map((e) => `${e.name}:${e.type}`).sort();
+ assert.deepStrictEqual(names, ['game.js:file', 'index.html:file']);
+ for (const p of ['/etc/passwd', '/workspace/../etc/passwd', '../etc/hosts', '/proc/self/environ', '/opt/ov/supervisor.js']) {
+ await assert.rejects(() => sbx.readFile(p), /outside the workspace/, `${p} is refused`);
+ }
+ await assert.rejects(() => sbx.writeFile('/workspace/../tmp/x', 'x'), /outside the workspace/);
+ const editors = await sbx.exec(['sh', '-c', 'for e in vim vi nano; do command -v $e; done'], { timeoutMs: 10_000 });
+ assert.ok(String(editors.stdout).trim(), 'a terminal editor is there too');
+ });
+
+ await check('the browser opens the public web through the proxy, and nothing of the host', async () => {
+ if (!browserPath) { console.log('sandbox tools: skipped (no headless Chromium: set CODES_SANDBOX_BROWSER or install Playwright\'s chrome-headless-shell)'); return; }
+ const page = await sbx.openPage('http://origin.example/page');
+ assert.strictEqual(page.ok, true, JSON.stringify(page).slice(0, 300));
+ assert.strictEqual(page.title, 'Origin page');
+ assert.match(page.text, /origin-ok/);
+ assert.ok(page.screenshot && page.screenshot.length > 100, 'a screenshot of the page');
+ for (const url of [`http://127.0.0.1:${world.canaryPort}/`, `http://localhost:${world.canaryPort}/`, 'http://169.254.169.254/latest/meta-data/', 'http://private.example/',
+ ...world.hostLan.map((ip) => `http://${ip}:${world.canaryPort}/`), `https://origin.example:${world.canaryPort}/`]) {
+ const r = await sbx.openPage(url);
+ assert.ok(!String(r.text || '').includes(world.canary), `${url} showed the host's canary`);
+ }
+ assert.strictEqual(world.hits.canary, 0, 'the host\'s canary server was never reached by the browser');
+ assert.deepStrictEqual(world.hits.unexpectedDials, []);
+ });
+
+ await sbx.wipe('end');
+ await world.close();
+ fs.rmSync(runtimeDir, { recursive: true, force: true });
+ console.log(failures ? `\n${failures} failed` : '\nall passed');
+ process.exit(failures ? 1 : 0);
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,318 @@
@@ -0,0 +1,318 @@
+'use strict';
+/**
+ * The chat plays sandbox worker's parts that need no bubblewrap (these run everywhere, CI included; the real sandbox
+ * is tried in sandbox-escape, sandbox-limits and sandbox-tools):
+ *
+ * - the bubblewrap command line: every namespace, nothing of the host but the read-only list, nothing writable but
+ * the tmpfs areas, a cleared environment;
+ * - the seccomp program: the architecture check first, the denied calls, nothing for an unknown architecture;
+ * - the heartbeat signature;
+ * - the supervisor's protocol (run here without a sandbox): the terminal, the editor confined to the workspace,
+ * time-outs and capped output;
+ * - the worker: ready only with the self-test and the limits; its check-ins to Codes (signed) and Live (its own
+ * service token, every stream it serves) carry the isolation facts and no secret; its local API; the time box
+ * bounds; and no room means no new sandbox.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const os = require('os');
+const path = require('path');
+const { spawn } = require('child_process');
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }
+}
+
+(async () => {
+ console.log('sandbox worker');
+ const { buildArgs, etcFiles } = require('../sandbox/bwrap');
+ const seccomp = require('../sandbox/seccomp');
+ const signature = require('../sandbox/signature');
+ const { values } = require('../sandbox/limits');
+ const { createWorker, loadConfig } = require('../sandbox/worker');
+ const tmp = fs.mkdtempSync(path.join('/tmp', 'ovsbx-unit-'));
+
+ await check('bubblewrap: every namespace, no capability, a cleared environment, killed with the worker', () => {
+ const { args } = buildArgs({ sessionEtc: tmp, netDir: tmp, supervisorPath: path.join(__dirname, '..', 'sandbox', 'supervisor.js'), diskMb: 64, seccompFd: 3 });
+ for (const a of ['--unshare-user', '--unshare-pid', '--unshare-net', '--unshare-ipc', '--unshare-uts', '--unshare-cgroup', '--disable-userns',
+ '--die-with-parent', '--new-session', '--clearenv']) assert.ok(args.includes(a), `${a} is there`);
+ assert.ok(!args.includes('--share-net'), 'the host network is never shared');
+ assert.deepStrictEqual(args.slice(args.indexOf('--cap-drop'), args.indexOf('--cap-drop') + 2), ['--cap-drop', 'ALL']);
+ assert.deepStrictEqual(args.slice(args.indexOf('--seccomp'), args.indexOf('--seccomp') + 2), ['--seccomp', '3']);
+ assert.ok(args.indexOf('--remount-ro') >= 0 && args.includes('/'), 'the root is read-only');
+ });
+
+ await check('bubblewrap: nothing of the host but the read-only list; writable areas are tmpfs only', () => {
+ const { args, env } = buildArgs({ sessionEtc: tmp, netDir: tmp, supervisorPath: path.join(__dirname, '..', 'sandbox', 'supervisor.js'), diskMb: 64 });
+ const binds = [];
+ for (let i = 0; i < args.length; i++) {
+ if (['--bind', '--dev-bind', '--bind-try', '--dev-bind-try'].includes(args[i])) assert.fail(`a writable bind: ${args.slice(i, i + 3).join(' ')}`);
+ if (args[i] === '--ro-bind' || args[i] === '--ro-bind-try') binds.push([args[i + 1], args[i + 2]]);
+ }
+ const forbidden = ['/', '/home', '/root', '/etc', '/var', '/run', '/opt', '/srv', '/mnt', '/media', '/sys', '/proc', '/dev', os.homedir(), '/etc/ssl', '/etc/ssl/private', '/etc/openvibe', '/etc/shadow', '/etc/passwd', '/etc/ssh'];
+ for (const [src] of binds) assert.ok(!forbidden.includes(src), `${src} is never bound`);
+ const allowedTargets = /^(\/usr|\/etc\/.+|\/run\/ov\/net|\/opt\/ov\/supervisor\.js|\/opt\/node|\/opt\/browser|\/(bin|sbin|lib|lib32|lib64|libx32))$/;
+ for (const [, dest] of binds) assert.match(dest, allowedTargets, `${dest} is on the list`);
+ const tmpfs = args.map((a, i) => (a === '--tmpfs' ? args[i + 1] : null)).filter(Boolean).sort();
+ assert.deepStrictEqual(tmpfs.filter((p) => p !== '/usr/local'), ['/dev/shm', '/home/agent', '/tmp', '/workspace']);
+ for (const p of ['/tmp', '/home/agent', '/workspace']) {
+ const i = args.findIndex((a, k) => a === '--tmpfs' && args[k + 1] === p);
+ assert.deepStrictEqual(args.slice(i - 2, i), ['--size', String(64 * 1024 * 1024)], `${p} is size-limited`);
+ }
+ assert.deepStrictEqual(Object.keys(env).filter((k) => /SECRET|TOKEN|KEY|PASS|DATABASE|AWS|GITHUB/i.test(k)), [], 'no secret-shaped variable');
+ assert.strictEqual(env.HOME, '/home/agent');
+ assert.strictEqual(env.https_proxy, 'http://127.0.0.1:3128');
+ });
+
+ await check('the sandbox\'s own /etc: its user list and no DNS', () => {
+ const f = etcFiles();
+ assert.deepStrictEqual(f.passwd.trim().split('\n').map((l) => l.split(':')[0]), ['agent', 'nobody']);
+ assert.ok(!/nameserver/.test(f['resolv.conf']), 'no nameserver: names resolve at the proxy');
+ });
+
+ await check('seccomp: the architecture is checked first, the denied calls answer EPERM, an unknown one has no filter', () => {
+ for (const arch of ['x64', 'arm64']) {
+ const prog = seccomp.compile(arch);
+ assert.strictEqual(prog.length % 8, 0);
+ assert.strictEqual(prog.readUInt16LE(0), 0x20, 'it loads the architecture first');
+ assert.strictEqual(prog.readUInt32LE(4), 4);
+ assert.strictEqual(prog.readUInt32LE(12), seccomp.AUDIT_ARCH[arch]);
+ assert.strictEqual(prog.readUInt32LE(20), 0x80000000, 'another architecture kills the process');
+ assert.strictEqual(prog.readUInt32LE(prog.length - 4), 0x7fff0000, 'the rest is allowed');
+ for (const name of ['keyctl', 'add_key', 'ptrace', 'unshare', 'setns', 'mount', 'bpf', 'io_uring_setup', 'perf_event_open']) assert.ok(seccomp.denied(arch).includes(name), `${arch} denies ${name}`);
+ }
+ assert.strictEqual(seccomp.compile('ia32'), null);
+ const header = '/usr/include/x86_64-linux-gnu/asm/unistd_64.h';
+ if (fs.existsSync(header)) {
+ const nr = Object.fromEntries([...fs.readFileSync(header, 'utf8').matchAll(/#define __NR_(\w+)\s+(\d+)/g)].map((m) => [m[1], Number(m[2])]));
+ for (const [name, n] of Object.entries(seccomp.SYSCALLS.x64)) assert.strictEqual(nr[name], n, `x86-64 ${name} is ${nr[name]}`);
+ }
+ });
+
+ await check('the heartbeat signature: the secret, the body and the time all count', () => {
+ const body = '{"worker_id":"sbw-x"}';
+ const h = signature.sign('s3cret', body, 1_700_000_000_000);
+ assert.deepStrictEqual(signature.verify({ secrets: ['s3cret'], header: h, body, now: 1_700_000_000_500 }), { ok: true, t: 1_700_000_000_000 });
+ assert.strictEqual(signature.verify({ secrets: ['other'], header: h, body, now: 1_700_000_000_500 }).code, 'signature.invalid');
+ assert.strictEqual(signature.verify({ secrets: ['s3cret'], header: h, body: `${body} `, now: 1_700_000_000_500 }).code, 'signature.invalid');
+ assert.strictEqual(signature.verify({ secrets: ['s3cret'], header: h, body, now: 1_700_000_100_000 }).code, 'signature.stale');
+ assert.strictEqual(signature.verify({ secrets: [], header: h, body }).code, 'sandbox.not_configured');
+ assert.strictEqual(signature.verify({ secrets: ['s3cret'], header: 'v1=abc', body }).code, 'signature.missing');
+ });
+
+ await check('the cgroup values: memory counts the tmpfs areas, cpu is a quota of 100 ms', () => {
+ assert.deepStrictEqual(values({ memoryMb: 1024, diskMb: 256, pids: 128, cpuPercent: 150 }), { memory: (1024 + 768) * 1024 * 1024, pids: 128, cpu: '150000 100000', cpuPercent: 150 });
+ });
+
+ // ── The supervisor, run here without a sandbox (its protocol, not its isolation) ──
+ const ws = path.join(tmp, 'workspace');
+ fs.mkdirSync(ws);
+ fs.writeFileSync(path.join(tmp, 'outside.txt'), 'outside');
+ fs.symlinkSync(path.join(tmp, 'outside.txt'), path.join(ws, 'link-out'));
+ const sup = spawn(process.execPath, [path.join(__dirname, '..', 'sandbox', 'supervisor.js')], {
+ env: { PATH: process.env.PATH, OV_SANDBOX_WORKSPACE: ws, OV_SANDBOX_PROXY_PORT: '0' }, stdio: ['pipe', 'pipe', 'pipe'],
+ });
+ const frames = [];
+ const waiters = [];
+ let sbuf = '';
+ sup.stdout.on('data', (d) => {
+ sbuf += d;
+ let i;
+ while ((i = sbuf.indexOf('\n')) >= 0) {
+ const f = JSON.parse(sbuf.slice(0, i));
+ sbuf = sbuf.slice(i + 1);
+ frames.push(f);
+ for (const w of [...waiters]) if (w.test(f)) { waiters.splice(waiters.indexOf(w), 1); w.resolve(f); }
+ }
+ });
+ const waitFrame = (test) => new Promise((resolve) => { const hit = frames.find(test); if (hit) resolve(hit); else waiters.push({ test, resolve }); });
+ let seq = 0;
+ const ask = async (msg) => {
+ const id = ++seq;
+ sup.stdin.write(`${JSON.stringify({ ...msg, id })}\n`);
+ const end = await waitFrame((f) => f.id === id && (f.ev === 'exit' || f.ev === 'done'));
+ const out = Buffer.concat(frames.filter((f) => f.id === id && f.ev === 'out' && f.fd === 1).map((f) => Buffer.from(f.data, 'base64'))).toString();
+ return { ...end, out };
+ };
+ await waitFrame((f) => f.ev === 'ready');
+
+ await check('the supervisor: a command runs in the workspace, with input, and its exit code comes back', async () => {
+ const r = await ask({ op: 'exec', argv: ['sh', '-c', 'pwd; cat; exit 4'], stdin: Buffer.from('hello').toString('base64') });
+ assert.strictEqual(r.code, 4);
+ assert.strictEqual(r.out, `${fs.realpathSync(ws)}\nhello`);
+ });
+
+ await check('the supervisor: a command that runs too long is killed, and its output is capped', async () => {
+ const slow = await ask({ op: 'exec', argv: ['sleep', '20'], timeoutMs: 300 });
+ assert.strictEqual(slow.signal, 'SIGKILL');
+ assert.strictEqual(slow.timedOut, true);
+ const big = await ask({ op: 'exec', argv: ['sh', '-c', 'head -c 300000 /dev/zero'], maxOutput: 1000 });
+ assert.strictEqual(big.out.length, 1000);
+ assert.strictEqual(big.truncated, true);
+ });
+
+ await check('the supervisor: the editor writes, reads and lists the workspace, and refuses anything outside it', async () => {
+ assert.strictEqual((await ask({ op: 'write', path: 'game/index.html', data: Buffer.from('<canvas>').toString('base64') })).ok, true);
+ const r = await ask({ op: 'read', path: '/workspace/game/index.html'.replace('/workspace', ws) });
+ assert.strictEqual(Buffer.from(r.data, 'base64').toString(), '<canvas>');
+ const l = await ask({ op: 'list', path: 'game' });
+ assert.deepStrictEqual(l.entries.map((e) => e.name), ['index.html']);
+ for (const p of ['../outside.txt', '/etc/passwd', 'link-out', `${ws}/../outside.txt`]) {
+ const x = await ask({ op: 'read', path: p });
+ assert.strictEqual(x.ok, false, `${p} is refused`);
+ assert.match(x.error, /outside the workspace/);
+ }
+ const w = await ask({ op: 'write', path: 'link-out', data: Buffer.from('x').toString('base64') });
+ assert.strictEqual(w.ok, false, 'a write through a link out of the workspace is refused');
+ assert.strictEqual(fs.readFileSync(path.join(tmp, 'outside.txt'), 'utf8'), 'outside');
+ assert.strictEqual((await ask({ op: 'exec', argv: ['true'], cwd: '/etc' })).code, 127, 'a working directory outside the workspace is refused');
+ });
+
+ await check('the supervisor: an unknown request is an error, garbage is ignored, and the process lives on', async () => {
+ sup.stdin.write('not json\n{"x":1}\n');
+ assert.strictEqual((await ask({ op: 'format-disk' })).ok, false);
+ assert.strictEqual((await ask({ op: 'ping' })).ok, true);
+ });
+ sup.stdin.end();
+
+ // ── The worker, with a fake sandbox and fake neighbours ──
+ const calls = [];
+ const fakeFetch = async (url, init) => { calls.push({ url, init }); return { status: 200, text: async () => '' }; };
+ const CODES_SECRET = 'codes-secret-for-the-test-0123456789';
+ const fakeSandboxes = [];
+ const fakeCreate = async (o) => {
+ const sbx = {
+ id: o.id, state: 'running', opts: o,
+ info: () => ({ id: o.id, state: sbx.state, ends_at: new Date(Date.now() + o.timeBoxMs).toISOString() }),
+ wipe: async (reason) => { sbx.state = 'wiped'; endResolve({ reason }); return { reason }; },
+ exec: async (argv) => ({ code: 0, signal: null, stdout: argv.join(' '), stderr: '' }),
+ };
+ let endResolve;
+ sbx.ended = new Promise((r) => { endResolve = r; });
+ fakeSandboxes.push(sbx);
+ return sbx;
+ };
+ const config = {
+ ...loadConfig({ CODES_SANDBOX_RUNTIME_DIR: tmp, CODES_SANDBOX_LIVE_STREAMS: 'ags_owner1,bad stream!,ags_owner2', CODES_SANDBOX_LIVE_URL: 'http://live.test', CODES_SANDBOX_CODES_URL: 'http://codes.test', CODES_SANDBOX_WORKER_SECRET: CODES_SECRET, CODES_SANDBOX_MAX_SESSIONS: '2' }),
+ socket: path.join(tmp, 'w.sock'),
+ };
+ const passed = { ok: true, checks: Object.fromEntries(require('../sandbox/selftest').CHECKS.map((c) => [c, true])), failed: [], at: new Date().toISOString() };
+ const make = (over = {}) => createWorker({
+ config, createSandbox: fakeCreate, fetchImpl: fakeFetch, log: { log() {}, error() {} },
+ support: { ok: true, version: '0.9.0', seccomp: true }, limiter: { ok: true, kind: 'cgroup' },
+ runSelfTest: async () => passed, tokens: { getToken: async () => 'svc-token-for-live' }, ...over,
+ });
+
+ await check('the worker is ready only with the self-test passed and the limits there', async () => {
+ const w = make();
+ await w.selfTest();
+ assert.strictEqual(w.health().ready, true);
+ const noLimits = make({ limiter: { ok: false, kind: 'none', reason: 'no cgroup' } });
+ await noLimits.selfTest();
+ assert.strictEqual(noLimits.health().ready, false);
+ assert.ok(noLimits.health().reasons.some((r) => /limits/.test(r)));
+ const failing = make({ runSelfTest: async () => ({ ...passed, ok: false, failed: ['egress_refuses_private'], checks: { ...passed.checks, egress_refuses_private: false } }) });
+ await failing.selfTest();
+ assert.strictEqual(failing.health().ready, false);
+ assert.strictEqual(failing.health().isolation.egress.public_only, false);
+ await assert.rejects(() => failing.openSession({}), /not ready/, 'a worker that is not ready makes no sandbox');
+ });
+
+ await check('the check-ins: Codes signed, Live with the worker\'s own token for every stream it serves, the facts and no secret', async () => {
+ const w = make();
+ await w.selfTest();
+ calls.length = 0;
+ await w.openSession({ stream_id: 'ags_session3' });
+ await w.announce();
+ const codes = calls.filter((c) => c.url === 'http://codes.test/internal/sandbox-workers/heartbeat');
+ assert.strictEqual(codes.length, 1);
+ assert.strictEqual(signature.verify({ secrets: [CODES_SECRET], header: codes[0].init.headers[signature.HEADER], body: codes[0].init.body }).ok, true);
+ const live = calls.filter((c) => c.url.startsWith('http://live.test/')).map((c) => c.url).sort();
+ assert.deepStrictEqual(live, ['http://live.test/api/streams/agent/ags_owner1/plays/worker', 'http://live.test/api/streams/agent/ags_owner2/plays/worker', 'http://live.test/api/streams/agent/ags_session3/plays/worker']);
+ for (const c of calls.filter((x) => x.url.startsWith('http://live.test/'))) assert.strictEqual(c.init.headers.authorization, 'Bearer svc-token-for-live');
+ const body = JSON.parse(calls[0].init.body);
+ assert.strictEqual(body.ready, true);
+ assert.strictEqual(body.isolation.network_namespace, true);
+ assert.strictEqual(body.isolation.host_credentials, 'none');
+ assert.strictEqual(body.isolation.limits.enforced, true);
+ for (const c of calls) {
+ assert.ok(!String(c.init.body).includes(CODES_SECRET), 'the Codes secret never travels');
+ assert.ok(!String(c.init.body).includes(tmp), 'no path of the host');
+ assert.ok(!/svc-token/.test(String(c.init.body)), 'no token in a body');
+ }
+ });
+
+ await check('no service token, no Live check-in; no Codes secret, no heartbeat', async () => {
+ const w = make({ tokens: null, config: { ...config, codes: { url: 'http://codes.test', secret: '' } } });
+ await w.selfTest();
+ calls.length = 0;
+ await w.announce();
+ assert.deepStrictEqual(calls, []);
+ });
+
+ await check('a session\'s time box is the owner\'s 5 to 30 minutes, and no room means no new sandbox', async () => {
+ const w = make();
+ await w.selfTest();
+ fakeSandboxes.length = 0;
+ const a = await w.openSession({ time_box_minutes: 90 });
+ const b = await w.openSession({ time_box_minutes: 1 });
+ assert.strictEqual(fakeSandboxes[0].opts.timeBoxMs, 30 * 60_000);
+ assert.strictEqual(fakeSandboxes[1].opts.timeBoxMs, 5 * 60_000);
+ await assert.rejects(() => w.openSession({}), /no room/);
+ await assert.rejects(() => w.openSession({ stream_id: '../x' }), /no room|bad stream/);
+ await a.sbx.wipe('end');
+ await w.openSession({});
+ await b.sbx.wipe('end');
+ });
+
+ await check('the local API: a Unix socket only the worker\'s user and group may use; health, sessions, Wipe now', async () => {
+ const w = make();
+ fs.mkdirSync(path.join(tmp, 's-stale0001', 'net'), { recursive: true });
+ await w.start();
+ try {
+ assert.ok(!fs.existsSync(path.join(tmp, 's-stale0001')), 'a crashed worker\'s leftover sandbox directory is swept at start');
+ assert.strictEqual(fs.statSync(config.socket).mode & 0o777, 0o660);
+ const req = (method, p, body) => new Promise((resolve, reject) => {
+ const r = http.request({ socketPath: config.socket, method, path: p, headers: { 'content-type': 'application/json' } }, (res) => {
+ let t = ''; res.on('data', (d) => { t += d; }); res.on('end', () => resolve({ status: res.statusCode, json: t ? JSON.parse(t) : null }));
+ });
+ r.on('error', reject);
+ r.end(body ? JSON.stringify(body) : undefined);
+ });
+ const h = await req('GET', '/health');
+ assert.strictEqual(h.json.ready, true);
+ const s = await req('POST', '/sessions', { stream_id: 'ags_x', time_box_minutes: 10 });
+ assert.strictEqual(s.status, 201);
+ const e = await req('POST', `/sessions/${s.json.id}/exec`, { argv: ['echo', 'hi'] });
+ assert.strictEqual(e.json.stdout, 'echo hi');
+ const wiped = await req('POST', `/sessions/${s.json.id}/wipe`);
+ assert.strictEqual(wiped.json.reason, 'wipe_now');
+ assert.strictEqual(wiped.json.state, 'wiped');
+ assert.strictEqual((await req('GET', '/sessions/zzzzzzzzzz')).status, 404);
+ assert.strictEqual((await req('GET', '/sessions/../../etc')).status, 404);
+ } finally { await w.stop(); }
+ assert.ok(!fs.existsSync(config.socket), 'the socket is removed when the worker stops');
+ });
+
+ await check('the command line: --check prints the health and fails when the worker would not be ready (no limits here)', async () => {
+ const run = spawn(process.execPath, [path.join(__dirname, '..', 'bin', 'openvibe-sandbox-worker.js'), '--check'], {
+ env: { PATH: process.env.PATH, CODES_SANDBOX_RUNTIME_DIR: path.join(tmp, 'check'), CODES_SANDBOX_LIMITS: 'none', CODES_SANDBOX_WORKER_SECRET: 'not-printed-0123456789' },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let out = '';
+ run.stdout.on('data', (d) => { out += d; });
+ run.stderr.on('data', () => {});
+ const code = await new Promise((r) => run.on('exit', r));
+ assert.strictEqual(code, 1, 'not ready without limits');
+ const h = JSON.parse(out);
+ assert.strictEqual(h.ready, false);
+ assert.ok(h.reasons.some((r) => /limits/.test(r)), h.reasons.join('; '));
+ assert.ok(!out.includes('not-printed-0123456789'), 'the secret is not printed');
+ });
+
+ fs.rmSync(tmp, { recursive: true, force: true });
+ console.log(failures ? `\n${failures} failed` : '\nall passed');
+ process.exit(failures ? 1 : 0);
+})().catch((e) => { console.error(e); process.exit(1); });