From 5e849942c3b9d36fdd48c80e9d13cb66212d9731 Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 10 Jul 2026 13:43:51 -0400 Subject: [PATCH] feat(dev): add isolated sandbox script for local dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/desktop-sandbox.sh runs a Hermes desktop instance in an isolated sandbox — separate HERMES_HOME, separate Electron userData, and a distinct app name (HERMES_DESKTOP_APP_NAME) so it doesn't compete with the main desktop instance's single-instance lock. Two modes: - Ephemeral (default): temp dir, cleaned up on exit - --persistent: stored under .hermes-sandbox/ in the worktree git root, survives restarts for repeat testing In the Nix devShell the script is available as 'sandbox'. Also makes APP_NAME overridable via HERMES_DESKTOP_APP_NAME in main.ts — app.setName() runs before requestSingleInstanceLock(), so the overridden name changes the lock key. collectRelaunchEnv already preserves HERMES_DESKTOP_* vars through self-update relaunches; test updated to cover the new env var. --- .gitignore | 3 + apps/desktop/README.md | 2 + apps/desktop/electron/main.ts | 2 +- apps/desktop/electron/update-relaunch.test.ts | 2 + nix/devShell.nix | 6 +- scripts/dev-sandbox.sh | 152 ++++++++++++++++++ website/docs/developer-guide/contributing.md | 58 ++++--- 7 files changed, 199 insertions(+), 26 deletions(-) create mode 100755 scripts/dev-sandbox.sh diff --git a/.gitignore b/.gitignore index e4240ea36e7..c48fcb3515a 100644 --- a/.gitignore +++ b/.gitignore @@ -119,6 +119,9 @@ docs/superpowers/* # treat it as a local edit and autostash it on every run (#38529). .hermes-bootstrap-complete +# Persistent dev sandbox dir (scripts/dev-sandbox.sh --persistent) +.hermes-sandbox/ + # Interrupted-update breadcrumb + recovery lock written next to the shared venv # by `hermes update` / launch-time self-heal. Runtime state, never a code change # — ignore so `git status` stays clean and update's autostash skips them. diff --git a/apps/desktop/README.md b/apps/desktop/README.md index d342f0255f6..276704523f8 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -67,6 +67,8 @@ npm run dev # Vite renderer + Electron, which boots the Python backend Point the app at a specific source checkout, or sandbox it away from your real config: ```bash +# throwaway HERMES_HOME, separate Electron userData, distinct app name to avoid the single-instance lock +../scripts/dev-sandbox.sh npm run dev HERMES_DESKTOP_HERMES_ROOT=/path/to/clone npm run dev HERMES_HOME=/tmp/throwaway npm run dev npm run dev:fake-boot # exercise the startup overlay with deterministic delays diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index ba50d6d7cc1..a19201147ce 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -424,7 +424,7 @@ const BOOT_FAKE_STEP_MS = (() => { return Math.max(120, raw) })() -const APP_NAME = 'Hermes' +const APP_NAME = process.env.HERMES_DESKTOP_APP_NAME || 'Hermes' const TITLEBAR_HEIGHT = 34 const MACOS_TRAFFIC_LIGHTS_HEIGHT = 14 diff --git a/apps/desktop/electron/update-relaunch.test.ts b/apps/desktop/electron/update-relaunch.test.ts index d42582552d0..13f2d1df0b6 100644 --- a/apps/desktop/electron/update-relaunch.test.ts +++ b/apps/desktop/electron/update-relaunch.test.ts @@ -162,6 +162,7 @@ test('collectRelaunchEnv preserves HERMES_HOME + HERMES_DESKTOP_* + sandbox opt- HERMES_DESKTOP_REMOTE_URL: 'http://box:9119', HERMES_DESKTOP_REMOTE_TOKEN: 'secret', HERMES_DESKTOP_HERMES_ROOT: '/home/u/dev/hermes', + HERMES_DESKTOP_APP_NAME: 'HermesSandbox', ELECTRON_DISABLE_SANDBOX: '1', // sandbox opt-out — preserved PATH: '/usr/bin', // not preserved HOME: '/home/u', // not preserved @@ -173,6 +174,7 @@ test('collectRelaunchEnv preserves HERMES_HOME + HERMES_DESKTOP_* + sandbox opt- HERMES_DESKTOP_REMOTE_URL: 'http://box:9119', HERMES_DESKTOP_REMOTE_TOKEN: 'secret', HERMES_DESKTOP_HERMES_ROOT: '/home/u/dev/hermes', + HERMES_DESKTOP_APP_NAME: 'HermesSandbox', ELECTRON_DISABLE_SANDBOX: '1' }) assert.deepEqual(collectRelaunchEnv(null), {}) diff --git a/nix/devShell.nix b/nix/devShell.nix index b7dd91fae11..e93e10ef056 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -32,6 +32,10 @@ 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 + '') uv ] ++ self'.packages.default.passthru.devDeps; @@ -42,7 +46,7 @@ # for the devshell to pick up the src export HERMES_PYTHON_SRC_ROOT=$(git rev-parse --show-toplevel) echo "Hermes Agent dev shell in $HERMES_PYTHON_SRC_ROOT" - echo "Ready. Run 'hermes' to start." + echo "Ready. Run 'hermes' or 'sandbox hermes' to start." ''; }; }; diff --git a/scripts/dev-sandbox.sh b/scripts/dev-sandbox.sh new file mode 100755 index 00000000000..afb942539f7 --- /dev/null +++ b/scripts/dev-sandbox.sh @@ -0,0 +1,152 @@ +#!/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. +# +# 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 +# +# 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 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +print_help() { + cat <<'EOF' +Usage: dev-sandbox.sh [--persistent] [--] + +Run a Hermes instance in an isolated sandbox. + +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. + --delete Delete the existing persistent sandbox in .hermes-sandbox. + -h, --help Show this help message. + +Environment: + HERMES_DEV_SANDBOX_NAME Override the app name (default: HermesSandbox) + HERMES_DEV_SANDBOX_DIR Override the persistent dir name (default: .hermes-sandbox) + +Examples: + dev-sandbox.sh hermes desktop + dev-sandbox.sh --persistent hermes desktop + dev-sandbox.sh -- npm run dev +EOF +} + +PERSISTENT=false +DELETE=false + +while [ "$#" -gt 0 ]; do + case "$1" in + --persistent) + PERSISTENT=true + shift + ;; + --delete) + DELETE=true + shift + ;; + -h|--help) + print_help + exit 0 + ;; + --) + shift + break + ;; + *) + break + ;; + esac +done + +if [ "$#" -eq 0 ]; then + print_help >&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/..")" +GIT_ROOT="$(cd "$GIT_ROOT" && pwd)" +PERSISTENT_SANDBOX_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 + fi + 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" +else + SANDBOX_ROOT="$(mktemp -d -t hermes-sandbox.XXXXXX)" +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" + +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 +else + echo "[sandbox] ephemeral (will be cleaned up on exit)" >&2 +fi + +if [ "$PERSISTENT" = false ]; then + cleanup() { + chmod -R u+w "$SANDBOX_ROOT" + rm -rf -- "$SANDBOX_ROOT" + } + trap cleanup EXIT + trap 'cleanup; exit 130' INT TERM +fi + +"$@" +rc=$? +exit $rc diff --git a/website/docs/developer-guide/contributing.md b/website/docs/developer-guide/contributing.md index 52ec92d28ad..5f5d7fd0490 100644 --- a/website/docs/developer-guide/contributing.md +++ b/website/docs/developer-guide/contributing.md @@ -31,12 +31,12 @@ We value contributions in this order: ### Prerequisites -| Requirement | Notes | -|-------------|-------| -| **Git** | With the `git-lfs` extension installed | -| **Python 3.11–3.13** | uv will install it if missing | -| **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) | -| **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) | +| Requirement | Notes | +| -------------------- | --------------------------------------------------------------------------------------------- | +| **Git** | With the `git-lfs` extension installed | +| **Python 3.11–3.13** | uv will install it if missing | +| **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) | +| **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) | ### Install with the standard installer @@ -66,6 +66,14 @@ git checkout -b fix/description scripts/run_tests.sh ``` +You can also run a fully isolated Hermes instance (throwaway HERMES_HOME, separate Electron +userData, distinct Electron app name to avoid the single-instance lock): + +```bash +scripts/dev-sandbox.sh python -m hermes_cli.main +scripts/dev-sandbox.sh --persistent python -m hermes_cli.main desktop # state survives restarts, but lives in the worktree :) +``` + ### Manual clone fallback Use this only if you intentionally do not want Hermes' managed install layout @@ -143,7 +151,7 @@ See **[Platform Support](../getting-started/platform-support.md)**. Native Windo When contributing code, keep these rules in mind: -- **Don't add unguarded `signal.SIGKILL` references.** It's not defined on Windows. Either route through `gateway.status.terminate_pid(pid, force=True)` (the centralized primitive that does `taskkill /T /F` on Windows and SIGKILL on POSIX), or fall back with `getattr(signal, "SIGKILL", signal.SIGTERM)`. +- **Don't add unguarded `signal.SIGKILL` references.** It's not defined on Windows. Either route through `gateway.status.terminate_pid(pid, force=True)` (the centralized primitive that does `taskkill /T /F` on Windows and SIGKILL on POSIX), or fall back with `getattr(signal, "SIGKILL", signal.SIGTERM)`. - **Catch `OSError` alongside `ProcessLookupError` on `os.kill(pid, 0)` probes.** Windows raises `OSError` (WinError 87, "parameter is incorrect") for an already-gone PID instead of `ProcessLookupError`. - **Don't force the terminal to POSIX semantics.** `os.setsid`, `os.killpg`, `os.getpgid`, `os.fork` all raise on Windows — gate them with `if sys.platform != "win32":` or `if os.name != "nt":`. - **Open files with an explicit `encoding="utf-8"`.** The Python default on Windows is the system locale (often cp1252), which mojibakes or crashes on non-Latin text. @@ -198,15 +206,15 @@ Hermes has terminal access. Security matters. ### Existing Protections -| Layer | Implementation | -|-------|---------------| -| **Sudo password piping** | Uses `shlex.quote()` to prevent shell injection | -| **Dangerous command detection** | Regex patterns in `tools/approval.py` with user approval flow | -| **Cron prompt injection** | Scanner blocks instruction-override patterns | -| **Write deny list** | Protected paths resolved via `os.path.realpath()` to prevent symlink bypass | -| **Skills guard** | Security scanner for hub-installed skills | -| **Code execution sandbox** | Child process runs with API keys stripped | -| **Container hardening** | Docker: all capabilities dropped, no privilege escalation, PID limits | +| Layer | Implementation | +| ------------------------------- | --------------------------------------------------------------------------- | +| **Sudo password piping** | Uses `shlex.quote()` to prevent shell injection | +| **Dangerous command detection** | Regex patterns in `tools/approval.py` with user approval flow | +| **Cron prompt injection** | Scanner blocks instruction-override patterns | +| **Write deny list** | Protected paths resolved via `os.path.realpath()` to prevent symlink bypass | +| **Skills guard** | Security scanner for hub-installed skills | +| **Code execution sandbox** | Child process runs with API keys stripped | +| **Container hardening** | Docker: all capabilities dropped, no privilege escalation, PID limits | ### Contributing Security-Sensitive Code @@ -238,6 +246,7 @@ refactor/description # Code restructuring ### PR Description Include: + - **What** changed and **why** - **How to test** it - **What platforms** you tested on @@ -251,18 +260,19 @@ We use [Conventional Commits](https://www.conventionalcommits.org/): (): ``` -| Type | Use for | -|------|---------| -| `fix` | Bug fixes | -| `feat` | New features | -| `docs` | Documentation | -| `test` | Tests | -| `refactor` | Code restructuring | -| `chore` | Build, CI, dependency updates | +| Type | Use for | +| ---------- | ----------------------------- | +| `fix` | Bug fixes | +| `feat` | New features | +| `docs` | Documentation | +| `test` | Tests | +| `refactor` | Code restructuring | +| `chore` | Build, CI, dependency updates | Scopes: `cli`, `gateway`, `tools`, `skills`, `agent`, `install`, `whatsapp`, `security` Examples: + ``` fix(cli): prevent crash in save_config_value when model is a string feat(gateway): add WhatsApp multi-user session isolation