mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
feat(dev): add isolated sandbox script for local dev
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.
This commit is contained in:
parent
291eae63b7
commit
5e849942c3
7 changed files with 199 additions and 26 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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), {})
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
|
|
|||
152
scripts/dev-sandbox.sh
Executable file
152
scripts/dev-sandbox.sh
Executable file
|
|
@ -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] [--] <command...>
|
||||
|
||||
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
|
||||
|
|
@ -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>(<scope>): <description>
|
||||
```
|
||||
|
||||
| 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue