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)

OP OpenVibers committed 6 days ago · 6c3365505e · 28 files, +3466 −0

* Chat plays: the sandbox worker a viewer's request runs in (bubblewrap, an egress proxy on a socket, cgroup limits, Wipe now)

Agent Streams S6 (design 2026-11-06 §1, §3, §5): one sandbox per session, made with
bubblewrap unprivileged: its own user, pid, network, ipc, uts and cgroup namespaces, no
nested user namespace, no capability, a seccomp filter, a cleared environment, a read-only
root of /usr and a short /etc list, and size-limited tmpfs for the workspace, home and /tmp.
Its only way out is the worker's HTTP proxy on a Unix socket, which applies the egress
guard (openvibe-shared/egress) after DNS resolution and on every redirect hop: public
http and https on ports 80 and 443 only, never a private, loopback, link-local, CGNAT or
metadata address, never the host's own. CPU, memory and process count come from a cgroup
of its own (Delegate=yes in the unit, or a user scope on a workstation); the time box and
Wipe now kill everything in it and leave nothing on the host.

The worker self-tests from inside a real sandbox, checks in every 30 s with Live (its own
Network service token, live.agent_sandbox.register) and Codes (signed heartbeat,
/internal/sandbox-workers, migration 0028), and serves sessions, the terminal, the editor
and a headless browser on a local socket. The escape tests came first: from inside, every
private target, spelling, rebinding name, redirect, SMTP and other port, the host's
environment, ~/.ssh, the worker's token, abstract sockets and fds are refused, and Wipe now
leaves no process, directory, socket or cgroup. deploy/sandbox has the unit, the env
example, the AppArmor profile for bwrap and the install script; nothing is installed.

* Sandbox worker: sweep a crashed worker's leftover sandbox directories and empty cgroups at start

The processes died with the worker (bubblewrap's --die-with-parent); their session
directories and empty sbx-* cgroups did not. The worker removes both before its self-test.

* Sandbox tools test: uname -n (a CI image has no hostname binary), and the editor check makes its own files

Changes

ddiff --git a/.env.example b/.env.example
iindex 81e23d0..a171d59 100644
---- a/.env.example
++++ b/.env.example
@@@ -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=90000
ddiff --git a/bin/openvibe-sandbox-worker.js b/bin/openvibe-sandbox-worker.js
nnew file mode 100755
iindex 0000000..3a71f04
---- /dev/null
++++ b/bin/openvibe-sandbox-worker.js
@@@ -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); });
ddiff --git a/deploy/sandbox/apparmor-bwrap b/deploy/sandbox/apparmor-bwrap
nnew file mode 100644
iindex 0000000..366be09
---- /dev/null
++++ b/deploy/sandbox/apparmor-bwrap
@@@ -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>
++}
ddiff --git a/deploy/sandbox/install.sh b/deploy/sandbox/install.sh
nnew file mode 100755
iindex 0000000..b5bd793
---- /dev/null
++++ b/deploy/sandbox/install.sh
@@@ -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
ddiff --git a/deploy/sandbox/openvibe-codes-sandbox.service b/deploy/sandbox/openvibe-codes-sandbox.service
nnew file mode 100644
iindex 0000000..2634a04
---- /dev/null
++++ b/deploy/sandbox/openvibe-codes-sandbox.service
@@@ -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
ddiff --git a/deploy/sandbox/sandbox-worker.env.example b/deploy/sandbox/sandbox-worker.env.example
nnew file mode 100644
iindex 0000000..0511f5a
---- /dev/null
++++ b/deploy/sandbox/sandbox-worker.env.example
@@@ -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
ddiff --git a/migrations/0028_sandbox_workers.sql b/migrations/0028_sandbox_workers.sql
nnew file mode 100644
iindex 0000000..fc2dc1d
---- /dev/null
++++ b/migrations/0028_sandbox_workers.sql
@@@ -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()
++);
ddiff --git a/sandbox/browser.js b/sandbox/browser.js
nnew file mode 100644
iindex 0000000..19bc568
---- /dev/null
++++ b/sandbox/browser.js
@@@ -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 };
ddiff --git a/sandbox/bwrap.js b/sandbox/bwrap.js
nnew file mode 100644
iindex 0000000..4995b2e
---- /dev/null
++++ b/sandbox/bwrap.js
@@@ -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 };
ddiff --git a/sandbox/egress-proxy.js b/sandbox/egress-proxy.js
nnew file mode 100644
iindex 0000000..ac02470
---- /dev/null
++++ b/sandbox/egress-proxy.js
@@@ -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 };
ddiff --git a/sandbox/limits.js b/sandbox/limits.js
nnew file mode 100644
iindex 0000000..744b04b
---- /dev/null
++++ b/sandbox/limits.js
@@@ -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 };
ddiff --git a/sandbox/seccomp.js b/sandbox/seccomp.js
nnew file mode 100644
iindex 0000000..6dfab2e
---- /dev/null
++++ b/sandbox/seccomp.js
@@@ -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 };
ddiff --git a/sandbox/selftest.js b/sandbox/selftest.js
nnew file mode 100644
iindex 0000000..ee7e934
---- /dev/null
++++ b/sandbox/selftest.js
@@@ -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 };
ddiff --git a/sandbox/session.js b/sandbox/session.js
nnew file mode 100644
iindex 0000000..6f7e3f8
---- /dev/null
++++ b/sandbox/session.js
@@@ -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 };
ddiff --git a/sandbox/signature.js b/sandbox/signature.js
nnew file mode 100644
iindex 0000000..e4e777a
---- /dev/null
++++ b/sandbox/signature.js
@@@ -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 };
ddiff --git a/sandbox/supervisor.js b/sandbox/supervisor.js
nnew file mode 100644
iindex 0000000..9a2c849
---- /dev/null
++++ b/sandbox/supervisor.js
@@@ -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();
++}
ddiff --git a/sandbox/worker.js b/sandbox/worker.js
nnew file mode 100644
iindex 0000000..a8b2d1f
---- /dev/null
++++ b/sandbox/worker.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex c5daa73..c78bd41 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 }));
ddiff --git a/server/config.js b/server/config.js
iindex 530d1d5..5918768 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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)),
++ },
};
}
ddiff --git a/server/domain/sandbox-workers.js b/server/domain/sandbox-workers.js
nnew file mode 100644
iindex 0000000..752f889
---- /dev/null
++++ b/server/domain/sandbox-workers.js
@@@ -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 };
ddiff --git a/server/http/sandbox-workers.js b/server/http/sandbox-workers.js
nnew file mode 100644
iindex 0000000..d3b55b5
---- /dev/null
++++ b/server/http/sandbox-workers.js
@@@ -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 };
ddiff --git a/test/helpers/sandbox-world.js b/test/helpers/sandbox-world.js
nnew file mode 100644
iindex 0000000..86c43c9
---- /dev/null
++++ b/test/helpers/sandbox-world.js
@@@ -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,
++};
ddiff --git a/test/sandbox-egress-proxy.test.js b/test/sandbox-egress-proxy.test.js
nnew file mode 100644
iindex 0000000..f824b2f
---- /dev/null
++++ b/test/sandbox-egress-proxy.test.js
@@@ -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); });
ddiff --git a/test/sandbox-escape.test.js b/test/sandbox-escape.test.js
nnew file mode 100644
iindex 0000000..35e67f0
---- /dev/null
++++ b/test/sandbox-escape.test.js
@@@ -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); });
ddiff --git a/test/sandbox-limits.test.js b/test/sandbox-limits.test.js
nnew file mode 100644
iindex 0000000..8577be6
---- /dev/null
++++ b/test/sandbox-limits.test.js
@@@ -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); });
ddiff --git a/test/sandbox-registry.test.js b/test/sandbox-registry.test.js
nnew file mode 100644
iindex 0000000..e14ee25
---- /dev/null
++++ b/test/sandbox-registry.test.js
@@@ -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); });
ddiff --git a/test/sandbox-tools.test.js b/test/sandbox-tools.test.js
nnew file mode 100644
iindex 0000000..6fbda50
---- /dev/null
++++ b/test/sandbox-tools.test.js
@@@ -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); });
ddiff --git a/test/sandbox-worker.test.js b/test/sandbox-worker.test.js
nnew file mode 100644
iindex 0000000..1363cb4
---- /dev/null
++++ b/test/sandbox-worker.test.js
@@@ -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); });