refactor: remove duplicate sandbox and process plumbing

Keep the minimal sandbox command's existing app/data identity while sharing
its parser, seeding, execution and cleanup with the standard sandbox.
Remove the unused SDK adapter copy and protocol shadow from the environment
facade, plus unconsumed checkout-updater dependencies and their dead helper.

Validation: 72 focused Python tests across sandbox and adapter/backend
paths, 5 checkout-updater tests, Electron typecheck and ESLint passed.
Ruff, touched-file Windows checks and the TS ratchet pass. Sandbox tests
also passed against the original scripts before consolidation.
This commit is contained in:
ethernet
2026-09-13 12:41:27 -04:00
parent 5aff1bf793
commit a6eb3ef738
9 changed files with 92 additions and 315 deletions

View File

@@ -2948,8 +2948,6 @@ function runGit(args, options: any = {}): Promise<{ code: number; stdout: string
})
}
const firstLine = text => (text || '').split('\n').find(Boolean) || ''
function emitUpdateProgress(payload) {
const merged = { stage: 'idle', message: '', percent: null, error: null, ...payload, at: Date.now() }
rememberLog(`[updates] ${merged.stage}: ${merged.message || merged.error || ''}`)
@@ -3116,7 +3114,6 @@ function resolveCheckoutUpdateStrategy(): UpdaterStrategy {
isMac: IS_MAC,
defaultUpdateBranch: DEFAULT_UPDATE_BRANCH,
updateHandoffDwellMs: UPDATE_HANDOFF_DWELL_MS,
directoryExists,
readSourceUpdate: (updateRoot: string, opts: { force?: boolean }): Promise<SourceUpdate | null> => readSourceUpdate({
python: findPythonForRoot(updateRoot),
git: resolveGitBinary(),
@@ -3127,7 +3124,6 @@ function resolveCheckoutUpdateStrategy(): UpdaterStrategy {
}),
resolveUpdateRoot,
resolveUpdaterBinary,
firstLine,
emitUpdateProgress,
rememberLog,

View File

@@ -23,9 +23,8 @@ it('offers manual recovery only for a missing source probe, never for a broken p
readSourceUpdate: probe,
hermesHome: home, isWindows: process.platform === 'win32', isMac: process.platform === 'darwin',
defaultUpdateBranch: 'main', updateHandoffDwellMs: 0,
directoryExists: fs.existsSync,
resolveUpdateRoot: (): string => root, resolveUpdaterBinary: vi.fn((): string => 'frozen-updater'),
firstLine: (text: string): string => text.split('\n')[0], emitUpdateProgress: vi.fn(), rememberLog: vi.fn(),
emitUpdateProgress: vi.fn(), rememberLog: vi.fn(),
startHermes: vi.fn(async (): Promise<void> => {}),
stopBackendsForUpdate: vi.fn(async (): Promise<void> => {}),
repairMacUpdaterHelper: vi.fn(), preflightStateDb: vi.fn(), runningAppBundle: (): null => null,

View File

@@ -17,10 +17,8 @@ it.each(['not-a-git-checkout', 'update-root-steward-owned-git-tree', 'fetch-fail
isMac: process.platform === 'darwin',
defaultUpdateBranch: 'main',
updateHandoffDwellMs: 0,
directoryExists: (): boolean => true,
resolveUpdateRoot: (): string => 'repo',
resolveUpdaterBinary: vi.fn((): null => null),
firstLine: (text: string): string => text.split('\n')[0],
emitUpdateProgress: vi.fn(),
rememberLog: vi.fn(),
startHermes: vi.fn(async (): Promise<void> => {}),

View File

@@ -141,9 +141,7 @@ import urllib.request\nfrom urllib.parse import urlsplit\noriginal = urllib.requ
readSourceUpdate: (install: string, opts: { force?: boolean }): Promise<SourceUpdate | null> => readSourceUpdate({
python, git: 'git', updateRoot: install, hermesHome: home, force: opts.force
}),
directoryExists: fs.existsSync,
resolveUpdaterBinary: (): null => null,
firstLine: (text: string): string => text.split('\n')[0],
emitUpdateProgress: vi.fn(),
rememberLog: vi.fn(),

View File

@@ -33,9 +33,7 @@ export interface CheckoutStrategyDeps {
isMac: boolean
defaultUpdateBranch: string
updateHandoffDwellMs: number
directoryExists: (filePath: string) => boolean
resolveUpdaterBinary: () => string | null
firstLine: (text: string) => string
emitUpdateProgress: (payload: { stage: string; message: string; percent: number | null }) => void
rememberLog: (chunk: unknown) => void

View File

@@ -1,198 +1,3 @@
#!/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-minimal-sandbox/ in the worktree git root).
#
# Usage:
# scripts/dev-minimal-sandbox.sh python -m hermes_cli.main
# scripts/dev-minimal-sandbox.sh hermes desktop
# scripts/dev-minimal-sandbox.sh electron .
# scripts/dev-minimal-sandbox.sh -- npm run dev # from apps/desktop/
# scripts/dev-minimal-sandbox.sh --persistent hermes desktop
# scripts/dev-minimal-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-minimal-sandbox.sh --from ~/.hermes hermes desktop
#
# Override the app name (default: HermesSandbox):
# HERMES_DEV_SANDBOX_NAME=Staging scripts/dev-minimal-sandbox.sh hermes desktop
#
# Override the persistent sandbox dir name (default: .hermes-sandbox):
# HERMES_DEV_SANDBOX_DIR=.staging-sandbox scripts/dev-minimal-sandbox.sh --persistent hermes desktop
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
print_help() {
cat <<'EOF'
Usage: dev-minimal-sandbox.sh [--persistent] [--from DIR] [--] <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.
--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.
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-minimal-sandbox.sh hermes desktop
dev-minimal-sandbox.sh --persistent hermes desktop
dev-minimal-sandbox.sh --from ~/.hermes hermes desktop
dev-minimal-sandbox.sh -- npm run dev
EOF
}
PERSISTENT=false
DELETE=false
SEED_DIR=""
while [ "$#" -gt 0 ]; do
case "$1" in
--persistent)
PERSISTENT=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
;;
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
print_help >&2
exit 1
fi
SANDBOX_DIR_NAME="${HERMES_DEV_SANDBOX_DIR:-.hermes-minimal-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="HermesMinimalSandbox-${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-minimal-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"
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
fi
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
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
# Preserve this entrypoint's separate identity without duplicating sandbox lifecycle.
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/dev-sandbox.sh"

View File

@@ -28,33 +28,44 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENTRYPOINT="$(basename "$0")"
# The minimal entrypoint is an alias, but its existing data and app identity stay separate.
if [ "$ENTRYPOINT" = dev-minimal-sandbox.sh ]; then
DEFAULT_DIR=.hermes-minimal-sandbox
APP_PREFIX=HermesMinimalSandbox
TEMP_PREFIX=hermes-minimal-sandbox
else
DEFAULT_DIR=.hermes-sandbox
APP_PREFIX=HermesSandbox
TEMP_PREFIX=hermes-sandbox
fi
print_help() {
cat <<'EOF'
Usage: dev-sandbox.sh [--persistent] [--from DIR] [--] <command...>
cat <<EOF
Usage: $ENTRYPOINT [--persistent] [--from DIR] [--] <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
git root, in $DEFAULT_DIR/). 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.
--delete Delete the existing persistent sandbox in $DEFAULT_DIR.
-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)
HERMES_DEV_SANDBOX_NAME Override the app name (default prefix: $APP_PREFIX)
HERMES_DEV_SANDBOX_DIR Override the persistent dir name (default: $DEFAULT_DIR)
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
$ENTRYPOINT hermes desktop
$ENTRYPOINT --persistent hermes desktop
$ENTRYPOINT --from ~/.hermes hermes desktop
$ENTRYPOINT -- npm run dev
EOF
}
@@ -117,7 +128,7 @@ if [ "$#" -eq 0 ]; then
fi
SANDBOX_DIR_NAME="${HERMES_DEV_SANDBOX_DIR:-.hermes-sandbox}"
SANDBOX_DIR_NAME="${HERMES_DEV_SANDBOX_DIR:-$DEFAULT_DIR}"
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"
@@ -148,14 +159,14 @@ 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}"
DEFAULT_SANDBOX_NAME="${APP_PREFIX}-${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)"
SANDBOX_ROOT="$(mktemp -d -t "${TEMP_PREFIX}.XXXXXX")"
fi
export HERMES_HOME="$SANDBOX_ROOT/hermes-home"

View File

@@ -0,0 +1,64 @@
"""Both development entrypoints keep their data identities and cleanup contract."""
from __future__ import annotations
import json
import os
from pathlib import Path
import subprocess
import sys
import pytest
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("entry,prefix", [("dev-sandbox.sh", "HermesSandbox-"), ("dev-minimal-sandbox.sh", "HermesMinimalSandbox-")])
def test_ephemeral_sandbox_preserves_command_and_removes_state(tmp_path, entry, prefix):
root = Path(__file__).resolve().parents[2]
checkout = tmp_path / "checkout with spaces"
checkout.mkdir()
subprocess.run(["git", "init", "-q", str(checkout)], check=True, stdin=subprocess.DEVNULL, timeout=10)
output = tmp_path / "observed.json"
probe = tmp_path / "probe.py"
probe.write_text(
"import json, os, sys\nfrom pathlib import Path\n"
"keys = ['HERMES_HOME', 'HERMES_DESKTOP_USER_DATA_DIR', 'HERMES_DESKTOP_APP_NAME']\n"
"Path(sys.argv[1]).write_text(json.dumps({'env': {k: os.environ[k] for k in keys}, 'args': sys.argv[2:]}), encoding='utf-8')\n"
"sys.exit(7)\n", encoding="utf-8",
)
env = {key: value for key, value in os.environ.items() if not key.startswith("HERMES_DEV_SANDBOX_")}
result = subprocess.run(["bash", str(root / "scripts" / entry), "--", sys.executable,
str(probe), str(output), "argument with spaces"], cwd=checkout,
env=env, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=20)
assert result.returncode == 7, result.stderr
observed = json.loads(output.read_text(encoding="utf-8-sig"))
home = Path(observed["env"]["HERMES_HOME"])
assert observed["args"] == ["argument with spaces"]
assert observed["env"]["HERMES_DESKTOP_APP_NAME"].startswith(prefix)
assert Path(observed["env"]["HERMES_DESKTOP_USER_DATA_DIR"]).parent == home.parent
assert not home.parent.exists()
@pytest.mark.platforms("posix")
def test_persistent_identities_seed_once_and_honor_overrides(tmp_path):
root = Path(__file__).resolve().parents[2]
checkout = tmp_path / "checkout"
checkout.mkdir()
subprocess.run(["git", "init", "-q", str(checkout)], check=True, stdin=subprocess.DEVNULL, timeout=10)
seed = tmp_path / "seed"
seed.mkdir()
(seed / "marker").write_text("first", encoding="utf-8")
env = {key: value for key, value in os.environ.items() if not key.startswith("HERMES_DEV_SANDBOX_")}
for entry, directory in [("dev-sandbox.sh", ".hermes-sandbox"), ("dev-minimal-sandbox.sh", ".hermes-minimal-sandbox")]:
command = ["bash", str(root / "scripts" / entry), "--persistent", "--from", str(seed), "--", "true"]
subprocess.run(command, cwd=checkout, env=env, check=True, stdin=subprocess.DEVNULL, capture_output=True, timeout=20)
marker = checkout / directory / "hermes-home" / "marker"
assert marker.read_text(encoding="utf-8-sig") == "first"
marker.write_text("retained", encoding="utf-8")
subprocess.run(command, cwd=checkout, env=env, check=True, stdin=subprocess.DEVNULL, capture_output=True, timeout=20)
assert marker.read_text(encoding="utf-8-sig") == "retained"
env.update(HERMES_DEV_SANDBOX_DIR=".custom", HERMES_DEV_SANDBOX_NAME="CustomSandbox")
result = subprocess.run(["bash", str(root / "scripts/dev-minimal-sandbox.sh"), "--persistent", "--", "env"],
cwd=checkout, env=env, check=True, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=20)
assert f"HERMES_HOME={checkout / '.custom/hermes-home'}" in result.stdout
assert "HERMES_DESKTOP_APP_NAME=CustomSandbox" in result.stdout
assert (checkout / ".custom/user-data").is_dir()

View File

@@ -17,7 +17,7 @@ import time
import uuid
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Callable, IO, Iterable, Protocol
from typing import Callable, Iterable
from hermes_constants import get_hermes_home
from tools.interrupt import consume_yield, is_interrupted, is_thread_interrupted
@@ -142,98 +142,6 @@ def _file_mtime_key(host_path: str) -> tuple[float, int] | None:
return None
# ---------------------------------------------------------------------------
# ProcessHandle protocol
# ---------------------------------------------------------------------------
class ProcessHandle(Protocol):
"""Duck type that every backend's _run_bash() must return.
subprocess.Popen satisfies this natively. SDK backends (Modal, Daytona)
return _ThreadedProcessHandle which adapts their blocking calls.
"""
def poll(self) -> int | None: ...
def kill(self) -> None: ...
def wait(self, timeout: float | None = None) -> int: ...
@property
def stdout(self) -> IO[str] | None: ...
@property
def returncode(self) -> int | None: ...
class _ThreadedProcessHandle:
"""Adapter for SDK backends (Modal, Daytona) that have no real subprocess.
Wraps a blocking ``exec_fn() -> (output_str, exit_code)`` in a background
thread and exposes a ProcessHandle-compatible interface. An optional
``cancel_fn`` is invoked on ``kill()`` for backend-specific cancellation
(e.g. Modal sandbox.terminate, Daytona sandbox.stop).
"""
def __init__(
self,
exec_fn: Callable[[], tuple[str, int]],
cancel_fn: Callable[[], None] | None = None,
):
self._cancel_fn = cancel_fn
self._done = threading.Event()
self._returncode: int | None = None
self._error: Exception | None = None
# Pipe for stdout — drain thread in _wait_for_process reads the read end.
read_fd, write_fd = os.pipe()
self._stdout = os.fdopen(read_fd, "r", encoding="utf-8", errors="replace") # windows-footgun: ok (pipe is BOM-free)
self._write_fd = write_fd
def _worker():
try:
output, exit_code = exec_fn()
self._returncode = exit_code
# Write output into the pipe so drain thread picks it up.
try:
os.write(self._write_fd, output.encode("utf-8", errors="replace"))
except OSError:
pass
except Exception as exc:
self._error = exc
self._returncode = 1
finally:
try:
os.close(self._write_fd)
except OSError:
pass
self._done.set()
t = threading.Thread(target=_worker, daemon=True)
t.start()
@property
def stdout(self):
return self._stdout
@property
def returncode(self) -> int | None:
return self._returncode
def poll(self) -> int | None:
return self._returncode if self._done.is_set() else None
def kill(self):
if self._cancel_fn:
try:
self._cancel_fn()
except Exception:
pass
def wait(self, timeout: float | None = None) -> int:
self._done.wait(timeout=timeout)
return self._returncode
# ---------------------------------------------------------------------------
# BaseEnvironment
# ---------------------------------------------------------------------------