feat(dev-sandbox): support fake installer / fake main / git clones

allow you to simulate the whole official curl | bash installer,
and subsequent hermes updates.

Run development commands in a bubblewrap filesystem and network sandbox
with a local HTTPS MITM fixture server and a fake github
git-upload-pack transport.
Package the sandbox command and expose it from the nix devShell.

Stage the local installer at its canonical fake HTTPS URL and add a
persistent installation/update test path. Route root installs through
sandbox-owned filesystem locations and snapshot dirty source worktrees
into temporary fake commits so update tests can fast-forward without
changing the real checkout.

Add an explicit --from-main installer mode that fetches the official
upstream main outside the sealed sandbox, installs from that snapshot,
and then promotes the fake remote to the current worktree so update
flows can be exercised with a fast-forward.
This commit is contained in:
ethernet 2026-07-28 13:17:59 -04:00
parent c7dd9e5670
commit db87ddca9f
5 changed files with 710 additions and 148 deletions

View file

@ -6446,10 +6446,14 @@ def _desktop_linux_needs_no_sandbox() -> bool:
unprivileged desktop user on an AppArmor-restricted host. The root case
should remain an explicit user choice.
"""
if os.environ.get("ELECTRON_DISABLE_SANDBOX", 0) == "1":
return True
if sys.platform != "linux":
return False
if hasattr(os, "geteuid") and os.geteuid() == 0:
return False
try:
with open("/proc/sys/kernel/apparmor_restrict_unprivileged_userns", encoding="utf-8") as f:
return f.read().strip() == "1"

View file

@ -30,10 +30,7 @@
mkdir -p $out/bin
install -Dm755 ${../hermes} $out/bin/hermes
'')
(pkgs.runCommand "dev-sandbox" { } ''
mkdir -p $out/bin
install -Dm755 ${../scripts/dev-sandbox.sh} $out/bin/sandbox
'')
self'.packages.sandbox
uv
# Headless Wayland compositor for E2E tests (test:e2e:visual).
# cage renders a single client with no window management, so

View file

@ -9,6 +9,9 @@
...
}:
let
sandbox = pkgs.callPackage ./sandbox.nix { };
minimal = pkgs.callPackage ./hermes-agent.nix {
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default;
@ -46,6 +49,8 @@
packages = {
default = full;
inherit sandbox;
inherit minimal;
# Ships discord.py + python-telegram-bot + slack-sdk so a plain

120
nix/sandbox.nix Normal file
View file

@ -0,0 +1,120 @@
{
# electron deps
alsa-lib,
at-spi2-atk,
atk,
cairo,
cups,
dbus,
expat,
fontconfig,
freetype,
glib,
gtk3,
libdrm,
libgbm,
libxkbcommon,
mesa,
nspr,
nss,
pango,
systemd,
libX11,
libXcomposite,
libXdamage,
libXext,
libXfixes,
libXrandr,
libXrender,
libXtst,
libxcb,
# sandbox deps
bash,
bubblewrap,
cacert,
coreutils,
curl,
gawk,
git,
glibc,
gnumake,
gnugrep,
gnused,
gzip,
netcat-gnu,
nodejs_22,
openssl,
python3,
slirp4netns,
stdenv,
gnutar,
# etc
writeShellApplication,
lib,
}:
let
electronRuntime = [
alsa-lib
at-spi2-atk
atk
cairo
cups
dbus
expat
fontconfig
freetype
glib
gtk3
libdrm
libgbm
libxkbcommon
mesa
nspr
nss
pango
systemd
libX11
libXcomposite
libXdamage
libXext
libXfixes
libXrandr
libXrender
libXtst
libxcb
];
in
writeShellApplication {
name = "sandbox";
runtimeInputs = [
bash
bubblewrap
cacert
coreutils
curl
gawk
git
glibc.bin
gnumake
gnugrep
gnused
gzip
netcat-gnu
nodejs_22
openssl
python3
slirp4netns
stdenv.cc
gnutar
]
++ electronRuntime;
text = ''
export DEV_SANDBOX_REAL_CA_CERT=${cacert}/etc/ssl/certs/ca-bundle.crt
export DEV_SANDBOX_DYNAMIC_LINKER=${stdenv.cc.bintools.dynamicLinker}
export DEV_SANDBOX_NODE_DIR=${nodejs_22}
export DEV_SANDBOX_ELECTRON_LD_LIBRARY_PATH=${lib.makeLibraryPath electronRuntime}
exec ${../scripts/dev-sandbox.sh} "$@"
'';
}

View file

@ -1,198 +1,634 @@
#!/usr/bin/env bash
# Run a Hermes instance in an isolated sandbox — separate HERMES_HOME,
# separate Electron userData, and a distinct Desktop app name so it doesn't compete
# with your main desktop instance's single-instance lock.
# Run a command in a disposable, network-isolated fake Internet.
#
# By default the sandbox is throwaway: a temp dir is created and removed on
# exit. Use --persistent to keep the sandbox across restarts (stored under
# .hermes-sandbox/ in the worktree git root).
#
# Usage:
# scripts/dev-sandbox.sh python -m hermes_cli.main
# scripts/dev-sandbox.sh hermes desktop
# scripts/dev-sandbox.sh electron .
# scripts/dev-sandbox.sh -- npm run dev # from apps/desktop/
# scripts/dev-sandbox.sh --persistent hermes desktop
# scripts/dev-sandbox.sh --persistent -- npm run dev
#
# Seed the sandbox HERMES_HOME from an existing directory (e.g. your main
# ~/.hermes) so config, sessions, skills, etc. are pre-populated:
# scripts/dev-sandbox.sh --from ~/.hermes hermes desktop
#
# Override the app name (default: HermesSandbox):
# HERMES_DEV_SANDBOX_NAME=Staging scripts/dev-sandbox.sh hermes desktop
#
# Override the persistent sandbox dir name (default: .hermes-sandbox):
# HERMES_DEV_SANDBOX_DIR=.staging-sandbox scripts/dev-sandbox.sh --persistent hermes desktop
# The command runs in bubblewrap's private user, mount, PID, and network
# namespaces. Its only writable filesystem is SANDBOX_ROOT. HTTP(S) goes to
# a local static MITM proxy. github.com SSH uses a sandbox-local git-upload-pack
# shim; neither transport can reach the host network.
set -euo pipefail
if [ "${1:-}" = "--internal-run" ]; then
shift
: "${DEV_SANDBOX_ROOT:?missing DEV_SANDBOX_ROOT}"
: "${DEV_SANDBOX_BASH:?missing DEV_SANDBOX_BASH}"
: "${DEV_SANDBOX_SLIRP4NETNS:?missing DEV_SANDBOX_SLIRP4NETNS}"
: "${DEV_SANDBOX_INTERACTIVE:?missing DEV_SANDBOX_INTERACTIVE}"
node_env=()
if [ -n "${DEV_SANDBOX_NODE_DIR:-}" ]; then
node_env+=(--setenv npm_config_nodedir "$DEV_SANDBOX_NODE_DIR")
fi
electron_env=()
if [ -n "${DEV_SANDBOX_ELECTRON_LD_LIBRARY_PATH:-}" ]; then
electron_env+=(
--setenv LD_LIBRARY_PATH "$DEV_SANDBOX_ELECTRON_LD_LIBRARY_PATH"
--setenv HERMES_DESKTOP_DISABLE_GPU 1
)
fi
gui_mounts=()
if [ -n "${DEV_SANDBOX_WAYLAND_SOCKET:-}" ]; then
runtime_dir="${DEV_SANDBOX_XDG_RUNTIME_DIR:?missing DEV_SANDBOX_XDG_RUNTIME_DIR}"
runtime_parent="$(dirname "$runtime_dir")"
runtime_grandparent="$(dirname "$runtime_parent")"
gui_mounts+=(
--dir "$runtime_grandparent"
--dir "$runtime_parent"
--dir "$runtime_dir"
--bind "$DEV_SANDBOX_WAYLAND_SOCKET" "$DEV_SANDBOX_WAYLAND_SOCKET"
--setenv XDG_RUNTIME_DIR "$runtime_dir"
--setenv WAYLAND_DISPLAY "${DEV_SANDBOX_WAYLAND_DISPLAY:?missing DEV_SANDBOX_WAYLAND_DISPLAY}"
)
fi
runtime_mounts=()
if [ -d /nix ] && [[ "$(readlink -f "$DEV_SANDBOX_BASH")" == /nix/* ]]; then
runtime_mounts+=(--ro-bind /nix /nix)
else
# Non-Nix Linux distributions keep dynamic executables and their loaders
# below these system paths. They are read-only in the sandbox.
for path in /usr /bin /sbin /lib /lib64; do
[ -e "$path" ] && runtime_mounts+=(--ro-bind "$path" "$path")
done
fi
sandbox_info="$DEV_SANDBOX_ROOT/root/logs/bwrap-info.json"
: > "$sandbox_info"
bwrap \
--unshare-user --uid 0 --gid 0 --unshare-pid --unshare-net \
--die-with-parent --proc /proc --dev /dev --tmpfs /tmp \
"${gui_mounts[@]}" \
"${runtime_mounts[@]}" \
--dir /usr \
--dir /bin \
--dir /lib64 \
--bind "$DEV_SANDBOX_ROOT/root" /work \
--bind "$DEV_SANDBOX_ROOT/root/bin" /bin \
--bind "$DEV_SANDBOX_ROOT/root/lib64" /lib64 \
--bind "$DEV_SANDBOX_ROOT/root/usr/bin" /usr/bin \
--bind "$DEV_SANDBOX_ROOT/root/usr/local" /usr/local \
--bind "$DEV_SANDBOX_ROOT/home" /root \
--bind "$DEV_SANDBOX_ROOT/etc" /etc \
--chdir /work/repo \
--clearenv \
--setenv PATH "/usr/local/bin:/usr/bin:$PATH" \
--setenv HOME /root \
--setenv USER root \
--setenv LOGNAME root \
--setenv CURL_CA_BUNDLE /work/certs/ca.pem \
--setenv SSL_CERT_FILE /work/certs/ca.pem \
--setenv GIT_SSL_CAINFO /work/certs/ca.pem \
--setenv NODE_EXTRA_CA_CERTS /work/certs/real-ca.pem \
--setenv HTTP_PROXY http://127.0.0.1:8080 \
--setenv HTTPS_PROXY http://127.0.0.1:8080 \
--setenv ALL_PROXY http://127.0.0.1:8080 \
--setenv NO_PROXY '' \
--setenv DEV_SANDBOX_INTERACTIVE "$DEV_SANDBOX_INTERACTIVE" \
--setenv ELECTRON_DISABLE_SANDBOX 1 \
"${node_env[@]}" \
"${electron_env[@]}" \
--info-fd 3 \
-- "$DEV_SANDBOX_BASH" -ceu '
python3 /work/proxy.py /work/http /work/certs /work/certs/real-ca.pem >/work/logs/proxy.log 2>&1 &
proxy_pid=$!
cleanup() {
kill "$proxy_pid" 2>/dev/null || true
wait "$proxy_pid" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
for _ in $(seq 1 50); do
nc -z 127.0.0.1 8080 && break
sleep 0.05
done
if ! nc -z 127.0.0.1 8080; then
cat /work/logs/proxy.log >&2 || true
exit 1
fi
"$@"
' sandbox-command "$@" 3>"$sandbox_info" &
bwrap_pid=$!
for _ in $(seq 1 100); do
[ -s "$sandbox_info" ] && break
if ! kill -0 "$bwrap_pid" 2>/dev/null; then
wait "$bwrap_pid"
exit $?
fi
sleep 0.05
done
sandbox_pid="$(awk -F: '/"child-pid"/ {gsub(/[^0-9]/, "", $2); print $2}' "$sandbox_info")"
if [ -z "$sandbox_pid" ]; then
echo 'error: Bubblewrap did not report a sandbox child PID' >&2
exit 1
fi
slirp_ready="$DEV_SANDBOX_ROOT/root/logs/slirp.ready"
: > "$slirp_ready"
"$DEV_SANDBOX_SLIRP4NETNS" --configure --disable-host-loopback --ready-fd=3 \
--userns-path="/proc/$sandbox_pid/ns/user" "$sandbox_pid" tap0 \
3>"$slirp_ready" >"$DEV_SANDBOX_ROOT/root/logs/slirp.log" 2>&1 &
slirp_pid=$!
cleanup() {
kill "$slirp_pid" 2>/dev/null || true
wait "$slirp_pid" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
for _ in $(seq 1 100); do
[ -s "$slirp_ready" ] && break
if ! kill -0 "$slirp_pid" 2>/dev/null; then
cat "$DEV_SANDBOX_ROOT/root/logs/slirp.log" >&2 || true
exit 1
fi
sleep 0.05
done
if [ ! -s "$slirp_ready" ]; then
echo 'error: timed out waiting for sandbox network setup' >&2
exit 1
fi
wait "$bwrap_pid"
exit $?
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
print_help() {
cat <<'EOF'
Usage: dev-sandbox.sh [--persistent] [--from DIR] [--] <command...>
Usage: dev-sandbox.sh [options] [--] <command...>
dev-sandbox.sh install [options] [--] [installer arguments...]
Run a Hermes instance in an isolated sandbox.
Run COMMAND in a throwaway chroot-like bubblewrap sandbox. The sandbox has no
writable host mounts: only its own root, mounted at /work, is writable.
Options:
--persistent Keep the sandbox dir across restarts (under the worktree
git root, in .hermes-sandbox/). Without this flag the
sandbox is a temp dir that is removed on exit.
--from DIR Copy DIR into the sandbox HERMES_HOME as the starting
point (config, sessions, skills, etc.).
Ignored if the sandbox HERMES_HOME already has content
(e.g. reusing a --persistent sandbox) to avoid clobbering.
--delete Delete the existing persistent sandbox in .hermes-sandbox.
-h, --help Show this help message.
--persistent Keep the whole sandbox under .hermes-sandbox/.
--delete Delete the persistent sandbox (asks first).
--from DIR One-time copy of DIR into the sandbox's $HOME.
Existing persistent sandboxes are never overwritten.
--http-root DIR Copy DIR into the fake web server root for this run.
Requests map to DIR/<host>/<path>; no URL is forwarded.
--installer PATH With `install`, serve PATH at the canonical install.sh
URL. Default: scripts/install.sh in this worktree.
--from-main With `install`, fetch the real upstream main installer
and repository, then advance fake main to this folder
after a successful install for update testing.
-h, --help Show this help.
Environment:
HERMES_DEV_SANDBOX_NAME Override the app name (default: HermesSandbox)
HERMES_DEV_SANDBOX_DIR Override the persistent dir name (default: .hermes-sandbox)
The fake web server signs certificates with a CA trusted only inside this
sandbox. HTTP_PROXY/HTTPS_PROXY send fixture URLs there first; other HTTP(S)
requests pass through the sandbox's rootless outbound network. SSH to github.com
runs a sandbox-local upload-pack shim, never your SSH config, agent,
known-hosts file, or authorized keys.
Fake github main always comes from this folder. If it has staged, unstaged, or
non-ignored untracked changes, the sandbox warns and creates a temporary local
commit containing them; it never stages or commits the real worktree.
Examples:
dev-sandbox.sh hermes desktop
dev-sandbox.sh --persistent hermes desktop
dev-sandbox.sh --from ~/.hermes hermes desktop
dev-sandbox.sh -- npm run dev
# create a sandbox, install this branch as `main`, and then drop to a shell,
# skipping `hermes setup` & the browser tools for speed.
scripts/dev-sandbox.sh install --persistent --skip-setup --skip-browser
# Install the official upstream main. You're dropped into a shell where
# you can run `hermes update`.
scripts/dev-sandbox.sh install --persistent --from-main
EOF
}
PERSISTENT=false
DELETE=false
SEED_DIR=""
HTTP_ROOT=""
INSTALL_SHORTCUT=false
INSTALLER_PATH=""
INSTALL_FROM_MAIN=false
if [ "${1:-}" = install ]; then
INSTALL_SHORTCUT=true
shift
fi
while [ "$#" -gt 0 ]; do
case "$1" in
--persistent)
PERSISTENT=true
shift
;;
--persistent) PERSISTENT=true; shift ;;
--delete) DELETE=true; shift ;;
--from)
if [ "$#" -lt 2 ] || [[ "$2" == -* ]]; then
echo "error: --from requires a directory argument" >&2
exit 1
fi
SEED_DIR="$2"
shift 2
;;
--from=*)
SEED_DIR="${1#--from=}"
if [ -z "$SEED_DIR" ]; then
echo "error: --from requires a directory argument" >&2
exit 1
fi
shift
;;
--delete)
DELETE=true
shift
;;
-h|--help)
print_help
exit 0
;;
--)
shift
break
;;
*)
break
;;
[ "$#" -ge 2 ] || { echo 'error: --from needs a directory' >&2; exit 1; }
SEED_DIR="$2"; shift 2 ;;
--http-root)
[ "$#" -ge 2 ] || { echo 'error: --http-root needs a directory' >&2; exit 1; }
HTTP_ROOT="$2"; shift 2 ;;
--installer)
[ "$#" -ge 2 ] || { echo 'error: --installer needs a file' >&2; exit 1; }
INSTALLER_PATH="$2"; shift 2 ;;
--from-main) INSTALL_FROM_MAIN=true; shift ;;
--from=*|--http-root=*|--installer=*)
key="${1%%=*}"; value="${1#*=}"
[ -n "$value" ] || { echo "error: $key needs a value" >&2; exit 1; }
case "$key" in
--from) SEED_DIR="$value" ;;
--http-root) HTTP_ROOT="$value" ;;
--installer) INSTALLER_PATH="$value" ;;
esac
shift ;;
-h|--help) print_help; exit 0 ;;
--) shift; break ;;
*) break ;;
esac
done
if [ -n "$SEED_DIR" ]; then
if [ ! -d "$SEED_DIR" ]; then
echo "error: --from dir '$SEED_DIR' does not exist" >&2
exit 1
fi
# Resolve to absolute path so it's valid after we cd later.
SEED_DIR="$(cd "$SEED_DIR" && pwd)"
fi
if [ "$#" -eq 0 ]; then
if [ "$INSTALL_SHORTCUT" = false ] && [ "$#" -eq 0 ]; then
print_help >&2
exit 1
fi
if [ -n "$INSTALLER_PATH" ] && [ "$INSTALL_SHORTCUT" = false ]; then
echo 'error: --installer is only valid with the install shortcut' >&2
exit 1
fi
if [ "$INSTALL_FROM_MAIN" = true ] && [ "$INSTALL_SHORTCUT" = false ]; then
echo 'error: --from-main is only valid with the install shortcut' >&2
exit 1
fi
if [ "$INSTALL_FROM_MAIN" = true ] && [ -n "$INSTALLER_PATH" ]; then
echo 'error: --from-main and --installer cannot be combined' >&2
exit 1
fi
SANDBOX_DIR_NAME="${HERMES_DEV_SANDBOX_DIR:-.hermes-sandbox}"
GIT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR/..")"
for dir in "$SEED_DIR" "$HTTP_ROOT"; do
[ -z "$dir" ] || [ -d "$dir" ] || { echo "error: directory '$dir' does not exist" >&2; exit 1; }
done
GIT_ROOT="${HERMES_SANDBOX_SOURCE_ROOT:-$(git rev-parse --show-toplevel)}"
GIT_ROOT="$(cd "$GIT_ROOT" && pwd)"
PERSISTENT_SANDBOX_ROOT="$GIT_ROOT/$SANDBOX_DIR_NAME"
if [ "$INSTALL_SHORTCUT" = true ] && [ "$INSTALL_FROM_MAIN" = false ] && [ -z "$INSTALLER_PATH" ]; then
INSTALLER_PATH="$GIT_ROOT/scripts/install.sh"
fi
if [ -n "$INSTALLER_PATH" ] && [ ! -f "$INSTALLER_PATH" ]; then
echo "error: installer '$INSTALLER_PATH' does not exist" >&2
exit 1
fi
COMMIT="$(git -C "$GIT_ROOT" rev-parse --verify 'HEAD^{commit}')" || {
echo "error: current folder has no HEAD commit" >&2
exit 1
}
SANDBOX_DIR_NAME="${HERMES_DEV_SANDBOX_DIR:-.hermes-sandbox}"
PERSISTENT_ROOT="$GIT_ROOT/$SANDBOX_DIR_NAME"
if [ "$DELETE" = true ]; then
if [ -d "$PERSISTENT_SANDBOX_ROOT" ]; then
read -r -p "[sandbox] delete $PERSISTENT_SANDBOX_ROOT? [y/N] " REPLY
case "$REPLY" in
[yY]|[yY][eE][sS])
echo "[sandbox] deleting $PERSISTENT_SANDBOX_ROOT" >&2
rm -rf -- "$PERSISTENT_SANDBOX_ROOT"
;;
*)
echo "[sandbox] aborted" >&2
exit 1
;;
esac
else
echo "[sandbox] nothing to delete at $PERSISTENT_SANDBOX_ROOT" >&2
if [ ! -d "$PERSISTENT_ROOT" ]; then
echo "[sandbox] nothing to delete at $PERSISTENT_ROOT" >&2
exit 0
fi
read -r -p "[sandbox] delete $PERSISTENT_ROOT? [y/N] " reply
case "$reply" in
y|Y|yes|YES) rm -rf -- "$PERSISTENT_ROOT" ;;
*) echo '[sandbox] aborted' >&2; exit 1 ;;
esac
exit 0
fi
# Derive a per-worktree app name so multiple checkouts don't collide.
# Each worktree has its own toplevel path even though they share one repo,
# so we hash that path into a short, stable suffix.
WORKTREE_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR/..")"
WORKTREE_ROOT="$(cd "$WORKTREE_ROOT" && pwd)"
WORKTREE_HASH="$(printf '%s' "$WORKTREE_ROOT" | cksum | cut -d' ' -f1)"
WORKTREE_NAME="$(basename "$WORKTREE_ROOT")"
DEFAULT_SANDBOX_NAME="HermesSandbox-${WORKTREE_NAME}-${WORKTREE_HASH}"
SANDBOX_NAME="${HERMES_DEV_SANDBOX_NAME:-$DEFAULT_SANDBOX_NAME}"
if [ "$PERSISTENT" = true ]; then
SANDBOX_ROOT="$PERSISTENT_SANDBOX_ROOT"
SANDBOX_ROOT="$PERSISTENT_ROOT"
else
SANDBOX_ROOT="$(mktemp -d -t hermes-sandbox.XXXXXX)"
cleanup() { chmod -R u+w "$SANDBOX_ROOT"; rm -rf -- "$SANDBOX_ROOT"; }
trap cleanup EXIT INT TERM
fi
export HERMES_HOME="$SANDBOX_ROOT/hermes-home"
export HERMES_DESKTOP_USER_DATA_DIR="$SANDBOX_ROOT/user-data"
export HERMES_DESKTOP_APP_NAME="$SANDBOX_NAME"
mkdir -p "$HERMES_HOME" "$HERMES_DESKTOP_USER_DATA_DIR"
if [ -n "$SEED_DIR" ]; then
# Only seed when the sandbox HERMES_HOME is empty — avoids clobbering an
# existing persistent sandbox on re-run.
if [ -z "$(ls -A "$HERMES_HOME" 2>/dev/null)" ]; then
echo "[sandbox] seeding HERMES_HOME from $SEED_DIR" >&2
cp -a "$SEED_DIR/." "$HERMES_HOME/"
else
echo "[sandbox] --from ignored: $HERMES_HOME already has content" >&2
mkdir -p "$SANDBOX_ROOT"/{root,home,etc}
UPSTREAM_REPO=""
UPSTREAM_COMMIT=""
if [ "$INSTALL_FROM_MAIN" = true ]; then
echo '[sandbox] fetching real upstream main for installer/update test' >&2
UPSTREAM_REPO="$(mktemp -d -t hermes-sandbox-upstream.XXXXXX)"
git -C "$UPSTREAM_REPO" init -q
if ! git -C "$UPSTREAM_REPO" fetch -q https://github.com/NousResearch/hermes-agent.git refs/heads/main; then
rm -rf -- "$UPSTREAM_REPO"
echo 'error: failed to fetch real upstream main' >&2
exit 1
fi
UPSTREAM_COMMIT="$(git -C "$UPSTREAM_REPO" rev-parse FETCH_HEAD)"
fi
if [ ! -e "$SANDBOX_ROOT/root/repo/.sandbox-source" ]; then
mkdir -p "$SANDBOX_ROOT/root/repo"
# Persistent roots live under the worktree, so copying with cp would recurse
# into the sandbox itself. tar also lets us exclude a worktree's .git file,
# which can point at the host's shared worktree metadata.
tar -C "$GIT_ROOT" --exclude='./.git' --exclude="./$SANDBOX_DIR_NAME" -cf - . \
| tar -C "$SANDBOX_ROOT/root/repo" -xf -
: > "$SANDBOX_ROOT/root/repo/.sandbox-source"
fi
echo "[sandbox] HERMES_HOME=$HERMES_HOME" >&2
echo "[sandbox] userData=$HERMES_DESKTOP_USER_DATA_DIR" >&2
echo "[sandbox] appName=$HERMES_DESKTOP_APP_NAME" >&2
if [ "$PERSISTENT" = true ]; then
echo "[sandbox] persistent: $SANDBOX_ROOT" >&2
if [ -n "$SEED_DIR" ] && [ ! -e "$SANDBOX_ROOT/.seeded" ]; then
echo "[sandbox] seeding home from $SEED_DIR" >&2
cp -a "$SEED_DIR/." "$SANDBOX_ROOT/home/"
: > "$SANDBOX_ROOT/.seeded"
fi
rm -rf "$SANDBOX_ROOT/root/http"
mkdir -p "$SANDBOX_ROOT/root/http"
if [ -n "$HTTP_ROOT" ]; then
cp -a "$HTTP_ROOT/." "$SANDBOX_ROOT/root/http/"
fi
if [ "$INSTALL_SHORTCUT" = true ]; then
mkdir -p "$SANDBOX_ROOT/root/http/hermes-agent.nousresearch.com"
if [ "$INSTALL_FROM_MAIN" = true ]; then
git -C "$UPSTREAM_REPO" show "$UPSTREAM_COMMIT:scripts/install.sh" \
> "$SANDBOX_ROOT/root/http/hermes-agent.nousresearch.com/install.sh"
else
cp -a "$INSTALLER_PATH" "$SANDBOX_ROOT/root/http/hermes-agent.nousresearch.com/install.sh"
fi
set -- bash -c '
set +e
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- "$@"
install_status=$?
if [ "$install_status" -eq 0 ] && [ -f /work/promote-main ]; then
next_main=$(cat /work/promote-main)
if git --git-dir=/work/repos/hermes-agent.git update-ref refs/heads/main "$next_main"; then
rm -f /work/promote-main
printf "[sandbox] fake main advanced to this folder for update testing\n" >&2
else
printf "[sandbox] failed to advance fake main after install\n" >&2
install_status=1
fi
fi
if [ "$DEV_SANDBOX_INTERACTIVE" = true ]; then
printf "\n[sandbox] installer exited %s; entering sandbox shell\n" "$install_status" >&2
exec </dev/tty >/dev/tty 2>&1
exec bash -i
fi
exit "$install_status"
' sandbox-installer "$@"
fi
mkdir -p "$SANDBOX_ROOT/root"/{bin,certs,lib64,logs,repos,ssh,usr/bin,usr/local}
REAL_CA_CERT="${DEV_SANDBOX_REAL_CA_CERT:-}"
if [ -z "$REAL_CA_CERT" ]; then
for candidate in /etc/ssl/certs/ca-certificates.crt /etc/ssl/cert.pem; do
if [ -f "$candidate" ]; then
REAL_CA_CERT="$candidate"
break
fi
done
fi
if [ ! -f "$REAL_CA_CERT" ]; then
echo 'error: no system CA bundle found for outbound sandbox HTTPS' >&2
exit 1
fi
if [ ! -f "$SANDBOX_ROOT/root/certs/real-ca.pem" ]; then
cp "$REAL_CA_CERT" "$SANDBOX_ROOT/root/certs/real-ca.pem"
fi
printf 'nameserver 10.0.2.3\n' > "$SANDBOX_ROOT/etc/resolv.conf"
SANDBOX_SHELL="$(command -v bash)"
DYNAMIC_LINKER="${DEV_SANDBOX_DYNAMIC_LINKER:-}"
if [ -z "$DYNAMIC_LINKER" ]; then
for candidate in /nix/store/*-glibc-*/lib/ld-linux-*.so.*; do
if [ -f "$candidate" ]; then
DYNAMIC_LINKER="$candidate"
break
fi
done
fi
if [ ! -f "$DYNAMIC_LINKER" ]; then
echo 'error: no glibc dynamic linker found for sandboxed release binaries' >&2
exit 1
fi
ln -sf "$SANDBOX_SHELL" "$SANDBOX_ROOT/root/bin/sh"
ln -sf "$(command -v ls)" "$SANDBOX_ROOT/root/bin/ls"
ln -sf "$(command -v env)" "$SANDBOX_ROOT/root/usr/bin/env"
ln -sf "$DYNAMIC_LINKER" "$SANDBOX_ROOT/root/lib64/$(basename "$DYNAMIC_LINKER")"
printf 'root:x:0:0:Sandbox Root:/root:%s\n' "$SANDBOX_SHELL" > "$SANDBOX_ROOT/etc/passwd"
printf 'root:x:0:\n' > "$SANDBOX_ROOT/etc/group"
printf 'hosts: files dns\n' > "$SANDBOX_ROOT/etc/nsswitch.conf"
printf '127.0.0.1 localhost\n' > "$SANDBOX_ROOT/etc/hosts"
SOURCE_REPO="$GIT_ROOT"
SOURCE_REF="$COMMIT"
SNAPSHOT_REPO=""
FAKE_REPO="$SANDBOX_ROOT/root/repos/hermes-agent.git"
git -C "$SANDBOX_ROOT/root/repos" init --bare -q hermes-agent.git
if [ "$INSTALL_FROM_MAIN" = true ]; then
git --git-dir="$FAKE_REPO" fetch -q --force "$UPSTREAM_REPO" \
"$UPSTREAM_COMMIT:refs/heads/main"
fi
if [ -n "$(git -C "$GIT_ROOT" status --porcelain)" ]; then
echo '[sandbox] warning: current folder is dirty; creating a temporary fake commit for main' >&2
SNAPSHOT_REPO="$(mktemp -d -t hermes-sandbox-snapshot.XXXXXX)"
git -C "$SNAPSHOT_REPO" init -q
git -C "$SNAPSHOT_REPO" fetch -q "$GIT_ROOT" "$COMMIT"
git -C "$SNAPSHOT_REPO" config user.name 'Hermes sandbox'
git -C "$SNAPSHOT_REPO" config user.email 'sandbox@invalid'
GIT_DIR="$SNAPSHOT_REPO/.git" GIT_WORK_TREE="$GIT_ROOT" git read-tree "$COMMIT"
GIT_DIR="$SNAPSHOT_REPO/.git" GIT_WORK_TREE="$GIT_ROOT" \
git add -A -- .
SNAPSHOT_TREE="$(GIT_DIR="$SNAPSHOT_REPO/.git" git write-tree)"
SNAPSHOT_PARENT="$COMMIT"
if EXISTING_MAIN="$(git --git-dir="$FAKE_REPO" rev-parse --verify refs/heads/main 2>/dev/null)"; then
git -C "$SNAPSHOT_REPO" fetch -q "$FAKE_REPO" "$EXISTING_MAIN"
SNAPSHOT_PARENT="$EXISTING_MAIN"
fi
SOURCE_REF="$(GIT_DIR="$SNAPSHOT_REPO/.git" git commit-tree "$SNAPSHOT_TREE" -p "$SNAPSHOT_PARENT" \
-m 'sandbox snapshot of dirty worktree')"
SOURCE_REPO="$SNAPSHOT_REPO"
fi
if [ "$INSTALL_FROM_MAIN" = true ]; then
git --git-dir="$FAKE_REPO" fetch -q --force "$SOURCE_REPO" \
"$SOURCE_REF:refs/hermes-sandbox/next"
printf '%s\n' "$SOURCE_REF" > "$SANDBOX_ROOT/root/promote-main"
else
echo "[sandbox] ephemeral (will be cleaned up on exit)" >&2
git --git-dir="$FAKE_REPO" fetch -q --force "$SOURCE_REPO" \
"$SOURCE_REF:refs/heads/main"
fi
git --git-dir="$FAKE_REPO" symbolic-ref HEAD refs/heads/main
if [ -n "$SNAPSHOT_REPO" ]; then
rm -rf -- "$SNAPSHOT_REPO"
fi
if [ -n "$UPSTREAM_REPO" ]; then
rm -rf -- "$UPSTREAM_REPO"
fi
if [ "$PERSISTENT" = false ]; then
cleanup() {
chmod -R u+w "$SANDBOX_ROOT"
rm -rf -- "$SANDBOX_ROOT"
if [ ! -f "$SANDBOX_ROOT/root/certs/ca.pem" ]; then
openssl req -x509 -newkey rsa:2048 -nodes -days 2 \
-subj '/CN=Hermes dev sandbox CA' \
-keyout "$SANDBOX_ROOT/root/certs/ca.key" \
-out "$SANDBOX_ROOT/root/certs/ca.pem" >/dev/null 2>&1
fi
GIT_UPLOAD_PACK="$(command -v git-upload-pack)"
printf '#!%s\nexec %q /work/repos/hermes-agent.git\n' "$SANDBOX_SHELL" "$GIT_UPLOAD_PACK" \
> "$SANDBOX_ROOT/root/usr/bin/ssh"
chmod 700 "$SANDBOX_ROOT/root/usr/bin/ssh"
cat > "$SANDBOX_ROOT/root/proxy.py" <<'PY'
import pathlib, socket, ssl, subprocess, sys, threading
from urllib.parse import unquote, urlsplit
ROOT, CERTS, REAL_CA = map(pathlib.Path, sys.argv[1:])
def read_request(conn):
data = b""
while b"\r\n\r\n" not in data and len(data) < 65536:
part = conn.recv(4096)
if not part:
return b""
data += part
return data
def cert_for(host):
safe = ''.join(char if char.isalnum() or char in '.-' else '_' for char in host)
cert, key = CERTS / f'{safe}.pem', CERTS / f'{safe}.key'
if not cert.exists():
csr = CERTS / f'{safe}.csr'
subprocess.run(['openssl', 'req', '-newkey', 'rsa:2048', '-nodes',
'-subj', f'/CN={host}', '-addext', f'subjectAltName=DNS:{host}',
'-keyout', str(key), '-out', str(csr)], check=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.run(['openssl', 'x509', '-req', '-days', '2', '-in', str(csr),
'-CA', str(CERTS / 'ca.pem'), '-CAkey', str(CERTS / 'ca.key'),
'-CAcreateserial', '-copy_extensions', 'copy', '-out', str(cert)],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return cert, key
def file_for(host, target):
path = urlsplit(target).path or '/'
parts = pathlib.PurePosixPath(unquote(path)).parts
if '..' in parts:
return None
candidate = ROOT / host / pathlib.PurePosixPath(*[part for part in parts if part != '/'])
if candidate.is_dir():
candidate /= 'index.html'
return candidate if candidate.is_file() else None
def respond_fixture(conn, found):
body = found.read_bytes()
header = b'HTTP/1.1 200 OK\r\n'
conn.sendall(header + f'Content-Length: {len(body)}\r\nConnection: close\r\n\r\n'.encode() + body)
def close_request(request, target=None):
headers, separator, body = request.partition(b'\r\n\r\n')
lines = headers.split(b'\r\n')
if target is not None:
method, _, version = lines[0].split(b' ', 2)
lines[0] = b' '.join((method, target.encode(), version))
lines = [line for line in lines if not line.lower().startswith(b'proxy-connection:')]
lines.append(b'Connection: close')
return b'\r\n'.join(lines) + separator + body
def relay(source, destination):
while True:
chunk = source.recv(65536)
if not chunk:
return
destination.sendall(chunk)
def forward_https(conn, host, port, request):
context = ssl.create_default_context(cafile=str(REAL_CA))
with socket.create_connection((host, port), timeout=30) as raw:
with context.wrap_socket(raw, server_hostname=host) as upstream:
upstream.sendall(close_request(request))
relay(upstream, conn)
def forward_http(conn, host, port, request, target):
parsed = urlsplit(target)
path = parsed.path or '/'
if parsed.query:
path += f'?{parsed.query}'
with socket.create_connection((host, port), timeout=30) as upstream:
upstream.sendall(close_request(request, path))
relay(upstream, conn)
def handle_request(conn):
with conn:
request = read_request(conn)
if not request:
return
line = request.split(b'\r\n', 1)[0].decode('iso-8859-1')
method, target, _ = line.split(' ', 2)
if method.upper() == 'CONNECT':
host, _, port_text = target.rpartition(':')
port = int(port_text or '443')
conn.sendall(b'HTTP/1.1 200 Connection Established\r\n\r\n')
cert, key = cert_for(host)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(cert, key)
with context.wrap_socket(conn, server_side=True) as tls:
nested = read_request(tls)
if nested:
nested_target = nested.split(b'\r\n', 1)[0].decode('iso-8859-1').split(' ', 2)[1]
found = file_for(host, nested_target)
if found is not None:
respond_fixture(tls, found)
else:
forward_https(tls, host, port, nested)
return
parsed = urlsplit(target)
host = parsed.hostname
if not host:
for header in request.split(b'\r\n')[1:]:
if header.lower().startswith(b'host:'):
host = header.split(b':', 1)[1].strip().decode().split(':', 1)[0]
break
host = host or 'unknown'
found = file_for(host, target)
if found is not None:
respond_fixture(conn, found)
else:
forward_http(conn, host, parsed.port or 80, request, target)
def handle(conn):
try:
handle_request(conn)
except Exception as error:
print(f'proxy request failed: {error!r}', file=sys.stderr, flush=True)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('127.0.0.1', 8080))
server.listen()
while True:
conn, _ = server.accept()
threading.Thread(target=handle, args=(conn,), daemon=True).start()
PY
if [ "$INSTALL_FROM_MAIN" = true ]; then
echo "[sandbox] fake main: real upstream main ($UPSTREAM_COMMIT)" >&2
echo "[sandbox] prepared update: current folder ($SOURCE_REF)" >&2
else
echo "[sandbox] fake main: current folder ($SOURCE_REF)" >&2
fi
echo "[sandbox] root: $SANDBOX_ROOT" >&2
echo "[sandbox] http root: $SANDBOX_ROOT/root/http" >&2
[ "$PERSISTENT" = true ] && echo '[sandbox] persistent' >&2 || echo '[sandbox] ephemeral' >&2
for command in awk bash bwrap curl git nc openssl python3 slirp4netns tar; do
command -v "$command" >/dev/null || {
echo "error: missing required command: $command" >&2
exit 1
}
trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM
done
INTERACTIVE=false
if [ -t 0 ] && [ -t 1 ]; then
INTERACTIVE=true
fi
NODE_DIR="${DEV_SANDBOX_NODE_DIR:-}"
if [ -z "$NODE_DIR" ] && command -v node >/dev/null; then
NODE_DIR="$(dirname "$(dirname "$(command -v node)")")"
fi
WAYLAND_SOCKET=""
if [ -n "${XDG_RUNTIME_DIR:-}" ] && [ -n "${WAYLAND_DISPLAY:-}" ] \
&& [ -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" ]; then
WAYLAND_SOCKET="$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY"
fi
"$@"
rc=$?
exit $rc
exec env \
DEV_SANDBOX_ROOT="$SANDBOX_ROOT" \
DEV_SANDBOX_BASH="$(command -v bash)" \
DEV_SANDBOX_SLIRP4NETNS="$(command -v slirp4netns)" \
DEV_SANDBOX_REAL_CA_CERT="$REAL_CA_CERT" \
DEV_SANDBOX_INTERACTIVE="$INTERACTIVE" \
DEV_SANDBOX_NODE_DIR="$NODE_DIR" \
DEV_SANDBOX_ELECTRON_LD_LIBRARY_PATH="${DEV_SANDBOX_ELECTRON_LD_LIBRARY_PATH:-}" \
DEV_SANDBOX_XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-}" \
DEV_SANDBOX_WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-}" \
DEV_SANDBOX_WAYLAND_SOCKET="$WAYLAND_SOCKET" \
"$BASH_SOURCE" --internal-run "$@"