diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a005c88f30..3dc91272e6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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. diff --git a/AGENTS.md b/AGENTS.md index b99c267f85..771de9758f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ced14d7721..dd9c5c00f0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/activate b/activate old mode 100644 new mode 100755 index eb7e48da22..0583576467 --- a/activate +++ b/activate @@ -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="" diff --git a/activate.ps1 b/activate.ps1 old mode 100644 new mode 100755 index 0b979a92f9..75ebdf7695 --- a/activate.ps1 +++ b/activate.ps1 @@ -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 } diff --git a/apps/bootstrap-installer/src-tauri/src/install_script.rs b/apps/bootstrap-installer/src-tauri/src/install_script.rs index 24e0bd0893..1e407d4d14 100644 --- a/apps/bootstrap-installer/src-tauri/src/install_script.rs +++ b/apps/bootstrap-installer/src-tauri/src/install_script.rs @@ -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 { 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(); } diff --git a/apps/desktop/electron/bootstrap-quit.test.ts b/apps/desktop/electron/bootstrap-quit.test.ts index 1d0a8614e7..013738d0b2 100644 --- a/apps/desktop/electron/bootstrap-quit.test.ts +++ b/apps/desktop/electron/bootstrap-quit.test.ts @@ -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 { diff --git a/apps/desktop/electron/desktop-uninstall.ts b/apps/desktop/electron/desktop-uninstall.ts index 20d70a822b..d1d9686e1f 100644 --- a/apps/desktop/electron/desktop-uninstall.ts +++ b/apps/desktop/electron/desktop-uninstall.ts @@ -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.', diff --git a/apps/desktop/electron/remote-lifecycle.test.ts b/apps/desktop/electron/remote-lifecycle.test.ts index 583f4edcab..0989d435a8 100644 --- a/apps/desktop/electron/remote-lifecycle.test.ts +++ b/apps/desktop/electron/remote-lifecycle.test.ts @@ -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 = [ diff --git a/datagen-config-examples/run_browser_tasks.sh b/datagen-config-examples/run_browser_tasks.sh index 1d62e3abba..7d3856511a 100755 --- a/datagen-config-examples/run_browser_tasks.sh +++ b/datagen-config-examples/run_browser_tasks.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # ============================================================================= # Example: Browser-Focused Data Generation diff --git a/native/fts5_cjk/build.sh b/native/fts5_cjk/build.sh index 900c360a1a..ed877a97b7 100755 --- a/native/fts5_cjk/build.sh +++ b/native/fts5_cjk/build.sh @@ -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 diff --git a/optional-skills/gaming/minecraft-modpack-server/SKILL.md b/optional-skills/gaming/minecraft-modpack-server/SKILL.md index 23f8822414..a1ae7e5a3a 100644 --- a/optional-skills/gaming/minecraft-modpack-server/SKILL.md +++ b/optional-skills/gaming/minecraft-modpack-server/SKILL.md @@ -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//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" diff --git a/optional-skills/mlops/evaluation/evaluating-llms-harness/SKILL.md b/optional-skills/mlops/evaluation/evaluating-llms-harness/SKILL.md index 306b4f6fe9..87a950e94d 100644 --- a/optional-skills/mlops/evaluation/evaluating-llms-harness/SKILL.md +++ b/optional-skills/mlops/evaluation/evaluating-llms-harness/SKILL.md @@ -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" diff --git a/optional-skills/mlops/evaluation/evaluating-llms-harness/references/distributed-eval.md b/optional-skills/mlops/evaluation/evaluating-llms-harness/references/distributed-eval.md index 2132e5bef7..6c8eabde7d 100644 --- a/optional-skills/mlops/evaluation/evaluating-llms-harness/references/distributed-eval.md +++ b/optional-skills/mlops/evaluation/evaluating-llms-harness/references/distributed-eval.md @@ -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 diff --git a/optional-skills/mlops/inference/serving-llms-vllm/references/server-deployment.md b/optional-skills/mlops/inference/serving-llms-vllm/references/server-deployment.md index da5b837bca..d1fadcc3c9 100644 --- a/optional-skills/mlops/inference/serving-llms-vllm/references/server-deployment.md +++ b/optional-skills/mlops/inference/serving-llms-vllm/references/server-deployment.md @@ -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 diff --git a/optional-skills/mlops/lambda-labs/references/advanced-usage.md b/optional-skills/mlops/lambda-labs/references/advanced-usage.md index 1902d8c5c5..2a4c172a23 100644 --- a/optional-skills/mlops/lambda-labs/references/advanced-usage.md +++ b/optional-skills/mlops/lambda-labs/references/advanced-usage.md @@ -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 diff --git a/optional-skills/mlops/llava/references/training.md b/optional-skills/mlops/llava/references/training.md index 9ab89c96f9..a4c83e31eb 100644 --- a/optional-skills/mlops/llava/references/training.md +++ b/optional-skills/mlops/llava/references/training.md @@ -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" diff --git a/optional-skills/mlops/pytorch-lightning/references/distributed.md b/optional-skills/mlops/pytorch-lightning/references/distributed.md index 886b3c75ac..cf471cc5c0 100644 --- a/optional-skills/mlops/pytorch-lightning/references/distributed.md +++ b/optional-skills/mlops/pytorch-lightning/references/distributed.md @@ -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 diff --git a/optional-skills/mlops/torchtitan/SKILL.md b/optional-skills/mlops/torchtitan/SKILL.md index 43fa525d1a..a9fac73707 100644 --- a/optional-skills/mlops/torchtitan/SKILL.md +++ b/optional-skills/mlops/torchtitan/SKILL.md @@ -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 diff --git a/optional-skills/research/duckduckgo-search/scripts/duckduckgo.sh b/optional-skills/research/duckduckgo-search/scripts/duckduckgo.sh index 1553d45968..9daecbf55f 100755 --- a/optional-skills/research/duckduckgo-search/scripts/duckduckgo.sh +++ b/optional-skills/research/duckduckgo-search/scripts/duckduckgo.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # DuckDuckGo Search Helper Script # Wrapper around ddgs CLI with sensible defaults # Usage: ./duckduckgo.sh [max_results] diff --git a/optional-skills/research/searxng-search/scripts/searxng.sh b/optional-skills/research/searxng-search/scripts/searxng.sh index 12fe792d09..9f11b66009 100755 --- a/optional-skills/research/searxng-search/scripts/searxng.sh +++ b/optional-skills/research/searxng-search/scripts/searxng.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Usage: ./searxng.sh [max_results] [engines] # Example: ./searxng.sh "python async" 10 "google,bing" diff --git a/scripts/check_bash_shebangs.py b/scripts/check_bash_shebangs.py new file mode 100644 index 0000000000..a5145b3d45 --- /dev/null +++ b/scripts/check_bash_shebangs.py @@ -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()) diff --git a/scripts/desktop-update/posix.sh b/scripts/desktop-update/posix.sh index bbf3097592..d9ff73ce23 100755 --- a/scripts/desktop-update/posix.sh +++ b/scripts/desktop-update/posix.sh @@ -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 diff --git a/scripts/desktop-update/repro.sh b/scripts/desktop-update/repro.sh index f2d39a3bf8..7635ef7d92 100755 --- a/scripts/desktop-update/repro.sh +++ b/scripts/desktop-update/repro.sh @@ -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 diff --git a/scripts/kill_modal.sh b/scripts/kill_modal.sh index 1e9a331282..58b8d9f838 100755 --- a/scripts/kill_modal.sh +++ b/scripts/kill_modal.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Kill all running Modal apps (sandboxes, deployments, etc.) # # Usage: diff --git a/scripts/smoke-payload.sh b/scripts/smoke-payload.sh index b096996103..e8040c5ced 100644 --- a/scripts/smoke-payload.sh +++ b/scripts/smoke-payload.sh @@ -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 diff --git a/scripts/toolperf_abeval/run_all.sh b/scripts/toolperf_abeval/run_all.sh index be96e22fdf..06c004878d 100755 --- a/scripts/toolperf_abeval/run_all.sh +++ b/scripts/toolperf_abeval/run_all.sh @@ -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: diff --git a/setup-hermes.ps1 b/setup-hermes.ps1 index 376402cfd0..9bf39f3ec7 100644 --- a/setup-hermes.ps1 +++ b/setup-hermes.ps1 @@ -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 # --------------------------------------------------------------------------- diff --git a/setup-hermes.sh b/setup-hermes.sh index 135584d485..cc9065ad16 100755 --- a/setup-hermes.sh +++ b/setup-hermes.sh @@ -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 # ============================================================================ diff --git a/skills/creative/p5js/scripts/render.sh b/skills/creative/p5js/scripts/render.sh index 81e65cf2f3..ea653f226c 100755 --- a/skills/creative/p5js/scripts/render.sh +++ b/skills/creative/p5js/scripts/render.sh @@ -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 # diff --git a/skills/creative/p5js/scripts/serve.sh b/skills/creative/p5js/scripts/serve.sh index 34055d5967..7dc7b05b85 100755 --- a/skills/creative/p5js/scripts/serve.sh +++ b/skills/creative/p5js/scripts/serve.sh @@ -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) # diff --git a/skills/creative/p5js/scripts/setup.sh b/skills/creative/p5js/scripts/setup.sh index 33f9e0e172..5f6e310512 100755 --- a/skills/creative/p5js/scripts/setup.sh +++ b/skills/creative/p5js/scripts/setup.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # p5.js Skill — Dependency Verification # Run: bash skills/creative/p5js/scripts/setup.sh diff --git a/tests/agent/test_shell_hooks_tree_kill.py b/tests/agent/test_shell_hooks_tree_kill.py index 942f51e920..748c5853e2 100644 --- a/tests/agent/test_shell_hooks_tree_kill.py +++ b/tests/agent/test_shell_hooks_tree_kill.py @@ -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) diff --git a/tests/cron/test_cron_no_agent.py b/tests/cron/test_cron_no_agent.py index 669f97f1d1..b45d654f5c 100644 --- a/tests/cron/test_cron_no_agent.py +++ b/tests/cron/test_cron_no_agent.py @@ -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" diff --git a/tests/cron/test_jobs_shrink_merge_80624.py b/tests/cron/test_jobs_shrink_merge_80624.py index 423c3fe93d..964eecbc82 100644 --- a/tests/cron/test_jobs_shrink_merge_80624.py +++ b/tests/cron/test_jobs_shrink_merge_80624.py @@ -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 diff --git a/tests/gateway/test_webhook_offloop_delivery.py b/tests/gateway/test_webhook_offloop_delivery.py index c33083e9d3..96fd9cea98 100644 --- a/tests/gateway/test_webhook_offloop_delivery.py +++ b/tests/gateway/test_webhook_offloop_delivery.py @@ -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 diff --git a/tests/hermes_cli/test_gateway_restart_loop.py b/tests/hermes_cli/test_gateway_restart_loop.py index 8b4c777518..e63ac4bb5d 100644 --- a/tests/hermes_cli/test_gateway_restart_loop.py +++ b/tests/hermes_cli/test_gateway_restart_loop.py @@ -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", diff --git a/tests/hermes_cli/test_git_probe_tree_kill.py b/tests/hermes_cli/test_git_probe_tree_kill.py index 1a38d7fa37..464616fd46 100644 --- a/tests/hermes_cli/test_git_probe_tree_kill.py +++ b/tests/hermes_cli/test_git_probe_tree_kill.py @@ -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) diff --git a/tests/hermes_cli/test_linux_desktop_entry.py b/tests/hermes_cli/test_linux_desktop_entry.py index 9445b70d57..b50842a6a7 100644 --- a/tests/hermes_cli/test_linux_desktop_entry.py +++ b/tests/hermes_cli/test_linux_desktop_entry.py @@ -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", ) diff --git a/tests/pm/test_activate_scripts.py b/tests/pm/test_activate_scripts.py index 2fd4e38f23..a32ce658c0 100644 --- a/tests/pm/test_activate_scripts.py +++ b/tests/pm/test_activate_scripts.py @@ -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-- 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 diff --git a/tests/pm/test_activation_runtime.py b/tests/pm/test_activation_runtime.py index 390bb49e6e..a88a50d2dd 100644 --- a/tests/pm/test_activation_runtime.py +++ b/tests/pm/test_activation_runtime.py @@ -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", + ] diff --git a/tests/pm/test_activation_setup.py b/tests/pm/test_activation_setup.py new file mode 100644 index 0000000000..f651a1293c --- /dev/null +++ b/tests/pm/test_activation_setup.py @@ -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 diff --git a/tests/scripts/test_bash_shebangs.py b/tests/scripts/test_bash_shebangs.py new file mode 100644 index 0000000000..fd412ce1d3 --- /dev/null +++ b/tests/scripts/test_bash_shebangs.py @@ -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)" diff --git a/tests/test_desktop_update_shim_profile_cleanup.py b/tests/test_desktop_update_shim_profile_cleanup.py index ca565619f6..0d545e6626 100644 --- a/tests/test_desktop_update_shim_profile_cleanup.py +++ b/tests/test_desktop_update_shim_profile_cleanup.py @@ -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' diff --git a/tests/test_desktop_update_shim_progress.py b/tests/test_desktop_update_shim_progress.py index 5af2ab9e96..fc34774e64 100644 --- a/tests/test_desktop_update_shim_progress.py +++ b/tests/test_desktop_update_shim_progress.py @@ -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. diff --git a/tests/test_desktop_update_tcc_heal.py b/tests/test_desktop_update_tcc_heal.py index a8eae77c49..519c856aee 100644 --- a/tests/test_desktop_update_tcc_heal.py +++ b/tests/test_desktop_update_tcc_heal.py @@ -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 diff --git a/tests/tools/test_blocked_command_guidance.py b/tests/tools/test_blocked_command_guidance.py index 4b5a87a95c..3ce7b98d35 100644 --- a/tests/tools/test_blocked_command_guidance.py +++ b/tests/tools/test_blocked_command_guidance.py @@ -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): diff --git a/tests/tools/test_credential_files.py b/tests/tools/test_credential_files.py index b2dbe86763..9a6d5887d9 100644 --- a/tests/tools/test_credential_files.py +++ b/tests/tools/test_credential_files.py @@ -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") diff --git a/tools/approval_floors.py b/tools/approval_floors.py index d85c4c60c9..ecd11344cf 100644 --- a/tools/approval_floors.py +++ b/tools/approval_floors.py @@ -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"), diff --git a/website/docs/reference/package-management.md b/website/docs/reference/package-management.md index 58e50193eb..0f26a26b1b 100644 --- a/website/docs/reference/package-management.md +++ b/website/docs/reference/package-management.md @@ -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. diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index c20852630d..e065b31ad3 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -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 diff --git a/website/docs/user-guide/skills/bundled/mlops/mlops-evaluation-evaluating-llms-harness.md b/website/docs/user-guide/skills/bundled/mlops/mlops-evaluation-evaluating-llms-harness.md index 96a5423450..452112a244 100644 --- a/website/docs/user-guide/skills/bundled/mlops/mlops-evaluation-evaluating-llms-harness.md +++ b/website/docs/user-guide/skills/bundled/mlops/mlops-evaluation-evaluating-llms-harness.md @@ -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" diff --git a/website/docs/user-guide/skills/optional/gaming/gaming-minecraft-modpack-server.md b/website/docs/user-guide/skills/optional/gaming/gaming-minecraft-modpack-server.md index 2f0e925f23..95b080ea66 100644 --- a/website/docs/user-guide/skills/optional/gaming/gaming-minecraft-modpack-server.md +++ b/website/docs/user-guide/skills/optional/gaming/gaming-minecraft-modpack-server.md @@ -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//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" diff --git a/website/docs/user-guide/skills/optional/mlops/mlops-evaluation-evaluating-llms-harness.md b/website/docs/user-guide/skills/optional/mlops/mlops-evaluation-evaluating-llms-harness.md index 8d449ee126..46e614f016 100644 --- a/website/docs/user-guide/skills/optional/mlops/mlops-evaluation-evaluating-llms-harness.md +++ b/website/docs/user-guide/skills/optional/mlops/mlops-evaluation-evaluating-llms-harness.md @@ -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" diff --git a/website/docs/user-guide/skills/optional/mlops/mlops-torchtitan.md b/website/docs/user-guide/skills/optional/mlops/mlops-torchtitan.md index 9a814bf816..9af71bf89d 100644 --- a/website/docs/user-guide/skills/optional/mlops/mlops-torchtitan.md +++ b/website/docs/user-guide/skills/optional/mlops/mlops-torchtitan.md @@ -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 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/cron.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/cron.md index e994473bf6..b03efba644 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/cron.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/cron.md @@ -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 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/mlops/mlops-evaluation-evaluating-llms-harness.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/mlops/mlops-evaluation-evaluating-llms-harness.md index ff1db16ee6..c0d3d4dc3b 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/mlops/mlops-evaluation-evaluating-llms-harness.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/mlops/mlops-evaluation-evaluating-llms-harness.md @@ -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" diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/mlops/mlops-torchtitan.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/mlops/mlops-torchtitan.md index 4e9e551fa1..001440a911 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/mlops/mlops-torchtitan.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/mlops/mlops-torchtitan.md @@ -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