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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> => {}),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
64
tests/scripts/test_dev_sandbox.py
Normal file
64
tests/scripts/test_dev_sandbox.py
Normal 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()
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user