fix activation of devenv, use /usr/bin/env bash everywhere

This commit is contained in:
ethernet
2026-09-11 11:17:18 -04:00
parent 3c2e1bd452
commit 8f6d98e4c3
58 changed files with 733 additions and 257 deletions

View File

@@ -165,6 +165,9 @@ jobs:
- name: Run footgun checker
run: python scripts/check-windows-footguns.py --all
- name: Require portable Bash shebangs
run: python scripts/check_bash_shebangs.py
# The Sep 2026 decomposition kept old import paths alive for external plugins
# (PLUGIN-COMPAT blocks, see COMPAT_MANIFEST.md). They are removed on schedule by
# reverting one commit, so in-tree code must never depend on them.

View File

@@ -173,8 +173,13 @@ session-scoped. Assert the GUI session gets the tool **with the env var absent**
## Development Environment
```bash
source .venv/bin/activate # or: source venv/bin/activate
source ./activate # provisions/syncs PM tools + dependencies, then activates
```
Select an isolated development `HERMES_HOME` and `HERMES_RUNTIME_DIR` first;
see `website/docs/reference/package-management.md#developer-workflow`.
PowerShell: `. .\activate.ps1`. `deactivate` restores the prior environment.
For tests, use the independent test environment in `CONTRIBUTING.md` (or Nix);
PM activation's `PYTHONPATH` does not survive the test runner's environment scrub.
`scripts/run_tests.sh` probes `.venv`, then `venv`, then `$HOME/.hermes/hermes-agent/venv`
(worktrees sharing the main checkout's venv).

View File

@@ -119,7 +119,8 @@ Use the [PM developer workflow](website/docs/reference/package-management.md#dev
dependency changes, and test environments. Select your development
home before setup so experimental code does not migrate production data.
After successful setup, activate from the repository root in each new shell.
Activate from the repository root in each new shell. Activation runs setup's
runtime-only path, so it provisions a fresh checkout and syncs stale dependencies.
Bash:
@@ -136,8 +137,9 @@ python hermes --version
```
Run `python hermes` for this checkout, not a global `hermes` alias. PM activation
adds installed tools and the selected dependency tree. It does not install
packages or JS workspaces. `deactivate` restores the prior shell environment.
syncs tools and Python dependencies before adding them to the shell. It does not
install JS workspaces or rewrite launchers and shell configuration. `deactivate`
restores the prior shell environment.
### Manual development and test environment

20
activate Normal file → Executable file
View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# ============================================================================
# venv-style activation for the Hermes dev environment (pm-managed tools).
#
@@ -8,18 +8,26 @@
# Emits the composed pm env (PATH + tool vars) into the CURRENT shell, with
# save/restore: `deactivate` undoes exactly what activation changed.
#
# Requires a completed `./setup-hermes.sh` — it never invokes uv. It runs
# the pm store's pinned python (fallback: the repo venv) to emit the env
# JSON, then exports it here.
# Sync through setup before selecting the environment. PM owns freshness;
# activation does not maintain a second dependency stamp.
# ============================================================================
_HERMES_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Setup runs in a child: failures must not exit or partially activate the
# caller's shell, and activation must not republish launchers or shell config.
if ! (
unset PYTHONHOME PYTHONPATH VIRTUAL_ENV
bash "$_HERMES_REPO/setup-hermes.sh" --runtime-only
) >&2; then
printf '%s\n' 'activate: setup failed; shell environment unchanged' >&2
return 1 2>/dev/null || exit 1
fi
# Guard against double-sourcing: venv activate precedent (re-activating is a
# no-op re-save; we keep it idempotent by deactivating first).
if [ -n "${__HERMES_ACTIVATED:-}" ]; then
deactivate
fi
_HERMES_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Bootstrap Python only emits the environment; it does not install anything.
_hermes_repo="$_HERMES_REPO"
_hermes_py=""

20
activate.ps1 Normal file → Executable file
View File

@@ -1,7 +1,23 @@
# Source this file to apply the installed PM environment; deactivate restores it.
# Source this file to sync and apply the PM environment; deactivate restores it.
$ErrorActionPreference = 'Stop'
if (Test-Path function:deactivate) { deactivate }
$repo = $PSScriptRoot
$bootstrapSaved = @{}
foreach ($key in @('PYTHONPATH', 'PYTHONHOME', 'VIRTUAL_ENV')) {
$bootstrapSaved[$key] = [Environment]::GetEnvironmentVariable($key)
Remove-Item "env:$key" -ErrorAction SilentlyContinue
}
try {
# Run separately so setup's exit/failure cannot terminate the sourced shell.
$shell = (Get-Process -Id $PID).Path
& $shell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$repo\setup-hermes.ps1" -RuntimeOnly | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'activate: setup failed; shell environment unchanged' }
} finally {
foreach ($key in $bootstrapSaved.Keys) {
if ($null -eq $bootstrapSaved[$key]) { Remove-Item "env:$key" -ErrorAction SilentlyContinue }
else { Set-Item "env:$key" $bootstrapSaved[$key] }
}
}
if (Test-Path function:deactivate) { deactivate }
$py = $null
foreach ($candidate in @("$repo\.venv\Scripts\python.exe", "$repo\venv\Scripts\python.exe")) {
if (Test-Path -LiteralPath $candidate) { $py = $candidate; break }

View File

@@ -252,7 +252,7 @@ const UTF8_BOM: &[u8] = &[0xEF, 0xBB, 0xBF];
/// Prepare bytes for the on-disk bootstrap cache.
///
/// `.ps1` files get a UTF-8 BOM (unless one is already present). `.sh` files
/// are left unchanged — a BOM would break `#!/bin/bash`.
/// are left unchanged — a BOM would break `#!/usr/bin/env bash`.
pub(crate) fn prepare_cached_script_bytes(kind: ScriptKind, bytes: &[u8]) -> Vec<u8> {
match kind {
ScriptKind::Ps1 => {
@@ -429,9 +429,9 @@ mod tests {
#[test]
fn prepare_cached_sh_stays_bomless() {
let out = prepare_cached_script_bytes(ScriptKind::Sh, b"#!/bin/bash\n");
let out = prepare_cached_script_bytes(ScriptKind::Sh, b"#!/usr/bin/env bash\n");
assert!(!out.starts_with(UTF8_BOM));
assert_eq!(out, b"#!/bin/bash\n");
assert_eq!(out, b"#!/usr/bin/env bash\n");
}
#[test]
@@ -492,10 +492,10 @@ mod tests {
let dir = std::env::temp_dir().join(format!("hermes-bom-sh-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cached = dir.join("install-main.sh");
std::fs::write(&cached, b"#!/bin/bash\n").unwrap();
std::fs::write(&cached, b"#!/usr/bin/env bash\n").unwrap();
upgrade_cached_script(ScriptKind::Sh, &cached, &|_| {});
assert_eq!(std::fs::read(&cached).unwrap(), b"#!/bin/bash\n");
assert_eq!(std::fs::read(&cached).unwrap(), b"#!/usr/bin/env bash\n");
std::fs::remove_dir_all(&dir).unwrap();
}

View File

@@ -17,7 +17,7 @@ for (const boundary of ['resolution', 'manifest'] as const) {
fs.mkdirSync(path.join(home, 'scripts'))
fs.writeFileSync(
path.join(home, 'scripts/install.sh'),
`#!/bin/bash\nprintf started > "$HERMES_HOME/manifest-started"\nprintf 'manifest-pid=%s\\n' "$$"\nwhile :; do :; done\n`
`#!/usr/bin/env bash\nprintf started > "$HERMES_HOME/manifest-started"\nprintf 'manifest-pid=%s\\n' "$$"\nwhile :; do :; done\n`
)
try {

View File

@@ -237,7 +237,7 @@ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot,
const q = s => `'${String(s).replace(/'/g, `'\\''`)}'`
const lines = [
'#!/bin/bash',
'#!/usr/bin/env bash',
'set -u',
'# Wait (up to ~30s) for the desktop process to exit so the venv python',
'# and the app bundle are no longer in use.',

View File

@@ -586,7 +586,7 @@ test.skipIf(process.platform === 'win32')(
await mkdir(venvBin, { recursive: true })
await symlink(python, pythonLink)
await writeFile(entrypoint, 'import time\ntime.sleep(30)\n', 'utf8')
await writeFile(launcher, `#!/bin/bash\nexec "${pythonLink}" "${entrypoint}" "$@"\n`, 'utf8')
await writeFile(launcher, `#!/usr/bin/env bash\nexec "${pythonLink}" "${entrypoint}" "$@"\n`, 'utf8')
await chmod(launcher, 0o755)
const backendFlags = [

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# =============================================================================
# Example: Browser-Focused Data Generation

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# Build libfts5_cjk.so and install to ~/.hermes/lib/ (or $1).
#
# Uses the system sqlite3ext.h when present, else the vendored copy in

View File

@@ -132,7 +132,7 @@ Check with: `sudo ufw status | grep 25565`
### 8. Create Launch Script
```bash
cat > ~/start-minecraft.sh << 'EOF'
#!/bin/bash
#!/usr/bin/env bash
cd ~/minecraft-server/server
java @user_jvm_args.txt @libraries/net/neoforged/neoforge/<VERSION>/unix_args.txt nogui
EOF
@@ -144,7 +144,7 @@ Note: For Forge (not NeoForge), the args file path differs. Check `startserver.s
Create backup script:
```bash
cat > ~/minecraft-server/backup.sh << 'SCRIPT'
#!/bin/bash
#!/usr/bin/env bash
SERVER_DIR="$HOME/minecraft-server/server"
BACKUP_DIR="$HOME/minecraft-server/backups"
WORLD_DIR="$SERVER_DIR/world"

View File

@@ -168,7 +168,7 @@ Training Progress Tracking:
Evaluate every N training steps:
```bash
#!/bin/bash
#!/usr/bin/env bash
# eval_checkpoint.sh
CHECKPOINT_DIR=$1
@@ -272,7 +272,7 @@ microsoft/phi-2
**Step 2: Run evaluations**
```bash
#!/bin/bash
#!/usr/bin/env bash
# eval_all_models.sh
TASKS="mmlu,gsm8k,hellaswag,truthfulqa"

View File

@@ -324,7 +324,7 @@ Memory per GPU = Total Memory / TP
**Submit job**:
```bash
#!/bin/bash
#!/usr/bin/env bash
#SBATCH --nodes=4
#SBATCH --gpus-per-node=8
#SBATCH --ntasks-per-node=1

View File

@@ -228,7 +228,7 @@ curl http://localhost:8000/health
**Readiness check** (wait for model loaded):
```bash
#!/bin/bash
#!/usr/bin/env bash
until curl -f http://localhost:8000/health; do
echo "Waiting for vLLM to be ready..."
sleep 5

View File

@@ -281,7 +281,7 @@ def monitor_job(ip: str, ssh_key_path: str, log_file: str = "train.log"):
### Slurm job submission
```bash
#!/bin/bash
#!/usr/bin/env bash
#SBATCH --job-name=llm-training
#SBATCH --nodes=4
#SBATCH --ntasks-per-node=8

View File

@@ -97,7 +97,7 @@ with open("custom_data.json", "w") as f:
### Fine-tune script
```bash
#!/bin/bash
#!/usr/bin/env bash
# Set paths
DATA_PATH="custom_data.json"

View File

@@ -207,7 +207,7 @@ trainer.fit(model, train_loader)
**SLURM job script**:
```bash
#!/bin/bash
#!/usr/bin/env bash
#SBATCH --nodes=4
#SBATCH --ntasks-per-node=8
#SBATCH --gres=gpu:8

View File

@@ -159,7 +159,7 @@ context_parallel_degree = 1 # Increase for long sequences
**Step 2: Set up SLURM script**
```bash
#!/bin/bash
#!/usr/bin/env bash
#SBATCH --job-name=llama70b
#SBATCH --nodes=32
#SBATCH --ntasks-per-node=8

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# DuckDuckGo Search Helper Script
# Wrapper around ddgs CLI with sensible defaults
# Usage: ./duckduckgo.sh <query> [max_results]

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# Usage: ./searxng.sh <query> [max_results] [engines]
# Example: ./searxng.sh "python async" 10 "google,bing"

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Require PATH-resolved Bash in scripts, generated scripts, and examples.
Nix and other non-FHS environments need not provide Bash at a fixed bin path.
Scan tracked text, including extensionless scripts and embedded shell payloads.
"""
from __future__ import annotations
import argparse
from pathlib import Path
import re
import subprocess
FIXED_BASH = re.compile(rb"#![ \t]*/(?:usr/)?bin/bash\b")
def check(root: Path) -> list[str]:
tracked = subprocess.run(
["git", "ls-files", "-z"], cwd=root, check=True, capture_output=True,
).stdout
findings = []
for name in tracked.split(b"\0"):
if not name:
continue
relative = name.decode("utf-8", errors="surrogateescape")
path = root / relative
if path.is_symlink() or not path.is_file():
continue
data = path.read_bytes()
if b"\0" in data:
continue
for number, line in enumerate(data.splitlines(), 1):
if FIXED_BASH.search(line):
findings.append(f"{relative}:{number}: use #!/usr/bin/env bash")
return findings
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
args = parser.parse_args()
findings = check(args.root)
for finding in findings:
print(finding)
print(f"Bash shebang check: {len(findings)} violation(s)")
return int(bool(findings))
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# posix.sh -- repo-owned macOS/Linux Desktop update hand-off.
#
# The whole job: wait for the Desktop to exit, run `hermes update`, tell the

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# repro.sh -- reproduce desktop-update paths against a sandboxed HERMES_HOME.
#
# Nothing here touches your real ~/.hermes or checkout. Each mode builds (or

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# Kill all running Modal apps (sandboxes, deployments, etc.)
#
# Usage:

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# Smoke-test a staged pm payload (`pm bundle --out`): the raw store python
# boots hermes_cli/pm out of the staged repo snapshot (cwd at the staged
# repo, no network, lazy installs off). The desktop's self-relative CLI

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# Full A/B eval: N models x 2 arms x 9 tasks x R reps.
#
# Usage:

View File

@@ -10,6 +10,7 @@
# 3. Point you at `.\activate.ps1` - the venv-style way to put the pm env
# (PATH + tool vars) into your current session.
# ============================================================================
param([switch]$RuntimeOnly)
$ErrorActionPreference = 'Stop'
Write-Host ''
@@ -87,6 +88,8 @@ try {
}
Write-Host 'Tools + dependencies installed (hash-verified via pm + uv.lock)' -ForegroundColor Green
if ($RuntimeOnly) { exit 0 }
# ---------------------------------------------------------------------------
# Environment file
# ---------------------------------------------------------------------------

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# ============================================================================
# Hermes Agent Setup Script — THE dev-environment entry point.
# ============================================================================
@@ -15,6 +15,14 @@
set -e
# Activation needs only provisioning, not user-facing installation side effects.
runtime_only=false
case "${1:-}" in
--runtime-only) runtime_only=true ;;
'') ;;
*) printf 'Unknown setup option: %s\n' "$1" >&2; exit 2 ;;
esac
# Colors
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
@@ -154,6 +162,10 @@ if ! "$boot_py" -m pm.cli install; then
fi
echo -e "${GREEN}✓${NC} Tools + dependencies installed (hash-verified via pm + uv.lock)"
if [ "$runtime_only" = true ]; then
exit 0
fi
# ============================================================================
# Environment file
# ============================================================================

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# p5.js Skill — Headless Render Pipeline
# Renders a p5.js sketch to MP4 video via Puppeteer + ffmpeg
#

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# p5.js Skill — Local Development Server
# Serves the current directory over HTTP for loading local assets (fonts, images)
#

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# p5.js Skill — Dependency Verification
# Run: bash skills/creative/p5js/scripts/setup.sh

View File

@@ -44,7 +44,7 @@ def _write_forking_script(tmp_path, stall_after: bool):
script.write_text(
textwrap.dedent(
f"""\
#!/bin/bash
#!/usr/bin/env bash
sleep 300 &
echo $! > {marker}
{tail}
@@ -105,7 +105,7 @@ def test_successful_hook_preserves_detached_helpers(tmp_path):
script.write_text(
textwrap.dedent(
f"""\
#!/bin/bash
#!/usr/bin/env bash
sleep 300 > /dev/null 2>&1 < /dev/null &
echo $! > {marker}
exit 0
@@ -128,7 +128,7 @@ def test_successful_hook_preserves_detached_helpers(tmp_path):
def test_hook_child_leads_own_process_group(tmp_path):
"""The hook child must lead its own group (killpg ownership precondition)."""
script = tmp_path / "pgid.sh"
script.write_text("#!/bin/bash\necho \"$$ $(ps -o pgid= -p $$ | tr -d ' ')\"\n")
script.write_text("#!/usr/bin/env bash\necho \"$$ $(ps -o pgid= -p $$ | tr -d ' ')\"\n")
script.chmod(0o755)
r = _spawn(_spec(str(script), timeout=10), "{}")
@@ -142,7 +142,7 @@ def test_fast_path_contract_unchanged(tmp_path):
"""stdin JSON delivery, stdout/stderr capture, and exit codes still work."""
script = tmp_path / "echoer.sh"
script.write_text(
"#!/bin/bash\ncat\necho errline >&2\nexit 3\n"
"#!/usr/bin/env bash\ncat\necho errline >&2\nexit 3\n"
)
script.chmod(0o755)

View File

@@ -95,7 +95,7 @@ def test_run_job_no_agent_success_returns_script_stdout(hermes_env):
from cron.scheduler import run_job
script_path = hermes_env / "scripts" / "alert.sh"
script_path.write_text("#!/bin/bash\necho 'RAM 92% on host'\n")
script_path.write_text("#!/usr/bin/env bash\necho 'RAM 92% on host'\n")
job = create_job(
prompt=None, schedule="every 5m", script="alert.sh", no_agent=True, deliver="local"
@@ -125,7 +125,7 @@ def test_run_job_no_agent_reloads_dotenv_before_script(hermes_env, monkeypatch):
monkeypatch.setattr(env_loader, "load_hermes_dotenv", fake_load)
script_path = hermes_env / "scripts" / "probe.sh"
script_path.write_text('#!/bin/bash\necho "ok"\n')
script_path.write_text('#!/usr/bin/env bash\necho "ok"\n')
job = create_job(
prompt=None, schedule="every 5m", script="probe.sh", no_agent=True, deliver="local"

View File

@@ -20,7 +20,7 @@ def hermes_env(tmp_path, monkeypatch):
home.mkdir()
(home / "scripts").mkdir()
(home / "cron").mkdir()
(home / "scripts" / "watch.sh").write_text("#!/bin/bash\necho alert\n")
(home / "scripts" / "watch.sh").write_text("#!/usr/bin/env bash\necho alert\n")
monkeypatch.setenv("HERMES_HOME", str(home))
import importlib

View File

@@ -28,7 +28,7 @@ def fake_gh(tmp_path, monkeypatch):
if sys.platform.startswith("win"):
pytest.skip("POSIX shell stub")
gh = tmp_path / "gh"
gh.write_text("#!/bin/bash\nsleep 1\necho posted\nexit 0\n", encoding="utf-8")
gh.write_text("#!/usr/bin/env bash\nsleep 1\necho posted\nexit 0\n", encoding="utf-8")
gh.chmod(gh.stat().st_mode | stat.S_IXUSR)
monkeypatch.setenv("PATH", f"{tmp_path}{os.pathsep}{os.environ['PATH']}")
return gh

View File

@@ -385,7 +385,7 @@ class TestCronCreateLifecycleBlock:
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
scripts_dir = tmp_path / ".hermes" / "scripts"
scripts_dir.mkdir(parents=True)
(scripts_dir / "restart.sh").write_text("#!/bin/bash\nhermes gateway restart\n", encoding="utf-8")
(scripts_dir / "restart.sh").write_text("#!/usr/bin/env bash\nhermes gateway restart\n", encoding="utf-8")
args = Namespace(
cron_command="create",
schedule="1h",
@@ -561,7 +561,7 @@ class TestTerminalToolGatewayLifecycleGuard:
import tools.terminal_tool as tt
script = tmp_path / "delayed-ops.sh"
script.write_text("#!/bin/bash\nsleep 45\nhermes gateway restart\n", encoding="utf-8")
script.write_text("#!/usr/bin/env bash\nsleep 45\nhermes gateway restart\n", encoding="utf-8")
self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True)
result = json.loads(tt.terminal_tool(command=f"/bin/bash {script}"))
@@ -573,7 +573,7 @@ class TestTerminalToolGatewayLifecycleGuard:
import tools.terminal_tool as tt
script = tmp_path / "health-check.sh"
script.write_text("#!/bin/bash\nprintf 'healthy\\n'\n", encoding="utf-8")
script.write_text("#!/usr/bin/env bash\nprintf 'healthy\\n'\n", encoding="utf-8")
self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True)
result = json.loads(tt.terminal_tool(
@@ -699,7 +699,7 @@ class TestTerminalToolGatewayLifecycleGuard:
script = tmp_path / "wrapper.sh"
script.write_text(
"#!/bin/bash\nlaunchctl submit -l ai.hermes.loop -- /bin/true\n"
"#!/usr/bin/env bash\nlaunchctl submit -l ai.hermes.loop -- /bin/true\n"
)
self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True)
@@ -712,7 +712,7 @@ class TestTerminalToolGatewayLifecycleGuard:
import tools.terminal_tool as tt
script = tmp_path / "relative.sh"
script.write_text("#!/bin/bash\nhermes gateway restart\n", encoding="utf-8")
script.write_text("#!/usr/bin/env bash\nhermes gateway restart\n", encoding="utf-8")
class _FakeEnv:
env = {}
@@ -732,7 +732,7 @@ class TestTerminalToolGatewayLifecycleGuard:
import tools.terminal_tool as tt
script = tmp_path / "delayed.sh"
script.write_text("#!/bin/bash\nhermes gateway stop\n", encoding="utf-8")
script.write_text("#!/usr/bin/env bash\nhermes gateway stop\n", encoding="utf-8")
script.chmod(0o700)
self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True)
@@ -756,7 +756,7 @@ class TestTerminalToolGatewayLifecycleGuard:
import tools.terminal_tool as tt
script = tmp_path / "options.sh"
script.write_text("#!/bin/bash\nhermes gateway restart\n", encoding="utf-8")
script.write_text("#!/usr/bin/env bash\nhermes gateway restart\n", encoding="utf-8")
self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True)
result = json.loads(tt.terminal_tool(
@@ -769,7 +769,7 @@ class TestTerminalToolGatewayLifecycleGuard:
import tools.terminal_tool as tt
script = tmp_path / "nested.sh"
script.write_text("#!/bin/bash\nlaunchctl submit -l ai.hermes.loop -- /bin/true\n", encoding="utf-8")
script.write_text("#!/usr/bin/env bash\nlaunchctl submit -l ai.hermes.loop -- /bin/true\n", encoding="utf-8")
class _FakeEnv:
env = {}
@@ -790,9 +790,9 @@ class TestTerminalToolGatewayLifecycleGuard:
import tools.terminal_tool as tt
inner = tmp_path / "inner.sh"
inner.write_text("#!/bin/bash\nhermes gateway restart\n", encoding="utf-8")
inner.write_text("#!/usr/bin/env bash\nhermes gateway restart\n", encoding="utf-8")
outer = tmp_path / "outer.sh"
outer.write_text("#!/bin/bash\n/bin/bash inner.sh\n", encoding="utf-8")
outer.write_text("#!/usr/bin/env bash\n/bin/bash inner.sh\n", encoding="utf-8")
class _FakeEnv:
env = {}
@@ -846,7 +846,7 @@ class TestTerminalToolGatewayLifecycleGuard:
calls = []
script = tmp_path / "health-check.sh"
script.write_text("#!/bin/bash\nprintf 'healthy\\n'\n", encoding="utf-8")
script.write_text("#!/usr/bin/env bash\nprintf 'healthy\\n'\n", encoding="utf-8")
class _FakeEnv:
env = {}
@@ -906,7 +906,7 @@ class TestLifecycleGuardModule:
contains_gateway_lifecycle_command_or_referenced_script,
)
script = tmp_path / "restart.sh"
script.write_text("#!/bin/bash\nhermes gateway restart\n")
script.write_text("#!/usr/bin/env bash\nhermes gateway restart\n")
assert (
contains_gateway_lifecycle_command_or_referenced_script(f". {script}")
is True
@@ -925,7 +925,7 @@ class TestLifecycleGuardModule:
contains_gateway_lifecycle_command_or_referenced_script,
)
script = tmp_path / "padded.sh"
script.write_bytes(b"#!/bin/bash\n# pad\x00\nhermes gateway restart\n")
script.write_bytes(b"#!/usr/bin/env bash\n# pad\x00\nhermes gateway restart\n")
assert (
contains_gateway_lifecycle_command_or_referenced_script(f"bash {script}")
is True
@@ -938,7 +938,7 @@ class TestLifecycleGuardModule:
contains_gateway_lifecycle_command_or_referenced_script,
)
script = tmp_path / "restart.sh"
script.write_text("#!/bin/bash\nhermes gateway restart\n")
script.write_text("#!/usr/bin/env bash\nhermes gateway restart\n")
assert (
contains_gateway_lifecycle_command_or_referenced_script(f"source {script}")
is True
@@ -951,7 +951,7 @@ class TestLifecycleGuardModule:
contains_gateway_lifecycle_command_or_referenced_script,
)
script = tmp_path / "activate.sh"
script.write_text("#!/bin/bash\nexport PATH=/usr/bin:$PATH\n")
script.write_text("#!/usr/bin/env bash\nexport PATH=/usr/bin:$PATH\n")
assert (
contains_gateway_lifecycle_command_or_referenced_script(f". {script}")
is False
@@ -1019,7 +1019,7 @@ class TestLifecycleGuardModule:
contains_gateway_lifecycle_command_or_referenced_script,
)
script = tmp_path / "huge.sh"
script.write_bytes(b"#!/bin/bash\n# \x00" + b"x" * (1024 * 1024 + 64) + b"\n")
script.write_bytes(b"#!/usr/bin/env bash\n# \x00" + b"x" * (1024 * 1024 + 64) + b"\n")
assert (
contains_gateway_lifecycle_command_or_referenced_script(f"bash {script}")
is True
@@ -1031,7 +1031,7 @@ class TestLifecycleGuardModule:
contains_gateway_lifecycle_command_or_referenced_script,
)
script = tmp_path / "safe.sh"
script.write_bytes(b"#!/bin/bash\necho hello\n")
script.write_bytes(b"#!/usr/bin/env bash\necho hello\n")
assert (
contains_gateway_lifecycle_command_or_referenced_script(f"bash {script}")
is False
@@ -1051,7 +1051,7 @@ class TestLifecycleGuardModule:
def test_script_with_command_raises(self, tmp_path, monkeypatch):
from cron.lifecycle_guard import GatewayLifecycleBlocked, check_gateway_lifecycle
script = tmp_path / "restart.sh"
script.write_text("#!/bin/bash\nhermes gateway restart\n", encoding="utf-8")
script.write_text("#!/usr/bin/env bash\nhermes gateway restart\n", encoding="utf-8")
with pytest.raises(GatewayLifecycleBlocked):
check_gateway_lifecycle("clean prompt", str(script))
@@ -1059,7 +1059,7 @@ class TestLifecycleGuardModule:
from cron.lifecycle_guard import GatewayLifecycleBlocked, check_gateway_lifecycle
script = tmp_path / "persistent.sh"
script.write_text(
"#!/bin/bash\nlaunchctl submit -l ai.hermes.loop -- /bin/true\n"
"#!/usr/bin/env bash\nlaunchctl submit -l ai.hermes.loop -- /bin/true\n"
)
with pytest.raises(GatewayLifecycleBlocked):
check_gateway_lifecycle("clean prompt", str(script))
@@ -1075,7 +1075,7 @@ class TestLifecycleGuardModule:
):
from cron.lifecycle_guard import GatewayLifecycleBlocked, check_gateway_lifecycle
script = tmp_path / "persistent.sh"
script.write_text(f"#!/bin/bash\n{line}\n", encoding="utf-8")
script.write_text(f"#!/usr/bin/env bash\n{line}\n", encoding="utf-8")
with pytest.raises(GatewayLifecycleBlocked):
check_gateway_lifecycle("clean prompt", str(script))
@@ -1225,8 +1225,8 @@ class TestLifecycleGuardModule:
a .sh script that itself invokes a lifecycle command is caught."""
from cron.lifecycle_guard import GatewayLifecycleBlocked, check_gateway_lifecycle
script = tmp_path / "wrapper.sh"
script.write_text("#!/bin/bash\n./deploy.sh\n", encoding="utf-8")
(tmp_path / "deploy.sh").write_text("#!/bin/bash\nhermes gateway stop\n", encoding="utf-8")
script.write_text("#!/usr/bin/env bash\n./deploy.sh\n", encoding="utf-8")
(tmp_path / "deploy.sh").write_text("#!/usr/bin/env bash\nhermes gateway stop\n", encoding="utf-8")
with pytest.raises(GatewayLifecycleBlocked):
check_gateway_lifecycle("daily ops", str(script))
@@ -1884,7 +1884,7 @@ class TestTerminalToolGatewayLifecycleGuardRemote:
def execute(self, command, **kwargs):
calls.append(command)
if "head -c" in command and "/remote/workspace/remote.sh" in command:
return {"output": "#!/bin/bash\nhermes gateway restart\n", "returncode": 0}
return {"output": "#!/usr/bin/env bash\nhermes gateway restart\n", "returncode": 0}
return {"output": "", "returncode": 0}
fake_env = _RemoteEnv()
@@ -1911,8 +1911,8 @@ class TestCronCreateLifecycleBlockExtra:
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
scripts_dir = tmp_path / ".hermes" / "scripts"
scripts_dir.mkdir(parents=True)
(scripts_dir / "inner.sh").write_text("#!/bin/bash\nhermes gateway restart\n", encoding="utf-8")
(scripts_dir / "outer.sh").write_text("#!/bin/bash\n/bin/bash inner.sh\n", encoding="utf-8")
(scripts_dir / "inner.sh").write_text("#!/usr/bin/env bash\nhermes gateway restart\n", encoding="utf-8")
(scripts_dir / "outer.sh").write_text("#!/usr/bin/env bash\n/bin/bash inner.sh\n", encoding="utf-8")
args = Namespace(
cron_command="create",
schedule="1h",

View File

@@ -34,7 +34,7 @@ def _write_forking_script(tmp_path, marker_name="child.pid"):
script.write_text(
textwrap.dedent(
f"""\
#!/bin/bash
#!/usr/bin/env bash
sleep 300 &
echo $! > {marker}
sleep 300
@@ -84,7 +84,7 @@ def test_timeout_kills_descendants(tmp_path):
def test_posix_spawn_uses_own_process_group(tmp_path):
"""The probe child must lead its own process group (killpg precondition)."""
script = tmp_path / "pgid.sh"
script.write_text("#!/bin/bash\necho \"$$ $(ps -o pgid= -p $$ | tr -d ' ')\"\n")
script.write_text("#!/usr/bin/env bash\necho \"$$ $(ps -o pgid= -p $$ | tr -d ' ')\"\n")
script.chmod(0o755)
out = bounded_git_probe([str(script)], timeout=5.0)

View File

@@ -218,7 +218,7 @@ def test_exec_leaves_shell_wrapper_launchers_alone(tmp_path, xdg_home, monkeypat
root = _make_project(tmp_path)
hermes_bin = tmp_path / "bin" / "hermes"
hermes_bin.parent.mkdir()
hermes_bin.write_text('#!/bin/bash\nexec /opt/hermes/venv/bin/python "$@"\n', encoding="utf-8")
hermes_bin.write_text('#!/usr/bin/env bash\nexec /opt/hermes/venv/bin/python "$@"\n', encoding="utf-8")
hermes_bin.chmod(0o755)
monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", lambda: str(hermes_bin))
monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: [])
@@ -287,7 +287,7 @@ def test_exec_converges_from_repo_script_argv0_to_installed_wrapper(
repo_script.chmod(0o755)
wrapper = tmp_path / "installed" / "bin" / "hermes"
wrapper.parent.mkdir(parents=True)
wrapper.write_text(f'#!/bin/bash\nexec {sys.executable} "$@"\n', encoding="utf-8")
wrapper.write_text(f'#!/usr/bin/env bash\nexec {sys.executable} "$@"\n', encoding="utf-8")
wrapper.chmod(0o755)
# argv[0] = repo script; PATH lookup finds the installed wrapper.
@@ -316,7 +316,7 @@ def test_exec_never_persists_a_bare_interpreter_command(
root = _make_project(tmp_path)
wrapper = tmp_path / "installed" / "bin" / "hermes"
wrapper.parent.mkdir(parents=True)
wrapper.write_text("#!/bin/bash\nexit 0\n", encoding="utf-8")
wrapper.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8")
wrapper.chmod(0o755)
interpreter = tmp_path / "uv" / "cpython-3.11.15" / "bin" / "python3.11"
@@ -411,7 +411,7 @@ def test_exec_uses_known_wrapper_when_path_lookup_misses(
known_wrapper = tmp_path / "known-home" / ".local" / "bin" / "hermes"
known_wrapper.parent.mkdir(parents=True)
known_wrapper.write_text(
f'#!/bin/bash\nexec {root / "venv" / "bin" / "python"} {root / "hermes"} "$@"\n',
f'#!/usr/bin/env bash\nexec {root / "venv" / "bin" / "python"} {root / "hermes"} "$@"\n',
encoding="utf-8",
)
known_wrapper.chmod(0o755)
@@ -460,7 +460,7 @@ def test_exec_rejects_known_wrapper_from_another_checkout(
foreign_wrapper = tmp_path / "known-home" / ".local" / "bin" / "hermes"
foreign_wrapper.parent.mkdir(parents=True)
foreign_wrapper.write_text(
f"#!/bin/bash\nexec {other_root / 'venv' / 'bin' / 'python'} "
f"#!/usr/bin/env bash\nexec {other_root / 'venv' / 'bin' / 'python'} "
f'{other_root / "hermes"} "$@"\n',
encoding="utf-8",
)
@@ -800,7 +800,7 @@ def test_wrapper_ownership_rejects_sibling_extensions(suffix, tmp_path):
checkout.mkdir()
evil = tmp_path / "evil-shim"
evil.write_text(
f"#!/bin/bash\n"
f"#!/usr/bin/env bash\n"
f"exec {checkout}{suffix}/venv/bin/python "
f'{checkout}{suffix}/hermes "$@"\n',
encoding="utf-8",
@@ -829,7 +829,7 @@ def test_wrapper_ownership_accepts_shim_via_symlinked_home(tmp_path, monkeypatch
shim = home_link / ".local" / "bin" / "hermes"
shim.parent.mkdir(parents=True)
shim.write_text(
f"#!/bin/bash\n"
f"#!/usr/bin/env bash\n"
f"exec {lexical_checkout}/venv/bin/python "
f'{lexical_checkout}/hermes "$@"\n',
encoding="utf-8",
@@ -992,7 +992,7 @@ def test_probe_accepts_shell_launcher_wrapper(tmp_path, xdg_home, monkeypatch):
good_wrapper = xdg_home / ".local" / "bin" / "hermes"
good_wrapper.parent.mkdir(parents=True)
good_wrapper.write_text(
f"#!/bin/bash\nexec {root / 'venv' / 'bin' / 'python'} "
f"#!/usr/bin/env bash\nexec {root / 'venv' / 'bin' / 'python'} "
f'{root / "hermes"} "$@"\n',
encoding="utf-8",
)

View File

@@ -1,12 +1,8 @@
"""The venv-style activate scripts (./activate, ./activate.ps1).
"""Run the real activation scripts with setup replaced at its process boundary.
`source ./activate` must put the composed pm env into the CURRENT shell
without ever invoking uv: it runs the pm store's pinned python (fallback:
the repo venv) to emit `python -m pm.cli env`, then exports the result,
with a venv-activate-style `deactivate` that restores the prior state.
These are real input->output checks against a fake store (same layout the
pm store uses: facts.json + a python-<version>-<target> entry), following
tests/pm conventions for faking the store.
The isolated checkout uses the real PM environment reader and fake installed
artifacts. Setup records each sync and publishes a selected environment; no test
sources the working checkout or runs its installer against the developer's home.
"""
from __future__ import annotations
@@ -127,10 +123,8 @@ def _fake_store(tmp_path: Path) -> tuple[Path, Path]:
# spawnable interpreter instead (see _spawnable_python). The wrapper
# works even named python.exe because activate execs it through
# bash/MSYS, which honors #!-scripts regardless of suffix.
interpreter = entry / "bin" / (
"python.exe" if sys.platform.startswith("win") else "python"
)
interpreter.parent.mkdir(parents=True)
interpreter = entry / ("python.exe" if sys.platform.startswith("win") else "bin/python3")
interpreter.parent.mkdir(parents=True, exist_ok=True)
real = _spawnable_python()
wrapper = "#!/bin/sh\nexec '%s' \"$@\"\n" % _posix(real)
interpreter.write_text(wrapper, encoding="utf-8")
@@ -156,8 +150,30 @@ def _fake_store(tmp_path: Path) -> tuple[Path, Path]:
return store, entry
def _isolated_checkout(tmp_path: Path) -> Path:
root = tmp_path / "checkout with spaces"
root.mkdir()
shutil.copytree(REPO_ROOT / "pm", root / "pm", ignore=shutil.ignore_patterns("__pycache__"))
(root / "hermes_cli").mkdir()
for relative in ("activate", "activate.ps1", "hermes_constants.py", "hermes_cli/__init__.py",
"hermes_cli/runtime_paths.py", "hermes_cli/runtime_state.py"):
shutil.copy2(REPO_ROOT / relative, root / relative)
# Environment-only tests do not exercise provisioning; the runtime tests
# replace these stubs with a publisher that records and applies each sync.
(root / "setup-hermes.sh").write_text('test "$#" = 1 && test "$1" = --runtime-only\n', encoding="utf-8")
(root / "setup-hermes.ps1").write_text(
"param([switch]$RuntimeOnly)\nif (-not $RuntimeOnly) { exit 2 }\n", encoding="utf-8",
)
return root
def _bash_env(store: Path) -> dict:
env = os.environ.copy()
env = _child_env()
home = store.parent / "home"
home.mkdir(exist_ok=True)
env.update(HOME=_posix(home), USERPROFILE=str(home), HERMES_HOME=_posix(home / "hermes"))
for key in ("PYTHONHOME", "PYTHONPATH", "VIRTUAL_ENV", "BASH_ENV", "__HERMES_ACTIVATED"):
env.pop(key, None)
env["HERMES_RUNTIME_DIR"] = _posix(store)
# Keep the real env out of the composed pm output so the canary export
# is the only thing activate adds beyond the ambient environment.
@@ -173,18 +189,11 @@ def test_bash_scripts_pass_syntax_check():
assert result.returncode == 0, f"{script.name}: {result.stderr}"
def test_activate_never_invokes_uv():
"""The fast path must run the provisioned python directly — setup is
the only place uv bootstrap logic lives."""
source = ACTIVATE.read_text(encoding="utf-8")
assert "uv run" not in source
assert "ensure_pinned_uv" not in source
def test_source_activate_exports_the_pm_env(tmp_path: Path):
root = _isolated_checkout(tmp_path)
store, _ = _fake_store(tmp_path)
script = (
f'source "{_posix(ACTIVATE)}" && '
f'source "{_posix(root / "activate")}" && '
f'test -n "$__HERMES_ACTIVATED" && '
f'printf "%s" "${CANARY}"'
)
@@ -192,7 +201,7 @@ def test_source_activate_exports_the_pm_env(tmp_path: Path):
[_bash(), "-c", script],
capture_output=True,
text=True,
cwd=_posix(REPO_ROOT),
cwd=_posix(tmp_path),
env=_bash_env(store),
)
assert result.returncode == 0, result.stderr
@@ -200,9 +209,10 @@ def test_source_activate_exports_the_pm_env(tmp_path: Path):
def test_deactivate_restores_the_prior_shell(tmp_path: Path):
root = _isolated_checkout(tmp_path)
store, _ = _fake_store(tmp_path)
script = (
f'source "{_posix(ACTIVATE)}" && deactivate && '
f'source "{_posix(root / "activate")}" && deactivate && '
f'test -z "${{{CANARY}+set}}" && '
f'test -z "${{__HERMES_ACTIVATED+set}}" && '
f"! declare -F deactivate >/dev/null && "
@@ -212,7 +222,7 @@ def test_deactivate_restores_the_prior_shell(tmp_path: Path):
[_bash(), "-c", script],
capture_output=True,
text=True,
cwd=_posix(REPO_ROOT),
cwd=_posix(tmp_path),
env=_bash_env(store),
)
assert result.returncode == 0, result.stderr
@@ -220,12 +230,8 @@ def test_deactivate_restores_the_prior_shell(tmp_path: Path):
def test_activate_fails_cleanly_without_a_store(tmp_path: Path):
env = os.environ.copy()
env["HERMES_RUNTIME_DIR"] = _posix(tmp_path / "empty-store")
env.pop(CANARY, None)
isolated = tmp_path / "no-install" / "activate"
isolated.parent.mkdir()
shutil.copy2(ACTIVATE, isolated)
env = _bash_env(tmp_path / "empty-store")
isolated = _isolated_checkout(tmp_path) / "activate"
script = (
f'source "{_posix(isolated)}" 2>/dev/null; '
f'test $? -ne 0 && echo refused'
@@ -234,7 +240,7 @@ def test_activate_fails_cleanly_without_a_store(tmp_path: Path):
[_bash(), "-c", script],
capture_output=True,
text=True,
cwd=_posix(REPO_ROOT),
cwd=_posix(tmp_path),
env=env,
)
# Without any provisioned python the source must refuse — never
@@ -285,56 +291,25 @@ def test_powershell_scripts_parse():
assert result.returncode == 0, f"{script.name}: {result.stdout}{result.stderr}"
@pytest.mark.platforms("windows")
def test_powershell_activate_exports_and_deactivates(tmp_path: Path):
ps = _powershell()
if ps is None:
pytest.skip("no PowerShell host available")
store, _ = _fake_store(tmp_path)
# The store interpreter is a /bin/sh wrapper — PowerShell needs a real
# python.exe, so stage the running interpreter (+ its DLLs/zips) into
# the fake entry; if that does not yield a runnable python, skip.
entry = store / json.loads(store.joinpath("facts.json").read_text())["packages"][
"python"
]["entry"]
exe = Path(sys.executable)
shim = entry / "bin" / "python.exe"
for src in exe.parent.glob("*.dll"):
shutil.copy2(src, entry / "bin" / src.name)
for base in (exe.parent, Path(sys.base_prefix)):
for zipname in ("python311.zip", "python312.zip"):
if (base / zipname).exists():
shutil.copy2(base / zipname, entry / "bin" / zipname)
if (exe.parent / "Lib").is_dir():
shutil.copytree(exe.parent / "Lib", entry / "bin" / "Lib", dirs_exist_ok=True)
shutil.copy2(exe, shim)
probe = subprocess.run(
[str(shim), "-c", "print(1)"], capture_output=True, text=True, env=_child_env()
# Native venv redirectors resolve the base DLLs/stdlib without copying CPython.
root = _isolated_checkout(tmp_path)
env = _bash_env(tmp_path / "store")
subprocess.run(
[str(_spawnable_python()), "-m", "venv", "--without-pip", str(root / ".venv")],
check=True, capture_output=True, env=env, timeout=60,
)
if probe.returncode != 0:
pytest.skip("copied interpreter is not runnable on this host")
ps = _powershell()
assert ps, "native Windows test requires PowerShell"
result = subprocess.run(
[
ps,
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
f"$env:HERMES_RUNTIME_DIR = '{store}'; "
f". '{ACTIVATE_PS1}'; "
f"$active = $env:{CANARY}; "
f"deactivate; "
f"$after = $env:{CANARY}; "
f"Write-Output ('active=' + $active + ' after=' + $after)",
],
capture_output=True,
text=True,
cwd=str(REPO_ROOT),
env=_child_env(),
[ps, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command",
f"$ErrorActionPreference='Stop'; $env:PYTHONPATH='caller-original'; "
f". '{root / 'activate.ps1'}'; "
"Write-Output ('active=' + $env:PYTHONPATH); deactivate; "
"Write-Output ('after=' + $env:PYTHONPATH)"],
capture_output=True, text=True, cwd=str(tmp_path), env=env, timeout=40,
)
assert result.returncode == 0, result.stdout + result.stderr
assert "active=env-ok" in result.stdout
assert "after=" in result.stdout and "after=env-ok" not in result.stdout
assert f"active={root}{os.pathsep}" in result.stdout
assert "after=caller-original" in result.stdout

View File

@@ -1,66 +1,206 @@
"""Activation runs read-only from any cwd and restores the caller environment."""
"""Activation syncs through setup before selecting or changing the caller's env."""
import json
import os
from pathlib import Path
import shutil
import shlex
import subprocess
import sys
import textwrap
import pytest
from tests.pm.test_activate_scripts import (
_bash, _bash_env, _isolated_checkout, _posix, _powershell, _spawnable_python,
)
def _sync_checkout(tmp_path: Path):
root = _isolated_checkout(tmp_path)
env = _bash_env(tmp_path / "store")
python = _spawnable_python()
# The setup seam publishes a bootstrap and a selected generation. PM's
# freshness algorithms have their own tests; here a warm setup call is a
# no-op and activation must still call it, rather than cache its own answer.
(root / "sync.py").write_text(textwrap.dedent('''\
import json, os, pathlib, shutil, sys
from hermes_cli.runtime_paths import runtime_facts_path, site_packages
root = pathlib.Path(__file__).parent
record = {"argv": sys.argv[1:], "python_env": {
key: os.environ.get(key) for key in ("PYTHONHOME", "PYTHONPATH", "VIRTUAL_ENV")}}
with (root / "calls.jsonl").open("a", encoding="utf-8") as stream:
stream.write(json.dumps(record) + "\\n")
assert all(value is None for value in record["python_env"].values()), record
assert sys.argv[1:] == ["runtime-only"], record
print("setup progress")
if (root / "fail").exists():
sys.exit(42)
bootstrap = root / ".venv"
if not bootstrap.exists():
shutil.copytree(root / "prepared-bootstrap", bootstrap)
generation = (root / "input").read_text(encoding="utf-8").strip()
facts = runtime_facts_path(root)
selected = facts.parent / "environments" / generation / "venv"
if not selected.exists():
site_packages(selected).mkdir(parents=True)
(selected / "pyvenv.cfg").write_text("home = fixture", encoding="utf-8")
facts.write_text(json.dumps({"packages": {"venv": {"environment": str(selected)}}}), encoding="utf-8")
with (root / "builds").open("a", encoding="utf-8") as stream:
stream.write(generation + "\\n")
'''), encoding="utf-8")
(root / "input").write_text("first", encoding="utf-8")
if os.name == "nt":
subprocess.run(
[str(python), "-m", "venv", "--without-pip", str(root / "prepared-bootstrap")],
check=True, capture_output=True, env=env, timeout=60,
)
else:
binary = root / "prepared-bootstrap" / "bin" / "python"
binary.parent.mkdir(parents=True)
binary.write_text(f"#!/bin/sh\nexec {shlex.quote(str(python))} \"$@\"\n", encoding="utf-8")
binary.chmod(0o755)
(root / "setup-hermes.sh").write_text(
'test "$#" = 1 && test "$1" = --runtime-only || exit 2\n'
f'cd {shlex.quote(str(root))} || exit 3\n'
f'exec {shlex.quote(str(python))} sync.py runtime-only\n', encoding="utf-8",
)
(root / "setup-hermes.ps1").write_text(
"param([switch]$RuntimeOnly)\n"
"if (-not $RuntimeOnly) { exit 2 }\n"
"$ErrorActionPreference = 'Stop'\n"
"$record = @{pid=$PID; executable=(Get-Process -Id $PID).Path; "
"argv=[Environment]::GetCommandLineArgs()}\n"
"$record | ConvertTo-Json -Compress | Add-Content -LiteralPath \"$PSScriptRoot\\ps-calls.jsonl\"\n"
"Set-Location -LiteralPath $PSScriptRoot\n"
f"& '{python}' sync.py runtime-only\nexit $LASTEXITCODE\n", encoding="utf-8",
)
return root, env
def _assert_syncs(root: Path):
calls = [json.loads(line) for line in (root / "calls.jsonl").read_text(encoding="utf-8").splitlines()]
assert calls == [{"argv": ["runtime-only"], "python_env": {
"PYTHONHOME": None, "PYTHONPATH": None, "VIRTUAL_ENV": None,
}}] * 3
assert (root / "builds").read_text(encoding="utf-8").splitlines() == ["first", "second"]
@pytest.mark.platforms("posix")
def test_bash_cold_sync_changed_input_and_warm_noop(tmp_path):
root, env = _sync_checkout(tmp_path)
assert not (root / ".venv").exists()
script = f'''
set -e
export PYTHONPATH=caller-original VIRTUAL_ENV=caller-venv
original_path="$PATH"
source "{_posix(root / 'activate')}"
printf '%s\\n' "$PYTHONPATH"
printf second > "{_posix(root / 'input')}"
source "{_posix(root / 'activate')}"
printf '%s\\n' "$PYTHONPATH"
source "{_posix(root / 'activate')}"
printf '%s\\n' "$PYTHONPATH"
test "$PWD" = "{_posix(tmp_path)}"
deactivate
test "$PATH" = "$original_path"
test "$PYTHONPATH" = caller-original
test "$VIRTUAL_ENV" = caller-venv
'''
run = subprocess.run([_bash(), "-c", script], cwd=tmp_path, env=env,
capture_output=True, text=True, timeout=40)
assert run.returncode == 0, run.stdout + run.stderr
first, second, warm = run.stdout.splitlines()
assert first.startswith(str(root) + os.pathsep) and "/first/venv/" in first
assert second.startswith(str(root) + os.pathsep) and "/second/venv/" in second
assert warm == second
assert run.stderr.count("setup progress") == 3
_assert_syncs(root)
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("already_active", [False, True])
def test_bash_setup_failure_preserves_caller(tmp_path, already_active):
root, env = _sync_checkout(tmp_path)
activate = shlex.quote(str(root / "activate"))
script = f'''
set -e
{f'source {activate}' if already_active else ':'}
export PYTHONHOME=caller-home PYTHONPATH=caller-path VIRTUAL_ENV=caller-venv
before_env=$(export -p)
before_function=$(declare -f deactivate || :)
before_active=${{__HERMES_ACTIVATED-unset}}
before_cwd="$PWD"
touch {shlex.quote(str(root / 'fail'))}
if source {activate}; then exit 9; fi
test "$(export -p)" = "$before_env"
test "$(declare -f deactivate || :)" = "$before_function"
test "${{__HERMES_ACTIVATED-unset}}" = "$before_active"
test "$PWD" = "$before_cwd"
{"deactivate" if already_active else ':'}
printf preserved
'''
run = subprocess.run([_bash(), "-c", script], cwd=tmp_path, env=env,
capture_output=True, text=True, timeout=40)
assert run.returncode == 0, run.stdout + run.stderr
assert run.stdout == "preserved"
calls = (root / "calls.jsonl").read_text(encoding="utf-8").splitlines()
assert len(calls) == (2 if already_active else 1)
assert json.loads(calls[-1])["python_env"] == dict.fromkeys(("PYTHONHOME", "PYTHONPATH", "VIRTUAL_ENV"))
@pytest.mark.platforms("windows")
def test_powershell_activation_roundtrip_selected_environment(tmp_path, monkeypatch):
from hermes_cli.runtime_paths import runtime_facts_path
from pm.paths import repo_root
from pm.store import current_target
from pm.lock import Lockfile
source = repo_root()
root = tmp_path / "checkout"
root.mkdir()
shutil.copytree(source / "pm", root / "pm", ignore=shutil.ignore_patterns("__pycache__"))
(root / "hermes_cli").mkdir()
for relative in ("activate.ps1", "hermes_constants.py", "hermes_cli/__init__.py",
"hermes_cli/runtime_paths.py", "hermes_cli/runtime_state.py"):
shutil.copyfile(source / relative, root / relative)
subprocess.run([sys._base_executable, "-m", "venv", "--without-pip", str(root / ".venv")],
check=True, capture_output=True, timeout=60)
home = tmp_path / "home"
monkeypatch.setenv("HERMES_HOME", str(home))
facts = runtime_facts_path(root)
selected = facts.parent / "environments" / "a" / "venv"
site = selected / "Lib" / "site-packages"
site.mkdir(parents=True)
(selected / "pyvenv.cfg").write_text("home = test", encoding="utf-8")
facts.write_text(json.dumps({"packages": {"venv": {"environment": str(selected)}}}), encoding="utf-8")
store = tmp_path / "tools"
store.mkdir()
lock = Lockfile(root / "pm" / "lock.json")
target = current_target()
entry = store / "python-test"
entry.mkdir()
(store / "facts.json").write_text(json.dumps({"schema": 1, "packages": {"python": {
"entry": entry.name, "version": lock.version("python"), "target": target,
"artifacts": [a["sha256"] for a in lock.artifacts("python", target)],
"env": {"HERMES_ACTIVATE_CANARY": "active"},
}}}), encoding="utf-8")
env = dict(os.environ, HERMES_RUNTIME_DIR=str(store), PYTHONPATH="caller-original",
PATHEXT=".COM;.EXE;.BAT;.CMD")
def test_powershell_cold_sync_changed_input_warm_and_failure(tmp_path):
# Native child-process and venv semantics cannot be reproduced by faking win32.
root, env = _sync_checkout(tmp_path)
ps = _powershell()
assert ps, "native Windows test requires PowerShell"
script = tmp_path / "run.ps1"
script.write_text(
f"$ErrorActionPreference='Stop'; . '{root / 'activate.ps1'}'; "
"[PSCustomObject]@{canary=$env:HERMES_ACTIVATE_CANARY;pythonpath=$env:PYTHONPATH}|ConvertTo-Json -Compress; "
"deactivate; Write-Output ('restored=' + $env:PYTHONPATH); "
"Write-Output ('canary=' + $env:HERMES_ACTIVATE_CANARY)", encoding="utf-8",
)
powershell = shutil.which("powershell")
assert powershell
run = subprocess.run([powershell,"-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File",str(script)],
cwd=tmp_path,env=env,capture_output=True,text=True,timeout=40)
script.write_text(f'''
$ErrorActionPreference = 'Stop'
$env:PYTHONPATH = 'caller-original'
$env:PYTHONHOME = 'caller-home'
$env:VIRTUAL_ENV = 'caller-venv'
$originalPath = $env:PATH
$originalCwd = (Get-Location).Path
. '{root / 'activate.ps1'}'
Write-Output ('selection=' + $env:PYTHONPATH)
Set-Content -LiteralPath '{root / 'input'}' -Value second
. '{root / 'activate.ps1'}'
Write-Output ('selection=' + $env:PYTHONPATH)
. '{root / 'activate.ps1'}'
Write-Output ('selection=' + $env:PYTHONPATH)
Write-Output ('parent=' + $PID)
$beforeEnv = Get-ChildItem env: | Sort-Object Name | ConvertTo-Json -Compress
$beforeFunction = (Get-Item function:deactivate).Definition
Set-Content -LiteralPath '{root / 'fail'}' -Value fail
$failed = $false
try {{ . '{root / 'activate.ps1'}' }} catch {{ $failed = $true }}
if (-not $failed) {{ throw 'setup failure accepted' }}
if ((Get-ChildItem env: | Sort-Object Name | ConvertTo-Json -Compress) -ne $beforeEnv) {{ throw 'env changed on failure' }}
if ((Get-Item function:deactivate).Definition -ne $beforeFunction) {{ throw 'deactivation lost' }}
if ((Get-Location).Path -ne $originalCwd) {{ throw 'cwd changed' }}
deactivate
if ($env:PATH -ne $originalPath -or $env:PYTHONPATH -ne 'caller-original' -or
$env:PYTHONHOME -ne 'caller-home' -or $env:VIRTUAL_ENV -ne 'caller-venv') {{ throw 'restore failed' }}
''', encoding="utf-8")
run = subprocess.run([ps, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script)],
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=90)
assert run.returncode == 0, run.stdout + run.stderr
lines = run.stdout.splitlines()
record = json.loads(lines[0])
assert record["canary"] == "active"
assert record["pythonpath"] == str(root) + os.pathsep + str(site)
assert lines[1:] == ["restored=caller-original", "canary="]
selected = [line.removeprefix("selection=") for line in run.stdout.splitlines() if line.startswith("selection=")]
assert len(selected) == 3
assert "\\first\\venv\\" in selected[0]
assert "\\second\\venv\\" in selected[1]
assert selected[1] == selected[2]
assert all(value.startswith(str(root) + os.pathsep) for value in selected)
calls = [json.loads(line) for line in (root / "calls.jsonl").read_text(encoding="utf-8").splitlines()]
assert len(calls) == 4
assert all(call["python_env"] == dict.fromkeys(("PYTHONHOME", "PYTHONPATH", "VIRTUAL_ENV")) for call in calls)
assert (root / "builds").read_text(encoding="utf-8").splitlines() == ["first", "second"]
parent = next(line.removeprefix("parent=") for line in run.stdout.splitlines() if line.startswith("parent="))
ps_calls = [json.loads(line) for line in (root / "ps-calls.jsonl").read_text(encoding="utf-8-sig").splitlines()]
assert len(ps_calls) == 4
for call in ps_calls:
assert str(call["pid"]) != parent
assert Path(call["executable"]).samefile(ps)
assert [arg.lower() for arg in call["argv"][1:]] == [
"-noprofile", "-noninteractive", "-executionpolicy", "bypass", "-file",
str(root / "setup-hermes.ps1").lower(), "-runtimeonly",
]

View File

@@ -0,0 +1,211 @@
"""Bash activation crosses real setup, PM install, and uv dependency publication.
Only the downloaded tool payloads are fixtures: bootstrap uv locates the test
interpreter, then delegates every dependency operation to real, offline uv.
PM/activation/setup code is copied unmodified; all state is disposable.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import shlex
import shutil
import stat
import subprocess
import sys
import pytest
from pm.lock import Lockfile
from pm.store import current_target
from tests.pm.test_pm_core import make_tar, served # noqa: F401 -- shared HTTP fixture
from tests.pm.test_workspace_build_inputs import _wheel
pytestmark = pytest.mark.platforms("posix")
REPO = Path(__file__).resolve().parents[2]
def _snapshot(paths):
"""Notice creation, rewriting, chmod, and new children in protected locations."""
result = {}
for root in paths:
for path in [root, *sorted(root.rglob("*"))]:
if path.exists():
info = path.stat()
result[str(path)] = (
stat.S_IMODE(info.st_mode), info.st_mtime_ns,
path.read_bytes() if path.is_file() else None,
)
return result
def test_activation_real_setup_pm_lifecycle(tmp_path, served):
bash, uv = shutil.which("bash"), shutil.which("uv")
assert bash and uv, "this integration contract requires Bash and real uv"
interpreter = str(Path(sys._base_executable).resolve())
core = tmp_path / "checkout with spaces"
core.mkdir()
home = tmp_path / "home"
home.mkdir()
hermes_home = home / ".hermes"
runtime = tmp_path / "runtime"
scratch = tmp_path / "tmp"
scratch.mkdir()
env = {
"PATH": os.environ["PATH"], "HOME": str(home), "HERMES_HOME": str(hermes_home),
"HERMES_RUNTIME_DIR": str(runtime), "SHELL": bash, "TMPDIR": str(scratch),
"XDG_CONFIG_HOME": str(home / ".config"), "XDG_CACHE_HOME": str(home / ".cache"),
"LANG": "C.UTF-8", "PYTHONNOUSERSITE": "1", "UV_OFFLINE": "1",
"UV_CACHE_DIR": str(home / ".cache" / "uv"), "UV_PYTHON_DOWNLOADS": "never",
}
for name in ("activate", "setup-hermes.sh", "hermes_constants.py", "utils.py"):
shutil.copy2(REPO / name, core / name)
for name in ("pm", "hermes_cli"):
shutil.copytree(REPO / name, core / name, ignore=shutil.ignore_patterns("__pycache__"))
# Plugin selection imports the real CLI config reader even with no plugins.
# Supply its installed YAML dependency, not a stub parser or config module.
import yaml
shutil.copytree(Path(yaml.__file__).parent, core / "yaml", ignore=shutil.ignore_patterns("__pycache__"))
# Never copy real user files: these are deliberately public fixture sentinels.
protected = [core / ".env", home / ".local" / "bin", hermes_home / "skills",
home / ".bashrc", home / ".bash_profile", home / ".zshrc"]
for path in (home / ".local" / "bin" / "hermes", hermes_home / "skills" / "keep.md",
home / ".bashrc", home / ".bash_profile", home / ".zshrc"):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("user-owned fixture\n", encoding="utf-8")
(core / ".env.example").write_text("FIXTURE_ONLY=example\n", encoding="utf-8")
(core / "skills").mkdir()
(core / "skills" / "bundled.md").write_text("must not be seeded\n", encoding="utf-8")
untouched = _snapshot(protected)
wheels = tmp_path / "wheels"
wheels.mkdir()
_wheel(wheels, "activation_dep", "1.0")
(core / "pyproject.toml").write_text(
'[project]\nname="activation-proof"\nversion="1"\nrequires-python=">=3.11"\n'
'dependencies=["activation-dep==1.0"]\n[project.optional-dependencies]\nall=[]\n'
'[tool.uv]\npackage=false\nno-index=true\n'
f'find-links=[{json.dumps(wheels.as_posix())}]\n', encoding="utf-8",
)
locked = subprocess.run(
[uv, "lock", "--offline", "--python", interpreter], cwd=core, env=env,
capture_output=True, text=True, timeout=60,
)
assert locked.returncode == 0, locked.stdout + locked.stderr
dependency_lock = (core / "uv.lock").read_bytes()
docroot, base_url = served
# Both the shell bootstrap and PM's fallback downloader stay on loopback.
(core / "pm" / "artifact-mirror.json").write_text(
json.dumps({"origin": base_url, "prefix": "mirror/"}, indent=2) + "\n",
encoding="utf-8",
)
calls = tmp_path / "uv-calls"
uv_script = (
f"#!{bash}\n"
f"printf '%s\\n' \"$*\" >> {shlex.quote(str(calls))}\n"
'if [ "$1 $2" = "python install" ]; then exit 0; fi\n'
f'if [ "$1 $2" = "python find" ]; then printf \'%s\\n\' {shlex.quote(interpreter)}; exit 0; fi\n'
f"exec {shlex.quote(uv)} --offline \"$@\"\n"
)
_, uv_digest = make_tar(docroot, "uv.tar.gz", {"uv-fixture/uv": uv_script})
python_script = f'#!{bash}\nexec {shlex.quote(interpreter)} "$@"\n'
lock = Lockfile(core / "pm" / "lock.json")
# Replace shipped pins, not PM implementations or its package registry.
lock.path.unlink()
lock = Lockfile(lock.path)
target = current_target()
version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}+fixture"
def pin_python(revision):
filename = f"python-{revision}.tar.gz"
_, digest = make_tar(docroot, filename, {
"python/bin/python3": python_script,
"python/revision": revision,
})
lock.set_pin("python", version, {target: {"url": f"{base_url}/{filename}", "sha256": digest}})
lock.save()
return digest
lock.set_pin("uv", "fixture", {target: {"url": f"{base_url}/uv.tar.gz", "sha256": uv_digest}})
first_digest = pin_python("first")
def activate(*, succeeds=True):
# No `set -e`: inspect source's status and prove the caller survives.
script = '''
prior_path=$PATH
prior_pythonpath=${PYTHONPATH-}
source "$1/activate"
status=$?
if [ "$status" != 0 ]; then
test "$PATH" = "$prior_path" || exit 91
test "${PYTHONPATH-}" = "$prior_pythonpath" || exit 92
test -z "${__HERMES_ACTIVATED-}" || exit 93
printf 'CALLER_SURVIVED:%s\\n' "$status"
exit "$status"
fi
python3 -c 'import activation_dep, json, os; print(json.dumps({"version": activation_dep.__version__, "module": activation_dep.__file__, "pythonpath": os.environ["PYTHONPATH"]}))' || exit 94
deactivate
test "$PATH" = "$prior_path" || exit 95
test "${PYTHONPATH-}" = "$prior_pythonpath" || exit 96
'''
result = subprocess.run(
[bash, "--noprofile", "--norc", "-c", script, "activation-test", str(core)],
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=90,
)
assert _snapshot(protected) == untouched
assert (result.returncode == 0) is succeeds, result.stdout + result.stderr
return result
def selection():
records = list(hermes_home.glob("installs/*/facts.json"))
assert len(records) == 1
return json.loads(records[0].read_text())["packages"]["venv"]
def operations(name):
return [line for line in calls.read_text().splitlines() if line.split()[0] == name]
cold = activate()
first = selection()
probe = json.loads(cold.stdout)
assert probe["version"] == "1.0"
assert Path(probe["module"]).is_relative_to(Path(first["environment"]))
assert probe["pythonpath"].split(os.pathsep)[0] == str(core)
assert len(operations("venv")) == len(operations("sync")) == 1
facts = json.loads((runtime / "facts.json").read_text())["packages"]
assert facts["python"]["artifacts"] == [first_digest]
assert facts["uv"]["artifacts"] == [uv_digest]
# Also catch chmod of an existing .env, not just unwanted first creation.
(core / ".env").write_text("FIXTURE_ONLY=existing\n", encoding="utf-8")
(core / ".env").chmod(0o644)
untouched = _snapshot(protected)
activate()
assert selection() == first
assert len(operations("venv")) == len(operations("sync")) == 1
assert len([line for line in operations("python") if line.startswith("python install ")]) == 2
second_digest = pin_python("second")
activate()
second = selection()
assert second["stamp"] != first["stamp"]
assert second["environment"] != first["environment"]
assert Path(first["environment"]).is_dir()
assert len(operations("venv")) == len(operations("sync")) == 2
facts = json.loads((runtime / "facts.json").read_text())["packages"]
assert facts["python"]["artifacts"] == [second_digest]
assert (core / "uv.lock").read_bytes() == dependency_lock
# A real uv failure must cross PM -> setup -> source without selection or
# caller mutation. An invalid local lock fails deterministically offline.
(core / "uv.lock").write_text("not valid TOML [\n", encoding="utf-8")
failed = activate(succeeds=False)
assert "pm install failed" in failed.stderr
assert "setup failed" in failed.stderr
assert "CALLER_SURVIVED:" in failed.stdout
assert selection() == second
assert len(operations("sync")) == 3
assert len([line for line in operations("python") if line.startswith("python install ")]) == 4

View File

@@ -0,0 +1,42 @@
"""The repository shebang checker scans tracked scripts and embedded payloads."""
from pathlib import Path
import subprocess
import sys
CHECKER = Path(__file__).resolve().parents[2] / "scripts" / "check_bash_shebangs.py"
def test_checker_reports_and_clears_fixed_bash_paths(tmp_path):
subprocess.run(["git", "init", "-q", str(tmp_path)], check=True)
fixed = "#!" + "/bin/bash"
files = {
"activate": fixed + "\necho ready\n",
"example.md": "```bash\n" + fixed + "\n```\n",
"generator.py": 'script = "' + fixed + '\\necho ready\\n"\n',
"other.sh": "#!/bin/sh\n",
"portable.sh": "#!/usr/bin/env bash\n",
"usr.sh": "#! /usr/bin/" + "bash\n",
}
for name, text in files.items():
(tmp_path / name).write_text(text, encoding="utf-8")
subprocess.run(["git", "add", "--", *files], cwd=tmp_path, check=True)
(tmp_path / "untracked.sh").write_text(fixed, encoding="utf-8")
command = [sys.executable, str(CHECKER), "--root", str(tmp_path)]
red = subprocess.run(command, capture_output=True, text=True, check=False)
assert red.returncode == 1
assert red.stdout.splitlines() == [
"activate:1: use #!/usr/bin/env bash",
"example.md:2: use #!/usr/bin/env bash",
"generator.py:1: use #!/usr/bin/env bash",
"usr.sh:1: use #!/usr/bin/env bash",
"Bash shebang check: 4 violation(s)",
]
for name, text in files.items():
(tmp_path / name).write_text(
text.replace(fixed, "#!/usr/bin/env bash").replace("#! /usr/bin/" + "bash", "#!/usr/bin/env bash"),
encoding="utf-8",
)
green = subprocess.run(command, capture_output=True, text=True, check=False)
assert green.returncode == 0, green.stdout + green.stderr
assert green.stdout.strip() == "Bash shebang check: 0 violation(s)"

View File

@@ -13,7 +13,7 @@ def test_shim_removes_only_its_owned_profile(tmp_path, outcome):
bin_dir.mkdir()
browser = bin_dir / "google-chrome"
browser.write_text(
'#!/bin/bash\ntrap "exit 0" TERM\n'
'#!/usr/bin/env bash\ntrap "exit 0" TERM\n'
'for arg in "$@"; do\n'
'case "$arg" in --user-data-dir=*) dir="${arg#--user-data-dir=}" ;; esac\n'
'done\nmkdir -p "$dir"\nprintf "%s" "$dir" > "$HOME/launched-profile"\n'

View File

@@ -115,7 +115,7 @@ def test_unreadable_status_still_serves_a_running_state(progress):
# Stands in for `hermes update`, and reports the stage that was on screen
# while it ran -- the update child is the only thing that can observe the
# window's state at the exact moment of the longest wait in the hand-off.
FAKE_HERMES = """#!/bin/bash
FAKE_HERMES = """#!/usr/bin/env bash
# The hand-off probes `update --help` for --keep-stash support before the
# real update call; answer it without consuming a counted call so the
# exits.N mapping below still refers to actual update attempts.

View File

@@ -31,9 +31,9 @@ import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
POSIX_SH = REPO_ROOT / "scripts" / "desktop-update" / "posix.sh"
GOOD_STUB = "#!/bin/bash\n# bootable interpreter stub\nexit 0\n"
GOOD_STUB = "#!/usr/bin/env bash\n# bootable interpreter stub\nexit 0\n"
BAD_STUB = (
"#!/bin/bash\n"
"#!/usr/bin/env bash\n"
"echo \"Fatal Python error: init_fs_encoding: failed to get the Python "
"codec of the filesystem encoding\" >&2\n"
"echo \"ModuleNotFoundError: No module named 'encodings'\" >&2\n"
@@ -42,7 +42,7 @@ BAD_STUB = (
# Boots only when invoked via a path whose basename is exactly `python`:
# used to force the post-heal verification probe to fail (rollback path).
PICKY_STUB = (
"#!/bin/bash\n"
"#!/usr/bin/env bash\n"
"[ \"$(basename \"$0\")\" = \"python\" ] && exit 0\n"
"exit 1\n"
)
@@ -225,7 +225,7 @@ class TestHandoffSurvivesBrickAB:
hermes = root / "venv/bin/hermes"
_write_exe(
hermes,
"#!/bin/bash\n"
"#!/usr/bin/env bash\n"
'exec "$(cd "$(dirname "$0")" && pwd)/python3" -c "import encodings"\n',
)
return hermes

View File

@@ -24,7 +24,7 @@ class TestParserLimitRecovery:
assert saved.exists()
body = saved.read_text()
assert cmd in body
assert body.startswith("#!/bin/bash")
assert body.startswith("#!/usr/bin/env bash")
assert f"bash {saved}" in r["message"]
def test_save_failure_falls_back_to_manual_recipe(self, monkeypatch):

View File

@@ -163,7 +163,7 @@ class TestIterSkillsFiles:
(skills_dir / "cat" / "myskill").mkdir(parents=True)
(skills_dir / "cat" / "myskill" / "SKILL.md").write_text("# skill")
(skills_dir / "cat" / "myskill" / "scripts").mkdir()
(skills_dir / "cat" / "myskill" / "scripts" / "run.sh").write_text("#!/bin/bash")
(skills_dir / "cat" / "myskill" / "scripts" / "run.sh").write_text("#!/usr/bin/env bash")
# Add a symlink that should be filtered
secret = tmp_path / "secret"
secret.write_text("nope")

View File

@@ -71,7 +71,7 @@ def _save_blocked_payload(command: str) -> str | None:
old.unlink()
path = script_dir / f"blocked-{int(time.time())}-{uuid.uuid4().hex[:8]}.sh"
path.write_text(
"#!/bin/bash\n"
"#!/usr/bin/env bash\n"
"# Auto-saved by Hermes: this command exceeded the inline command\n"
"# parser limit and was blocked from direct execution. Review it,\n"
f"# then run it via: bash {path}\n" + command + ("" if command.endswith("\n") else "\n"),

View File

@@ -122,9 +122,9 @@ requires trust in that plugin and its dependencies.
## Developer workflow {#developer-workflow}
PM prepares the toolchain for a source checkout. Activation makes that installed
toolchain available in a shell. Neither operation selects your editor's Python
interpreter or redirects an installed desktop app to this checkout.
Activation asks PM to prepare or sync the toolchain for a source checkout, then
makes it available in the shell. It does not select your editor's Python
interpreter or redirect an installed desktop app to this checkout.
### Prepare a checkout
@@ -149,7 +149,6 @@ Bash, from the repository root:
```bash
export HERMES_HOME="$HOME/hermes-dev-data"
export HERMES_RUNTIME_DIR="$HERMES_HOME/tools"
bash setup-hermes.sh
source ./activate
```
@@ -158,14 +157,16 @@ PowerShell, from the repository root:
```powershell
$env:HERMES_HOME = Join-Path $HOME 'hermes-dev-data'
$env:HERMES_RUNTIME_DIR = Join-Path $env:HERMES_HOME 'tools'
.\setup-hermes.ps1
. .\activate.ps1
```
`HERMES_RUNTIME_DIR` in these examples is a process-local development override.
It makes the bootstrap and PM use the same writable store. Do not persist a
path into an installed MSIX or macOS bundle. The setup scripts provision tools
and the `all` Python extra. They do not select `dev` or install JS workspaces.
path into an installed MSIX or macOS bundle. Activation runs the setup script's
runtime-only path to provision tools and sync the `all` Python extra. It does
not select `dev` or install JS workspaces. It also skips setup's user-facing
installation work: shell configuration, launchers, `.env`, and bundled skills.
Run the setup script separately if you want that full installation workflow.
The bootstrap uses uv to install and locate Python, then waits for uv to exit.
That Python runs PM directly. PM can then replace its uv entry without a running
@@ -175,7 +176,7 @@ PyYAML, including when dependency installation fails.
### Activate an existing installation
In each new shell, restore your development-home values and enter the checkout.
Then activate it without running installation again:
Then activate it; there is no separate setup command to remember:
| Shell | Enter | Leave |
|---|---|---|
@@ -187,9 +188,14 @@ The leading dot and space in PowerShell are required. Executing
The POSIX script uses Bash syntax. Use Bash for this recipe rather than `sh`,
fish, or assuming that a Zsh startup file has Bash semantics.
Activation prepends installed PM tools to `PATH`. It sets `PYTHONPATH` to this
checkout and its selected dependency tree. It does not download packages,
change an OS-wide PATH, or activate a conventional venv prompt.
Each activation invokes PM's install/sync path. PM reuses current tools and
dependency generations; missing or stale inputs can require downloads and a
rebuild. A setup failure returns an error before changing the activated shell
environment, including when re-sourcing an already active environment.
After sync, activation prepends installed PM tools to `PATH` and sets
`PYTHONPATH` to this checkout and its selected dependency tree. It does not
change an OS-wide PATH or activate a conventional venv prompt.
Start in a clean shell rather than nesting this inside another venv.
`deactivate` restores the environment values captured by the activation script.
It does not uninstall packages or stop processes that you started.
@@ -240,13 +246,15 @@ requirements in the [desktop build guide](https://github.com/NousResearch/hermes
### Refresh dependencies without changing branches
After a branch or lockfile change, prepare dependencies with this checkout's PM:
After a branch or lockfile change, source the activation script again to sync
and select the new dependencies (`source ./activate` in Bash or
`. .\activate.ps1` in PowerShell). To sync without activating a shell:
```bash
python -m pm.cli install
```
Then leave and reactivate the environment, and restart affected processes.
After a standalone sync, reactivate the environment. Restart affected processes.
Use `python -m pm.cli doctor` for tool diagnostics and `python -m pm.cli status`
for the latest sync receipt. Do not run `hermes update` just to refresh a
feature branch: it is an application update and can change the source branch.

View File

@@ -1055,7 +1055,7 @@ The `wakeAgent` gate gives you a $0 way to decide whether a scheduled job should
**File-change gate** — only run when a watched file has new content since the last successful tick. The scheduler records each job's `last_run_at`; compare it against the file's mtime.
```bash
#!/bin/bash
#!/usr/bin/env bash
# ~/.hermes/scripts/feed-changed.sh
FEED="$HOME/data/feed.json"
STATE="$HOME/.hermes/scripts/.feed-changed.last"
@@ -1080,7 +1080,7 @@ cronjob(action="create", name="process-feed",
**External-flag gate** — only run when some other process has signalled readiness (e.g. a deploy hook drops a file, a CI job sets a value in your state store).
```bash
#!/bin/bash
#!/usr/bin/env bash
# ~/.hermes/scripts/flag-ready.sh
if test -f /tmp/new-data-ready; then
rm -f /tmp/new-data-ready

View File

@@ -185,7 +185,7 @@ Training Progress Tracking:
Evaluate every N training steps:
```bash
#!/bin/bash
#!/usr/bin/env bash
# eval_checkpoint.sh
CHECKPOINT_DIR=$1
@@ -289,7 +289,7 @@ microsoft/phi-2
**Step 2: Run evaluations**
```bash
#!/bin/bash
#!/usr/bin/env bash
# eval_all_models.sh
TASKS="mmlu,gsm8k,hellaswag,truthfulqa"

View File

@@ -151,7 +151,7 @@ Check with: `sudo ufw status | grep 25565`
### 8. Create Launch Script
```bash
cat > ~/start-minecraft.sh << 'EOF'
#!/bin/bash
#!/usr/bin/env bash
cd ~/minecraft-server/server
java @user_jvm_args.txt @libraries/net/neoforged/neoforge/<VERSION>/unix_args.txt nogui
EOF
@@ -163,7 +163,7 @@ Note: For Forge (not NeoForge), the args file path differs. Check `startserver.s
Create backup script:
```bash
cat > ~/minecraft-server/backup.sh << 'SCRIPT'
#!/bin/bash
#!/usr/bin/env bash
SERVER_DIR="$HOME/minecraft-server/server"
BACKUP_DIR="$HOME/minecraft-server/backups"
WORLD_DIR="$SERVER_DIR/world"

View File

@@ -185,7 +185,7 @@ Training Progress Tracking:
Evaluate every N training steps:
```bash
#!/bin/bash
#!/usr/bin/env bash
# eval_checkpoint.sh
CHECKPOINT_DIR=$1
@@ -289,7 +289,7 @@ microsoft/phi-2
**Step 2: Run evaluations**
```bash
#!/bin/bash
#!/usr/bin/env bash
# eval_all_models.sh
TASKS="mmlu,gsm8k,hellaswag,truthfulqa"

View File

@@ -176,7 +176,7 @@ context_parallel_degree = 1 # Increase for long sequences
**Step 2: Set up SLURM script**
```bash
#!/bin/bash
#!/usr/bin/env bash
#SBATCH --job-name=llama70b
#SBATCH --nodes=32
#SBATCH --ntasks-per-node=8

View File

@@ -651,7 +651,7 @@ print(json.dumps({"wakeAgent": True, "context": {"new_issues": latest - prev}}))
**文件变更门控**——仅在被监视文件自上次成功 tick 以来有新内容时运行。调度器记录每个任务的 `last_run_at`;将其与文件的 mtime 比较。
```bash
#!/bin/bash
#!/usr/bin/env bash
# ~/.hermes/scripts/feed-changed.sh
FEED="$HOME/data/feed.json"
STATE="$HOME/.hermes/scripts/.feed-changed.last"
@@ -676,7 +676,7 @@ cronjob(action="create", name="process-feed",
**外部标志门控**——仅在其他进程发出就绪信号时运行(例如,部署 hook 落下一个文件,CI 任务在状态存储中设置一个值)。
```bash
#!/bin/bash
#!/usr/bin/env bash
# ~/.hermes/scripts/flag-ready.sh
if test -f /tmp/new-data-ready; then
rm -f /tmp/new-data-ready

View File

@@ -185,7 +185,7 @@ lm_eval --model hf \
每 N 个训练步骤评估一次:
```bash
#!/bin/bash
#!/usr/bin/env bash
# eval_checkpoint.sh
CHECKPOINT_DIR=$1
@@ -289,7 +289,7 @@ microsoft/phi-2
**步骤 2:运行评估**
```bash
#!/bin/bash
#!/usr/bin/env bash
# eval_all_models.sh
TASKS="mmlu,gsm8k,hellaswag,truthfulqa"

View File

@@ -165,7 +165,7 @@ context_parallel_degree = 1 # Increase for long sequences
**步骤 2:设置 SLURM 脚本**
```bash
#!/bin/bash
#!/usr/bin/env bash
#SBATCH --job-name=llama70b
#SBATCH --nodes=32
#SBATCH --ntasks-per-node=8