Merge origin/main into feat/plugin-catalog

Python plugin CLI/loader/web/tui files taken from main wholesale; the
catalog layer is re-ported onto main's decomposed shapes in the
following commits. plugin_index.py removed (catalog is the sole
discovery system).
This commit is contained in:
Teknium
2026-09-09 04:15:27 -07:00
5636 changed files with 754596 additions and 780439 deletions
+94
View File
@@ -0,0 +1,94 @@
"""Propose catalog quality updates from Artificial Analysis.
Authoring-time helper — NEVER called at runtime (their terms forbid
client-side keys, the fleet would burn the rate limit, and a
recommendation must not change because a third-party endpoint
hiccuped). Run it when adding a model or refreshing the ordering;
review the printed diff and edit catalog.json yourself. The script
proposes, the commit decides.
The catalog's `quality` stays OUR field: AA-informed where they cover a
model, editorially set where they don't (day-0 releases lag their evals;
some entries never appear). AA's Intelligence Index grades the
full-precision cloud model, not our Q4 build — fine for ordering, never
for display.
Usage:
export AA_API_KEY=... # from https://artificialanalysis.ai (free tier)
python scripts/aa_quality_sync.py
Attribution: scores by Artificial Analysis (https://artificialanalysis.ai).
"""
from __future__ import annotations
import json
import os
import sys
import urllib.request
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
CATALOG_PATH = REPO_ROOT / "hermes_cli" / "local_runtime" / "catalog.json"
AA_URL = "https://artificialanalysis.ai/api/v2/data/llms/models"
# Catalog entry id -> AA slug. Hand-maintained: AA's naming rarely matches
# HF repo names, and a wrong match silently mis-ranks a model. An entry
# absent here (or mapped to None) is editorial-only and never overwritten.
AA_SLUG_BY_ENTRY = {
"qwen3.8-27b": "qwen3-8-27b",
"qwen3.8-flash-next": "qwen3-8-flash-next",
"qwen3.6-35b-a3b": "qwen3-6-35b-a3b",
"deepseek-v4-flash": "deepseek-v4-flash",
}
def fetch_aa_models(api_key: str) -> dict[str, dict]:
req = urllib.request.Request(AA_URL, headers={"x-api-key": api_key})
with urllib.request.urlopen(req, timeout=30) as r:
doc = json.load(r)
return {m["slug"]: m for m in doc.get("data", [])}
def main() -> int:
api_key = os.environ.get("AA_API_KEY", "").strip()
if not api_key:
print("AA_API_KEY not set — create a free key at "
"https://artificialanalysis.ai and export it.", file=sys.stderr)
return 2
catalog = json.loads(CATALOG_PATH.read_text(encoding="utf-8"))
aa = fetch_aa_models(api_key)
print(f"{'entry':24s} {'catalog q':>9s} {'AA index':>9s} note")
print("-" * 70)
for model in catalog["models"]:
entry_id = model["id"]
current = model.get("quality", 0)
slug = AA_SLUG_BY_ENTRY.get(entry_id)
if not slug:
print(f"{entry_id:24s} {current:>9d} {'—':>9s} editorial only (no AA mapping)")
continue
hit = aa.get(slug)
if hit is None:
print(f"{entry_id:24s} {current:>9d} {'—':>9s} not in AA data (slug {slug!r})")
continue
index = (hit.get("evaluations") or {}).get(
"artificial_analysis_intelligence_index")
if index is None:
print(f"{entry_id:24s} {current:>9d} {'—':>9s} AA row lacks the index")
continue
proposed = round(float(index))
marker = "" if proposed == current else " <-- proposes change"
print(f"{entry_id:24s} {current:>9d} {proposed:>9d}{marker}")
print("\nReview against the decision table before editing: a quality "
"change that flips cells in tests/hermes_cli/"
"test_local_recommendation.py is the actual decision being made.")
print("Attribution: scores by Artificial Analysis "
"(https://artificialanalysis.ai).")
return 0
if __name__ == "__main__":
sys.exit(main())
+35
View File
@@ -55,6 +55,24 @@ def _legacy_login(email: str) -> str | None:
return None
def _case_collision(email: str) -> str | None:
"""An existing mapping whose filename differs from `email` only in case.
Returns the colliding filename, or None. Exact matches are not collisions --
that is the ordinary "already mapped" path handled by the caller.
"""
if not EMAILS_DIR.is_dir():
return None
# casefold (not lower) matches how macOS/Windows fold non-ASCII text —
# same key scripts/check-case-collisions.py uses repo-wide.
folded = email.casefold()
for entry in EMAILS_DIR.iterdir():
if entry.name != email and entry.name.casefold() == folded:
return entry.name
return None
def add_contributor(email: str, login: str, comment: str = "") -> int:
email = email.strip()
login = login.strip().lstrip("@")
@@ -67,6 +85,23 @@ def add_contributor(email: str, login: str, comment: str = "") -> int:
return 2
path = EMAILS_DIR / email
# One file per email means the FILENAME is the key, and on a
# case-insensitive filesystem (Windows, default macOS) two emails differing
# only in case are the same file. Creating both makes the repo impossible to
# check out cleanly there -- `git status` reports a phantom modification
# forever, because whichever file git wrote second wins on disk. Refuse for
# the same reason a conflicting login is refused: resolve it deliberately.
collision = _case_collision(email)
if collision is not None:
print(
f"error: {email} collides with existing mapping {collision} on "
"case-insensitive filesystems (Windows/macOS) — the two are the same "
"file there. Reuse that mapping, or resolve manually.",
file=sys.stderr,
)
return 1
existing = read_mapping_file(path) if path.is_file() else None
if existing is None:
existing = _legacy_login(email)
+2 -1
View File
@@ -94,7 +94,8 @@ def main():
print("agent-browser CLI not found — skipping subprocess bench.")
sub_times = []
else:
from tools.browser_tool import _run_browser_command, _last_session_key
from tools.browser_tool_session import _run_browser_command
from tools.browser_tool import _last_session_key
task_id = _last_session_key("bench-eval")
sub_times = []
for _ in range(args.iterations):
+7 -12
View File
@@ -28,20 +28,15 @@ from datetime import datetime, timezone
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, REPO_ROOT)
# Ensure HERMES_HOME is set (needed by tools/skills_hub.py imports)
# Ensure HERMES_HOME is set (needed by tools/skills_hub*.py imports)
os.environ.setdefault("HERMES_HOME", os.path.join(os.path.expanduser("~"), ".hermes"))
from tools.skills_hub import (
GitHubAuth,
GitHubSource,
SkillsShSource,
OptionalSkillSource,
WellKnownSkillSource,
ClawHubSource,
LobeHubSource,
BrowseShSource,
SkillMeta,
)
from tools.skills_hub_clawhub import ClawHubSource
from tools.skills_hub_github import GitHubAuth, GitHubSource
from tools.skills_hub_models import SkillMeta
from tools.skills_hub_official import OptionalSkillSource
from tools.skills_hub_skillssh import SkillsShSource
from tools.skills_hub_sources import BrowseShSource, LobeHubSource, WellKnownSkillSource
import httpx
OUTPUT_PATH = os.path.join(REPO_ROOT, "website", "static", "api", "skills-index.json")
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
Blocking check for tracked files whose paths collide when case is ignored.
Linux is case-sensitive; Windows and macOS (default) are not. Two tracked
paths that differ only by case — ``README.md`` and ``readme.md``, or
``src/Foo.py`` and ``SRC/foo.py`` — coexist happily in a Linux checkout and
silently break every clone on a case-insensitive host: the filesystem can
hold only one of them, so checkout either refuses or whichever file is
written last wins and clobbers the other. Git itself won't stop the pair
from landing — it only warns at checkout time, on a case-insensitive FS,
for whichever client happens to do the checkout, and the collision is
invisible on Linux. This check is the enforcement point: scan the index,
fail the build, name the offenders.
Usage:
# Check the checkout this script lives in (CI + the common local case)
python scripts/check-case-collisions.py
# Check an arbitrary git checkout (tests, other worktrees)
python scripts/check-case-collisions.py /path/to/other/repo
Exit status:
0 — no case-colliding tracked paths
1 — at least one collision group (paths printed to stdout)
2 — not in a git repository / git failed
Comparison key: the casefolded FULL path (``str.casefold``), not the
basename — on a case-insensitive filesystem the entire path is
case-insensitive, so ``dir/Foo.txt`` and ``DIR/foo.txt`` collide just like
same-directory pairs. ``casefold`` (not ``lower``) is used because it
matches how the OSes fold case for non-ASCII text (straße vs strasse,
sigma variants); a pair it flags is a genuine collision on macOS/Windows
even when Linux disagrees.
Deliberately out of scope: Unicode NFC/NFD normalization collisions (macOS
stores NFD, Linux NFC). git already handles those at checkout via
``core.precomposeunicode``; this check is strictly about case.
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
from collections import defaultdict
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"root",
nargs="?",
default=str(REPO_ROOT),
help="git checkout to scan (default: the repo this script lives in)",
)
args = parser.parse_args()
try:
os.chdir(args.root)
except OSError as exc:
print(f"::error::cannot enter {args.root}: {exc}")
return 2
proc = subprocess.run(["git", "ls-files", "-z"], capture_output=True)
if proc.returncode != 0:
msg = proc.stderr.decode("utf-8", errors="replace").strip()
print(f"::error::git ls-files failed in {args.root}: {msg}")
return 2
paths = [
p.decode("utf-8", errors="surrogateescape")
for p in proc.stdout.split(b"\0")
if p
]
by_casefold: dict[str, list[str]] = defaultdict(list)
for path in paths:
by_casefold[path.casefold()].append(path)
collisions = {key: group for key, group in by_casefold.items() if len(group) > 1}
if not collisions:
print(f"::notice::{len(paths)} tracked files, no case-colliding paths.")
return 0
print(
f"::error::Found {len(collisions)} case-collision group(s) among "
f"{len(paths)} tracked files."
)
print(
"Paths that differ only by case are ONE file on Windows/macOS but "
"several on Linux - the pair breaks every clone on a case-insensitive "
"host. Rename one member of each group so the paths differ beyond case."
)
print()
for key, group in sorted(collisions.items()):
for path in sorted(group):
print(f" {path}")
print()
print(
"Fix: `git mv` one path in each group to a name that doesn't collide. "
"On Windows/macOS you may need two steps (`git mv a.txt tmp && git mv "
"tmp A.txt`) because the filesystem can't hold both spellings at once."
)
return 1
if __name__ == "__main__":
sys.exit(main())
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Fail when in-tree code depends on a plugin-compat pointer.
``compat_manifest.json`` lists every name the Sep 2026 decomposition kept importable from its OLD
module purely for external plugins (the `PLUGIN-COMPAT` blocks). Those blocks are removed on a
schedule by reverting the commit that added them, so nothing inside this repository may depend on
them — otherwise the revert breaks the tree. This check walks every first-party Python file (source
AND tests) and flags:
from <facade> import <compat_name> # direct import through the old path
import <facade>; <facade>.<compat_name> # attribute access through the old path
patch("<facade>.<compat_name>") / monkeypatch.setattr(<facade>, "<compat_name>")
Exit 1 with a file:line list on any hit. Run: python scripts/check_compat_pointers.py
"""
from __future__ import annotations
import ast
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
MANIFEST = ROOT / "compat_manifest.json"
SKIP_DIRS = {".git", "node_modules", "website", "skills", "optional-skills", "apps", "evals", "build", "MagicMock", ".worktrees", "__pycache__"}
def _py_files():
for p in ROOT.rglob("*.py"):
parts = p.relative_to(ROOT).parts
if parts[0] in SKIP_DIRS or p.name == "check_compat_pointers.py":
continue
# The compat layer's own contract test uses the pointers on purpose; it is deleted with them.
if p.name == "test_compat_manifest_targets.py":
continue
yield p
def main() -> int:
if not MANIFEST.exists():
print("compat_manifest.json missing — nothing to check (compat layer already reverted?)")
return 0
entries = json.loads(MANIFEST.read_text(encoding="utf-8"))["entries"]
compat: dict[str, set[str]] = {}
for e in entries:
compat.setdefault(e["facade"], set()).add(e["name"])
facades = set(compat)
hits: list[str] = []
str_pat = re.compile(r"""["']((?:[A-Za-z_][\w]*\.)+[A-Za-z_]\w*)["']""")
for path in _py_files():
rel = path.relative_to(ROOT)
try:
src = path.read_text(encoding="utf-8", errors="ignore")
tree = ast.parse(src)
except SyntaxError:
continue
# module-level facade import aliases in this file: alias -> facade
aliases: dict[str, str] = {}
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module in facades and node.level == 0:
bad = [a.name for a in node.names if a.name in compat[node.module]]
for b in bad:
hits.append(f"{rel}:{node.lineno}: from {node.module} import {b}")
elif isinstance(node, ast.Import):
for a in node.names:
if a.name in facades:
aliases[a.asname or a.name] = a.name
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
for a in node.names:
full = f"{node.module}.{a.name}"
if full in facades:
aliases[a.asname or a.name] = full
for node in ast.walk(tree):
if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name):
fac = aliases.get(node.value.id)
if fac and node.attr in compat[fac]:
hits.append(f"{rel}:{node.lineno}: {node.value.id}.{node.attr} (via {fac})")
elif isinstance(node, ast.Call):
# monkeypatch.setattr(<facade alias>, "<name>", ...) / patch.object(<facade alias>, "<name>")
fn = node.func
is_setattr = (isinstance(fn, ast.Attribute) and fn.attr in ("setattr", "delattr", "object")) or (
isinstance(fn, ast.Name) and fn.id in ("setattr", "delattr", "getattr", "hasattr"))
if is_setattr and len(node.args) >= 2 and isinstance(node.args[0], ast.Name) and isinstance(node.args[1], ast.Constant) and isinstance(node.args[1].value, str):
fac = aliases.get(node.args[0].id)
if fac and node.args[1].value in compat[fac]:
hits.append(f"{rel}:{node.lineno}: setattr/patch({node.args[0].id}, \"{node.args[1].value}\") (via {fac})")
elif isinstance(node, ast.Constant) and isinstance(node.value, str):
m = str_pat.fullmatch(node.value.strip())
if m:
dotted = m.group(1); fac, _, name = dotted.rpartition(".")
if fac in facades and name in compat[fac]:
hits.append(f"{rel}:{node.lineno}: \"{dotted}\" (string patch target)")
if hits:
print("❌ in-tree code depends on plugin-compat pointers (scheduled for removal):")
for h in sorted(set(hits)):
print(" " + h)
print(f"\n{len(set(hits))} site(s). Import from the defining module instead (see COMPAT_MANIFEST.md).")
return 1
print(f"✅ no in-tree dependency on the {len(entries)} plugin-compat pointers")
return 0
if __name__ == "__main__":
sys.exit(main())
+50
View File
@@ -24,6 +24,7 @@ violation (does not modify files).
from __future__ import annotations
import ast
import os
import re
import sys
@@ -84,6 +85,50 @@ SKIP_DIRS = {
}
_SPLAT_RE = re.compile(r"\*\*\s*([A-Za-z_][A-Za-z0-9_]*)")
def _splat_carries_stdin(call_text: str, content: str) -> bool:
"""True when the call splats ``**name`` / ``**name(...)`` and ``name`` is defined in
the same file (assignment or ``def``) whose OWN expression/body sets ``stdin=``.
Shared kwargs helpers (``_RUN_KW = dict(..., stdin=DEVNULL)``, ``def _run_kwargs(): return
dict(..., stdin=DEVNULL)``) legitimately carry the guard; we only accept them when the
definition provably sets stdin= — never on the helper's name alone, and never because an
unrelated later call in the file happens to pass ``stdin=``.
"""
names = set(_SPLAT_RE.findall(call_text))
if not names:
return False
try:
tree = ast.parse(content)
except SyntaxError:
return False
for name in names:
node = None
for n in ast.walk(tree):
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name == name:
node = n
break
if isinstance(n, (ast.Assign, ast.AnnAssign)):
targets = n.targets if isinstance(n, ast.Assign) else [n.target]
if any(isinstance(t, ast.Name) and t.id == name for t in targets):
node = n.value if n.value is not None else n
break
if node is None:
return False
# stdin appears as a keyword (dict(stdin=...)) or as a dict-literal key ({"stdin": ...})
# somewhere INSIDE this definition — not merely nearby in the file.
has = any(
(isinstance(sub, ast.keyword) and sub.arg == "stdin")
or (isinstance(sub, ast.Constant) and sub.value == "stdin")
for sub in ast.walk(node)
)
if not has:
return False
return True
def find_subprocess_calls(content: str, filepath: str) -> list[dict]:
"""Find all subprocess/os/asyncio calls missing stdin= in content."""
violations = []
@@ -131,6 +176,11 @@ def find_subprocess_calls(content: str, filepath: str) -> list[dict]:
if "input=" in call_text:
break
# Splats a same-file kwargs helper whose definition
# sets stdin= → the guard travels with the helper.
if _splat_carries_stdin(call_text, content):
break
# Inline exemption marker on the call itself or within
# the few comment lines immediately above it → the call
# intentionally inherits stdin.
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Reject profile export archives before publication.
``.gitignore`` and ``.dockerignore`` are useful first-line filters, but both
can be bypassed (for example with ``git add -f`` or a non-standard build
context). This check is the blocking, executable policy at the CI and image
publication boundaries. It intentionally checks the filesystem rather than
Git's index so a generated archive cannot enter a build after checkout.
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
_PROFILE_ARCHIVE_SUFFIXES = (".tar.gz", ".tgz")
def find_forbidden_profile_archives(root: Path) -> list[Path]:
"""Return profile archive paths anywhere in the checkout."""
root = root.resolve()
if not root.is_dir():
raise ValueError(f"repository root is not a directory: {root}")
offenders: list[Path] = []
for directory, dirnames, filenames in os.walk(root, followlinks=False):
dirnames[:] = [
name
for name in dirnames
if name not in {".git", ".venv", "venv", "node_modules", "__pycache__"}
]
for name in (*dirnames, *filenames):
if name.casefold().endswith(_PROFILE_ARCHIVE_SUFFIXES):
offenders.append((Path(directory) / name).relative_to(root))
return sorted(offenders, key=lambda path: path.as_posix().casefold())
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Reject profile export archives in the checkout."
)
parser.add_argument(
"--root",
type=Path,
default=Path.cwd(),
help="repository root to inspect (default: current directory)",
)
args = parser.parse_args(argv)
try:
offenders = find_forbidden_profile_archives(args.root)
except ValueError as exc:
parser.error(str(exc))
if not offenders:
print("No profile export archives detected in the checkout.")
return 0
print(
"::error::profile export archives are forbidden "
"in source and Docker build contexts"
)
for path in offenders:
print(f" {path.as_posix()}")
print(
"Move the archive outside the checkout or pass an explicit external "
"output path to the profile export command."
)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Public-surface diff against a base ref: dropped public names and dropped test functions.
The Sep 2026 whole-codebase refactor (PR #102117) opened with 1,703 public top-level names dropped
across 341 modules, 1,000 public methods in 166, and 130 ``def test_`` deleted in 54 files.
Reviewers found ~30 of the names by hand; the rest surfaced as post-merge fixes (10 commits
restoring symbols/re-exports, 6 restoring tests, plus a qwen OAuth break that went past import
smoke because the caller used ``module.attr``). Every one of those was catchable in seconds with
this script; nothing ran it because it did not exist.
Rules (deliberately narrow, no allowlist file to maintain):
- A PUBLIC top-level name (function, class, module-level assignment, re-exported import) or a
public/dunder method of a module present on BOTH sides may be moved and re-exported or
deprecated, never silently removed. A deleted MODULE is not a drop here (that is a visible
decision; ``check_doc_paths`` territory).
- A ``tests/**`` file present on both sides must not end with fewer ``def test_`` than it started
with. Deleting a whole test file is, again, a visible decision and not flagged.
Advisory by default (prints the report, exit 0). ``--strict`` exits 1 on any finding so a
refactor brief or a CI lane can make it a hard gate. Runs in ~30 s over the whole tree.
Usage:
python scripts/ci/check_public_surface.py [--base origin/main] [--head HEAD] [--strict] [--json out.json]
"""
from __future__ import annotations
import argparse
import ast
import json
import re
import subprocess
import sys
SRC_DIRS = ("agent", "gateway", "hermes_cli", "tools", "tui_gateway", "cron", "acp_adapter", "plugins")
_TEST_DEF_RE = re.compile(r"^\s*(?:async\s+)?def test_", re.M)
def _git(*args: str) -> str:
return subprocess.run(["git", *args], capture_output=True, text=True, encoding="utf-8", errors="replace").stdout
def _changed_py(base: str, head: str) -> list[str]:
out = _git("diff", "--name-only", "--diff-filter=M", base, head)
return [f for f in out.split() if f.endswith(".py")]
def _show(rev: str, path: str) -> str | None:
r = subprocess.run(["git", "show", f"{rev}:{path}"], capture_output=True, text=True, encoding="utf-8", errors="replace")
return r.stdout if r.returncode == 0 else None
def public_toplevel(src: str) -> set[str] | None:
"""Public top-level names: defs, classes, assigned names, and imported/re-exported names."""
try:
tree = ast.parse(src)
except SyntaxError:
return None
names: set[str] = set()
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
names.add(node.name)
elif isinstance(node, ast.Assign):
for target in node.targets:
names.update(x.id for x in ast.walk(target) if isinstance(x, ast.Name))
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
names.add(node.target.id)
elif isinstance(node, (ast.Import, ast.ImportFrom)):
names.update((a.asname or a.name).split(".")[0] for a in node.names)
return {n for n in names if not n.startswith("_")}
def _is_public_method(name: str) -> bool:
return not name.startswith("_") or name.startswith("__")
def public_methods(src: str) -> set[str]:
"""``Class.method`` for public and dunder methods REACHABLE on each top-level class: defined on it
or inherited from a base class defined in the same module. Extracting a method into a mixin/base
that the class still derives from is not a removal (the attribute still resolves)."""
try:
tree = ast.parse(src)
except SyntaxError:
return set()
classes = {n.name: n for n in tree.body if isinstance(n, ast.ClassDef)}
def own(cls: ast.ClassDef) -> set[str]:
return {m.name for m in cls.body if isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef)) and _is_public_method(m.name)}
def reachable(cls: ast.ClassDef, seen: set[str]) -> set[str]:
names = own(cls)
for base in cls.bases:
base_name = base.id if isinstance(base, ast.Name) else (base.attr if isinstance(base, ast.Attribute) else None)
if base_name in classes and base_name not in seen:
names |= reachable(classes[base_name], seen | {base_name})
return names
out: set[str] = set()
for name, cls in classes.items():
out.update(f"{name}.{m}" for m in reachable(cls, {name}))
return out
def _is_source(path: str) -> bool:
return "/" not in path or path.split("/", 1)[0] in SRC_DIRS
def diff_surface(base: str, head: str) -> dict:
dropped_names: dict[str, list[str]] = {}
dropped_methods: dict[str, list[str]] = {}
test_drops: dict[str, tuple[int, int]] = {}
for path in _changed_py(base, head):
before, after = _show(base, path), _show(head, path)
if before is None or after is None:
continue
if path.startswith("tests/"):
nb, na = len(_TEST_DEF_RE.findall(before)), len(_TEST_DEF_RE.findall(after))
if na < nb:
test_drops[path] = (nb, na)
continue
if not _is_source(path) or path.startswith("tests"):
continue
pb, pa = public_toplevel(before), public_toplevel(after)
if pb is not None and pa is not None and (gone := sorted(pb - pa)):
dropped_names[path] = gone
if gone_m := sorted(public_methods(before) - public_methods(after)):
dropped_methods[path] = gone_m
return {"dropped_names": dropped_names, "dropped_methods": dropped_methods, "test_drops": test_drops}
def render(report: dict) -> str:
lines: list[str] = []
n_names = sum(len(v) for v in report["dropped_names"].values())
n_meth = sum(len(v) for v in report["dropped_methods"].values())
n_tests = sum(b - a for b, a in report["test_drops"].values())
lines.append(
f"public-surface: {n_names} public top-level name(s) dropped in {len(report['dropped_names'])} module(s); "
f"{n_meth} public/dunder method(s) dropped in {len(report['dropped_methods'])}; "
f"{n_tests} test def(s) dropped in {len(report['test_drops'])} file(s)"
)
for path, names in sorted(report["dropped_names"].items()):
lines.append(f" {path}: -{', '.join(names[:12])}{' …' if len(names) > 12 else ''}")
for path, names in sorted(report["dropped_methods"].items()):
lines.append(f" {path}: -{', '.join(names[:8])}{' …' if len(names) > 8 else ''}")
for path, (b, a) in sorted(report["test_drops"].items()):
lines.append(f" {path}: {b} -> {a} test defs")
if n_names or n_meth or n_tests:
lines.append(
" A public name may be moved and re-exported or deprecated, never silently removed (in-tree refs say "
"nothing about plugins); a test file must not lose test defs unless the commit names the private symbol "
"they pinned."
)
return "\n".join(lines)
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=(__doc__ or "").split("\n\n")[0])
ap.add_argument("--base", default="origin/main")
ap.add_argument("--head", default="HEAD")
ap.add_argument("--strict", action="store_true", help="exit 1 on any finding")
ap.add_argument("--json", dest="json_out", help="also write the report as JSON")
args = ap.parse_args(argv)
for ref in (args.base, args.head):
if subprocess.run(["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], capture_output=True).returncode != 0:
print(f"public-surface: cannot resolve ref {ref!r} (fetch it first); refusing to report a clean diff", file=sys.stderr)
return 2
base = _git("merge-base", args.base, args.head).strip()
if not base:
print(f"public-surface: no merge-base between {args.base!r} and {args.head!r}", file=sys.stderr)
return 2
report = diff_surface(base, args.head)
print(render(report))
if args.json_out:
with open(args.json_out, "w", encoding="utf-8") as fh:
json.dump(report, fh, indent=1)
findings = any(report[k] for k in ("dropped_names", "dropped_methods", "test_drops"))
return 1 if (args.strict and findings) else 0
if __name__ == "__main__":
sys.exit(main())
+97 -1
View File
@@ -25,6 +25,12 @@ Lanes:
must not run it.
* ``npm_lock`` — semantic package-lock.json diff PR comment.
* ``installer`` — PowerShell installer tests (Windows runner).
* ``desktop_updater`` — the Windows desktop-update hand-off script and the
tests that drive the REAL ``windows.ps1`` (``-SelfTestUi`` / pipe drain /
retry policy). These are integration tests of a PowerShell process on a
shared runner; running them on every Python PR made their timing noise
everyone's problem. They still run on push (fail-open) and whenever the
script, its siblings, or their tests change.
* ``rust`` — ``cargo test`` for the Tauri bootstrap installer. ``.rs``
lives under ``apps/``, so without this lane a Rust change matched ``frontend``
and only the TypeScript matrix ran.
@@ -57,9 +63,12 @@ from __future__ import annotations
import json
import os
import subprocess
import sys
_FRONTEND = ("ui-tui/", "web/", "apps/") # TS typecheck-matrix packages
# Shipped page outside those packages, exercised by the desktop Electron suite.
_FRONTEND_FILES = {"scripts/desktop-update/ui.html"}
_ROOT_NPM = {"package.json", "package-lock.json"} # shifts every package's tree
_DOCKER_META = ("docker/", ".hadolint.yml", "Dockerfile") # docker setup
_NIX_PATHS = ("nix/",) # nix files
@@ -109,6 +118,17 @@ _MCP_CATALOG_FILES = {"hermes_cli/mcp_catalog.py"}
_INSTALLER_PATHS = ("scripts/tests/",)
_INSTALLER_FILES = {"scripts/install.ps1", "scripts/install.cmd"}
# Windows desktop-update hand-off (scripts/desktop-update/windows.ps1 + the
# Electron side that launches it) and the pytest files that spawn it.
_DESKTOP_UPDATER_PATHS = ("scripts/desktop-update/",)
_DESKTOP_UPDATER_TEST_PREFIX = "tests/test_desktop_update_"
_DESKTOP_UPDATER_FILES = {
"apps/desktop/electron/updater-process.ts",
"apps/desktop/electron/managed-ssh-update.ts",
"tests/conftest.py",
"pyproject.toml",
}
# Rust crates — currently just the Tauri bootstrap installer (Hermes-Setup).
# These live under ``apps/``, so before this lane existed a ``.rs`` edit matched
# ``frontend`` and nothing more: the TypeScript matrix built, cargo never ran,
@@ -162,6 +182,14 @@ def _is_installer(p: str) -> bool:
return p.startswith(_INSTALLER_PATHS) or p in _INSTALLER_FILES
def _is_desktop_updater(p: str) -> bool:
return (
p.startswith(_DESKTOP_UPDATER_PATHS)
or p.startswith(_DESKTOP_UPDATER_TEST_PREFIX)
or p in _DESKTOP_UPDATER_FILES
)
def _is_rust(p: str) -> bool:
return (
p.endswith(".rs")
@@ -188,7 +216,10 @@ def classify(files: list[str]) -> dict[str, bool]:
files = [f.strip() for f in files if f.strip()]
python = any(not _py_irrelevant(f) for f in files)
python_prod = any(not _py_irrelevant(f) and not _py_test_only(f) for f in files)
frontend = any(f.startswith(_FRONTEND) or f in _ROOT_NPM for f in files)
frontend = any(
f.startswith(_FRONTEND) or f in _ROOT_NPM or f in _FRONTEND_FILES
for f in files
)
deps = any(f == "pyproject.toml" for f in files)
npm_lock = any(f.split("/")[-1] == "package-lock.json" for f in files)
docker_meta = any(f.startswith(_DOCKER_META) for f in files)
@@ -205,6 +236,7 @@ def classify(files: list[str]) -> dict[str, bool]:
"uv_lock": any(f in ("pyproject.toml", "uv.lock") for f in files),
"npm_lock": npm_lock,
"installer": any(_is_installer(f) for f in files),
"desktop_updater": any(_is_desktop_updater(f) for f in files),
"rust": any(_is_rust(f) for f in files),
"mcp_catalog": any(_is_mcp_catalog(f) for f in files),
"ci_review": any(_is_ci_review(f) for f in files),
@@ -222,6 +254,7 @@ def classify(files: list[str]) -> dict[str, bool]:
ret["uv_lock"] = True
ret["npm_lock"] = True
ret["installer"] = True
ret["desktop_updater"] = True
ret["rust"] = True
ret["nix"] = True
ret["ci_review"] = True
@@ -230,9 +263,72 @@ def classify(files: list[str]) -> dict[str, bool]:
return ret
def _pull_request_number() -> str | None:
"""Read the PR number from the Actions event payload, if present."""
event_path = os.environ.get("GITHUB_EVENT_PATH")
if not event_path:
return None
try:
with open(event_path, encoding="utf-8") as fh:
payload = json.load(fh)
except (OSError, json.JSONDecodeError):
return None
number = (payload.get("pull_request") or {}).get("number")
return str(number) if number else None
def pull_request_changed_files() -> list[str]:
"""Recover the PR file list when the compare API returned nothing.
``detect-changes`` calls ``repos/.../compare/base...head`` with raw SHAs.
A fork force-push can 404 for ~30s until GitHub attaches the new head SHA
to the base repo, so the action fails open with an empty file list. That
forces ``ci_review=true`` and blocks the PR on a ``ci-reviewed`` label
even when no CI-sensitive file changed.
The pull-request files endpoint already knows the PR's files (it is how
this action used to classify), so use it as a fallback on pull_request
events only. Push/dispatch keep the empty-diff fail-open.
"""
if os.environ.get("EVENT_NAME") != "pull_request":
return []
repo = os.environ.get("REPO") or os.environ.get("GITHUB_REPOSITORY") or ""
pr = _pull_request_number()
if not repo or not pr:
return []
try:
completed = subprocess.run(
[
"gh",
"api",
"--paginate",
f"repos/{repo}/pulls/{pr}/files",
"--jq",
".[].filename",
],
check=False,
capture_output=True,
text=True,
timeout=30,
)
except (OSError, subprocess.TimeoutExpired):
return []
if completed.returncode != 0:
return []
return [line.strip() for line in completed.stdout.splitlines() if line.strip()]
def main() -> int:
files = sys.stdin.read().splitlines()
if not any(f.strip() for f in files):
recovered = pull_request_changed_files()
if recovered:
print(
f"compare API returned no files; recovered {len(recovered)} "
"path(s) from the pull request files endpoint",
file=sys.stderr,
)
files = recovered
lanes = classify(files)
out = "\n".join([
*(f"{key}={str(value).lower()}" for key, value in lanes.items()),
+7
View File
@@ -57,6 +57,10 @@ IGNORED_PATTERNS = [
re.compile(r"^TRON$", re.IGNORECASE), # AgentMail agent
re.compile(r"^Happy$", re.IGNORECASE), # happy.engineering AI agent
re.compile(r"^Orca$", re.IGNORECASE), # Stably AI agent
# v0.21.0 audit additions:
re.compile(r"^Junie$", re.IGNORECASE), # JetBrains Junie AI agent
re.compile(r"^GPT-[\d.]+\s*Codex$", re.IGNORECASE), # OpenAI Codex model trailer
re.compile(r"^cursoragent$", re.IGNORECASE), # Cursor AI GitHub account
]
IGNORED_EMAILS = {
@@ -75,6 +79,9 @@ IGNORED_EMAILS = {
"tron-agent@agentmail.to", # TRON (AgentMail agent)
"yesreply@happy.engineering", # Happy (AI coding agent)
"help@stably.ai", # Orca (Stably AI agent)
# v0.21.0 audit additions — AI-agent co-author trailers:
"junie@jetbrains.com", # JetBrains Junie
"noreply@openai.com", # GPT-x Codex model trailer
}
+6 -1
View File
@@ -7,5 +7,10 @@
# Without it, that Desktop would silently fall back to the frozen staged
# Tauri binary for one update cycle — the exact rot this script family
# exists to escape.
& (Join-Path $PSScriptRoot "desktop-update\windows.ps1") @args
$target = Join-Path $PSScriptRoot "desktop-update\windows.ps1"
if (-not (Test-Path -LiteralPath $target -PathType Leaf)) {
Write-Error "The maintained Desktop updater is missing. Repair the Hermes installation before updating."
exit 3
}
& $target @args
exit $LASTEXITCODE
+193 -8
View File
@@ -39,6 +39,7 @@ ORIGINAL_ARGS=("$@")
INSTALL_ROOT="" BRANCH="main" DESKTOP_PID=0 RELAUNCH_TARGET=""
RELAUNCH_CWD="" SANDBOX_FALLBACK=0 RELAUNCH_ARGS=()
NO_UI=0 NO_MARKER_CLEANUP=0 SELF_TEST_UI=0 SELF_TEST_GATE=0 SELF_TEST_MARKER=0
SELF_TEST_TCC_HEAL=0
HANDOFF_DAEMONIZED=0
while [ $# -gt 0 ]; do
case "$1" in
@@ -52,6 +53,7 @@ while [ $# -gt 0 ]; do
--no-marker-cleanup) NO_MARKER_CLEANUP=1; shift ;;
--self-test-ui) SELF_TEST_UI=1; shift ;;
--self-test-gate) SELF_TEST_GATE=1; shift ;;
--self-test-tcc-heal) SELF_TEST_TCC_HEAL=1; shift ;;
--daemonized) HANDOFF_DAEMONIZED=1; shift ;;
--self-test-marker) SELF_TEST_MARKER=1; NO_UI=1; NO_MARKER_CLEANUP=1; shift ;;
--) shift; RELAUNCH_ARGS=("$@"); shift $# ;;
@@ -70,7 +72,7 @@ RESULT="$HERMES_HOME/.hermes-update-result.json"
STATUS="${TMPDIR:-/tmp}/hermes-update-status.$$"
STARTED_AT="$(date +%s)" # the shim's elapsed clock; see serve-ui.py
UI_SERVER_PID="" UI_BROWSER_PID="" FINAL_CODE=1
UI_SERVER_PID="" UI_BROWSER_PID="" UI_PROFILE_DIR="" FINAL_CODE=1
FINAL_MSG="update did not complete"
DONE_NOTE="" # set when the update succeeded but the app will NOT reopen itself
@@ -243,6 +245,11 @@ start_ui() {
fi
{ [ -f "$html" ] && [ -n "$py" ] && [ -n "$browser" ]; } || { log "shim: no renderer; skipping UI"; return; }
UI_PROFILE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/hermes-update-ui-XXXXXXXX")" || {
UI_PROFILE_DIR=""
log "shim: could not allocate a browser profile; skipping UI"
return
}
publish_stage ""
# The Desktop's final teardown targets the updater process group. Put both
# UI processes in their own sessions so neither the HTTP server nor a Chrome
@@ -266,7 +273,7 @@ start_ui() {
# Throwaway profile: new window/process we own; user's browser untouched.
"$py" -c 'import os, signal, sys; os.setsid(); signal.signal(signal.SIGTERM, signal.SIG_DFL); os.execv(sys.argv[1], sys.argv[1:])' \
"$browser" --app="http://127.0.0.1:$port/" --user-data-dir="${TMPDIR:-/tmp}/hermes-update-ui-$$" \
"$browser" --app="http://127.0.0.1:$port/" --user-data-dir="$UI_PROFILE_DIR" \
--no-first-run --no-default-browser-check --window-size=280,320 >/dev/null 2>&1 &
UI_BROWSER_PID=$!
log "shim: app window on 127.0.0.1:$port"
@@ -288,6 +295,10 @@ stop_ui() { # error/manual outcomes keep the window up briefly so a watching
if [ -n "$UI_BROWSER_PID" ]; then
{ kill "$UI_BROWSER_PID" && wait "$UI_BROWSER_PID"; } 2>/dev/null
fi
if [ -n "$UI_PROFILE_DIR" ]; then
rm -rf "$UI_PROFILE_DIR" 2>/dev/null || true
UI_PROFILE_DIR=""
fi
UI_SERVER_PID="" UI_BROWSER_PID=""
}
@@ -316,6 +327,10 @@ linux_gate() {
sb="$unpacked/chrome-sandbox"
if [ ! -e "$sb" ]; then GATE=relaunch; return; fi
if [ -u "$sb" ] && [ "$(stat -c %u "$sb" 2>/dev/null)" = "0" ]; then GATE=relaunch; return; fi
# Namespace sandbox usable => Electron never consults the setuid helper,
# so a non-root chrome-sandbox does not block relaunch (mirrors the
# _desktop_linux_userns_sandbox_available() probe in hermes_cli/main.py).
if unshare --user --map-root-user true 2>/dev/null; then GATE=relaunch; return; fi
case "${ELECTRON_DISABLE_SANDBOX:-}" in 1|true|TRUE|True) GATE=relaunch; return ;; esac
[ "$SANDBOX_FALLBACK" -eq 1 ] && { GATE=relaunch; return; }
@@ -467,7 +482,151 @@ finish() {
}
trap finish EXIT
# ── legacy macOS TCC anchor self-heal (#95759) ──────────────────────────────
# The reverted TCC interpreter anchor (#95425/#95541) left some installs with
# a real-file `venv/bin/python` copy plus a `.tcc-anchor-source` marker, and
# `python3`/`python3.N` aliases that die at interpreter init ("No module
# named 'encodings'"). On those installs EVERY normal CLI entrypoint is dead
# (`venv/bin/hermes` has a `python3` shebang), so no Python-side heal —
# doctor OR in-update — can ever run. This shell is the last surface that
# still executes, so the heal lives here (recovery design after @aeonsong's
# #96231; heal-point observation by @ahrazzle / @tokenfires on #95759).
#
# Ping-pong coherence with the re-landed forward anchor
# (hermes_cli/macos_tcc_anchor.ensure_tcc_anchor, which re-anchors whenever
# `venv/bin/python` is a uv-managed symlink): this heal is gated on the
# interpreter FAILING its boot probe, so a healthy anchored install is never
# touched; and when it does restore symlinks, the very `hermes update` run it
# unblocks re-installs a boot-gated healthy anchor — a one-shot convergence,
# not a loop.
tcc_probe_python() { # interpreter path → 0 iff it boots a real stdlib.
# PYTHONHOME/PYTHONPATH are scrubbed: an inherited PYTHONHOME papers over
# exactly the prefix-resolution failure this probe exists to detect.
[ -x "$1" ] || return 1
env -u PYTHONHOME -u PYTHONPATH -u PYTHONSTARTUP -u __PYVENV_LAUNCHER__ \
"$1" -c 'import encodings' >/dev/null 2>&1
}
TCC_HEAL_STATE="not-run"
tcc_heal_rollback() { # restore every .tcc-heal-old.$$ backup in a bin dir
local b
for b in "$1"/*.tcc-heal-old.$$; do
[ -e "$b" ] || [ -L "$b" ] || continue
mv -f "$b" "${b%.tcc-heal-old.$$}" 2>/dev/null || true
done
}
tcc_heal_cleanup() { # heal landed: drop the backups
local b
for b in "$1"/*.tcc-heal-old.$$; do
[ -e "$b" ] || [ -L "$b" ] || continue
rm -f "$b" 2>/dev/null || true
done
}
tcc_alias_names() { # existing python3 / python3.N entries in a bin dir
local a
for a in "$1"/python3 "$1"/python3.*; do
[ -e "$a" ] || [ -L "$a" ] || continue
case "${a##*/}" in
python3|python3.[0-9]|python3.[0-9][0-9]) printf '%s\n' "$a" ;;
esac
done
}
tcc_anchor_heal() { # $1 = venv bin dir. 0 iff python3 boots on exit.
local bin="$1" marker src alias staged
local py="$bin/python" py3="$bin/python3"
if tcc_probe_python "$py3"; then TCC_HEAL_STATE="healthy"; return 0; fi
marker="$bin/.tcc-anchor-source"
[ -f "$marker" ] || { TCC_HEAL_STATE="no-marker"; return 1; }
src="$(head -1 "$marker" 2>/dev/null)"
case "$src" in
"$bin"/*) TCC_HEAL_STATE="unsafe-source"; return 1 ;;
/*) ;;
*) TCC_HEAL_STATE="invalid-marker"; return 1 ;;
esac
if tcc_probe_python "$py"; then
# #95541 alias-brick: the anchored copy itself boots; only the aliases
# are dead. Aliases over a real-file anchor must be REAL FILES (hard
# link, else copy) — an alias *symlink* onto the copy is the exact
# crash shape being healed here.
TCC_HEAL_STATE="healed-aliases"
while IFS= read -r alias; do
[ -n "$alias" ] || continue
mv "$alias" "$alias.tcc-heal-old.$$" 2>/dev/null \
|| { tcc_heal_rollback "$bin"; TCC_HEAL_STATE="failed"; return 1; }
if ! { ln "$py" "$alias" 2>/dev/null || cp -p "$py" "$alias" 2>/dev/null; }; then
tcc_heal_rollback "$bin"; TCC_HEAL_STATE="failed"; return 1
fi
done <<EOF_ALIASES
$(tcc_alias_names "$bin")
EOF_ALIASES
elif [ -x "$src" ] && tcc_probe_python "$src"; then
# Full restore to the pre-anchor layout: python → symlink to the
# marker-recorded store interpreter, aliases → symlinks to python.
TCC_HEAL_STATE="healed-symlinks"
mv "$py" "$py.tcc-heal-old.$$" 2>/dev/null \
|| { TCC_HEAL_STATE="failed"; return 1; }
if ! ln -s "$src" "$py" 2>/dev/null; then
tcc_heal_rollback "$bin"; TCC_HEAL_STATE="failed"; return 1
fi
while IFS= read -r alias; do
[ -n "$alias" ] || continue
staged="$alias.tcc-heal-new.$$"
mv "$alias" "$alias.tcc-heal-old.$$" 2>/dev/null \
|| { tcc_heal_rollback "$bin"; TCC_HEAL_STATE="failed"; return 1; }
if ! { ln -s python "$staged" 2>/dev/null && mv "$staged" "$alias" 2>/dev/null; }; then
rm -f "$staged" 2>/dev/null
tcc_heal_rollback "$bin"; TCC_HEAL_STATE="failed"; return 1
fi
done <<EOF_ALIASES
$(tcc_alias_names "$bin")
EOF_ALIASES
else
# Recorded source gone or itself unbootable (the vanished-uv-store
# class from #95759): fail closed, touch nothing.
TCC_HEAL_STATE="source-missing"; return 1
fi
if tcc_probe_python "$py3"; then
if [ "$TCC_HEAL_STATE" = "healed-symlinks" ]; then
# Marker asserts the anchored layout; the symlink layout is done
# with it. (The alias-heal branch KEEPS it: real-file python +
# real-file aliases is exactly the layout ensure_tcc_anchor marks.)
rm -f "$marker" 2>/dev/null || true
fi
tcc_heal_cleanup "$bin"
return 0
fi
tcc_heal_rollback "$bin"
TCC_HEAL_STATE="failed"
return 1
}
tcc_pick_update_invoke() { # sets UPDATE_INVOKE; safety net past a failed heal
# Last-resort class: aliases still dead but the anchored copy boots. The
# launchd gateway proves `venv/bin/python -m hermes_cli.main` works when
# every alias entrypoint is bricked — drive the update the same way.
local bin="$1"
UPDATE_INVOKE=("$bin/hermes")
if ! tcc_probe_python "$bin/python3" && tcc_probe_python "$bin/python"; then
UPDATE_INVOKE=("$bin/python" -m hermes_cli.main)
fi
}
# ── self-tests: no update, touch nothing ────────────────────────────────────
if [ "$SELF_TEST_TCC_HEAL" -eq 1 ]; then
# Runs the REAL heal + invoke selection against --install-root and reports;
# tests/test_desktop_update_tcc_heal.py drives the state matrix through it.
trap - EXIT
tcc_anchor_heal "$INSTALL_ROOT/venv/bin" || true
tcc_pick_update_invoke "$INSTALL_ROOT/venv/bin"
echo "state=$TCC_HEAL_STATE invoke=${UPDATE_INVOKE[*]}"
exit 0
fi
if [ "$SELF_TEST_GATE" -eq 1 ]; then
# Prints the gate decision for the given --install-root/--relaunch-target
# and exits; scripts/desktop-update/repro.sh gate asserts the matrix.
@@ -564,6 +723,23 @@ start_ui
HERMES_BIN="$INSTALL_ROOT/venv/bin/hermes"
[ -x "$HERMES_BIN" ] || { FINAL_CODE=3 FINAL_MSG="Update aborted: $HERMES_BIN is missing. The install needs repair (run the Hermes installer or hermes doctor)."; log "$FINAL_MSG"; exit 3; }
# Heal a venv the reverted TCC anchor left bricked BEFORE invoking the CLI:
# venv/bin/hermes execs venv/bin/python3, so a dead alias kills every attempt
# and its retry identically (#95759). macOS-only artifact; probe is cheap.
if [ "$(uname)" = "Darwin" ]; then
if tcc_anchor_heal "$INSTALL_ROOT/venv/bin"; then
case "$TCC_HEAL_STATE" in
healed-*) log "TCC anchor self-heal repaired the venv interpreter ($TCC_HEAL_STATE)" ;;
esac
else
log "TCC anchor self-heal could not repair the venv ($TCC_HEAL_STATE)"
fi
fi
tcc_pick_update_invoke "$INSTALL_ROOT/venv/bin"
if [ "${UPDATE_INVOKE[0]}" != "$HERMES_BIN" ]; then
log "venv/bin/python3 still unbootable; invoking the update via ${UPDATE_INVOKE[*]}"
fi
# Run FROM the install root: `hermes update` resolves the tree it mutates
# from the working directory, and we inherit the Desktop's cwd (which can be
# an unrelated repo — updating THAT instead of the install is the failure
@@ -580,14 +756,14 @@ export PYTHONUNBUFFERED=1
# know the flag and argparse would abort with exit 2, which collides with the
# "close all Hermes windows" sentinel.
KEEP_STASH=""
if "$HERMES_BIN" update --help 2>/dev/null | grep -q -- '--keep-stash'; then
if "${UPDATE_INVOKE[@]}" update --help 2>/dev/null | grep -q -- '--keep-stash'; then
KEEP_STASH="--keep-stash"
else
log "installed hermes predates --keep-stash; running without it"
fi
log "running: hermes update --yes --gateway $KEEP_STASH --branch $BRANCH"
log "running: ${UPDATE_INVOKE[*]} update --yes --gateway $KEEP_STASH --branch $BRANCH"
publish_stage "Updating code and dependencies"
OUT="$("$HERMES_BIN" update --yes --gateway $KEEP_STASH --branch "$BRANCH" 2>&1)"; CODE=$?
OUT="$("${UPDATE_INVOKE[@]}" update --yes --gateway $KEEP_STASH --branch "$BRANCH" 2>&1)"; CODE=$?
printf '%s\n' "$OUT" >> "$LOG" 2>/dev/null
log "hermes update exit code: $CODE"
@@ -609,7 +785,7 @@ if [ "$CODE" -ne 0 ] && [ "$CODE" -ne 2 ]; then
fi
log "retrying once (freshly pulled fix loads on the second run)"
publish_stage "Retrying update"
OUT="$("$HERMES_BIN" update --yes --gateway $KEEP_STASH --branch "$BRANCH" 2>&1)"; CODE=$?
OUT="$("${UPDATE_INVOKE[@]}" update --yes --gateway $KEEP_STASH --branch "$BRANCH" 2>&1)"; CODE=$?
printf '%s\n' "$OUT" >> "$LOG" 2>/dev/null
log "retry exit code: $CODE"
fi
@@ -621,12 +797,21 @@ trap 'on_signal TERM' TERM
if [ "$CODE" -eq 0 ] && printf '%s' "$OUT" | grep -q "Desktop build failed"; then
log "desktop build failed inside hermes update; retrying build"
publish_stage "Rebuilding Desktop"
"$HERMES_BIN" desktop --force-build --build-only >> "$LOG" 2>&1 || {
"${UPDATE_INVOKE[@]}" desktop --force-build --build-only >> "$LOG" 2>&1 || {
FINAL_CODE=6 FINAL_MSG="Code and dependencies updated, but the Desktop app rebuild failed - you are running the previous build. Run hermes desktop --force-build from a terminal to retry."
exit 6
}
fi
if [ "$CODE" -eq 0 ]; then FINAL_CODE=0 FINAL_MSG="Update complete."
else FINAL_CODE="$CODE" FINAL_MSG="Update failed (exit $CODE). Run hermes debug share in a terminal to send a report."; fi
else
FINAL_CODE="$CODE" FINAL_MSG="Update failed (exit $CODE). Run hermes debug share in a terminal to send a report."
# The bricked-venv class is fixable and must not read as a generic exit 1:
# a dead interpreter with a failed/impossible heal means retrying can never
# succeed — tell the user what is actually wrong (#95759).
if ! tcc_probe_python "$INSTALL_ROOT/venv/bin/python3" \
&& ! tcc_probe_python "$INSTALL_ROOT/venv/bin/python"; then
FINAL_MSG="Update failed: the Python interpreter inside $INSTALL_ROOT/venv cannot start (heal state: $TCC_HEAL_STATE). Reinstall the runtime with the Hermes installer, or run hermes doctor --fix from a terminal if any hermes command still works."
fi
fi
exit "$FINAL_CODE"
+16
View File
@@ -0,0 +1,16 @@
function Test-HermesUpdateShouldRetry {
param(
[int]$ExitCode,
[string]$InstallRoot
)
if ($ExitCode -eq 0) { return $false }
if ($ExitCode -ne 2) { return $true }
# Exit 2 is shared by non-retryable safety refusals and the self-lock
# deferral. Only the latter writes this marker. The handoff treats it as a
# retry signal for one fresh-process attempt, whose early-recovery pass
# completes core dependencies before native modules load.
$deferredInstallMarker = Join-Path $InstallRoot ".update-incomplete"
return Test-Path -LiteralPath $deferredInstallMarker
}
+39 -7
View File
@@ -99,8 +99,8 @@
font-size: 11px;
color: var(--foreground);
}
body.done #loader, body.error #loader { display: none; }
body.done #glyph, body.error #glyph { display: flex; }
body.done #loader, body.error #loader, body.disconnected #loader { display: none; }
body.done #glyph, body.error #glyph, body.disconnected #glyph { display: flex; }
</style>
</head>
<body>
@@ -205,6 +205,7 @@
const glyphEl = document.getElementById('glyph')
const defaultLine = lineEl.textContent /* what a stage-less run says */
let settled = false
let failures = 0
const elapsedText = s =>
s < 60 ? `${s}s elapsed` : `${Math.floor(s / 60)}m ${s % 60}s elapsed`
@@ -226,7 +227,8 @@
} else if (state.status === 'done') {
settle('done')
glyphEl.textContent = '\u2713'
lineEl.textContent = 'Opening Hermes\u2026'
titleEl.textContent = 'Update complete'
lineEl.textContent = 'Opening Hermes\u2026\nYou can close this window.'
} else if (state.status === 'manual') {
// Update landed but Hermes will NOT reopen itself (package skew,
// sandbox helper, launch rejected). The orchestrator leaves this
@@ -243,13 +245,43 @@
}
}
async function request(url, options = {}) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
try {
const response = await fetch(url, { ...options, cache: 'no-store', signal: controller.signal })
if (!response.ok) throw new Error('Progress request failed')
// Keep the deadline active while reading the body too: receiving
// headers alone does not prove that the progress server is responsive.
return options.method === 'POST' ? null : await response.json()
} finally {
clearTimeout(timeout)
}
}
async function poll() {
try {
const res = await fetch('/progress', { cache: 'no-store' })
if (res.ok) apply(await res.json())
const state = await request('/progress')
if (!state || !['running', 'done', 'manual', 'error'].includes(state.status)) {
throw new Error('Invalid progress response')
}
failures = 0
apply(state)
// The Windows server can now wait for delivery instead of guessing
// that one polling interval was enough. Older/POSIX servers omit this
// receipt. Apply first so a failed acknowledgement cannot hide a result.
if (settled && typeof state.receipt === 'string' && state.receipt) {
await request(`/ack/${encodeURIComponent(state.receipt)}`, { method: 'POST' })
}
} catch {
// Server gone: hold the last known state. The orchestrator owns
// closing this window; the relaunched Desktop owns the result.
if (!settled && ++failures >= 3) {
// A vanished server is not evidence of success OR failure. Leave an
// honest, finite state even if the browser refuses the close request.
settle('disconnected')
glyphEl.textContent = '!'
titleEl.textContent = 'Update status unavailable'
lineEl.textContent = 'The progress connection was lost.\nCheck Hermes for the update result. You can close this window.'
}
}
if (!settled) setTimeout(poll, 400)
}
+143 -10
View File
@@ -50,7 +50,8 @@ param(
[switch]$NoMarkerCleanup,
[switch]$SelfTestUi,
[switch]$SelfTestPipeDrain,
[switch]$SelfTestMarker
[switch]$SelfTestMarker,
[switch]$SelfTestWorkingDirectory
)
if (-not $SelfTestUi -and -not $SelfTestPipeDrain -and -not $InstallRoot) {
@@ -108,6 +109,8 @@ $script:UiState = [hashtable]::Synchronized(@{
status = "running" # running | done | manual | error
message = $script:UiStage
clock = $script:UiStopwatch
receipt = $null
acknowledged_receipt = $null
})
$script:UiServer = $null # @{ Listener; Runspace; PowerShell; Port; BrowserProc; Profile }
@@ -190,15 +193,26 @@ function Start-UiServer([string]$HtmlPath) {
$request = $reader.ReadLine()
# Drain headers so the client doesn't see a reset mid-send.
while ($true) { $h = $reader.ReadLine(); if ($null -eq $h -or $h -eq "") { break } }
if ($request -match "^GET /progress") {
if ($request -match "^GET /progress HTTP/1\.[01]$") {
$elapsed = [Math]::Floor($State.clock.Elapsed.TotalSeconds)
$snapshot = @{
status = $State.status
message = $State.message
elapsed_seconds = $elapsed
receipt = $State.receipt
} | ConvertTo-Json -Compress
Send-Response $stream "200 OK" "application/json; charset=utf-8" ([System.Text.Encoding]::UTF8.GetBytes($snapshot))
} elseif ($request -match "^GET / ") {
} elseif ($request -match "^POST /ack/([^ /?]+) HTTP/1\.[01]$") {
$receipt = $Matches[1]
if ($State.status -in @("done", "manual", "error") -and $State.receipt -and $receipt -ceq $State.receipt) {
# Flush acceptance before waking the owner that will
# close the listener. No request body is needed.
Send-Response $stream "204 No Content" "text/plain" ([byte[]]@())
$State.acknowledged_receipt = $receipt
} else {
Send-Response $stream "409 Conflict" "text/plain" ([System.Text.Encoding]::ASCII.GetBytes("unknown terminal receipt"))
}
} elseif ($request -match "^GET / HTTP/1\.[01]$") {
Send-Response $stream "200 OK" "text/html; charset=utf-8" $HtmlBytes
} else {
Send-Response $stream "404 Not Found" "text/plain" ([System.Text.Encoding]::ASCII.GetBytes("not found"))
@@ -212,6 +226,36 @@ function Start-UiServer([string]$HtmlPath) {
})
[void]$ps.BeginInvoke()
# Readiness handshake. BeginInvoke returns before the runspace has
# opened its pipeline and JIT'd the script block — on a loaded machine
# that is seconds, during which the kernel ACCEPTS connections into
# the listener's backlog and nobody answers them. Anything that
# trusted "listener bound" as "server serving" (the browser window
# opening to a page that never loads; the -SelfTestUi URL that CI
# polls) raced that gap. Prove one /progress round-trip before
# handing the port out, so the URL means "serving", not "bound".
$ready = $false
$readyDeadline = [DateTime]::UtcNow.AddSeconds(15)
while (-not $ready -and [DateTime]::UtcNow -lt $readyDeadline) {
try {
$probe = [System.Net.HttpWebRequest]::Create("http://127.0.0.1:$port/progress")
$probe.Timeout = 1000
$probe.ReadWriteTimeout = 1000
$probe.KeepAlive = $false
$resp = $probe.GetResponse()
try { $ready = ([int]$resp.StatusCode -eq 200) } finally { $resp.Close() }
} catch {
Start-Sleep -Milliseconds 100
}
}
if (-not $ready) {
Write-HandoffLog "progress server did not answer /progress within 15s; continuing without UI"
try { $listener.Stop() } catch {}
try { $ps.Stop() } catch {}
try { $rs.Close() } catch {}
return $null
}
return @{ Listener = $listener; Runspace = $rs; PowerShell = $ps; Port = $port; BrowserProc = $null; Profile = $null }
} catch {
try { if ($listener) { $listener.Stop() } } catch {}
@@ -253,11 +297,25 @@ function Stop-UiServer([switch]$LeaveWindow) {
}
function Publish-UiEvent([string]$Status, [string]$Message) {
# The event the shim listens for. One beat of poll latency (400ms) before
# teardown so the page actually renders the terminal state.
# A background browser can miss a fixed 900ms delivery window. Retain the
# terminal event until the page acknowledges applying this exact receipt.
# Older/headless clients cannot acknowledge, so teardown remains bounded.
$receipt = [Guid]::NewGuid().ToString('N')
$script:UiState.receipt = $receipt
$script:UiState.acknowledged_receipt = $null
$script:UiState.message = $Message
$script:UiState.status = $Status
if ($script:UiServer) { Start-Sleep -Milliseconds 900 }
if ($script:UiServer) {
$deliveryWait = [System.Diagnostics.Stopwatch]::StartNew()
while ($script:UiState.acknowledged_receipt -cne $receipt -and $deliveryWait.Elapsed.TotalSeconds -lt 10) {
Start-Sleep -Milliseconds 50
}
if ($script:UiState.acknowledged_receipt -ceq $receipt) {
Write-HandoffLog "shim: terminal state '$Status' acknowledged by the window"
} else {
Write-HandoffLog "shim: terminal state '$Status' was not acknowledged within 10s; closing the progress server"
}
}
}
function Get-UiElapsedText {
@@ -279,6 +337,8 @@ function Publish-UiProgress([string]$Message) {
$script:UiStage = $Message
$script:UiState.message = $Message
$script:UiState.status = "running"
$script:UiState.receipt = $null
$script:UiState.acknowledged_receipt = $null
if ($script:Ui) {
try {
$script:Ui.Sub.Text = Get-UiProgressLine
@@ -1119,6 +1179,12 @@ function Invoke-HermesStep([string]$Exe, [string[]]$HermesArgs, [string]$Tag) {
return @{ Code = $code; Output = $all; TreeQuiesced = (-not $stalled -or $proc.HasExited); StartedAfterJobAssignment = $true }
}
function Set-InstallRootCurrentDirectory([string]$Root) {
$resolved = [System.IO.Path]::GetFullPath($Root)
[Environment]::CurrentDirectory = $resolved
return $resolved
}
$finalCode = 1
$finalMsg = "update did not complete"
$script:TreeSafeToFinalize = $true
@@ -1385,6 +1451,46 @@ try {
exit 0
}
# StartAssigned passes a null CreateProcess currentDirectory, so children
# inherit the hand-off process directory rather than PowerShell's $PWD.
# Desktop launches us from HERMES_HOME; pin the process directory to the
# checkout before any update child can resolve files against the wrong tree.
try {
$resolvedInstallRoot = Set-InstallRootCurrentDirectory $InstallRoot
Write-HandoffLog "process cwd set to install root: $resolvedInstallRoot"
} catch {
$finalCode = 3
$finalMsg = "Update aborted: cannot enter the install root ($InstallRoot). Nothing was changed."
Write-HandoffLog $finalMsg
exit $finalCode
}
# Exercise the production cwd setup and native launcher without updating.
if ($SelfTestWorkingDirectory) {
$expectedRoot = [System.IO.Path]::GetFullPath($InstallRoot)
$probeExe = Join-Path $PSHOME "powershell.exe"
$probe = Invoke-HermesStep $probeExe @("-NoProfile", "-Command", "[Environment]::CurrentDirectory") "cwd"
$observed = $probe.Output.Trim()
if ($probe.Code -ne 0 -or -not [string]::Equals($observed, $expectedRoot, [StringComparison]::OrdinalIgnoreCase)) {
$finalMsg = "WORKING-DIRECTORY SELF-TEST: FAIL expected=$expectedRoot observed=$observed code=$($probe.Code)"
Write-Host $finalMsg
exit 1
}
$finalCode = 0
$finalMsg = "WORKING-DIRECTORY SELF-TEST: PASS $observed"
Write-Host $finalMsg
exit 0
}
# Check only the interpreter here: dependency recovery belongs to update.
$pythonExe = Join-Path $InstallRoot "venv\Scripts\python.exe"
if (-not (Test-Path -LiteralPath $pythonExe -PathType Leaf)) {
$finalCode = 3
$finalMsg = "Update aborted: $pythonExe is missing. Repair the installation and review antivirus quarantine before retrying."
Write-HandoffLog $finalMsg
exit $finalCode
}
# -- 1. Wait for the Desktop to exit (FAIL CLOSED) ----------------------
Publish-UiProgress "Waiting for Hermes to close"
if ($DesktopPid -gt 0) {
@@ -1498,10 +1604,25 @@ try {
$res = Invoke-HermesStep $pythonExe $updateArgs "update"
Write-HandoffLog "hermes update exit code: $($res.Code)"
if ($res.Code -ne 0 -and $res.Code -ne 2) {
# One retry for the update-boundary class (fresh code on disk, stale
# code in memory). Exit 2 ("close all Hermes windows") is not retryable.
Write-HandoffLog "first attempt failed; retrying once (freshly pulled fix loads on the second run)"
$retryPolicyPath = Join-Path $PSScriptRoot "retry-policy.ps1"
if (Test-Path -LiteralPath $retryPolicyPath) {
. $retryPolicyPath
$shouldRetry = Test-HermesUpdateShouldRetry -ExitCode $res.Code -InstallRoot $InstallRoot
} else {
# The child may have swapped to a checkout without the companion policy
# while this older script is still running in memory. Preserve the
# previous fail-closed behavior instead of calling an undefined function.
Write-HandoffLog "retry policy is unavailable after checkout swap; using legacy retry rules"
$shouldRetry = $res.Code -ne 0 -and $res.Code -ne 2
}
if ($shouldRetry) {
# One retry for update-boundary failures. Most exit-2 safety refusals
# remain terminal, but self-lock deferral also uses exit 2 and writes
# .update-incomplete after the code swap. That marker is only a retry
# signal here: the fresh process's early-recovery pass finishes core
# dependency sync before native modules load, then `update` continues
# the remaining Desktop/skills stages of the full pipeline.
Write-HandoffLog "first attempt left retryable update state; retrying once in a fresh process"
Publish-UiProgress "Retrying update"
$res = Invoke-HermesStep $pythonExe $updateArgs "update"
Write-HandoffLog "retry exit code: $($res.Code)"
@@ -1521,6 +1642,18 @@ try {
if ($rebuild.Code -ne 0) { $desktopBuildFailed = $true }
}
# A zero-exit update is not proof that the runtime survived the update.
if ($res.Code -eq 0 -and -not $desktopBuildFailed) {
$verifyCode = "import hermes_cli.main; from pathlib import Path; from hermes_cli.desktop_update_verify import verify_windows_desktop_update; verify_windows_desktop_update(Path.cwd())"
$verify = Invoke-HermesStep $pythonExe @("-c", $verifyCode) "verify"
if ($verify.Code -ne 0) {
$finalCode = 8
$finalMsg = "The updated Hermes runtime or Desktop build failed verification. Repair the installation and review antivirus quarantine before retrying."
Write-HandoffLog $finalMsg
exit $finalCode
}
}
if ($res.Code -eq 0 -and -not $desktopBuildFailed) {
$finalCode = 0
$finalMsg = "Update complete."
+143 -535
View File
@@ -1,591 +1,199 @@
#!/usr/bin/env bash
# Run a command in a disposable, network-isolated fake Internet.
# Run a Hermes instance in an isolated sandbox — separate HERMES_HOME,
# separate Electron userData, and a distinct Desktop app name so it doesn't compete
# with your main desktop instance's single-instance lock.
#
# The command runs in private user, mount, PID, and network namespaces. This
# script is stage 1: it builds the sandbox tree, mints the fake CA, and creates
# the user+network namespaces with `unshare` (see the namespace plan further
# down), then re-execs into scripts/sandbox/stage2-run.sh, which adds the
# mount/pid namespaces with bubblewrap and runs the payload. Its only writable
# filesystem is SANDBOX_ROOT. HTTP(S) goes to a local static MITM proxy;
# github.com SSH uses a sandbox-local git-upload-pack shim; neither transport
# can reach the host network.
# By default the sandbox is throwaway: a temp dir is created and removed on
# exit. Use --persistent to keep the sandbox across restarts (stored under
# .hermes-sandbox/ in the worktree git root).
#
# Usage:
# scripts/dev-sandbox.sh python -m hermes_cli.main
# scripts/dev-sandbox.sh hermes desktop
# scripts/dev-sandbox.sh electron .
# scripts/dev-sandbox.sh -- npm run dev # from apps/desktop/
# scripts/dev-sandbox.sh --persistent hermes desktop
# scripts/dev-sandbox.sh --persistent -- npm run dev
#
# Seed the sandbox HERMES_HOME from an existing directory (e.g. your main
# ~/.hermes) so config, sessions, skills, etc. are pre-populated:
# scripts/dev-sandbox.sh --from ~/.hermes hermes desktop
#
# Override the app name (default: HermesSandbox):
# HERMES_DEV_SANDBOX_NAME=Staging scripts/dev-sandbox.sh hermes desktop
#
# Override the persistent sandbox dir name (default: .hermes-sandbox):
# HERMES_DEV_SANDBOX_DIR=.staging-sandbox scripts/dev-sandbox.sh --persistent hermes desktop
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Helper files the sandbox needs: the stage-2 script it re-execs into, plus the
# files it copies in (the fake-internet proxy, the ssh shim, the openssl config).
# They sit next to this script in the repo, but the Nix wrapper installs the
# script into the store on its own, so it exports DEV_SANDBOX_ASSETS to point
# here.
SANDBOX_ASSETS="${DEV_SANDBOX_ASSETS:-$SCRIPT_DIR/sandbox}"
for asset in proxy.py ssh-shim.sh openssl.cnf stage2-run.sh; do
[ -f "$SANDBOX_ASSETS/$asset" ] || {
echo "error: missing sandbox asset: $SANDBOX_ASSETS/$asset" >&2
exit 1
}
done
print_help() {
cat <<'EOF'
Usage: dev-sandbox.sh [options] [--] <command...>
dev-sandbox.sh install [options] [--] [installer arguments...]
Usage: dev-sandbox.sh [--persistent] [--from DIR] [--] <command...>
Run COMMAND in a throwaway chroot-like bubblewrap sandbox. The sandbox has no
writable host mounts: only its own root, mounted at /work, is writable.
Run a Hermes instance in an isolated sandbox.
Options:
--persistent Keep the whole sandbox under .hermes-sandbox/.
--delete Delete the persistent sandbox (asks first).
--root Install as uid 0 with the root FHS layout: code in
/usr/local/lib/hermes-agent, command in
/usr/local/bin. Default is the user-level layout.
--from DIR One-time copy of DIR into the sandbox's $HOME.
Existing persistent sandboxes are never overwritten.
--http-root DIR Copy DIR into the fake web server root for this run.
Requests map to DIR/<host>/<path>; no URL is forwarded.
--installer PATH With `install`, serve PATH at the canonical install.sh
URL. Default: scripts/install.sh in this worktree.
--from-main With `install`, fetch the real upstream main installer
and repository, then advance fake main to this folder
after a successful install for update testing.
Shorthand for --install-ref refs/heads/main.
--install-ref REF Like --from-main, but installs REF instead of main:
a branch, a tag (v2026.7.7), or a SHA reachable from main.
Use it to test updating from an older release, not just
from the tip.
-h, --help Show this help.
Option order matters: every option above is consumed by THIS script, and
parsing stops at the first argument it does not recognize. Everything from
that point on is passed through to the command (or, with `install`, to the
installer). Put sandbox options first and separate installer arguments with
`--`, otherwise they arrive here and fail:
# WRONG — --from-main reaches install.sh, which rejects it
scripts/dev-sandbox.sh install --skip-setup --from-main
# RIGHT
scripts/dev-sandbox.sh install --from-main -- --skip-setup
Install layout: `install.sh` picks its layout from `id -u` alone, so uid is what
separates the two real-world Linux installs. By default the sandbox runs as an
unprivileged `hermes` user, giving the layout most people have —
$HERMES_HOME/hermes-agent plus a ~/.local/bin launcher. Pass --root for the FHS
one. Both are worth testing; they differ in more than paths (root also relocates
uv's Python to /usr/local/share for world-readability).
The fake web server signs certificates with a CA trusted only inside this
sandbox. HTTP_PROXY/HTTPS_PROXY send fixture URLs there first; other HTTP(S)
requests pass through the sandbox's rootless outbound network. SSH to github.com
runs a sandbox-local upload-pack shim, never your SSH config, agent,
known-hosts file, or authorized keys.
Fake github main always comes from this folder. If it has staged, unstaged, or
non-ignored untracked changes, the sandbox warns and creates a temporary local
commit containing them; it never stages or commits the real worktree.
--persistent Keep the sandbox dir across restarts (under the worktree
git root, in .hermes-sandbox/). Without this flag the
sandbox is a temp dir that is removed on exit.
--from DIR Copy DIR into the sandbox HERMES_HOME as the starting
point (config, sessions, skills, etc.).
Ignored if the sandbox HERMES_HOME already has content
(e.g. reusing a --persistent sandbox) to avoid clobbering.
--delete Delete the existing persistent sandbox in .hermes-sandbox.
-h, --help Show this help message.
Environment:
HERMES_DEV_SANDBOX_DIR Sandbox directory name, relative to the repo root
(default: .hermes-sandbox).
HERMES_DEV_SANDBOX_NAME Override the app name (default: HermesSandbox)
HERMES_DEV_SANDBOX_DIR Override the persistent dir name (default: .hermes-sandbox)
Examples:
# create a sandbox, install this branch as `main`, and then drop to a shell,
# skipping `hermes setup` & the browser tools for speed.
scripts/dev-sandbox.sh install --persistent -- --skip-setup --skip-browser
# Install the official upstream main. You're dropped into a shell where
# you can run `hermes update`.
scripts/dev-sandbox.sh install --persistent --from-main
dev-sandbox.sh hermes desktop
dev-sandbox.sh --persistent hermes desktop
dev-sandbox.sh --from ~/.hermes hermes desktop
dev-sandbox.sh -- npm run dev
EOF
}
PERSISTENT=false
DELETE=false
RUN_AS_USER=true
SEED_DIR=""
HTTP_ROOT=""
INSTALL_SHORTCUT=false
INSTALLER_PATH=""
# Which upstream commit the sandbox installs before the update routes run.
# Empty means "install this worktree's own installer" (no upstream fetch); set,
# it is anything git can resolve -- a branch, a tag (v2026.7.7), or a SHA
# reachable from main -- so "can a user two releases back still update?" is
# expressible. --from-main is shorthand for refs/heads/main.
INSTALL_REF=""
UPSTREAM_URL="${HERMES_DEV_SANDBOX_UPSTREAM:-https://github.com/NousResearch/hermes-agent.git}"
if [ "${1:-}" = install ]; then
INSTALL_SHORTCUT=true
shift
fi
while [ "$#" -gt 0 ]; do
case "$1" in
--persistent) PERSISTENT=true; shift ;;
--delete) DELETE=true; shift ;;
--root) RUN_AS_USER=false; shift ;;
--user) RUN_AS_USER=true; shift ;; # the default; accepted for symmetry
--persistent)
PERSISTENT=true
shift
;;
--from)
[ "$#" -ge 2 ] || { echo 'error: --from needs a directory' >&2; exit 1; }
SEED_DIR="$2"; shift 2 ;;
--http-root)
[ "$#" -ge 2 ] || { echo 'error: --http-root needs a directory' >&2; exit 1; }
HTTP_ROOT="$2"; shift 2 ;;
--installer)
[ "$#" -ge 2 ] || { echo 'error: --installer needs a file' >&2; exit 1; }
INSTALLER_PATH="$2"; shift 2 ;;
--from-main) INSTALL_REF="refs/heads/main"; shift ;;
--install-ref)
[ "$#" -ge 2 ] || { echo 'error: --install-ref needs a value' >&2; exit 1; }
INSTALL_REF="$2"
shift 2 ;;
--from=*|--http-root=*|--installer=*|--install-ref=*)
key="${1%%=*}"; value="${1#*=}"
[ -n "$value" ] || { echo "error: $key needs a value" >&2; exit 1; }
case "$key" in
--from) SEED_DIR="$value" ;;
--http-root) HTTP_ROOT="$value" ;;
--installer) INSTALLER_PATH="$value" ;;
--install-ref) INSTALL_REF="$value" ;;
esac
shift ;;
-h|--help) print_help; exit 0 ;;
--) shift; break ;;
*) break ;;
if [ "$#" -lt 2 ] || [[ "$2" == -* ]]; then
echo "error: --from requires a directory argument" >&2
exit 1
fi
SEED_DIR="$2"
shift 2
;;
--from=*)
SEED_DIR="${1#--from=}"
if [ -z "$SEED_DIR" ]; then
echo "error: --from requires a directory argument" >&2
exit 1
fi
shift
;;
--delete)
DELETE=true
shift
;;
-h|--help)
print_help
exit 0
;;
--)
shift
break
;;
*)
break
;;
esac
done
if [ "$INSTALL_SHORTCUT" = false ] && [ "$#" -eq 0 ]; then
if [ -n "$SEED_DIR" ]; then
if [ ! -d "$SEED_DIR" ]; then
echo "error: --from dir '$SEED_DIR' does not exist" >&2
exit 1
fi
# Resolve to absolute path so it's valid after we cd later.
SEED_DIR="$(cd "$SEED_DIR" && pwd)"
fi
if [ "$#" -eq 0 ]; then
print_help >&2
exit 1
fi
if [ -n "$INSTALLER_PATH" ] && [ "$INSTALL_SHORTCUT" = false ]; then
echo 'error: --installer is only valid with the install shortcut' >&2
exit 1
fi
if [ -n "$INSTALL_REF" ] && [ "$INSTALL_SHORTCUT" = false ]; then
echo 'error: --from-main / --install-ref are only valid with the install shortcut' >&2
exit 1
fi
if [ -n "$INSTALL_REF" ] && [ -n "$INSTALLER_PATH" ]; then
echo 'error: --from-main / --install-ref cannot be combined with --installer' >&2
exit 1
fi
for dir in "$SEED_DIR" "$HTTP_ROOT"; do
[ -z "$dir" ] || [ -d "$dir" ] || { echo "error: directory '$dir' does not exist" >&2; exit 1; }
done
GIT_ROOT="${HERMES_SANDBOX_SOURCE_ROOT:-$(git rev-parse --show-toplevel)}"
GIT_ROOT="$(cd "$GIT_ROOT" && pwd)"
if [ "$INSTALL_SHORTCUT" = true ] && [ -z "$INSTALL_REF" ] && [ -z "$INSTALLER_PATH" ]; then
INSTALLER_PATH="$GIT_ROOT/scripts/install.sh"
fi
if [ -n "$INSTALLER_PATH" ] && [ ! -f "$INSTALLER_PATH" ]; then
echo "error: installer '$INSTALLER_PATH' does not exist" >&2
exit 1
fi
COMMIT="$(git -C "$GIT_ROOT" rev-parse --verify 'HEAD^{commit}')" || {
echo "error: current folder has no HEAD commit" >&2
exit 1
}
SANDBOX_DIR_NAME="${HERMES_DEV_SANDBOX_DIR:-.hermes-sandbox}"
PERSISTENT_ROOT="$GIT_ROOT/$SANDBOX_DIR_NAME"
GIT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR/..")"
GIT_ROOT="$(cd "$GIT_ROOT" && pwd)"
PERSISTENT_SANDBOX_ROOT="$GIT_ROOT/$SANDBOX_DIR_NAME"
if [ "$DELETE" = true ]; then
if [ ! -d "$PERSISTENT_ROOT" ]; then
echo "[sandbox] nothing to delete at $PERSISTENT_ROOT" >&2
exit 0
if [ -d "$PERSISTENT_SANDBOX_ROOT" ]; then
read -r -p "[sandbox] delete $PERSISTENT_SANDBOX_ROOT? [y/N] " REPLY
case "$REPLY" in
[yY]|[yY][eE][sS])
echo "[sandbox] deleting $PERSISTENT_SANDBOX_ROOT" >&2
rm -rf -- "$PERSISTENT_SANDBOX_ROOT"
;;
*)
echo "[sandbox] aborted" >&2
exit 1
;;
esac
else
echo "[sandbox] nothing to delete at $PERSISTENT_SANDBOX_ROOT" >&2
fi
read -r -p "[sandbox] delete $PERSISTENT_ROOT? [y/N] " reply
case "$reply" in
y|Y|yes|YES) rm -rf -- "$PERSISTENT_ROOT" ;;
*) echo '[sandbox] aborted' >&2; exit 1 ;;
esac
exit 0
fi
# Derive a per-worktree app name so multiple checkouts don't collide.
# Each worktree has its own toplevel path even though they share one repo,
# so we hash that path into a short, stable suffix.
WORKTREE_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR/..")"
WORKTREE_ROOT="$(cd "$WORKTREE_ROOT" && pwd)"
WORKTREE_HASH="$(printf '%s' "$WORKTREE_ROOT" | cksum | cut -d' ' -f1)"
WORKTREE_NAME="$(basename "$WORKTREE_ROOT")"
DEFAULT_SANDBOX_NAME="HermesSandbox-${WORKTREE_NAME}-${WORKTREE_HASH}"
SANDBOX_NAME="${HERMES_DEV_SANDBOX_NAME:-$DEFAULT_SANDBOX_NAME}"
if [ "$PERSISTENT" = true ]; then
SANDBOX_ROOT="$PERSISTENT_ROOT"
SANDBOX_ROOT="$PERSISTENT_SANDBOX_ROOT"
else
SANDBOX_ROOT="$(mktemp -d -t hermes-sandbox.XXXXXX)"
cleanup() { chmod -R u+w "$SANDBOX_ROOT"; rm -rf -- "$SANDBOX_ROOT"; }
trap cleanup EXIT INT TERM
fi
mkdir -p "$SANDBOX_ROOT"/{root,home,etc}
UPSTREAM_REPO=""
UPSTREAM_COMMIT=""
if [ -n "$INSTALL_REF" ]; then
echo "[sandbox] fetching upstream $INSTALL_REF for installer/update test" >&2
UPSTREAM_REPO="$(mktemp -d -t hermes-sandbox-upstream.XXXXXX)"
git -C "$UPSTREAM_REPO" init -q
# Fetch the ref as given. A branch or tag name resolves on its own; a raw SHA
# needs the remote to allow fetching it directly, so fall back to fetching
# main and resolving the SHA locally (which works for any commit that is an
# ancestor of main -- the interesting case for "update from N versions ago").
#
# Peel to ^{commit} in both cases: an annotated tag fetches as a tag OBJECT,
# and using it directly fails later with "trying to write non-commit object
# ... to branch 'refs/heads/main'".
if git -C "$UPSTREAM_REPO" fetch -q "$UPSTREAM_URL" "$INSTALL_REF" 2>/dev/null; then
UPSTREAM_COMMIT="$(git -C "$UPSTREAM_REPO" rev-parse "FETCH_HEAD^{commit}")"
elif git -C "$UPSTREAM_REPO" fetch -q "$UPSTREAM_URL" refs/heads/main \
&& UPSTREAM_COMMIT="$(git -C "$UPSTREAM_REPO" rev-parse --verify -q "$INSTALL_REF^{commit}")"; then
:
export HERMES_HOME="$SANDBOX_ROOT/hermes-home"
export HERMES_DESKTOP_USER_DATA_DIR="$SANDBOX_ROOT/user-data"
export HERMES_DESKTOP_APP_NAME="$SANDBOX_NAME"
mkdir -p "$HERMES_HOME" "$HERMES_DESKTOP_USER_DATA_DIR"
if [ -n "$SEED_DIR" ]; then
# Only seed when the sandbox HERMES_HOME is empty — avoids clobbering an
# existing persistent sandbox on re-run.
if [ -z "$(ls -A "$HERMES_HOME" 2>/dev/null)" ]; then
echo "[sandbox] seeding HERMES_HOME from $SEED_DIR" >&2
cp -a "$SEED_DIR/." "$HERMES_HOME/"
else
rm -rf -- "$UPSTREAM_REPO"
echo "error: could not resolve upstream ref: $INSTALL_REF" >&2
echo ' Use a branch (main), a tag (v2026.7.7), or a SHA reachable from main.' >&2
exit 1
echo "[sandbox] --from ignored: $HERMES_HOME already has content" >&2
fi
fi
if [ ! -e "$SANDBOX_ROOT/root/repo/.sandbox-source" ]; then
mkdir -p "$SANDBOX_ROOT/root/repo"
# Persistent roots live under the worktree, so copying with cp would recurse
# into the sandbox itself. tar also lets us exclude a worktree's .git file,
# which can point at the host's shared worktree metadata.
tar -C "$GIT_ROOT" --exclude='./.git' --exclude="./$SANDBOX_DIR_NAME" -cf - . \
| tar -C "$SANDBOX_ROOT/root/repo" -xf -
: > "$SANDBOX_ROOT/root/repo/.sandbox-source"
fi
if [ -n "$SEED_DIR" ] && [ ! -e "$SANDBOX_ROOT/.seeded" ]; then
echo "[sandbox] seeding home from $SEED_DIR" >&2
cp -a "$SEED_DIR/." "$SANDBOX_ROOT/home/"
: > "$SANDBOX_ROOT/.seeded"
fi
rm -rf "$SANDBOX_ROOT/root/http"
mkdir -p "$SANDBOX_ROOT/root/http"
if [ -n "$HTTP_ROOT" ]; then
cp -a "$HTTP_ROOT/." "$SANDBOX_ROOT/root/http/"
fi
if [ "$INSTALL_SHORTCUT" = true ]; then
mkdir -p "$SANDBOX_ROOT/root/http/hermes-agent.nousresearch.com"
if [ -n "$INSTALL_REF" ]; then
git -C "$UPSTREAM_REPO" show "$UPSTREAM_COMMIT:scripts/install.sh" \
> "$SANDBOX_ROOT/root/http/hermes-agent.nousresearch.com/install.sh"
else
cp -a "$INSTALLER_PATH" "$SANDBOX_ROOT/root/http/hermes-agent.nousresearch.com/install.sh"
fi
set -- bash -c '
set +e
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- "$@"
install_status=$?
if [ "$install_status" -eq 0 ] && [ -f /work/promote-main ]; then
next_main=$(cat /work/promote-main)
if git --git-dir=/work/repos/hermes-agent.git update-ref refs/heads/main "$next_main"; then
rm -f /work/promote-main
printf "[sandbox] fake main advanced to this folder for update testing\n" >&2
else
printf "[sandbox] failed to advance fake main after install\n" >&2
install_status=1
fi
fi
if [ "$DEV_SANDBOX_INTERACTIVE" = true ]; then
printf "\n[sandbox] installer exited %s; entering sandbox shell\n" "$install_status" >&2
exec </dev/tty >/dev/tty 2>&1
exec bash -i
fi
exit "$install_status"
' sandbox-installer "$@"
fi
mkdir -p "$SANDBOX_ROOT/root"/{bin,certs,lib64,logs,repos,ssh,usr/bin,usr/local}
REAL_CA_CERT="${DEV_SANDBOX_REAL_CA_CERT:-}"
if [ -z "$REAL_CA_CERT" ]; then
for candidate in /etc/ssl/certs/ca-certificates.crt /etc/ssl/cert.pem; do
if [ -f "$candidate" ]; then
REAL_CA_CERT="$candidate"
break
fi
done
fi
if [ ! -f "$REAL_CA_CERT" ]; then
echo 'error: no system CA bundle found for outbound sandbox HTTPS' >&2
exit 1
fi
if [ ! -f "$SANDBOX_ROOT/root/certs/real-ca.pem" ]; then
cp "$REAL_CA_CERT" "$SANDBOX_ROOT/root/certs/real-ca.pem"
fi
printf 'nameserver 10.0.2.3\n' > "$SANDBOX_ROOT/etc/resolv.conf"
SANDBOX_SHELL="$(command -v bash)"
DYNAMIC_LINKER="${DEV_SANDBOX_DYNAMIC_LINKER:-}"
if [ -z "$DYNAMIC_LINKER" ]; then
# Nix store first: NixOS also ships a /lib64/ld-linux-x86-64.so.2 compat stub,
# so probing FHS paths first would quietly switch which loader a bare script
# invocation uses on this host. Globs that match nothing expand to themselves,
# so every candidate is -f tested. The FHS paths cover Debian/Ubuntu (where
# the loader is under /lib64 or a multiarch /lib dir), which is what CI runs.
for candidate in \
/nix/store/*-glibc-*/lib/ld-linux-*.so.* \
/lib64/ld-linux-x86-64.so.2 \
/lib/ld-linux-aarch64.so.1 \
/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 \
/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1
do
if [ -f "$candidate" ]; then
DYNAMIC_LINKER="$candidate"
break
fi
done
fi
if [ ! -f "$DYNAMIC_LINKER" ]; then
echo 'error: no glibc dynamic linker found for sandboxed release binaries' >&2
echo ' Set DEV_SANDBOX_DYNAMIC_LINKER to its path.' >&2
exit 1
fi
ln -sf "$SANDBOX_SHELL" "$SANDBOX_ROOT/root/bin/sh"
ln -sf "$(command -v ls)" "$SANDBOX_ROOT/root/bin/ls"
ln -sf "$(command -v env)" "$SANDBOX_ROOT/root/usr/bin/env"
ln -sf "$DYNAMIC_LINKER" "$SANDBOX_ROOT/root/lib64/$(basename "$DYNAMIC_LINKER")"
# Identity inside the sandbox. install.sh chooses its layout from `id -u`
# alone (see resolve_install_layout), so the uid here is what decides between
# the root FHS install and a user-level one.
if [ "$RUN_AS_USER" = true ]; then
SANDBOX_UID=1000
SANDBOX_GID=1000
SANDBOX_USER=hermes
SANDBOX_HOME=/home/hermes
echo "[sandbox] HERMES_HOME=$HERMES_HOME" >&2
echo "[sandbox] userData=$HERMES_DESKTOP_USER_DATA_DIR" >&2
echo "[sandbox] appName=$HERMES_DESKTOP_APP_NAME" >&2
if [ "$PERSISTENT" = true ]; then
echo "[sandbox] persistent: $SANDBOX_ROOT" >&2
else
SANDBOX_UID=0
SANDBOX_GID=0
SANDBOX_USER=root
SANDBOX_HOME=/root
fi
{
printf 'root:x:0:0:Sandbox Root:/root:%s\n' "$SANDBOX_SHELL"
if [ "$RUN_AS_USER" = true ]; then
printf '%s:x:%s:%s:Sandbox User:%s:%s\n' \
"$SANDBOX_USER" "$SANDBOX_UID" "$SANDBOX_GID" "$SANDBOX_HOME" "$SANDBOX_SHELL"
fi
} > "$SANDBOX_ROOT/etc/passwd"
{
printf 'root:x:0:\n'
if [ "$RUN_AS_USER" = true ]; then
printf '%s:x:%s:\n' "$SANDBOX_USER" "$SANDBOX_GID"
fi
} > "$SANDBOX_ROOT/etc/group"
# A user-level install writes the `hermes` launcher to ~/.local/bin and the
# checkout to $HERMES_HOME; both live under the sandbox HOME, which is bound
# from $SANDBOX_ROOT/home. bwrap maps our real uid to $SANDBOX_UID, so the
# host-side ownership of that directory is what the sandbox sees as its own.
printf 'hosts: files dns\n' > "$SANDBOX_ROOT/etc/nsswitch.conf"
printf '127.0.0.1 localhost\n' > "$SANDBOX_ROOT/etc/hosts"
SOURCE_REPO="$GIT_ROOT"
SOURCE_REF="$COMMIT"
SNAPSHOT_REPO=""
FAKE_REPO="$SANDBOX_ROOT/root/repos/hermes-agent.git"
git -C "$SANDBOX_ROOT/root/repos" init --bare -q hermes-agent.git
if [ -n "$INSTALL_REF" ]; then
git --git-dir="$FAKE_REPO" fetch -q --force "$UPSTREAM_REPO" \
"$UPSTREAM_COMMIT:refs/heads/main"
fi
if [ -n "$(git -C "$GIT_ROOT" status --porcelain)" ]; then
echo '[sandbox] warning: current folder is dirty; creating a temporary fake commit for main' >&2
SNAPSHOT_REPO="$(mktemp -d -t hermes-sandbox-snapshot.XXXXXX)"
git -C "$SNAPSHOT_REPO" init -q
git -C "$SNAPSHOT_REPO" fetch -q "$GIT_ROOT" "$COMMIT"
git -C "$SNAPSHOT_REPO" config user.name 'Hermes sandbox'
git -C "$SNAPSHOT_REPO" config user.email 'sandbox@invalid'
GIT_DIR="$SNAPSHOT_REPO/.git" GIT_WORK_TREE="$GIT_ROOT" git read-tree "$COMMIT"
GIT_DIR="$SNAPSHOT_REPO/.git" GIT_WORK_TREE="$GIT_ROOT" \
git add -A -- .
SNAPSHOT_TREE="$(GIT_DIR="$SNAPSHOT_REPO/.git" git write-tree)"
SNAPSHOT_PARENT="$COMMIT"
if EXISTING_MAIN="$(git --git-dir="$FAKE_REPO" rev-parse --verify refs/heads/main 2>/dev/null)"; then
git -C "$SNAPSHOT_REPO" fetch -q "$FAKE_REPO" "$EXISTING_MAIN"
SNAPSHOT_PARENT="$EXISTING_MAIN"
fi
SOURCE_REF="$(GIT_DIR="$SNAPSHOT_REPO/.git" git commit-tree "$SNAPSHOT_TREE" -p "$SNAPSHOT_PARENT" \
-m 'sandbox snapshot of dirty worktree')"
SOURCE_REPO="$SNAPSHOT_REPO"
echo "[sandbox] ephemeral (will be cleaned up on exit)" >&2
fi
if [ -n "$INSTALL_REF" ]; then
git --git-dir="$FAKE_REPO" fetch -q --force "$SOURCE_REPO" \
"$SOURCE_REF:refs/hermes-sandbox/next"
printf '%s\n' "$SOURCE_REF" > "$SANDBOX_ROOT/root/promote-main"
else
git --git-dir="$FAKE_REPO" fetch -q --force "$SOURCE_REPO" \
"$SOURCE_REF:refs/heads/main"
fi
git --git-dir="$FAKE_REPO" symbolic-ref HEAD refs/heads/main
if [ -n "$SNAPSHOT_REPO" ]; then
# Best-effort: it is a mktemp directory the OS will reap, and failing the whole
# run over a leftover object file would be worse than leaking it. Concurrent
# git activity in the worktree can still be writing here as we delete.
rm -rf -- "$SNAPSHOT_REPO" 2>/dev/null || true
fi
if [ -n "$UPSTREAM_REPO" ]; then
rm -rf -- "$UPSTREAM_REPO"
fi
# openssl reads a config even for `req -addext`, and its compiled-in path is a
# symlink into /etc/ssl on Debian/Ubuntu -- which the sandbox replaces. Ship our
# own and point OPENSSL_CONF at it, both here and inside the sandbox.
cp "$SANDBOX_ASSETS/openssl.cnf" "$SANDBOX_ROOT/root/certs/openssl.cnf"
if [ ! -f "$SANDBOX_ROOT/root/certs/ca.pem" ]; then
if ! ca_error="$(OPENSSL_CONF="$SANDBOX_ROOT/root/certs/openssl.cnf" \
openssl req -x509 -newkey rsa:2048 -nodes -days 2 \
-subj '/CN=Hermes dev sandbox CA' \
-extensions sandbox_ca_ext \
-keyout "$SANDBOX_ROOT/root/certs/ca.key" \
-out "$SANDBOX_ROOT/root/certs/ca.pem" 2>&1 >/dev/null)"; then
echo 'error: could not create the sandbox CA:' >&2
printf '%s\n' "$ca_error" >&2
exit 1
fi
fi
GIT_UPLOAD_PACK="$(command -v git-upload-pack)"
sed "s|@GIT_UPLOAD_PACK@|$GIT_UPLOAD_PACK|" "$SANDBOX_ASSETS/ssh-shim.sh" \
> "$SANDBOX_ROOT/root/usr/bin/ssh"
chmod 700 "$SANDBOX_ROOT/root/usr/bin/ssh"
# The fake-internet proxy and the ssh shim are real files under
# scripts/sandbox/ rather than heredocs, so they can be linted, syntax-checked
# and diffed like any other source. Copy them into the sandbox tree.
cp "$SANDBOX_ASSETS/proxy.py" "$SANDBOX_ROOT/root/proxy.py"
if [ -n "$INSTALL_REF" ]; then
echo "[sandbox] fake main: upstream $INSTALL_REF ($UPSTREAM_COMMIT)" >&2
echo "[sandbox] prepared update: current folder ($SOURCE_REF)" >&2
else
echo "[sandbox] fake main: current folder ($SOURCE_REF)" >&2
fi
echo "[sandbox] root: $SANDBOX_ROOT" >&2
echo "[sandbox] http root: $SANDBOX_ROOT/root/http" >&2
if [ "$RUN_AS_USER" = true ]; then
echo "[sandbox] identity: $SANDBOX_USER (uid $SANDBOX_UID) — installs are user-level under $SANDBOX_HOME" >&2
else
echo '[sandbox] identity: root (uid 0) — installs use the /usr/local FHS layout' >&2
fi
[ "$PERSISTENT" = true ] && echo '[sandbox] persistent' >&2 || echo '[sandbox] ephemeral' >&2
for command in awk bash bwrap curl git openssl python3 slirp4netns tar unshare; do
command -v "$command" >/dev/null || {
echo "error: missing required command: $command" >&2
exit 1
if [ "$PERSISTENT" = false ]; then
# shellcheck disable=SC2329 # invoked via trap
cleanup() {
chmod -R u+w "$SANDBOX_ROOT"
rm -rf -- "$SANDBOX_ROOT"
}
done
INTERACTIVE=false
if [ -t 0 ] && [ -t 1 ]; then
INTERACTIVE=true
fi
NODE_DIR="${DEV_SANDBOX_NODE_DIR:-}"
if [ -z "$NODE_DIR" ] && command -v node >/dev/null; then
NODE_DIR="$(dirname "$(dirname "$(command -v node)")")"
fi
WAYLAND_SOCKET=""
if [ -n "${XDG_RUNTIME_DIR:-}" ] && [ -n "${WAYLAND_DISPLAY:-}" ] \
&& [ -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" ]; then
WAYLAND_SOCKET="$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY"
trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM
fi
# Namespace plan (stage 1 -> stage 2).
#
# slirp4netns joins the target's userns and setuids to root before configuring
# the netns, so the userns MUST map a uid 0. bwrap's own --unshare-user maps
# exactly one uid, so it cannot both run the payload as uid 1000 and offer slirp
# a root to become: that combination fails with
# setns(CLONE_NEWNET): Operation not permitted.
#
# So stage 1 builds the namespaces here with two ranges:
# inner 0 <- a subuid, unused by the payload, present only so slirp can
# become root inside the namespace
# inner $SANDBOX_UID <- our real host uid, so everything the sandbox writes
# stays owned by us and `rm -rf` on a persistent sandbox needs
# no privileges or chown dance
# The payload then runs in stage 2, where bwrap adds the mount/pid namespaces
# without creating a userns at all.
#
# The root layout needs no subuid at all: inner 0 IS the host uid there.
netns_args=(--user --net)
if [ "$RUN_AS_USER" = true ]; then
host_user="$(id -un)"
subuid_base="$(awk -F: -v u="$host_user" '$1 == u {print $2; exit}' /etc/subuid)"
subgid_base="$(awk -F: -v u="$host_user" '$1 == u {print $2; exit}' /etc/subgid)"
if [ -z "$subuid_base" ] || [ -z "$subgid_base" ]; then
echo "error: no /etc/subuid or /etc/subgid range for $host_user" >&2
echo ' A user-level sandbox needs one spare subordinate id to host' >&2
echo " its internal root. Add e.g. '$host_user:100000:65536' to both," >&2
echo ' or use --root.' >&2
exit 1
fi
netns_args+=(
--map-users="0:$subuid_base:1" --map-users="$SANDBOX_UID:$(id -u):1"
--map-groups="0:$subgid_base:1" --map-groups="$SANDBOX_GID:$(id -g):1"
)
else
netns_args+=(--map-root-user)
fi
sandbox_pid_file="$SANDBOX_ROOT/root/logs/sandbox.pid"
slirp_ready="$SANDBOX_ROOT/root/logs/slirp.ready"
slirp_log="$SANDBOX_ROOT/root/logs/slirp.log"
: > "$sandbox_pid_file"
: > "$slirp_ready"
env \
DEV_SANDBOX_ROOT="$SANDBOX_ROOT" \
DEV_SANDBOX_BASH="$(command -v bash)" \
DEV_SANDBOX_REAL_CA_CERT="$REAL_CA_CERT" \
DEV_SANDBOX_INTERACTIVE="$INTERACTIVE" \
DEV_SANDBOX_USER="$SANDBOX_USER" \
DEV_SANDBOX_HOME="$SANDBOX_HOME" \
DEV_SANDBOX_NODE_DIR="$NODE_DIR" \
DEV_SANDBOX_ELECTRON_LD_LIBRARY_PATH="${DEV_SANDBOX_ELECTRON_LD_LIBRARY_PATH:-}" \
DEV_SANDBOX_XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-}" \
DEV_SANDBOX_WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-}" \
DEV_SANDBOX_WAYLAND_SOCKET="$WAYLAND_SOCKET" \
unshare "${netns_args[@]}" \
"$SANDBOX_ASSETS/stage2-run.sh" "$@" &
sandbox_launcher=$!
for _ in $(seq 1 200); do
[ -s "$sandbox_pid_file" ] && break
if ! kill -0 "$sandbox_launcher" 2>/dev/null; then
wait "$sandbox_launcher"
exit $?
fi
sleep 0.05
done
sandbox_pid="$(tr -dc '0-9' < "$sandbox_pid_file")"
if [ -z "$sandbox_pid" ]; then
echo 'error: sandbox did not report its PID' >&2
exit 1
fi
slirp4netns --configure --disable-host-loopback --ready-fd=3 \
--userns-path="/proc/$sandbox_pid/ns/user" "$sandbox_pid" tap0 \
3>"$slirp_ready" >"$slirp_log" 2>&1 &
slirp_pid=$!
cleanup_slirp() {
kill "$slirp_pid" 2>/dev/null || true
wait "$slirp_pid" 2>/dev/null || true
}
trap cleanup_slirp EXIT INT TERM
for _ in $(seq 1 200); do
[ -s "$slirp_ready" ] && break
if ! kill -0 "$slirp_pid" 2>/dev/null; then
cat "$slirp_log" >&2 || true
exit 1
fi
sleep 0.05
done
if [ ! -s "$slirp_ready" ]; then
echo 'error: timed out waiting for sandbox network setup' >&2
exit 1
fi
wait "$sandbox_launcher"
exit $?
"$@"
rc=$?
exit $rc
+6 -18
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable
@@ -14,6 +13,7 @@ from hermes_cli.config import (
get_env_path,
migrate_config,
)
from hermes_cli.config_backups import backup_config, list_config_backups
from hermes_cli.config_migrations import (
SUPPORT_FLOOR_VERSION,
support_floor_message,
@@ -21,26 +21,14 @@ from hermes_cli.config_migrations import (
from utils import env_var_enabled
def _backup_path(path: Path, stamp: str) -> Path:
base = path.with_name(f"{path.name}.bak-{stamp}")
if not base.exists():
return base
for index in range(1, 1000):
candidate = path.with_name(f"{path.name}.bak-{stamp}.{index}")
if not candidate.exists():
return candidate
raise RuntimeError(f"could not choose a backup path for {path}")
def _backup_existing(paths: Iterable[Path]) -> dict[Path, Path]:
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
"""Snapshot each file into backups/config/; an identical existing snapshot is reused."""
backups: dict[Path, Path] = {}
for path in paths:
if not path.is_file():
continue
dest = _backup_path(path, stamp)
shutil.copy2(path, dest)
backups[path] = dest
dest = backup_config(path, "pre-docker-migrate") or next(
iter(list_config_backups(path, "pre-docker-migrate")), None)
if dest is not None:
backups[path] = dest
return backups
+198
View File
@@ -0,0 +1,198 @@
"""Live staging E2E for the shared-metrics exporter.
Sends REAL packages through the REAL sender to the REAL staging ingest
service, then reports what the service acknowledged. Uses a throwaway
HERMES_HOME so the operator's own telemetry state is untouched.
Usage:
.venv/bin/python scripts/e2e_shared_metrics_staging.py
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
import uuid
from datetime import datetime, timezone
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO))
STAGING = "https://telemetry.staging-nousresearch.com/v1/telemetry"
def main() -> int:
scratch = Path(tempfile.mkdtemp(prefix="hermes-telemetry-e2e-"))
os.environ["HERMES_HOME"] = str(scratch)
# Staging is selected by writing config into the THROWAWAY profile, not by
# an environment override: a runtime env var that can retarget consented
# telemetry would be a consent hazard in production.
(scratch / "config.yaml").write_text(
"telemetry:\n"
" shared_metrics:\n"
" enabled: true\n"
" send: true\n"
f" endpoint: {STAGING}\n",
encoding="utf-8",
)
from hermes_cli.observability.shared_metrics import SharedMetricsStore
from hermes_cli.observability.shared_metrics_send_config import (
resolve_send_config,
)
from hermes_cli.observability.shared_metrics_sender import SharedMetricsSender
# Resolve through the real config path so this exercises what a user gets.
import yaml
resolved = resolve_send_config(
yaml.safe_load((scratch / "config.yaml").read_text(encoding="utf-8"))
)
if not resolved.send or resolved.endpoint != STAGING:
print(f"FAIL: config did not resolve to staging: {resolved}")
return 1
store = SharedMetricsStore(
database_path=scratch / "metrics.sqlite3",
outbox_directory=scratch / "outbox",
)
today = datetime.now(timezone.utc).date().isoformat()
# The generator only exports COMPLETED periods, so the realistic E2E
# package is yesterday's. It also has to be: the consent gate only
# releases a package once its whole period is confirmed consented, and
# today's period cannot be confirmed before it ends.
from datetime import timedelta
period_day = (
datetime.now(timezone.utc).date() - timedelta(days=1)
).isoformat()
# Open the consent window before the period, confirm it after — exactly
# what the runtime reconciler does across two days of hook fires.
from hermes_cli.observability.shared_metrics_sender import (
reconcile_send_consent,
)
from hermes_cli.sqlite_util import write_txn
with store._connection() as connection:
with write_txn(connection):
reconcile_send_consent(
connection,
True,
now=datetime.now(timezone.utc) - timedelta(days=2),
)
reconcile_send_consent(connection, True)
real_install_id = str(uuid.uuid4())
packages = []
# Two packages for today's period: the "head" and a later "tail", which is
# the real shape the outbox produces and the case the period gate exists
# for. One is large enough to exercise gzip.
for index, metric_count in ((0, 3), (1, 140)):
package_id = str(uuid.uuid4())
payload = {
"schema_version": "hermes.shared_metrics.v2",
"package_id": package_id,
"install_id": real_install_id,
"generated_at": datetime.now(timezone.utc).isoformat().replace(
"+00:00", "Z"
),
"period_start": f"{period_day}T00:00:00Z",
"period_end": f"{period_day}T23:59:59Z",
"resource": {
"hermes_version": "e2e-test",
"os_family": "macos",
"architecture": "arm64",
"install_method": "git",
},
"metrics": [
{
"name": f"hermes.e2e.metric.{i}",
"type": "counter",
"dimensions": {"outcome": "ok", "surface": "e2e"},
"value": i + 1,
}
for i in range(metric_count)
],
}
with store._connection() as connection:
connection.execute(
"""
INSERT INTO package_outbox(
package_id, period_start, period_end, payload_json,
created_at, exported_at
) VALUES (?, ?, ?, ?, ?, ?)
""",
(
package_id,
f"{period_day}T00:00:00Z",
f"{period_day}T23:59:59Z",
json.dumps(payload),
f"{period_day}T0{index}:00:00Z",
f"{period_day}T0{index}:00:01Z",
),
)
packages.append((package_id, metric_count))
print(f"scratch HERMES_HOME : {scratch}")
print(f"endpoint : {STAGING}")
print(f"local install_id : {real_install_id}")
print(f"packages queued : {len(packages)}")
for package_id, count in packages:
print(f" - {package_id} ({count} metrics)")
print()
outcome = SharedMetricsSender(store, resolved.endpoint).send_pending()
print(f"outcome: sent={outcome.sent} rejected={outcome.rejected} "
f"deferred={outcome.deferred}")
print()
failures = []
with store._connection() as connection:
rows = connection.execute(
"""
SELECT package_id, send_state, sent_at, send_attempts,
sent_install_id, last_error
FROM package_outbox ORDER BY created_at
"""
).fetchall()
for row in rows:
print(f"package : {row[0]}")
print(f" send_state : {row[1]}")
print(f" sent_at : {row[2]}")
print(f" attempts : {row[3]}")
print(f" transmitted : {row[4]}")
print(f" last_error : {row[5]}")
if row[1] != "sent":
failures.append(f"{row[0]} is {row[1]}: {row[5]}")
# Product decision 2026-08-27: the stable install_id is transmitted
# as-is; the transmitted value must be exactly the local id.
if row[4] != real_install_id:
failures.append(
f"{row[0]} transmitted {row[4]!r}, expected the install_id"
)
print()
if failures:
print("FAILURES:")
for failure in failures:
print(f" ✗ {failure}")
return 1
print("PASS: every package acknowledged 202 with the stable install_id.")
print()
print("Verify the objects in S3 with the package ids above:")
print(" aws s3 ls --recursive "
"s3://hermes-agent-telemetry-staging-767397871023-us-west-2-an/raw/ "
"| tail -20")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+7
View File
@@ -294,6 +294,13 @@ def is_windows() -> bool:
def run_gateway():
"""Run the gateway in foreground."""
# Startup-liveness watchdog (OOF-298): arm before importing the gateway
# graph so an import-time or pre-loop deadlock still gets respawned.
try:
from hermes_startup_watchdog import arm_startup_watchdog
arm_startup_watchdog()
except Exception:
pass
from gateway.run import start_gateway
print("Starting Hermes Gateway...")
print("Press Ctrl+C to stop.")
+299 -133
View File
@@ -387,18 +387,18 @@ $RepoUrlSsh = "git@github.com:NousResearch/hermes-agent.git"
$RepoUrlHttps = "https://github.com/NousResearch/hermes-agent.git"
$PythonVersion = "3.11"
# Minor versions the installer accepts when the requested $PythonVersion isn't
# available, in preference order. uv discovers both uv-managed and system
# interpreters, so this list also matches a pre-existing system Python. Single
# source of truth shared by Test-Python's fallback and Resolve-AvailablePythonVersion.
# available, in preference order. Only checkout-private uv-managed interpreters
# are eligible. Single source of truth shared by Test-Python's fallback and
# Resolve-AvailablePythonVersion.
$PythonFallbackVersions = @("3.12", "3.13", "3.10")
$PythonFindTimeoutMs = 30000
$NodeVersion = "22"
# The npm range the root package.json pins in `engines.npm`. A constant rather
# than a manifest read like the POSIX side does: Test-Node runs BEFORE the repo
# is cloned, so there is usually no package.json on disk yet (and none at all
# when install.ps1 is piped straight from the web). Get-NpmRange prefers the
# manifest whenever it does exist, so a drifted constant self-corrects on any
# run against an existing checkout.
$NpmRange = ">=12.0.0"
# when install.ps1 is piped straight from the web). Keep this fallback in sync
# with package.json; Get-NpmRange prefers the manifest once a checkout exists.
$NpmRange = "<11.10.0 || >=11.17.0"
# Stage-protocol version. Bumped only for genuinely breaking changes to the
# manifest schema, stage-name set semantics, or stdout JSON shape. Adding a
@@ -934,14 +934,93 @@ function Get-NpmRange {
return $NpmRange
}
# Upgrade the Hermes-managed Node tree's bundled npm into $NpmRange.
#
# The nodejs.org zip ships whatever npm that Node major bundles -- Node 26.5.1
# bundles npm 11.17.0, one minor below the root package.json's own
# `engines.npm` floor of >=12. The repo .npmrc sets `engine-strict=true`, so
# that is fatal rather than a warning and a brand-new install dies at the first
# `npm ci` with EBADENGINE. Provision the right npm here instead of reacting
# to the failure later.
# Convert the numeric core of an npm version or range operand into a stable
# three-component System.Version. npm reports semantic versions, but the
# installer only needs the numeric core for the comparator ranges authored in
# package.json (for example, <11.10.0 || >=11.17.0).
function ConvertTo-NpmVersion {
param([string]$Version)
if (-not $Version) { return $null }
$core = ($Version.Trim() -replace '^v', '' -replace '-.*$', '')
$parts = @($core -split '\.')
if ($parts.Count -lt 1 -or $parts.Count -gt 3) { return $null }
foreach ($part in $parts) {
if ($part -notmatch '^\d+$') { return $null }
}
while ($parts.Count -lt 3) { $parts += '0' }
try {
return [version]($parts -join '.')
} catch {
return $null
}
}
# Evaluate the comparator-only npm ranges used by the root manifest and the
# pre-clone fallback. Alternatives are separated with || and each alternative
# may contain one or more whitespace-separated <, <=, >, or >= comparators.
# Unknown range syntax fails closed so an incompatible system npm cannot reach
# npm ci and fail later with EBADENGINE.
function Test-NpmVersionOk {
param(
[string]$Version,
[string]$Range = (Get-NpmRange)
)
$actual = ConvertTo-NpmVersion $Version
if (-not $actual -or -not $Range) { return $false }
foreach ($alternative in @($Range -split '\s*\|\|\s*')) {
$clause = $alternative.Trim()
if (-not $clause) { continue }
$comparators = [regex]::Matches(
$clause,
'(?:^|\s)(<=|>=|<|>)\s*(\d+(?:\.\d+){0,2})(?=\s|$)'
)
if ($comparators.Count -eq 0) { continue }
$remainder = [regex]::Replace(
$clause,
'(?:^|\s)(?:<=|>=|<|>)\s*\d+(?:\.\d+){0,2}(?=\s|$)',
''
).Trim()
if ($remainder) { continue }
$matchesClause = $true
foreach ($comparator in $comparators) {
$target = ConvertTo-NpmVersion $comparator.Groups[2].Value
if (-not $target) {
$matchesClause = $false
break
}
$matchesComparator = switch ($comparator.Groups[1].Value) {
'<' { $actual -lt $target }
'<=' { $actual -le $target }
'>' { $actual -gt $target }
'>=' { $actual -ge $target }
default { $false }
}
if (-not $matchesComparator) {
$matchesClause = $false
break
}
}
if ($matchesClause) { return $true }
}
return $false
}
# Upgrade the Hermes-managed Node tree's bundled npm into $NpmRange when
# needed. Managed Node trees survive updates, so their bundled npm can drift
# outside a newer root package.json engine range. The repo .npmrc sets
# `engine-strict=true`, making that mismatch fatal at the first `npm ci`.
# Provision the right npm here instead of reacting to EBADENGINE later.
#
# Three details are load-bearing, mirroring _nb_ensure_bundled_npm_range in
# scripts/lib/node-bootstrap.sh and upgrade_managed_npm in
@@ -963,17 +1042,11 @@ function Update-ManagedNpm {
$range = Get-NpmRange
# Skip the network round-trip when the bundled npm already satisfies the
# range. Only the ">=N" shape we actually author is parsed; anything more
# exotic falls through to letting npm itself decide.
if ($range -match '^>=(\d+)') {
$want = [int]$Matches[1]
try {
$have = (& $npmCmd --version 2>$null)
if ($have -match '^(\d+)') {
if ([int]$Matches[1] -ge $want) { return $true }
}
} catch { }
}
# same range used by the system-Node acceptance gate.
try {
$have = (& $npmCmd --version 2>$null | Select-Object -First 1)
if ($have -and (Test-NpmVersionOk $have $range)) { return $true }
} catch { }
# In-app updates run while the desktop app's Node processes are alive.
# The managed npm lives inside the very tree they execute from, so an
@@ -1092,39 +1165,97 @@ function Resolve-UvCmd {
throw "uv is not installed. Run install.ps1 -Stage uv first."
}
function Initialize-ManagedPythonEnvironment {
# Python used by Hermes belongs to the checkout, never to another
# application or a user-level uv configuration. Keep this aligned with
# hermes_cli.managed_uv.managed_python_env(), which owns the update path.
foreach ($name in @(
"CONDA_DEFAULT_ENV", "CONDA_PREFIX", "UV_PROJECT_ENVIRONMENT",
"UV_NO_MANAGED_PYTHON", "UV_PYTHON", "UV_PYTHON_DOWNLOADS",
"UV_SYSTEM_PYTHON", "VIRTUAL_ENV", "PYTHONHOME", "PYTHONPATH"
)) {
Remove-Item -Path "Env:$name" -ErrorAction SilentlyContinue
}
$managedRoot = Join-Path $InstallDir ".hermes-runtime\python"
New-Item -ItemType Directory -Force -Path $managedRoot | Out-Null
$env:UV_MANAGED_PYTHON = "1"
$env:UV_NO_CONFIG = "1"
$env:UV_PYTHON_INSTALL_BIN = "0"
$env:UV_PYTHON_INSTALL_DIR = $managedRoot
$env:UV_PYTHON_INSTALL_REGISTRY = "0"
return [System.IO.Path]::GetFullPath($managedRoot)
}
function Resolve-AvailablePythonVersion {
# Return the first Python minor version uv can actually find, preferring the
# requested $PythonVersion and then $PythonFallbackVersions. Returns $null
# when none are available.
# Return the path and minor version of the first Hermes-managed interpreter
# uv can find, preferring the requested version and then fallback minors.
# System and application-owned interpreters are deliberately ineligible.
#
# This is the cross-process-safe counterpart to Test-Python's in-memory
# ``$script:PythonVersion = $fallbackVer`` mutation. Under Hermes-Setup.exe
# each ``-Stage NAME`` runs in a *fresh* powershell.exe, so the fallback the
# ``python`` stage settled on (e.g. 3.12 when 3.11 is absent) does NOT
# survive into the ``venv`` stage's process -- there $PythonVersion is back
# at its "3.11" default. Consumers re-resolve here instead of trusting that
# default, which is exactly the propagation gap behind issue #50769.
# Under Hermes-Setup.exe each stage runs in a fresh powershell.exe. The
# venv stage therefore re-resolves both version and provenance rather than
# relying on state selected by the earlier Python stage (#50769).
[string]$managedRoot = Initialize-ManagedPythonEnvironment
$managedPrefix = $managedRoot.TrimEnd('\') + '\'
$candidates = @($PythonVersion) + $PythonFallbackVersions
$seen = @{}
foreach ($ver in $candidates) {
if (-not $ver -or $seen.ContainsKey($ver)) { continue }
$seen[$ver] = $true
$process = $null
try {
$found = & $UvCmd python find $ver 2>$null
if ($found) { return $ver }
} catch { }
# PowerShell 5.1 can lose a nested native command's stdout when
# this installer itself is redirected by the desktop bootstrapper.
# Capture uv directly through ProcessStartInfo instead of relying
# on the native-command pipeline for the interpreter path.
$process = New-Object System.Diagnostics.Process
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
$startInfo.FileName = $UvCmd
$startInfo.Arguments = "python find $ver --managed-python --no-config"
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
$process.StartInfo = $startInfo
if (-not $process.Start()) { continue }
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
$stderrTask = $process.StandardError.ReadToEndAsync()
if (-not $process.WaitForExit($PythonFindTimeoutMs)) {
try { $process.Kill() } catch { }
$process.WaitForExit()
throw "uv python find $ver timed out after $PythonFindTimeoutMs ms"
}
$stdout = $stdoutTask.Result
$stderrTask.Result | Out-Null
if ($process.ExitCode -ne 0) { continue }
[string]$foundPath = ($stdout.Trim() -split "`r?`n") | Select-Object -Last 1
if ($foundPath) {
$absolute = [System.IO.Path]::GetFullPath($foundPath)
if ($absolute.StartsWith($managedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
return [PSCustomObject]@{
Path = $absolute
Version = $ver
}
}
}
} catch {
throw "Failed to resolve Hermes-managed Python $ver`: $_"
} finally {
if ($process) { $process.Dispose() }
}
}
return $null
}
function Test-Python {
Initialize-ManagedPythonEnvironment | Out-Null
Write-Info "Checking Python $PythonVersion..."
# Let uv find or install Python
# Only a checkout-private uv-managed interpreter satisfies this stage.
try {
$pythonPath = & $UvCmd python find $PythonVersion 2>$null
if ($pythonPath) {
$ver = & $pythonPath --version 2>$null
$resolvedPython = Resolve-AvailablePythonVersion
if ($resolvedPython) {
$ver = & $resolvedPython.Path --version 2>$null
Write-Success "Python found: $ver"
return $true
}
@@ -1147,15 +1278,15 @@ function Test-Python {
# semantics or stderr noise. This fix was previously landed as
# commit ec1714e71 and then lost in a release squash; reapplied here.
$ErrorActionPreference = "Continue"
$uvOutput = & $UvCmd python install $PythonVersion 2>&1
$uvOutput = & $UvCmd python install $PythonVersion --no-bin --no-registry --no-config 2>&1
$uvExitCode = $LASTEXITCODE
$ErrorActionPreference = $prevEAP
# Check if Python is now available (more reliable than exit code
# since uv may return non-zero due to "already installed" etc.)
$pythonPath = & $UvCmd python find $PythonVersion 2>$null
if ($pythonPath) {
$ver = & $pythonPath --version 2>$null
$resolvedPython = Resolve-AvailablePythonVersion
if ($resolvedPython) {
$ver = & $resolvedPython.Path --version 2>$null
Write-Success "Python installed: $ver"
return $true
}
@@ -1171,60 +1302,29 @@ function Test-Python {
Write-Warn "uv python install error: $_"
}
# Fallback: check if ANY Python 3.10+ is already available on the system
Write-Info "Trying to find any existing Python 3.10+..."
# Preserve the established minor-version fallback contract, but provision
# every fallback into the same private store instead of borrowing a system
# interpreter. This path is reached only when the preferred install failed.
foreach ($fallbackVer in $PythonFallbackVersions) {
try {
$pythonPath = & $UvCmd python find $fallbackVer 2>$null
if ($pythonPath) {
$ver = & $pythonPath --version 2>$null
Write-Success "Found fallback: $ver"
$script:PythonVersion = $fallbackVer
Write-Info "Trying managed Python fallback $fallbackVer..."
$previousFallbackEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
& $UvCmd python install $fallbackVer --no-bin --no-registry --no-config 2>&1 | Out-Null
$ErrorActionPreference = $previousFallbackEAP
$resolvedPython = Resolve-AvailablePythonVersion
if ($resolvedPython) {
$ver = & $resolvedPython.Path --version 2>$null
Write-Success "Python fallback installed: $ver"
return $true
}
} catch { }
}
# Fallback: try system python -- but skip the Microsoft Store stub.
# On Windows, %LOCALAPPDATA%\Microsoft\WindowsApps\python.exe is a 0-byte
# reparse-point stub that prints "Python was not found; run without
# arguments to install from the Microsoft Store..." to stdout and exits
# non-zero. Get-Command finds it; invoking it produces a confusing error
# that the user sees as our installer crashing.
$pythonCmd = Get-Command python -ErrorAction SilentlyContinue
if ($pythonCmd) {
$isStoreStub = $false
try {
$pythonSource = $pythonCmd.Source
if ($pythonSource -and $pythonSource -like "*\WindowsApps\*") {
$isStoreStub = $true
} else {
# Even outside WindowsApps, a 0-byte file is the stub
$item = Get-Item $pythonSource -ErrorAction SilentlyContinue
if ($item -and $item.Length -eq 0) { $isStoreStub = $true }
}
} catch { }
if (-not $isStoreStub) {
try {
$prevEAP2 = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$sysVer = & python --version 2>&1
$ErrorActionPreference = $prevEAP2
if ($sysVer -match "Python 3\.(1[0-9]|[1-9][0-9])") {
Write-Success "Using system Python: $sysVer"
return $true
}
} catch {
if ($prevEAP2) { $ErrorActionPreference = $prevEAP2 }
}
} catch {
if ($previousFallbackEAP) { $ErrorActionPreference = $previousFallbackEAP }
}
}
Write-Err "Failed to install Python $PythonVersion"
Write-Info "Install Python 3.11 manually, then re-run this script:"
Write-Info " https://www.python.org/downloads/"
Write-Info " Or: winget install Python.Python.3.11"
Write-Info "Check network access to uv's managed Python downloads, then retry."
return $false
}
@@ -1571,36 +1671,74 @@ function Set-GitBashEnvVar {
Write-Info "If needed, set HERMES_GIT_BASH_PATH manually to your bash.exe path."
}
# The dependency tree's real Node floor is >=22.22.0, set by react-router 8.3.0
# (`engines.node`). Keep this in sync with the root package.json: looser lets an
# install reach a `npm ci` that dies with EBADENGINE, stricter replaces a working
# user toolchain for nothing. Returns $true when a `node --version` string
# clears that floor.
# The dependency tree supports Node 22.22+, 24.11+, and 26+. nanoid 6 excludes
# Node 23 and 25 while its >=26 arm accepts later releases, and @babel/* 8.x
# requires ^22.18.0 || >=24.11.0 -- so accepting 23/25 or an early Node 24
# only defers the failure to `npm ci` under engine-strict. Keep this in sync
# with the root package.json.
function Test-NodeVersionOk {
param([string]$Version)
if ($Version -match '-') { return $false }
try {
$v = [version]($Version -replace '^v', '' -replace '-.*$', '')
$v = [version]($Version -replace '^v', '')
} catch {
return $false
}
if ($v.Major -eq 22) { return ($v.Minor -ge 22) }
return ($v.Major -gt 22)
if ($v.Major -eq 24) { return ($v.Minor -ge 11) }
return ($v.Major -ge 26)
}
# Accept a system Node only when its companion npm also satisfies the same
# range used to provision the Hermes-managed tree. Keeping this probe separate
# lets the initial PATH check and the post-winget check share one authority.
function Test-SystemNodeReady {
if (-not (Get-Command node -ErrorAction SilentlyContinue)) { return $false }
$version = node --version
if (Test-NodeVersionOk $version) {
Ensure-NodeExeOnPath | Out-Null
} else {
Write-Warn "Node.js $version is unsupported (Hermes requires Node 22.22+, 24.11+, or 26+)"
return $false
}
$npmRange = Get-NpmRange
$npmCmd = Get-Command npm.cmd -ErrorAction SilentlyContinue
if (-not $npmCmd) {
$npmCmd = Get-Command npm -ErrorAction SilentlyContinue
}
$npmVersion = $null
if ($npmCmd) {
try {
$npmVersion = (& $npmCmd --version 2>$null | Select-Object -First 1)
} catch { }
}
if ($npmVersion -and (Test-NpmVersionOk $npmVersion $npmRange)) {
Write-Success "Node.js $version with npm $npmVersion found"
return $true
}
if ($npmVersion) {
Write-Warn "Node.js $version uses npm $npmVersion, which does not satisfy Hermes requirement $npmRange"
} else {
Write-Warn "Node.js $version was found, but npm is missing or could not report its version"
}
return $false
}
function Test-Node {
Write-Info "Checking Node.js (for browser tools)..."
if (Get-Command node -ErrorAction SilentlyContinue) {
$version = node --version
if (Test-NodeVersionOk $version) {
Ensure-NodeExeOnPath | Out-Null
Write-Success "Node.js $version found"
$script:HasNode = $true
return $true
}
Write-Warn "Node.js $version is too old (Hermes requires Node >=26)"
if (Test-SystemNodeReady) {
$script:HasNode = $true
return $true
}
Write-Info "Using a Hermes-managed Node.js installation instead..."
# Prefer a Hermes-managed Node from a previous run over a too-old system one.
$managedNode = "$HermesHome\node\node.exe"
if ((Test-Path $managedNode) -and (Test-NodeVersionOk (& $managedNode --version))) {
@@ -1774,9 +1912,7 @@ function Test-Node {
$ErrorActionPreference = $prevEAP
# Refresh PATH
$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
if (Get-Command node -ErrorAction SilentlyContinue) {
$version = node --version
Write-Success "Node.js $version installed via winget"
if (Test-SystemNodeReady) {
$script:HasNode = $true
return $true
}
@@ -2426,13 +2562,12 @@ function Install-Venv {
# fresh process -- $PythonVersion is back at its "3.11" default. Trusting it
# here made `uv venv venv --python 3.11` fail with exit 2 on machines without
# 3.11 even though the `python` stage reported success (issue #50769).
$resolved = Resolve-AvailablePythonVersion
if ($resolved -and $resolved -ne $PythonVersion) {
Write-Info "Python $PythonVersion not available; using detected Python $resolved"
$script:PythonVersion = $resolved
$resolvedPython = Resolve-AvailablePythonVersion
if (-not $resolvedPython) {
throw "Hermes-managed Python is unavailable. Run install.ps1 -Stage python first."
}
Write-Info "Creating virtual environment with Python $PythonVersion..."
Write-Info "Creating virtual environment with Python $($resolvedPython.Version)..."
Push-Location $InstallDir
@@ -2551,16 +2686,36 @@ function Install-Venv {
}
}
# uv creates the venv and pins the Python version in one step. uv emits
# normal progress such as "Using CPython ..." on stderr; under Windows
# PowerShell 5.1 with EAP=Stop that stderr is a NativeCommandError unless
# we temporarily relax EAP and trust $LASTEXITCODE for real failures.
Invoke-NativeWithRelaxedErrorAction { & $UvCmd venv venv --python $PythonVersion }
# Relaxing EAP above means a *genuine* uv-venv failure (exit != 0) no longer
# aborts on its own. Capture $LASTEXITCODE immediately and fail fast, so the
# `venv` stage can't falsely report success (and Invoke-Stage can't emit
# ok=true) when the venv was never created.
$venvExitCode = $LASTEXITCODE
# Pass the already-validated private interpreter path and prohibit uv from
# resolving or downloading a different Python during venv creation. Use
# ProcessStartInfo because the desktop bootstrapper redirects this script;
# Windows PowerShell 5.1 can otherwise lose nested native output/exit state.
$venvProcess = New-Object System.Diagnostics.Process
try {
$venvStartInfo = New-Object System.Diagnostics.ProcessStartInfo
$venvStartInfo.FileName = $UvCmd
$venvStartInfo.Arguments = "venv venv --python `"$($resolvedPython.Path)`" --managed-python --no-python-downloads --no-config"
$venvStartInfo.WorkingDirectory = $InstallDir
$venvStartInfo.UseShellExecute = $false
$venvStartInfo.CreateNoWindow = $true
$venvStartInfo.RedirectStandardOutput = $true
$venvStartInfo.RedirectStandardError = $true
$venvProcess.StartInfo = $venvStartInfo
if (-not $venvProcess.Start()) {
throw "Failed to start uv while creating the virtual environment"
}
$venvStdoutTask = $venvProcess.StandardOutput.ReadToEndAsync()
$venvStderrTask = $venvProcess.StandardError.ReadToEndAsync()
$venvProcess.WaitForExit()
$venvStdout = $venvStdoutTask.Result
$venvStderr = $venvStderrTask.Result
$venvExitCode = $venvProcess.ExitCode
if ($venvStdout) { Write-Host $venvStdout.TrimEnd() }
if ($venvStderr) { Write-Host $venvStderr.TrimEnd() }
} finally {
$venvProcess.Dispose()
}
# Fail fast so the stage cannot report ok=true when uv failed.
if ($venvExitCode -ne 0) {
throw "Failed to create virtual environment (uv venv exited with $venvExitCode)"
}
@@ -2654,7 +2809,7 @@ function Install-Venv {
}
}
Write-Success "Virtual environment ready (Python $PythonVersion)"
Write-Success "Virtual environment ready (Python $($resolvedPython.Version))"
}
function Get-PendingVenvBackup {
@@ -3238,7 +3393,7 @@ function Copy-ConfigTemplates {
# upgrades the old comment-only scaffold to this text on next run, so
# drift is self-healing, but keep them in sync to avoid first-run churn.
$soulContent = @"
You are Hermes Agent, an intelligent AI assistant created by Nous Research. You are helpful, knowledgeable, and direct. You assist users with a wide range of tasks including answering questions, writing and editing code, analyzing information, creative work, and executing actions via your tools. You communicate clearly, admit uncertainty when appropriate, and prioritize being genuinely useful over being verbose unless otherwise directed below. Be targeted and efficient in your exploration and investigations.
You are Hermes Agent, built by Nous Research. Be direct: match the length of your reply to the weight of the ask -- a one-line question gets a one-line answer, and finished work gets a short report of what changed, what's verified, and what's left, never a replay of the process. No filler ("Great question," "I'd be happy to"), no restating the request back, no re-summarizing what you already said, no narrating tool calls the user can see. Plain claims over adjectives; when unsure, say so plainly. Agree because it's right, not because the user said it. Depth is earned -- give it when the user asks for detail, teaches, or the stakes demand it, not by default.
"@
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($soulPath, $soulContent, $utf8NoBom)
@@ -3887,8 +4042,8 @@ function Install-Desktop {
# Always re-resolve Node here. Stages run in separate PowerShell processes,
# so $script:HasNode from Stage-Node isn't visible; more importantly Test-Node
# enforces the build floor (Node >=26) and prepends the Hermes-managed
# Node to PATH, so the build never runs on a too-old system Node -- the cause
# enforces the supported Node lines and prepends the Hermes-managed Node to
# PATH, so the build never runs on an unsupported system Node -- the cause
# of the opaque "Build desktop app ... exit code 1" failure (Vite crashes on
# old Node).
Test-Node | Out-Null
@@ -4578,11 +4733,15 @@ function Write-Completion {
# or arrange to provide answers another way."
$InstallStages = @(
@{ Name = "uv"; Title = "Installing uv package manager"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Uv" }
@{ Name = "python"; Title = "Verifying Python $PythonVersion"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Python" }
@{ Name = "git"; Title = "Installing Git"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Git" }
@{ Name = "node"; Title = "Detecting Node.js"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Node" }
@{ Name = "system-packages"; Title = "Installing ripgrep and ffmpeg"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-SystemPackages" }
@{ Name = "repository"; Title = "Cloning Hermes repository"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Repository" }
# Managed Python lives under $InstallDir\.hermes-runtime, so the checkout
# must exist before this stage creates that directory. Otherwise the later
# repository stage treats the runtime-only directory as a broken checkout,
# parks it, and leaves Stage-Venv with no managed interpreter.
@{ Name = "python"; Title = "Verifying Python $PythonVersion"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Python" }
@{ Name = "venv"; Title = "Creating Python virtual environment"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Venv" }
@{ Name = "dependencies"; Title = "Installing Python dependencies"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Dependencies" }
@{ Name = "node-deps"; Title = "Installing Node.js dependencies"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-NodeDeps" }
@@ -4802,6 +4961,13 @@ function Main {
# iex` PowerShell session, and so failures in stage-driver mode produce a
# structured JSON error frame instead of a bare exception.
# Dot-sourcing loads the installer's real functions for isolated behavioral
# tests without running an install. Normal script and `irm | iex` entry points
# are unchanged.
if ($MyInvocation.InvocationName -eq ".") {
return
}
try {
if ($Ensure -ne "") {
if ($PSBoundParameters.ContainsKey("Stage")) {
+351 -100
View File
@@ -618,21 +618,64 @@ install_uv() {
check_python() {
if [ "$DISTRO" = "termux" ]; then
log_info "Checking Termux Python..."
if command -v python >/dev/null 2>&1; then
PYTHON_PATH="$(command -v python)"
if "$PYTHON_PATH" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' 2>/dev/null; then
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
log_success "Python found: $PYTHON_FOUND_VERSION"
return 0
# Hermes currently declares requires-python >=3.11,<3.14. Termux can
# expose a newer default `python` before dependencies have compatible
# wheels, so do not accept the default interpreter until the upper bound
# is verified. Prefer the project's pinned minor when present, then
# other explicit compatible interpreters.
for python_cmd in python3.11 python3.12 python3.13 python; do
if command -v "$python_cmd" >/dev/null 2>&1; then
local candidate_path
candidate_path="$(command -v "$python_cmd")"
if "$candidate_path" -c 'import sys; raise SystemExit(0 if (3, 11) <= sys.version_info[:2] < (3, 14) else 1)' 2>/dev/null; then
PYTHON_PATH="$candidate_path"
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
log_success "Python found: $PYTHON_FOUND_VERSION"
return 0
fi
fi
fi
done
log_info "Installing Python via pkg..."
pkg install -y python >/dev/null
PYTHON_PATH="$(command -v python)"
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
log_success "Python installed: $PYTHON_FOUND_VERSION"
return 0
if "$PYTHON_PATH" -c 'import sys; raise SystemExit(0 if (3, 11) <= sys.version_info[:2] < (3, 14) else 1)' 2>/dev/null; then
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
log_success "Python installed: $PYTHON_FOUND_VERSION"
return 0
fi
# Termux's default `python` package is outside the supported range
# (e.g. 3.14.x before Rust transitives ship cp314 wheels). The Termux
# User Repository (TUR) publishes versioned CPython packages
# (python3.13, python3.11), so try to provision a supported
# interpreter from there before giving up.
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null || true)"
log_warn "Termux Python $PYTHON_FOUND_VERSION is outside the supported range (>=3.11,<3.14)"
log_info "Trying the Termux User Repository (TUR) for a supported Python..."
pkg install -y tur-repo >/dev/null 2>&1 || true
local tur_pkg
for tur_pkg in python3.13 python3.12 python3.11; do
if ! pkg install -y "$tur_pkg" >/dev/null 2>&1; then
continue
fi
if ! command -v "$tur_pkg" >/dev/null 2>&1; then
continue
fi
local tur_path
tur_path="$(command -v "$tur_pkg")"
if "$tur_path" -c 'import sys; raise SystemExit(0 if (3, 11) <= sys.version_info[:2] < (3, 14) else 1)' 2>/dev/null; then
PYTHON_PATH="$tur_path"
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
log_success "Python installed from TUR: $PYTHON_FOUND_VERSION"
return 0
fi
done
log_error "Termux Python $PYTHON_FOUND_VERSION is not supported; Hermes requires Python >=3.11,<3.14"
log_info "Install a supported interpreter and re-run this script:"
log_info " pkg install tur-repo && pkg install python3.13"
exit 1
fi
log_info "Checking Python $PYTHON_VERSION..."
@@ -882,20 +925,30 @@ check_cxx_compiler() {
return 1
}
# The dependency tree's real Node floor is >=22.22.0, set by react-router 8.3.0
# (`engines.node`), with Vite ^8 next at `^20.19 || >=22.12`. Keep this in sync
# with the root package.json — a gate looser than the manifest lets an install
# proceed to a `npm ci` that then dies with EBADENGINE, and a gate stricter than
# the manifest replaces a working user toolchain for nothing. Returns 0 when the
# given `node --version` string clears the floor; anything below it is replaced
# with the Hermes-managed Node $NODE_VERSION.
# The dependency tree supports Node 22.22+, 24.11+, and 26+. nanoid 6 excludes
# Node 23 and 25 while its >=26 arm accepts later releases, and @babel/* 8.x
# requires ^22.18.0 || >=24.11.0 — so accepting 23/25 or an early Node 24
# here only defers the failure to `npm ci` under engine-strict. Keep this in
# sync with the root package.json. Anything outside the supported lines is
# replaced with the Hermes-managed Node $NODE_VERSION.
node_satisfies_build() {
local ver="${1#v}"
# Pre-release builds are rejected outright, however new they are. `node-pty`
# ships no Linux prebuild, so every install compiles it with node-gyp, which
# fetches the headers named by `process.release.headersUrl` — a URL only
# published for final releases. nodejs.org/dist/latest-v26.x currently
# serves node-v26.8.0-<os>-<arch>.tar.xz whose binary reports
# v26.8.0-alpha.0.0.0; its headers 404, and the install dies at
# `node-gyp rebuild` with the underlying error swallowed by the
# `node scripts/prebuild.js || node-gyp rebuild` fallback.
case "$ver" in *-*) return 1 ;; esac
local major="${ver%%.*}"
local minor="${ver#*.}"; minor="${minor%%.*}"
case "$major" in ''|*[!0-9]*) return 1 ;; esac
case "$minor" in ''|*[!0-9]*) minor=0 ;; esac
if [ "$major" -ge 22 ] && { [ "$major" -gt 22 ] || [ "$minor" -ge 22 ]; }; then return 0; fi
if [ "$major" -eq 22 ] && [ "$minor" -ge 22 ]; then return 0; fi
if [ "$major" -eq 24 ] && [ "$minor" -ge 11 ]; then return 0; fi
if [ "$major" -ge 26 ]; then return 0; fi
return 1
}
@@ -963,7 +1016,7 @@ check_node() {
if command -v node &> /dev/null && ! command -v npm &> /dev/null; then
log_warn "node found but npm is not on PATH (stray node symlink?) — installing Hermes-managed Node $NODE_VERSION LTS..."
elif command -v node &> /dev/null; then
log_warn "Node.js $(node --version) is too old (Hermes requires Node >=26) — installing Hermes-managed Node $NODE_VERSION..."
log_warn "Node.js $(node --version) is unsupported (Hermes requires Node 22.22+, 24.11+, or 26+) — installing Hermes-managed Node $NODE_VERSION..."
elif [ "$DISTRO" = "termux" ]; then
log_info "Node.js not found — installing Node.js via pkg..."
else
@@ -972,14 +1025,150 @@ check_node() {
install_node
}
# Download and adopt one Node release line (e.g. 26) into ~/.hermes/node/.
#
# Split out of install_node() so the caller can walk a list of candidate lines.
# A line is rejected — and the caller should try an older one — when nodejs.org
# publishes no tarball for this platform, when the download or extraction
# fails, or when the tree it ships fails node_satisfies_build.
#
# Returns 0 with the tree in place, PATH exported and HAS_NODE=true; 1 when the
# line is unusable. Nothing on disk is replaced until the candidate passes, so
# a rejected line leaves any existing managed Node untouched.
install_node_line() {
local node_line="$1"
local node_os="$2"
local node_arch="$3"
# Resolve the latest v${node_line}.x.x tarball name from the index page
local index_url="https://nodejs.org/dist/latest-v${node_line}.x/"
local tarball_name
tarball_name=$(curl -fsSL "$index_url" \
| grep -oE "node-v${node_line}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.xz" \
| head -1)
# Fallback to .tar.gz if .tar.xz not available
if [ -z "$tarball_name" ]; then
tarball_name=$(curl -fsSL "$index_url" \
| grep -oE "node-v${node_line}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.gz" \
| head -1)
fi
if [ -z "$tarball_name" ]; then
log_warn "Could not find Node.js $node_line binary for $node_os-$node_arch"
return 1
fi
local download_url="${index_url}${tarball_name}"
local tmp_dir
tmp_dir=$(mktemp -d)
log_info "Downloading $tarball_name..."
if ! curl -fsSL "$download_url" -o "$tmp_dir/$tarball_name"; then
log_warn "Download failed"
rm -rf "$tmp_dir"
return 1
fi
log_info "Extracting to ~/.hermes/node/..."
if [[ "$tarball_name" == *.tar.xz ]]; then
tar xf "$tmp_dir/$tarball_name" -C "$tmp_dir"
else
tar xzf "$tmp_dir/$tarball_name" -C "$tmp_dir"
fi
local extracted_dir
extracted_dir=$(ls -d "$tmp_dir"/node-v* 2>/dev/null | head -1)
if [ ! -d "$extracted_dir" ]; then
log_warn "Extraction failed"
rm -rf "$tmp_dir"
return 1
fi
# Trust the binary, not the filename. A tarball named for a final release
# can still carry a pre-release build — latest-v26.x serves
# node-v26.8.0-<os>-<arch>.tar.xz whose `node --version` is
# v26.8.0-alpha.0.0.0 — and adopting it leaves node-pty unbuildable for the
# life of the install. Probe the extracted tree before it replaces anything.
local candidate_ver
candidate_ver=$("$extracted_dir/bin/node" --version 2>/dev/null)
if ! node_satisfies_build "$candidate_ver"; then
log_warn "Node.js ${candidate_ver:-unreadable} from ${index_url} cannot build native modules — trying an older release line..."
rm -rf "$tmp_dir"
return 1
fi
# Place into ~/.hermes/node/ and symlink binaries into the same bin dir
# the hermes command uses (get_command_link_dir): /usr/local/bin for root
# FHS installs, $PREFIX/bin on Termux, ~/.local/bin otherwise.
rm -rf "$HERMES_HOME/node"
mkdir -p "$HERMES_HOME"
mv "$extracted_dir" "$HERMES_HOME/node"
rm -rf "$tmp_dir"
# Node's official linux-x64 builds (observed: v26.7.0) link
# libatomic.so.1, which minimal Debian/Ubuntu images do not ship —
# the freshly downloaded binary then fails to start. Install the
# library up front, best-effort; the version probe below reports
# clearly if the binary still cannot run (#87460).
if [ "$OS" = "linux" ] && { [ "$DISTRO" = "ubuntu" ] || [ "$DISTRO" = "debian" ]; }; then
if command -v apt-get >/dev/null 2>&1; then
local sudo_cmd=""
if [ "$(id -u 2>/dev/null || echo 1000)" -ne 0 ]; then
command -v sudo >/dev/null 2>&1 && sudo_cmd="sudo"
fi
$sudo_cmd env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq libatomic1 >/dev/null 2>&1 || true
fi
fi
local node_link_dir
node_link_dir="$(get_command_link_dir)"
mkdir -p "$node_link_dir"
ln -sf "$HERMES_HOME/node/bin/node" "$node_link_dir/node"
ln -sf "$HERMES_HOME/node/bin/npm" "$node_link_dir/npm"
ln -sf "$HERMES_HOME/node/bin/npx" "$node_link_dir/npx"
configure_managed_node_npm_prefix
export PATH="$HERMES_HOME/node/bin:$PATH"
local installed_ver
if ! installed_ver=$("$HERMES_HOME/node/bin/node" --version 2>&1); then
# The adopted Node exists but cannot start (observed: missing
# libatomic.so.1 on minimal Debian/Ubuntu, #87460). Degrade loudly,
# surface the loader's real error, and remove the broken tree and
# bin links so later steps and retry runs start clean. Signal the
# caller to try an older release line rather than claiming success.
log_error "Downloaded Node.js failed to start:"
printf '%s\n' "$installed_ver" >&2
log_info "On Debian/Ubuntu the usual fix is: sudo apt-get install -y libatomic1"
rm -rf "$HERMES_HOME/node"
rm -f "$node_link_dir/node" "$node_link_dir/npm" "$node_link_dir/npx"
return 1
fi
log_success "Node.js $installed_ver installed to ~/.hermes/node/"
HAS_NODE=true
return 0
}
install_node() {
if [ "$DISTRO" = "termux" ]; then
log_info "Installing Node.js via pkg..."
if pkg install -y nodejs >/dev/null; then
local installed_ver
installed_ver=$(node --version 2>/dev/null)
log_success "Node.js $installed_ver installed via pkg"
HAS_NODE=true
installed_ver=$(node --version 2>/dev/null || true)
if [ -n "$installed_ver" ]; then
log_success "Node.js $installed_ver installed via pkg"
HAS_NODE=true
else
# pkg succeeded but the binary cannot start — the same
# silent-success class the managed-download probe guards
# against (#87460). Degrade instead of claiming success.
log_error "Node.js installed via pkg failed to start:"
node --version >&2 || true
HAS_NODE=false
fi
else
log_warn "Failed to install Node.js via pkg"
HAS_NODE=false
@@ -1012,79 +1201,21 @@ install_node() {
;;
esac
# Resolve the latest v${NODE_VERSION}.x.x tarball name from the index page
local index_url="https://nodejs.org/dist/latest-v${NODE_VERSION}.x/"
local tarball_name
tarball_name=$(curl -fsSL "$index_url" \
| grep -oE "node-v${NODE_VERSION}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.xz" \
| head -1)
# Try the target line first, then the two previous even-numbered (LTS)
# lines. Whether a line is usable cannot be known from its index page —
# see install_node_line — and stepping down beats adopting a Node that
# leaves every native dependency unbuildable.
local node_line
for node_line in "$NODE_VERSION" "$((NODE_VERSION - 2))" "$((NODE_VERSION - 4))"; do
if install_node_line "$node_line" "$node_os" "$node_arch"; then
return 0
fi
done
# Fallback to .tar.gz if .tar.xz not available
if [ -z "$tarball_name" ]; then
tarball_name=$(curl -fsSL "$index_url" \
| grep -oE "node-v${NODE_VERSION}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.gz" \
| head -1)
fi
if [ -z "$tarball_name" ]; then
log_warn "Could not find Node.js $NODE_VERSION binary for $node_os-$node_arch"
log_info "Install manually: https://nodejs.org/en/download/"
HAS_NODE=false
return 0
fi
local download_url="${index_url}${tarball_name}"
local tmp_dir
tmp_dir=$(mktemp -d)
log_info "Downloading $tarball_name..."
if ! curl -fsSL "$download_url" -o "$tmp_dir/$tarball_name"; then
log_warn "Download failed"
rm -rf "$tmp_dir"
HAS_NODE=false
return 0
fi
log_info "Extracting to ~/.hermes/node/..."
if [[ "$tarball_name" == *.tar.xz ]]; then
tar xf "$tmp_dir/$tarball_name" -C "$tmp_dir"
else
tar xzf "$tmp_dir/$tarball_name" -C "$tmp_dir"
fi
local extracted_dir
extracted_dir=$(ls -d "$tmp_dir"/node-v* 2>/dev/null | head -1)
if [ ! -d "$extracted_dir" ]; then
log_warn "Extraction failed"
rm -rf "$tmp_dir"
HAS_NODE=false
return 0
fi
# Place into ~/.hermes/node/ and symlink binaries into the same bin dir
# the hermes command uses (get_command_link_dir): /usr/local/bin for root
# FHS installs, $PREFIX/bin on Termux, ~/.local/bin otherwise.
rm -rf "$HERMES_HOME/node"
mkdir -p "$HERMES_HOME"
mv "$extracted_dir" "$HERMES_HOME/node"
rm -rf "$tmp_dir"
local node_link_dir
node_link_dir="$(get_command_link_dir)"
mkdir -p "$node_link_dir"
ln -sf "$HERMES_HOME/node/bin/node" "$node_link_dir/node"
ln -sf "$HERMES_HOME/node/bin/npm" "$node_link_dir/npm"
ln -sf "$HERMES_HOME/node/bin/npx" "$node_link_dir/npx"
configure_managed_node_npm_prefix
export PATH="$HERMES_HOME/node/bin:$PATH"
local installed_ver
installed_ver=$("$HERMES_HOME/node/bin/node" --version 2>/dev/null)
log_success "Node.js $installed_ver installed to ~/.hermes/node/"
HAS_NODE=true
log_warn "No usable Node.js release line found for $node_os-$node_arch"
log_info "Install manually: https://nodejs.org/en/download/"
HAS_NODE=false
return 0
}
check_network_prerequisites() {
@@ -1468,7 +1599,57 @@ EOF
else
rm -rf "$INSTALL_DIR" 2>/dev/null # Clean up partial SSH clone
log_info "SSH failed, trying HTTPS..."
if git clone --depth 1 --branch "$BRANCH" "$REPO_URL_HTTPS" "$INSTALL_DIR"; then
# GitHub throttles packfile generation for large repos (this one:
# ~9.6k files at HEAD plus thousands of auto-generated branches)
# with repo-scoped HTTP 429s that are NOT client IP rate limits —
# an anonymous clone of a small repo succeeds and the API quota
# is untouched, but the single big pack behind `--depth 1` dies
# mid-transfer with "RPC failed; HTTP 429 / expected 'packfile'"
# (#89624, same throttle as the update path in #89287). Retry
# with backoff, then degrade to a blobless partial clone + fetch
# (many small packs instead of one big one — what gets past the
# throttle). Fully materialize the tree afterwards so the rest of
# the installer sees the normal files.
local clone_ok=false
local attempt=0
local max_attempts=4
for attempt in $(seq 1 "$max_attempts"); do
[ "$attempt" -gt 1 ] && log_info "Retrying HTTPS clone (attempt $attempt/$max_attempts)..."
if git clone --depth 1 --single-branch --branch "$BRANCH" \
"$REPO_URL_HTTPS" "$INSTALL_DIR"; then
clone_ok=true
break
fi
rm -rf "$INSTALL_DIR" 2>/dev/null # partial clone is unusable
[ "$attempt" -lt "$max_attempts" ] && sleep $((attempt * 5))
done
if [ "$clone_ok" != true ]; then
log_info "Direct clone throttled — trying blobless partial clone..."
# --no-checkout keeps the clone itself to commits+trees (small,
# gets past the pack throttle). Without it the blob fetch runs
# inside `git clone`'s own checkout step, the throttle kills
# the whole clone, and this fallback degrades to one more
# failed clone. The blobs are fetched by the reset below — a
# separate request the retry can actually wrap.
if git clone --depth 1 --single-branch --filter=blob:none \
--no-checkout --branch "$BRANCH" "$REPO_URL_HTTPS" "$INSTALL_DIR"; then
# Materialize the working tree: on a --no-checkout clone
# this reset is the step that fetches the blobs (several
# small packs instead of one big one). Fail closed — a
# half-materialized checkout must not report success and
# hand the rest of the installer an unusable tree.
if (cd "$INSTALL_DIR" \
&& (git reset --hard HEAD >/dev/null 2>&1 \
|| { sleep 5; git reset --hard HEAD >/dev/null 2>&1; })); then
clone_ok=true
else
rm -rf "$INSTALL_DIR" 2>/dev/null # unusable checkout
fi
else
rm -rf "$INSTALL_DIR" 2>/dev/null
fi
fi
if [ "$clone_ok" = true ]; then
log_success "Cloned via HTTPS"
else
log_error "Failed to clone repository"
@@ -1571,6 +1752,30 @@ setup_venv() {
log_success "Virtual environment ready (Python $PYTHON_VERSION)"
}
run_locked_uv_sync() {
# Bootstrap uv calls stay isolated from ambient config via UV_NO_CONFIG
# (#21269). A locked project sync is different: uv.lock records resolver
# settings from this checkout's [tool.uv], so hiding pyproject.toml makes
# uv 0.12+ reject the valid lock. Re-enable project discovery only for
# this subprocess while redirecting user/system config lookups to an empty
# directory. Keep HOME unchanged so caches, credentials, and git continue
# to work normally.
local project_env="$1"
local isolated_uv_config
local sync_rc
isolated_uv_config="$(mktemp -d)" || return 1
(
unset UV_NO_CONFIG UV_CONFIG_FILE
export XDG_CONFIG_HOME="$isolated_uv_config"
export XDG_CONFIG_DIRS="$isolated_uv_config"
UV_PROJECT_ENVIRONMENT="$project_env" $UV_CMD sync --extra all --locked
)
sync_rc=$?
rmdir "$isolated_uv_config" 2>/dev/null || true
return "$sync_rc"
}
install_deps() {
log_info "Installing dependencies..."
@@ -1709,7 +1914,20 @@ install_deps() {
# This respects the curation in pyproject.toml.
# uv's own progress UI handles TTY detection and downgrades
# gracefully when stdout/stderr aren't terminals.
if UV_PROJECT_ENVIRONMENT="$INSTALL_DIR/venv" $UV_CMD sync --extra all --locked; then
#
# Run the Tier-0 locked sync via run_locked_uv_sync: the global
# UV_NO_CONFIG export at script start (the #21269 sudo -u hygiene
# guard) also hides the project's own [tool.uv] policy —
# exclude-newer, its package exemptions, and override-dependencies —
# from uv. The resolver then runs under a different policy than the
# one uv.lock was resolved under, and --locked turns that mismatch
# fatal, so every fresh install fell through to the non-hash-verified
# PyPI tiers. The helper re-enables project config discovery for this
# one subprocess while keeping ambient user/system uv config hidden
# (redirected to an empty XDG dir), preserving the #21269 guarantee.
# Runtime code does the same before its locked syncs
# (hermes_cli/managed_uv.py).
if run_locked_uv_sync "$INSTALL_DIR/venv"; then
log_success "Main package installed (hash-verified via uv.lock)"
log_success "All dependencies installed"
return 0
@@ -2089,7 +2307,7 @@ copy_config_templates() {
# here is self-healing, but keep them in sync to avoid a churn on first run.
if [ ! -f "$HERMES_HOME/SOUL.md" ]; then
cat > "$HERMES_HOME/SOUL.md" << 'SOUL_EOF'
You are Hermes Agent, an intelligent AI assistant created by Nous Research. You are helpful, knowledgeable, and direct. You assist users with a wide range of tasks including answering questions, writing and editing code, analyzing information, creative work, and executing actions via your tools. You communicate clearly, admit uncertainty when appropriate, and prioritize being genuinely useful over being verbose unless otherwise directed below. Be targeted and efficient in your exploration and investigations.
You are Hermes Agent, built by Nous Research. Be direct: match the length of your reply to the weight of the ask — a one-line question gets a one-line answer, and finished work gets a short report of what changed, what's verified, and what's left, never a replay of the process. No filler ("Great question," "I'd be happy to"), no restating the request back, no re-summarizing what you already said, no narrating tool calls the user can see. Plain claims over adjectives; when unsure, say so plainly. Agree because it's right, not because the user said it. Depth is earned — give it when the user asks for detail, teaches, or the stakes demand it, not by default.
SOUL_EOF
log_success "Created ~/.hermes/SOUL.md (edit to customize personality)"
fi
@@ -2401,6 +2619,36 @@ configure_browser_env_from_system_browser() {
log_success "Configured browser tools to use $browser_path"
}
# Select the npm workspaces a CLI install actually needs, into the
# NODE_DEPS_WORKSPACE_ARGS array.
#
# A bare `npm install` at the repo root resolves package.json's `apps/*`
# glob, which materializes apps/desktop — and with it node-pty, which ships
# no Linux prebuild and falls back to `node-gyp rebuild`. On a host without
# make/gcc that rebuild fails, and since #85297 made a failed npm install
# fatal it aborts the whole install of a machine that will never launch
# Electron or a PTY addon (#38311, #38772). Desktop dependencies are
# installed by install_desktop(), reachable only via --include-desktop.
#
# Naming ui-tui/web excludes the unnamed apps/* workspaces, and
# --include-workspace-root keeps the root's own devDependencies (the shared
# ESLint flat config each workspace imports) from being pruned by the scoped
# install — the same closure `hermes update` installs
# (hermes_cli/main.py::_update_node_dependencies). Prebuilt/partial checkouts
# can lack a workspace, and naming a missing one makes npm fail hard, so fall
# back to a root-only install that still skips apps/*.
node_deps_workspace_args() {
local install_dir="$1"
NODE_DEPS_WORKSPACE_ARGS=()
[ -f "$install_dir/ui-tui/package.json" ] && NODE_DEPS_WORKSPACE_ARGS+=(--workspace ui-tui)
[ -f "$install_dir/web/package.json" ] && NODE_DEPS_WORKSPACE_ARGS+=(--workspace web)
if [ "${#NODE_DEPS_WORKSPACE_ARGS[@]}" -eq 0 ]; then
NODE_DEPS_WORKSPACE_ARGS=(--workspaces=false)
return 0
fi
NODE_DEPS_WORKSPACE_ARGS+=(--include-workspace-root)
}
install_node_deps() {
if [ "$HAS_NODE" = false ]; then
log_info "Skipping Node.js dependencies (Node not installed)"
@@ -2423,9 +2671,12 @@ install_node_deps() {
# installed", hiding the degradation from the user (#77003). Now it
# fails the install outright instead of burying the warning (#85297).
# Capture npm output so failures are diagnosable (#87340).
# Scoped to the workspaces a CLI install needs so apps/desktop's
# node-pty is never built here — see node_deps_workspace_args().
node_deps_workspace_args "$INSTALL_DIR"
local npm_log
npm_log="$(mktemp)"
if ! run_with_timeout "$NODE_DEPS_TIMEOUT" npm install --silent \
if ! run_with_timeout "$NODE_DEPS_TIMEOUT" npm install "${NODE_DEPS_WORKSPACE_ARGS[@]}" --silent \
>"$npm_log" 2>&1; then
log_error "npm install failed or timed out; Node.js dependencies were not installed"
if [ -s "$npm_log" ]; then
@@ -3219,8 +3470,8 @@ install_desktop() {
# failure, not a silent skip — a silent skip yields a "complete" install
# with no app and a confusing "couldn't find a built desktop" at launch.
# Always re-resolve Node here. Stages run in separate processes, so we can't
# trust an earlier check; more importantly check_node now enforces the build
# floor (Node >=26) and prepends the Hermes-managed Node to PATH, so
# trust an earlier check; more importantly check_node now enforces the
# supported Node lines and prepends the Hermes-managed Node to PATH, so
# the build never runs on a too-old system Node — the cause of the opaque
# "Build desktop app … exit code 1" failure (Vite crashes on old Node).
check_node
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Probe-the-probe for launch-capture/sitecustomize.py, no real install needed.
# Control rows FIRST: without the opt-in env var, and for non-launch argv
# shapes, subprocess.run must behave untouched. Then treatment rows: both
# launch shapes must be captured without spawning.
set -euo pipefail
CAP_DIR="$(cd "$(dirname "$0")" && pwd)/../tests/install/e2e-assets/launch-capture"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
run_py() {
# $1: with_var (yes/no); rest: python -c payload
local with_var="$1"; shift
if [ "$with_var" = yes ]; then
PYTHONPATH="$CAP_DIR${PYTHONPATH:+:$PYTHONPATH}" \
HERMES_E2E_CAPTURE_LAUNCH="$WORK/spec.json" python3 "$@"
else
PYTHONPATH="$CAP_DIR${PYTHONPATH:+:$PYTHONPATH}" python3 "$@"
fi
}
fail() { echo "PROBE FAILED: $*" >&2; exit 1; }
echo "--- control 1: no env var -> run() untouched, real spawn happens"
out="$(run_py no -c 'import subprocess; print(subprocess.run(["echo","real-spawn"],capture_output=True,text=True).stdout.strip())')"
[ "$out" = "real-spawn" ] || fail "control 1: expected real-spawn, got '$out'"
[ ! -e "$WORK/spec.json" ] || fail "control 1: spec written without opt-in"
echo "OK"
echo "--- control 2: env var set, NON-launch argv (npm run pack) -> passthrough"
rm -f "$WORK"/spec.json*
out="$(run_py yes -c 'import subprocess; r=subprocess.run(["echo","npm-build-ran"],capture_output=True,text=True); print(r.stdout.strip())')"
[ "$out" = "npm-build-ran" ] || fail "control 2: echo did not run"
# and an actual npm-shaped BUILD argv (argv[0]=npm but no electron token):
run_py yes -c 'import subprocess,sys; r=subprocess.run(["npm","run","pack"],capture_output=True); sys.exit(0)' 2>/dev/null || true
[ ! -e "$WORK/spec.json" ] || fail "control 2: build argv was wrongly captured"
echo "OK"
echo "--- treatment 1: source shape (npm exec -- electron .) captured, not spawned"
rm -f "$WORK"/spec.json*
run_py yes -c '
import subprocess
r = subprocess.run(["npm", "exec", "--", "electron", "."], cwd="/tmp", env={"HERMES_DESKTOP_CWD": "/tmp", "PATH": "/usr/bin"})
assert r.returncode == 0, r
'
[ -e "$WORK/spec.json" ] || fail "treatment 1: no spec written"
[ "$(cat "$WORK/spec.json.captured")" = "source" ] || fail "treatment 1: wrong shape"
python3 - "$WORK/spec.json" <<'EOF'
import json, sys
spec = json.load(open(sys.argv[1]))
assert spec["argv"] == ["npm", "exec", "--", "electron", "."], spec["argv"]
assert spec["cwd"] == "/tmp", spec["cwd"]
assert spec["env"]["HERMES_DESKTOP_CWD"] == "/tmp", "env= kwarg not captured"
assert spec["matchedShape"] == "source"
print("spec contents OK")
EOF
echo "OK"
echo "--- treatment 2: packaged shape captured, not spawned"
rm -f "$WORK"/spec.json*
run_py yes -c '
import subprocess
exe = "/x/apps/desktop/release/linux-unpacked/Hermes"
r = subprocess.run([exe, "--no-sandbox"], cwd="/tmp", env={"PATH": "/usr/bin"})
assert r.returncode == 0, r # a real spawn of this path would ENOENT
'
[ "$(cat "$WORK/spec.json.captured")" = "packaged" ] || fail "treatment 2: wrong shape"
echo "OK"
echo "--- treatment 3: windows-style packaged argv matches too"
rm -f "$WORK"/spec.json*
run_py yes -c '
import subprocess
r = subprocess.run(["C:\\x\\apps\\desktop\\release\\win-unpacked\\Hermes.exe"], env={})
assert r.returncode == 0
'
[ "$(cat "$WORK/spec.json.captured")" = "packaged" ] || fail "treatment 3: wrong shape"
echo "OK"
echo "ALL PROBES PASSED"
+24
View File
@@ -163,8 +163,20 @@ _nb_ensure_bundled_npm_range() {
return 1
}
# A pre-release Node (…-alpha/-beta/-rc/-pre/-nightly) never counts as modern,
# however high its major. nodejs.org publishes a headers tarball only for final
# releases, so node-gyp cannot build node-pty — which has no Linux prebuild —
# against one. Mirrors node_satisfies_build() in install.sh.
_nb_node_is_prerelease() {
case "$(node --version 2>/dev/null)" in
*-*) return 0 ;;
*) return 1 ;;
esac
}
_nb_have_modern_node() {
command -v node >/dev/null 2>&1 || return 1
_nb_node_is_prerelease && return 1
[ "$(_nb_node_major)" -ge "$HERMES_NODE_MIN_VERSION" ]
}
@@ -295,6 +307,18 @@ _nb_install_bundled_node() {
return 1
fi
# Trust the binary, not the filename: a tarball named for a final release
# can still carry a pre-release build (latest-v26.x serves
# node-v26.8.0-<os>-<arch>.tar.xz stamped v26.8.0-alpha.0.0.0). Probe it
# before it replaces a working managed tree.
case "$("$extracted/bin/node" --version 2>/dev/null)" in
*-*)
_nb_warn "Node $("$extracted/bin/node" --version 2>/dev/null) is a pre-release build — native modules cannot be built against it"
rm -rf "$tmp"
return 1
;;
esac
mkdir -p "$HERMES_HOME"
rm -rf "$HERMES_HOME/node"
mv "$extracted" "$HERMES_HOME/node"
+201
View File
@@ -0,0 +1,201 @@
"""Cross-process falsification of the per-session fence.
The unit tests share one interpreter, so they cannot see the failure this whole
change exists to prevent: two SEPARATE gateway processes, each with its own
snapshot of a conversation, both writing to it. That is how the defect was found
and it is the only way to prove it is closed.
Run against the fork's own HERMES_HOME so nothing here touches a real profile:
python scripts/probe_active_session_exclusivity.py
It drives two real ``python -m tui_gateway.entry`` processes over stdio JSON-RPC
and asserts the sequence the reviewer specified:
A resume S, submit -> claims the session
B resume S, submit -> typed SESSION_NOT_OWNED, no row, no turn
A exits -> its lease is pruned as a dead owner
B submit again -> succeeds
No provider is required. The fence is checked BEFORE the agent is built, so a
submit that later fails for want of a model still proves who owns the session --
which is the property under test, and keeps the probe free of credentials and of
inference cost.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
PYTHON = REPO / "venv" / "Scripts" / "python.exe"
if not PYTHON.exists(): # posix layout
PYTHON = REPO / "venv" / "bin" / "python"
class Gateway:
"""One gateway process, spoken to the way the TUI speaks to it."""
def __init__(self, name: str, home: Path):
env = dict(os.environ)
env["HERMES_HOME"] = str(home)
env["PYTHONUNBUFFERED"] = "1"
self.name = name
self.proc = subprocess.Popen(
[str(PYTHON), "-u", "-m", "tui_gateway.entry"],
cwd=str(REPO),
env=env,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
)
self._next = 1
self.ready()
def _read(self):
line = self.proc.stdout.readline()
if not line:
raise RuntimeError(f"[{self.name}] gateway closed its pipe")
line = line.strip()
if not line:
return None
try:
return json.loads(line)
except json.JSONDecodeError:
return None
def ready(self, timeout: float = 180.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
msg = self._read()
if msg and msg.get("method") == "event":
if msg.get("params", {}).get("type") == "gateway.ready":
return
raise RuntimeError(f"[{self.name}] never announced gateway.ready")
def call(self, method: str, params: dict, timeout: float = 180.0) -> dict:
rid = str(self._next)
self._next += 1
self.proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": rid, "method": method, "params": params}) + "\n")
self.proc.stdin.flush()
deadline = time.time() + timeout
while time.time() < deadline:
msg = self._read()
if msg and msg.get("id") == rid:
return msg
raise RuntimeError(f"[{self.name}] timed out calling {method}")
def close(self):
try:
self.proc.stdin.close()
except Exception:
pass
try:
self.proc.terminate()
self.proc.wait(timeout=15)
except Exception:
try:
self.proc.kill()
except Exception:
pass
def reason_of(response: dict):
return (response.get("error") or {}).get("data", {}).get("reason")
def registry(home: Path):
path = home / "runtime" / "active_sessions.json"
try:
return json.loads(path.read_text(encoding="utf-8")).get("entries", [])
except Exception:
return []
def main() -> int:
home = REPO / ".probe-home"
# A fresh profile each run: a lease left by a previous run would make the
# first check pass or fail for the wrong reason.
import shutil
shutil.rmtree(home, ignore_errors=True)
failures = []
def check(label: str, ok: bool, detail: str = ""):
print(f" {'PASS' if ok else 'FAIL'} {label}{(' -- ' + detail) if detail else ''}")
if not ok:
failures.append(label)
a = Gateway("A", home)
b = None
try:
created = a.call("session.create", {"cols": 80})
sid_a = created["result"]["session_id"]
# Opening a chat must not claim anything -- an idle composer is invisible
# and a slot held by one would fence a real turn for no reason.
check("session.create claims nothing", registry(home) == [], f"{len(registry(home))} entries")
a.call("prompt.submit", {"session_id": sid_a, "text": "probe: A takes the session"})
held = registry(home)
check("A's first turn claims a session", len(held) == 1, json.dumps(held)[:200])
if not held:
raise RuntimeError("A never claimed anything; nothing further can be tested")
# The STORED key, which only materialises when a turn is first submitted --
# and which is what the lease must be keyed on. A lease keyed on the live
# runtime id would fence nothing: two processes resuming one conversation
# have different runtime ids by construction.
key = held[0].get("session_id")
print(f"A live session {sid_a}, stored key {key}")
check("the lease is keyed on the STORED session, not the runtime handle",
bool(key) and key != sid_a, f"key={key} runtime={sid_a}")
b = Gateway("B", home)
resumed = b.call("session.resume", {"session_id": key})
check("B may still RESUME (reading is never fenced)", "result" in resumed,
json.dumps(resumed.get("error", ""))[:160])
sid_b = resumed.get("result", {}).get("session_id")
before = len(registry(home))
refused = b.call("prompt.submit", {"session_id": sid_b, "text": "probe: B must not write"})
check("B's submit is refused", refused.get("error") is not None,
json.dumps(refused.get("result", ""))[:120])
check("refusal is typed SESSION_NOT_OWNED", reason_of(refused) == "SESSION_NOT_OWNED",
str(reason_of(refused)))
check("refusal left the registry untouched", len(registry(home)) == before)
# A dies without releasing -- the crash case, not a clean handoff.
a.proc.kill()
a.proc.wait(timeout=30)
time.sleep(1.0)
retried = b.call("prompt.submit", {"session_id": sid_b, "text": "probe: B may write now"})
check("after A dies, B's retry is accepted", retried.get("error") is None,
json.dumps(retried.get("error", ""))[:200])
held = registry(home)
check("and B now owns the session", len(held) == 1 and held[0].get("session_id") == key,
json.dumps(held)[:160])
finally:
if b is not None:
b.close()
a.close()
print()
if failures:
print(f"FAILED: {len(failures)} check(s): {', '.join(failures)}")
return 1
print("All cross-process checks passed.")
return 0
if __name__ == "__main__":
sys.exit(main())
+7
View File
@@ -254,6 +254,8 @@ LEGACY_AUTHOR_MAP = {
"dale@dalenguyen.me": "dalenguyen", # PR #53678 salvage (strip VIRTUAL_ENV/CONDA_PREFIX from terminal subprocess env; #23473)
"prashantjain25@gmail.com": "prashantjain25", # PR #80740 salvage (custom-endpoint /v1/models disk cache; #72762)
"liruixinch@outlook.com": "HexLab98", # PR #53863 salvage (env-only proxy policy for auxiliary OpenAI clients on macOS; #53702)
"fangliquan@qq.com": "fangliquanflq", # PR #99265 (linear lowercase env-assignment redaction; #99255) - merged directly by maintainer
"devops@sycamore.group": "sycamoregroupltd", # PR #97779 salvage (estop fleet-root sentinel for profile gateways)
"blaryx@gmail.com": "Blaryxoff", # PR #32602 salvage (deep-merge PUT /api/config to preserve unrelated sections; #13396)
"diamondeyesfox@gmail.com": "DiamondEyesFox", # PR #53351 salvage (rebaseline in-place compression flushes to prevent duplicate compacted rows; #9096)
"piyrw9754@gmail.com": "rlaope", # PR #35075 salvage (align cron invisible-unicode set with install-time scanner; #35075)
@@ -2204,6 +2206,11 @@ def update_version_files(semver: str, calver_date: str):
r'^version\s*=\s*"[^"]+"',
f'version = "{semver}"',
pyproject,
# Turn-end file-mutation verifier footer appended by run_agent.py
# (``_format_file_mutation_failure_footer``). It's a UI affordance — reading "warning file mutation
# verifier, 2 files were NOT modified..." aloud is noise (#40772). The footer is a ``⚠️
# File-mutation verifier:`` header line followed by indented ``•`` bullet lines; strip the whole
# block.
flags=re.MULTILINE,
)
PYPROJECT_FILE.write_text(pyproject, encoding="utf-8")
+445
View File
@@ -0,0 +1,445 @@
#!/usr/bin/env node
// @ts-check
/**
* Expand the install/update support matrix into concrete E2E combinations.
*
* One source of truth for every {os, install-method, update-method} pair a
* user could be on. This file only DECLARES and EXPANDS: it knows nothing
* about which combinations CI can drive. Every combination is dispatched to
* its OS's run workflow, and THAT workflow natively skips the method pairs
* its driver cannot run yet -- capability knowledge lives next to each
* driver (install-e2e-run.yml for linux, install-e2e-macos-run.yml,
* and install-e2e-windows-run.yml). Correctness here is enforced by the type
* unions below (checked via `tsc --checkJs`), not by runtime validation --
* anything the types can't catch is self-evident on the next CI run.
*
* Used by .github/workflows/install-e2e.yml, which runs it with the picked
* release tags (annotated at pick time with what each tag's tree ships):
*
* node scripts/sandbox/generate-e2e-matrix.mjs \
* --tags '[{"ref":"v2026.8.3","desktop":true}]'
*
* Prints JSON: { linux: {include:[...]}, windows: {include:[...]},
* macos: {include:[...]} } -- every entry is {name, install_method,
* update_method, install_ref, tag_has_desktop} (the tag annotation lets
* a run workflow natively skip desktop-surface legs from releases that
* predate the desktop app).
*/
import fs from 'node:fs';
import path from 'node:path';
import { parseArgs } from 'node:util';
import { fileURLToPath } from 'node:url';
/**
* The closed method/version vocabulary. Workflows key off these exact
* strings, so they are types, not conventions.
*
* @typedef {'latest'} InstallerVersion
* The artifact published on the website right now -- Hermes-Setup.exe has
* no versioned archive yet. Widen this union when one exists.
* @typedef {'installer-script' | 'installer-script+desktop' | 'desktop-installer' | 'packaged-app'} InstallMethod
* installer-script is the platform's one-liner (curl | bash on
* linux/macos, irm | iex on windows); installer-script+desktop is the
* same one-liner with its desktop stage opted in (--include-desktop /
* -IncludeDesktop), which also builds the desktop app -- on windows it
* registers Start Menu / Desktop shortcuts too, on linux/macos it
* builds into the checkout without registering an OS entry point;
* packaged-app is declared but not used by any OS spec yet.
* @typedef {InstallMethod | 'hermes-update' | 'open-app-update' | 'hermes-desktop-app-update'} UpdateMethod
* Every install method doubles as an update method (re-run it over the
* existing install), plus the updater CLI and the two app-update
* variants. The variants differ by launch surface: open-app-update
* starts the app from the OS entry point the install registered
* (Start Menu / Desktop shortcuts) -- today only the windows desktop
* installer's stage registers one (install.sh --include-desktop
* builds the app but registers nothing), so these legs pair with a
* desktop-installer install; hermes-desktop-app-update starts the app
* via `hermes desktop`, which every install method provides on every
* OS that ships the desktop app. Both then update through the app's
* own Update button.
* @typedef {'linux' | 'windows' | 'macos'} Os
*
* @typedef {{method: InstallMethod, versions?: InstallerVersion[]}} InstallEntry
* @typedef {{method: UpdateMethod, versions?: InstallerVersion[]}} UpdateEntry
* `versions` expands the entry into one combination per version
* ("desktop-installer@latest").
* @typedef {{install: InstallEntry[], update: UpdateEntry[], secondUpdate?: never[]}} OsSpec
* secondUpdate (install -> update -> update again) is a real future axis
* -- the updater that RESULTS from an update must itself update -- typed
* `never[]` so declaring one is a type error until a leg implements it.
*
* @typedef {{ref: string, desktop: boolean}} TagAnnotation
* A picked release tag plus what its own tree ships (annotated by
* pick-releases in install-e2e.yml).
*
* @typedef {{name: string, leg_id: string, install_method: string, update_method: string,
* install_ref: string, tag_has_desktop?: boolean}} MatrixEntry
*/
/**
* Artifact-safe key for one leg: the matrix name with every character
* outside [A-Za-z0-9._-] collapsed to '-'. Reconstructed by the results
* renderer from the parsed job name (same formula, same bytes), and used
* by every run workflow to name its logs/player artifacts, so the report
* job can map a concluded leg back to its artifacts without GitHub
* linking jobs to artifacts.
* @param {string} name
* @returns {string}
*/
export function legId(name) {
return name.replace(/[^A-Za-z0-9._-]+/g, '-');
}
/** @type {Record<Os, OsSpec>} */
export const SPEC = {
windows: {
install: [
// irm https://hermes.nousresearch.com/install.ps1 | iex
{ method: 'installer-script' },
// The same one-liner with -IncludeDesktop: builds Hermes.exe AND
// registers Start Menu / Desktop shortcuts, so it is a second real
// path to a hand-launchable app.
{ method: 'installer-script+desktop' },
// Website Hermes-Setup.exe, clicked through the GUI.
{ method: 'desktop-installer', versions: ['latest'] },
],
update: [
{ method: 'installer-script' },
{ method: 'installer-script+desktop' },
// Run the bootstrap exe again over an existing install (--update flow).
{ method: 'desktop-installer', versions: ['latest'] },
{ method: 'hermes-update' },
// Settings -> About -> "Update now", app launched from the installed
// exe (the entry point the desktop installer created).
{ method: 'open-app-update' },
// Same button, app launched via `hermes desktop`.
{ method: 'hermes-desktop-app-update' },
],
},
macos: {
install: [
{ method: 'installer-script' },
{ method: 'installer-script+desktop' },
// The published Hermes-Setup.dmg from the website, mounted and run.
{ method: 'desktop-installer', versions: ['latest'] },
],
update: [
{ method: 'installer-script' },
{ method: 'installer-script+desktop' },
{ method: 'hermes-update' },
// install.sh --include-desktop builds the .app inside the checkout
// but registers no OS entry point, so open-app-update legs pair
// with a desktop-installer install (the published dmg).
{ method: 'open-app-update' },
{ method: 'hermes-desktop-app-update' },
],
},
linux: {
install: [
{ method: 'installer-script' },
{ method: 'installer-script+desktop' },
],
update: [
{ method: 'installer-script' },
{ method: 'installer-script+desktop' },
{ method: 'hermes-update' },
// No desktop installer and no packaged desktop artifact exist for
// linux, so there is no open-app-update; `hermes desktop` is always
// the source-mode path (build apps/desktop from the checkout, launch
// electron) and is the one app surface a linux install has.
{ method: 'hermes-desktop-app-update' },
],
},
};
/**
* Expand one method entry into concrete ids ("desktop-installer@latest").
* @param {InstallEntry | UpdateEntry} entry
* @returns {string[]}
*/
export function expandMethod(entry) {
if (!entry.versions) return [entry.method];
return entry.versions.map((v) => `${entry.method}@${v}`);
}
/**
* Every {os, install, update} combination in SPEC.
* @param {Record<Os, OsSpec>} spec
* @returns {{os: Os, install: string, update: string}[]}
*/
export function generateEnvironments(spec) {
/** @type {{os: Os, install: string, update: string}[]} */
const envs = [];
for (const [os, osSpec] of /** @type {[Os, OsSpec][]} */ (Object.entries(spec))) {
for (const install of osSpec.install.flatMap(expandMethod)) {
for (const update of osSpec.update.flatMap(expandMethod)) {
envs.push({ os, install, update });
}
}
}
return envs;
}
/**
* Split the combinations into one matrix per OS.
*
* `tags` (the released versions we test updating FROM) is the OUTER axis:
* for each tag, for each combination, one dispatch that installs the tag
* and updates to HEAD. No capability filtering happens here -- every
* declared combination is dispatched, and the OS's run workflow natively
* skips what its driver cannot run yet. Entry names carry everything (os,
* method pair, tag transition) because slash-joined leg names are all the
* graph renders.
*
* @param {{os: Os, install: string, update: string}[]} envs
* @param {TagAnnotation[]} tags
* @returns {Record<Os, {include: MatrixEntry[]}>}
*/
export function buildMatrices(envs, tags) {
/** @type {Record<Os, {include: MatrixEntry[]}>} */
const byOs = { linux: { include: [] }, windows: { include: [] }, macos: { include: [] } };
for (const env of envs) {
for (const tag of tags) {
/** @type {MatrixEntry} */
const entry = {
name: `${env.os}: ${env.install} -> ${env.update} (${tag.ref} -> HEAD)`,
leg_id: legId(`${env.os}: ${env.install} -> ${env.update} (${tag.ref} -> HEAD)`),
install_method: env.install,
update_method: env.update,
install_ref: tag.ref,
// Every OS declares desktop-surface methods (both app-update
// variants at minimum), so every leg carries the annotation and
// its run workflow can natively skip pre-desktop tags.
tag_has_desktop: tag.desktop,
};
byOs[env.os].include.push(entry);
}
}
return byOs;
}
/**
* Does this method id need the starting tag to ship the desktop app?
* Shared by the plan chart (pre-desktop cells) and the results chart
* (labeling WHY a skipped leg skipped).
* @param {string} m
* @returns {boolean}
*/
export function methodNeedsDesktop(m) {
return m.startsWith('desktop-installer') || m === 'installer-script+desktop' ||
m === 'open-app-update' || m === 'hermes-desktop-app-update';
}
/**
* Render the plan as a markdown cross-table for $GITHUB_STEP_SUMMARY:
* one row per {os, install -> update} combination, one column per
* starting tag. Every cell is dispatched; whether it RUNS or greys out
* is the run workflow's call (capability lives there, not here), so the
* chart only distinguishes the one thing the plan itself knows:
* desktop-surface legs from tags that predate the desktop app.
*
* @param {{os: Os, install: string, update: string}[]} envs
* @param {TagAnnotation[]} tags
* @returns {string}
*/
export function renderMarkdownPlan(envs, tags) {
const lines = [
'### Install & Update E2E plan',
'',
`${envs.length} combinations x ${tags.length} starting tags = ${envs.length * tags.length} legs`,
'',
`| combination | ${tags.map((t) => t.ref).join(' | ')} |`,
`|---|${tags.map(() => '---').join('|')}|`,
];
for (const env of envs) {
const cells = tags.map((tag) => {
if (
!tag.desktop &&
(methodNeedsDesktop(env.install) || methodNeedsDesktop(env.update))
) {
return 'pre-desktop';
}
return '⏳ ';
});
lines.push(`| \`${env.os}: ${env.install} -> ${env.update}\` | ${cells.join(' | ')} |`);
}
return lines.join('\n');
}
/**
* Render the run's OUTCOME as the same cross-table, from the run's own job
* list (GitHub Actions API): one row per combination, one column per tag,
* each cell the leg's conclusion. Input is NDJSON {name, conclusion} lines
* -- what `gh api --paginate --jq '.jobs[] | {name, conclusion}'` emits --
* and legs are recognized by the exact name shape buildMatrices mints
* ("os: install -> update (tag -> HEAD) / ..."), so unrelated jobs
* (pick-releases, the report job itself) fall out naturally.
*
* @param {{name: string, conclusion: string | null}[]} jobs
* @param {TagAnnotation[]} [tagAnnotations] When given (the same --tags the
* plan got), skipped cells carry their REASON: `pre-desktop` when a
* desktop-surface method meets a tag that predates apps/desktop, `TODO`
* when the pair is declared but no driver arm runs it yet.
* @param {Map<string, number>} [artifactById] Artifact name -> id for this
* run (the report job's --artifacts). Legs that RAN (success or failure)
* get TWO links: 📼 to the run's single player artifact (playback.html,
* archive:false names artifacts after the FILE) with the leg's logs zip
* as a #zip= HASH param — the artifact URL 307s server-side and strips
* the query, the hash survives — and ⬇️ to the raw logs zip. Logs
* artifacts are `install-e2e-logs-<leg_id>` (windows) or
* `install-e2e-logs-<leg_id>-<sha>` (posix arms append the sha at
* upload) — matched by prefix. leg_id is rebuilt from the parsed job
* name with the same formula the generator mints (legId).
* @returns {string}
*/
export function renderMarkdownResults(jobs, tagAnnotations = [], artifactById = new Map()) {
const knownRules = JSON.parse(fs.readFileSync(new URL('../../tests/install/e2e-assets/known-failures.json', import.meta.url), 'utf8'));
/** @type {Map<string, {number: number, rule: any}>} */
const footnotes = new Map();
const LEG = /^(linux|windows|macos): (\S+) -> (\S+) \(([^)]+) -> HEAD\) \//;
/** @type {Map<string, boolean>} */
const desktopByTag = new Map(tagAnnotations.map((t) => [t.ref, t.desktop]));
/**
* @param {string} install @param {string} update @param {string} tag
* @returns {string}
*/
const skipLabel = (install, update, tag) => {
if (desktopByTag.size === 0) return 'skip';
if (
desktopByTag.get(tag) === false &&
(methodNeedsDesktop(install) || methodNeedsDesktop(update))
) {
return 'pre-desktop';
}
return 'TODO';
};
// A combination can surface as SEVERAL jobs with the same leg name (a
// run workflow may have one inner job per driver arm; exactly one runs
// and the others natively skip), so cells merge by significance: a real
// outcome always beats a skip, and a bad outcome beats a good one.
const RANK = ['skip', 'TODO', 'pre-desktop', '&#x2705;', 'known', 'running', 'cancelled', '&#x274C;'];
const SKIPS = ['skip', 'TODO', 'pre-desktop'];
// Rendered success/failure cells carry artifact links after the glyph;
// rank by the leading token or every such cell would rank as unknown (-1)
// and lose to any skip already in the map.
/** @param {string} cell */
const rankOf = (cell) => RANK.findIndex((t) => cell === t || cell.startsWith(`${t} `));
/** @type {Map<string, Map<string, string>>} */
const rows = new Map();
/** @type {string[]} */
const tags = [];
for (const job of jobs) {
const m = job.name.match(LEG);
if (!m) continue;
const combo = `${m[1]}: ${m[2]} -> ${m[3]}`;
const tag = m[4];
if (!tags.includes(tag)) tags.push(tag);
if (!rows.has(combo)) rows.set(combo, new Map());
const cell = (() => {
const legId2 = legId(`${m[1]}: ${m[2]} -> ${m[3]} (${m[4]} -> HEAD)`);
// Logs artifacts: `install-e2e-logs-<leg_id>` (windows) or with a
// trailing `-<sha>` (posix arms append it at upload). Match by prefix.
const logsName = [...artifactById.keys()].find((n) => n.startsWith(`install-e2e-logs-${legId2}`));
// Player artifacts: the single per-run leg-player upload; archive:
// false names it after the FILE (playback.html), ignoring `name:`.
const playerName = [...artifactById.keys()].find((n) => n === 'playback.html');
const playerId = playerName !== undefined ? artifactById.get(playerName) : undefined;
const logsId = logsName !== undefined ? artifactById.get(logsName) : undefined;
// Two links per ran leg: the player with the zip as a HASH param
// (GitHub's artifact URL 307s to /suites/... and strips the query —
// the hash survives client-side), and the raw zip download.
const runBase = `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
const reel = (playerId !== undefined && logsId !== undefined)
? ` [📼](${runBase}/artifacts/${playerId}#zip=${encodeURIComponent(`${runBase}/artifacts/${logsId}`)}) [⬇️](${runBase}/artifacts/${logsId})`
: '';
switch (job.conclusion) {
case 'success': {
// Only the classifier's uploaded receipt turns a successful job into
// a known-failure cell. Tag membership alone never suppresses a red.
const rule = knownRules.find((/** @type {any} */ r) => artifactById.has(`install-e2e-known-${r.id}--${legId2}`));
if (!rule) return `&#x2705;${reel}`;
if (!footnotes.has(rule.id)) footnotes.set(rule.id, { number: footnotes.size + 1, rule });
return `known [^${footnotes.get(rule.id)?.number}]${reel}`;
}
case 'failure': return `&#x274C;${reel}`;
case 'skipped': return skipLabel(m[2], m[3], tag);
case 'cancelled': return 'cancelled';
default: return 'running';
}
})();
const byTag = /** @type {Map<string, string>} */ (rows.get(combo));
const prev = byTag.get(tag);
if (prev === undefined || rankOf(cell) > rankOf(prev)) {
byTag.set(tag, cell);
}
}
if (rows.size === 0) return '### Install & Update E2E results\n\n(no legs found in this run)\n';
const cells = [...rows.values()].flatMap((r) => [...r.values()]);
const passed = cells.filter((c) => c.startsWith('&#x2705;')).length;
const failed = cells.filter((c) => c.startsWith('&#x274C;')).length;
const skipped = cells.filter((c) => SKIPS.includes(c)).length;
const known = cells.filter((c) => c.startsWith('known ')).length;
const lines = [
'### Install & Update E2E results',
'',
`${passed} passed, ${failed} failed, ${known} known failures, ${skipped} skipped (TODO = declared, no driver arm yet; pre-desktop = the starting release predates apps/desktop), ${cells.length} legs total`,
'',
`| combination | ${tags.join(' | ')} |`,
`|---|${tags.map(() => '---').join('|')}|`,
];
for (const [combo, byTag] of rows) {
lines.push(`| \`${combo}\` | ${tags.map((t) => byTag.get(t) || '-').join(' | ')} |`);
}
lines.push('');
for (const { number, rule } of footnotes.values()) {
lines.push(`[^${number}]: **${rule.title}.** ${rule.explanation} [Evidence](${rule.evidence}).`);
}
return lines.join('\n');
}
/** @returns {Promise<string>} all of stdin */
function readStdin() {
return new Promise((resolve) => {
let data = '';
process.stdin.on('data', (c) => { data += c; });
process.stdin.on('end', () => resolve(data));
});
}
async function main() {
const { values } = parseArgs({
options: {
tags: { type: 'string', default: '[]' },
format: { type: 'string', default: 'json' },
artifacts: { type: 'string' },
},
});
if (values.format === 'results') {
const jobs = (await readStdin()).split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
const annotations = /** @type {TagAnnotation[]} */ (JSON.parse(values.tags));
/** @type {Map<string, number>} */
const artifactById = new Map();
if (values.artifacts) {
for (const line of (await fs.promises.readFile(values.artifacts, 'utf8')).split('\n')) {
if (!line.trim()) continue;
const a = JSON.parse(line);
if (typeof a.name === 'string' && typeof a.id === 'number') artifactById.set(a.name, a.id);
}
}
process.stdout.write(renderMarkdownResults(jobs, annotations, artifactById));
return;
}
const tags = /** @type {TagAnnotation[]} */ (JSON.parse(values.tags));
const envs = generateEnvironments(SPEC);
if (values.format === 'markdown') {
process.stdout.write(renderMarkdownPlan(envs, tags));
return;
}
const matrices = buildMatrices(envs, tags);
process.stdout.write(`${JSON.stringify(matrices, null, 2)}\n`);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
await main();
}
-43
View File
@@ -1,43 +0,0 @@
# Minimal openssl config for the dev sandbox.
#
# The sandbox replaces /etc wholesale, and on Debian/Ubuntu
# /usr/lib/ssl/openssl.cnf (openssl's compiled-in OPENSSLDIR) is a symlink into
# /etc/ssl -- so the config openssl insists on reading disappears and every
# `openssl req` fails with:
#
# Can't open "/usr/lib/ssl/openssl.cnf" for reading
#
# which surfaces to the payload as a bare `curl: (35) Recv failure`. Rather than
# reconstruct each distro's /etc/ssl, point OPENSSL_CONF at this file: the proxy
# only needs enough config for `req -addext` and `x509 -copy_extensions`.
[ req ]
distinguished_name = req_distinguished_name
[ req_distinguished_name ]
# Used by `req -x509` for the sandbox's own CA. Without an explicit
# basicConstraints the generated certificate is not a CA, and every leaf it
# signs is rejected by the client with "invalid CA certificate (79)".
[ sandbox_ca_ext ]
basicConstraints = critical,CA:true
keyUsage = critical,keyCertSign,cRLSign
subjectKeyIdentifier = hash
[ ca ]
default_ca = sandbox_ca
[ sandbox_ca ]
default_md = sha256
policy = policy_anything
email_in_dn = no
preserve = no
[ policy_anything ]
commonName = optional
countryName = optional
stateOrProvinceName = optional
localityName = optional
organizationName = optional
organizationalUnitName = optional
emailAddress = optional
-237
View File
@@ -1,237 +0,0 @@
"""MITM proxy backing the dev sandbox's fake Internet.
Listens on 127.0.0.1:8080 and is pointed at by http_proxy/https_proxy inside
the sandbox. For each request it either serves a fixture from the filesystem or
forwards to the real host:
* ``<root>/<host>/<path>`` exists -> serve it. This is how the sandbox answers
the canonical install URL with the installer under test, so the payload can
run the true ``curl -fsSL https://…/install.sh | bash`` one-liner.
* otherwise -> forward upstream, verifying against the real CA bundle. The
sandbox is isolated from the *host*, not from the internet: a real install
still has to reach PyPI and npm.
HTTPS is intercepted by minting a per-host certificate from the sandbox's own
throwaway CA, which the payload trusts via CURL_CA_BUNDLE / SSL_CERT_FILE.
Usage: proxy.py <fixture-root> <certs-dir> <real-ca-bundle>
"""
import os
import pathlib
import socket
import ssl
import subprocess
import sys
import threading
from urllib.parse import unquote, urlsplit
ROOT, CERTS, REAL_CA = map(pathlib.Path, sys.argv[1:])
LISTEN_ADDRESS = ('127.0.0.1', 8080)
MAX_REQUEST_BYTES = 65536
UPSTREAM_TIMEOUT_SECONDS = 30
CERT_VALIDITY_DAYS = 2
def read_request(conn):
data = b""
while b"\r\n\r\n" not in data and len(data) < MAX_REQUEST_BYTES:
part = conn.recv(4096)
if not part:
return b""
data += part
return data
def run_openssl(args):
"""Run openssl, raising with its stderr when it fails.
Discarding stderr here costs real debugging time: the caller sees only a
dropped connection (``curl: (35) Recv failure``) and the log holds nothing
but the argv, so an unwritable directory, a missing CA key, and an option
the host's openssl rejects all look identical.
"""
done = subprocess.run(
['openssl', *args], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE
)
if done.returncode != 0:
detail = done.stderr.decode('utf-8', 'replace').strip()
raise RuntimeError(
f'openssl {args[0]} failed (exit {done.returncode}): {detail}'
)
_CERT_LOCK = threading.Lock()
def cert_for(host):
"""Return a (cert, key) pair for host, minting it from the sandbox CA.
Minting is serialized and published atomically. The proxy is threaded, so
two concurrent requests for the same host would otherwise both run openssl
into the same paths, and a reader could pick up a finished certificate
beside a key from the other writer -- which TLS rejects as
``[X509: KEY_VALUES_MISMATCH] key values mismatch``.
"""
safe = ''.join(char if char.isalnum() or char in '.-' else '_' for char in host)
cert, key = CERTS / f'{safe}.pem', CERTS / f'{safe}.key'
if cert.exists() and key.exists():
return cert, key
with _CERT_LOCK:
# Re-check: another thread may have finished while we waited.
if cert.exists() and key.exists():
return cert, key
# Build under unique temp names, then rename into place. os.replace is
# atomic, so a reader sees either the old pair or the new one, never a
# half-written mix. The key lands first: the certificate's existence is
# what everything else keys off.
stamp = f'{os.getpid()}.{threading.get_ident()}'
tmp_key = CERTS / f'{safe}.key.{stamp}'
tmp_cert = CERTS / f'{safe}.pem.{stamp}'
csr = CERTS / f'{safe}.csr.{stamp}'
run_openssl([
'req', '-newkey', 'rsa:2048', '-nodes',
'-subj', f'/CN={host}',
'-addext', f'subjectAltName=DNS:{host}',
'-keyout', str(tmp_key), '-out', str(csr),
])
run_openssl([
'x509', '-req', '-days', str(CERT_VALIDITY_DAYS), '-in', str(csr),
'-CA', str(CERTS / 'ca.pem'), '-CAkey', str(CERTS / 'ca.key'),
'-CAcreateserial', '-copy_extensions', 'copy', '-out', str(tmp_cert),
])
csr.unlink(missing_ok=True)
os.replace(tmp_key, key)
os.replace(tmp_cert, cert)
return cert, key
def file_for(host, target):
"""Resolve a request to a fixture file, or None to forward upstream."""
path = urlsplit(target).path or '/'
parts = pathlib.PurePosixPath(unquote(path)).parts
if '..' in parts:
return None
candidate = ROOT / host / pathlib.PurePosixPath(*[p for p in parts if p != '/'])
if candidate.is_dir():
candidate /= 'index.html'
return candidate if candidate.is_file() else None
def respond_fixture(conn, found):
body = found.read_bytes()
headers = (
f'Content-Length: {len(body)}\r\nConnection: close\r\n\r\n'.encode()
)
conn.sendall(b'HTTP/1.1 200 OK\r\n' + headers + body)
def close_request(request, target=None):
"""Rewrite a proxied request for a direct upstream connection."""
headers, separator, body = request.partition(b'\r\n\r\n')
lines = headers.split(b'\r\n')
if target is not None:
method, _, version = lines[0].split(b' ', 2)
lines[0] = b' '.join((method, target.encode(), version))
lines = [
line for line in lines
if not line.lower().startswith(b'proxy-connection:')
]
lines.append(b'Connection: close')
return b'\r\n'.join(lines) + separator + body
def relay(source, destination):
while True:
chunk = source.recv(MAX_REQUEST_BYTES)
if not chunk:
return
destination.sendall(chunk)
def forward_https(conn, host, port, request):
context = ssl.create_default_context(cafile=str(REAL_CA))
with socket.create_connection((host, port), timeout=UPSTREAM_TIMEOUT_SECONDS) as raw:
with context.wrap_socket(raw, server_hostname=host) as upstream:
upstream.sendall(close_request(request))
relay(upstream, conn)
def forward_http(conn, host, port, request, target):
parsed = urlsplit(target)
path = parsed.path or '/'
if parsed.query:
path += f'?{parsed.query}'
with socket.create_connection((host, port), timeout=UPSTREAM_TIMEOUT_SECONDS) as upstream:
upstream.sendall(close_request(request, path))
relay(upstream, conn)
def handle_connect(conn, target):
"""Intercept a CONNECT tunnel, terminating TLS with a minted cert."""
host, _, port_text = target.rpartition(':')
port = int(port_text or '443')
conn.sendall(b'HTTP/1.1 200 Connection Established\r\n\r\n')
cert, key = cert_for(host)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(cert, key)
with context.wrap_socket(conn, server_side=True) as tls:
nested = read_request(tls)
if not nested:
return
line = nested.split(b'\r\n', 1)[0].decode('iso-8859-1')
nested_target = line.split(' ', 2)[1]
found = file_for(host, nested_target)
if found is not None:
respond_fixture(tls, found)
else:
forward_https(tls, host, port, nested)
def host_from_headers(request):
for header in request.split(b'\r\n')[1:]:
if header.lower().startswith(b'host:'):
value = header.split(b':', 1)[1].strip().decode()
return value.split(':', 1)[0]
return None
def handle_request(conn):
with conn:
request = read_request(conn)
if not request:
return
line = request.split(b'\r\n', 1)[0].decode('iso-8859-1')
method, target, _ = line.split(' ', 2)
if method.upper() == 'CONNECT':
handle_connect(conn, target)
return
parsed = urlsplit(target)
host = parsed.hostname or host_from_headers(request) or 'unknown'
found = file_for(host, target)
if found is not None:
respond_fixture(conn, found)
else:
forward_http(conn, host, parsed.port or 80, request, target)
def handle(conn):
try:
handle_request(conn)
except Exception as error:
print(f'proxy request failed: {error!r}', file=sys.stderr, flush=True)
def main():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(LISTEN_ADDRESS)
server.listen()
while True:
conn, _ = server.accept()
threading.Thread(target=handle, args=(conn,), daemon=True).start()
if __name__ == '__main__':
main()
-13
View File
@@ -1,13 +0,0 @@
#!/usr/bin/env bash
# Stand-in for ssh inside the dev sandbox.
#
# install.sh and `hermes update` clone over ssh first (git@github.com:...), so
# the sandbox needs an `ssh` that answers. Rather than run a real sshd, this
# ignores the host, user, and command git asked for and speaks the
# upload-pack protocol directly against the sandbox's bare repo -- which is
# what makes the ssh-first code path exercisable with no keys, no known_hosts,
# and no network.
#
# GIT_UPLOAD_PACK is substituted by dev-sandbox.sh when it installs this shim,
# because the host's git-upload-pack is not necessarily on the sandbox PATH.
exec @GIT_UPLOAD_PACK@ /work/repos/hermes-agent.git
-251
View File
@@ -1,251 +0,0 @@
#!/usr/bin/env bash
# Stage 2 of the dev sandbox: build the mounts and run the payload.
#
# Not called directly. scripts/dev-sandbox.sh (stage 1) creates the user and
# network namespaces with `unshare` and re-execs into this script inside them,
# so by the time this runs we are already at the target uid with a private
# netns. bwrap therefore does NOT create a userns here -- it only adds the
# mount and pid namespaces. (`unshare --user` grants its creator full
# capabilities in the new userns regardless of which uid it maps, which is what
# lets bwrap mount as a non-root uid.)
#
# The whole interface with stage 1 is the DEV_SANDBOX_* environment, asserted
# below: there are no shared functions or variables between the two stages.
# Stage 1 locates this script alongside the other sandbox assets (see
# DEV_SANDBOX_ASSETS in dev-sandbox.sh), so the Nix wrapper's store copy and a
# plain repo checkout both work.
set -euo pipefail
: "${DEV_SANDBOX_ROOT:?missing DEV_SANDBOX_ROOT}"
: "${DEV_SANDBOX_BASH:?missing DEV_SANDBOX_BASH}"
: "${DEV_SANDBOX_INTERACTIVE:?missing DEV_SANDBOX_INTERACTIVE}"
: "${DEV_SANDBOX_USER:?missing DEV_SANDBOX_USER}"
: "${DEV_SANDBOX_HOME:?missing DEV_SANDBOX_HOME}"
# Announce our pid so stage 1 can point slirp4netns at these namespaces,
# then hold until it reports the network is up.
slirp_ready="$DEV_SANDBOX_ROOT/root/logs/slirp.ready"
printf '%s\n' "$$" > "$DEV_SANDBOX_ROOT/root/logs/sandbox.pid"
for _ in $(seq 1 200); do
[ -s "$slirp_ready" ] && break
sleep 0.05
done
if [ ! -s "$slirp_ready" ]; then
echo 'error: timed out waiting for sandbox network setup' >&2
cat "$DEV_SANDBOX_ROOT/root/logs/slirp.log" >&2 || true
exit 1
fi
# The sandbox HOME is /root for a root install and /home/<user> for a
# user-level one. Only the latter needs its parent created first; --dir /
# is not a thing bwrap accepts.
home_mounts=()
home_parent="$(dirname "$DEV_SANDBOX_HOME")"
if [ "$home_parent" != / ]; then
home_mounts+=(--dir "$home_parent")
fi
home_mounts+=(--bind "$DEV_SANDBOX_ROOT/home" "$DEV_SANDBOX_HOME")
node_env=()
if [ -n "${DEV_SANDBOX_NODE_DIR:-}" ]; then
node_env+=(--setenv npm_config_nodedir "$DEV_SANDBOX_NODE_DIR")
fi
electron_env=()
if [ -n "${DEV_SANDBOX_ELECTRON_LD_LIBRARY_PATH:-}" ]; then
electron_env+=(
--setenv LD_LIBRARY_PATH "$DEV_SANDBOX_ELECTRON_LD_LIBRARY_PATH"
--setenv HERMES_DESKTOP_DISABLE_GPU 1
)
fi
gui_mounts=()
if [ -n "${DEV_SANDBOX_WAYLAND_SOCKET:-}" ]; then
runtime_dir="${DEV_SANDBOX_XDG_RUNTIME_DIR:?missing DEV_SANDBOX_XDG_RUNTIME_DIR}"
runtime_parent="$(dirname "$runtime_dir")"
runtime_grandparent="$(dirname "$runtime_parent")"
gui_mounts+=(
--dir "$runtime_grandparent"
--dir "$runtime_parent"
--dir "$runtime_dir"
--bind "$DEV_SANDBOX_WAYLAND_SOCKET" "$DEV_SANDBOX_WAYLAND_SOCKET"
--setenv XDG_RUNTIME_DIR "$runtime_dir"
--setenv WAYLAND_DISPLAY "${DEV_SANDBOX_WAYLAND_DISPLAY:?missing DEV_SANDBOX_WAYLAND_DISPLAY}"
)
fi
# How the sandbox gets a usable runtime, and where its own shims go.
#
# On Nix, every binary lives under /nix/store, so the sandbox can own /bin,
# /lib64 and /usr/bin outright and fill them with symlinks into the store.
#
# Elsewhere the runtime IS /usr, /bin, /lib, /lib64 -- so binding the
# sandbox's near-empty versions over them hides the real thing, and bwrap
# dies with `execvp /usr/bin/bash: No such file or directory`. Keep the host
# directories read-only and override only the individual files we shim.
#
# The same answer decides how /etc is handled further down.
if [ -d /nix ] && [[ "$(readlink -f "$DEV_SANDBOX_BASH")" == /nix/* ]]; then
USE_HOST_RUNTIME=false
else
USE_HOST_RUNTIME=true
fi
runtime_mounts=()
shim_mounts=()
if [ "$USE_HOST_RUNTIME" = false ]; then
runtime_mounts+=(--ro-bind /nix /nix)
shim_mounts+=(
--dir /usr
--dir /bin
--dir /lib64
--bind "$DEV_SANDBOX_ROOT/root/bin" /bin
--bind "$DEV_SANDBOX_ROOT/root/lib64" /lib64
--bind "$DEV_SANDBOX_ROOT/root/usr/bin" /usr/bin
)
else
for path in /usr /bin /sbin /lib /lib64; do
[ -e "$path" ] && runtime_mounts+=(--ro-bind "$path" "$path")
done
# The git-upload-pack shim standing in for github.com is the only file that
# must beat the host's copy; sh/ls/env are already there for real.
shim_mounts+=(--bind "$DEV_SANDBOX_ROOT/root/usr/bin/ssh" /usr/bin/ssh)
fi
# /etc: start from a copy of the host's and overwrite only the files we fake.
#
# Replacing the whole directory with a five-file one is the tempting shortcut
# and it is wrong: a distro puts things under /etc that binaries outside /etc
# depend on, so hiding all of it breaks tools that look fine on PATH. Two real
# examples, both Debian/Ubuntu: openssl's compiled-in openssl.cnf is a symlink
# into /etc/ssl, and /usr/bin/awk is a symlink to /etc/alternatives/awk -- with
# /etc replaced, openssl cannot mint a certificate and awk reports "not found".
# Those are two symptoms of one cause, and nothing says there are only two.
#
# Copying rather than mount-overlaying the individual files, because several of
# these are symlinks in the wild (resolv.conf -> ../run/systemd/... on Ubuntu,
# hosts and nsswitch.conf -> /etc/static/... on NixOS) and bwrap cannot bind a
# file onto a symlink whose target does not exist inside the sandbox.
#
# Symlinks are copied as symlinks, never dereferenced: on NixOS /etc/static
# points into the store and following it would copy gigabytes per sandbox. The
# store is already mounted at /nix on that path, and the host runtime dirs are
# mounted at their own paths, so absolute symlinks still resolve.
#
# The five we override, and why each must differ from the host's:
# passwd, group the sandbox identity, which does not exist on the host
# resolv.conf slirp4netns's DNS, not the host resolver
# nsswitch.conf files+dns only, so nothing consults host NSS modules
# hosts minimal, so no host entry leaks in
#
# os-release is removed rather than replaced. Installers branch on it to reach
# for a package manager -- `install.sh` reads ID from it and, on debian/ubuntu,
# offers to apt-get build tools, prompting on /dev/tty when sudo exists but is
# not passwordless. That prompt cannot be satisfied here (no terminal) and it is
# fatal under `set -e`. Inheriting the host's file would make the sandbox claim
# to be a distro whose package manager it cannot actually use; absent means
# DISTRO="unknown" and the apt path is skipped, which is the truth.
etc_mounts=()
if [ "$USE_HOST_RUNTIME" = true ] && [ -d /etc ]; then
sandbox_etc="$DEV_SANDBOX_ROOT/etc-merged"
rm -rf -- "$sandbox_etc"
mkdir -p "$sandbox_etc"
# -a keeps symlinks as symlinks; unreadable entries (shadow, sudoers) are
# skipped rather than failing the run.
cp -a /etc/. "$sandbox_etc/" 2>/dev/null || true
for etc_file in passwd group resolv.conf nsswitch.conf hosts; do
[ -f "$DEV_SANDBOX_ROOT/etc/$etc_file" ] || continue
rm -f "$sandbox_etc/$etc_file"
cp "$DEV_SANDBOX_ROOT/etc/$etc_file" "$sandbox_etc/$etc_file"
done
rm -f "$sandbox_etc/os-release" "$sandbox_etc/lsb-release"
etc_mounts+=(--ro-bind "$sandbox_etc" /etc)
else
etc_mounts+=(--bind "$DEV_SANDBOX_ROOT/etc" /etc)
fi
# /dev without a tty, so a script guarding on `[ -e /dev/tty ]` takes its
# no-terminal path.
#
# bwrap's --dev creates a /dev/tty NODE, but nothing in here has a controlling
# terminal, so opening it fails with "No such device or address". That is the
# worst of both: the guard passes and the read then fails. Under `set -e` --
# which install.sh uses -- a failed read inside a function aborts the whole
# installer, which is exactly how older releases died here while prompting for
# sudo to install ripgrep/ffmpeg.
#
# Making the tty real is not the fix: with an openable terminal that prompt
# blocks forever waiting for input nobody will type. Absent is what a headless
# machine looks like, and what every prompt in here should assume.
#
# --dev cannot be used with the node removed afterwards (bwrap refuses to mount
# a directory over a device node), so /dev is assembled explicitly.
dev_mounts=(
--tmpfs /dev
--dev-bind /dev/null /dev/null
--dev-bind /dev/zero /dev/zero
--dev-bind /dev/full /dev/full
--dev-bind /dev/random /dev/random
--dev-bind /dev/urandom /dev/urandom
--symlink /proc/self/fd /dev/fd
--symlink /proc/self/fd/0 /dev/stdin
--symlink /proc/self/fd/1 /dev/stdout
--symlink /proc/self/fd/2 /dev/stderr
)
if [ "$DEV_SANDBOX_INTERACTIVE" = true ]; then
# An interactive shell is deliberately given a terminal; keep bwrap's /dev.
dev_mounts=(--dev /dev)
fi
exec bwrap \
--unshare-pid \
--die-with-parent --proc /proc --tmpfs /tmp \
"${dev_mounts[@]}" \
"${gui_mounts[@]}" \
"${runtime_mounts[@]}" \
--bind "$DEV_SANDBOX_ROOT/root" /work \
"${shim_mounts[@]}" \
--bind "$DEV_SANDBOX_ROOT/root/usr/local" /usr/local \
"${home_mounts[@]}" \
"${etc_mounts[@]}" \
--chdir /work/repo \
--clearenv \
--setenv PATH "$DEV_SANDBOX_HOME/.local/bin:/usr/local/bin:/usr/bin:$PATH" \
--setenv HOME "$DEV_SANDBOX_HOME" \
--setenv USER "$DEV_SANDBOX_USER" \
--setenv LOGNAME "$DEV_SANDBOX_USER" \
--setenv CURL_CA_BUNDLE /work/certs/ca.pem \
--setenv SSL_CERT_FILE /work/certs/ca.pem \
--setenv GIT_SSL_CAINFO /work/certs/ca.pem \
--setenv NODE_EXTRA_CA_CERTS /work/certs/real-ca.pem \
--setenv OPENSSL_CONF /work/certs/openssl.cnf \
--setenv HTTP_PROXY http://127.0.0.1:8080 \
--setenv HTTPS_PROXY http://127.0.0.1:8080 \
--setenv ALL_PROXY http://127.0.0.1:8080 \
--setenv NO_PROXY '' \
--setenv DEV_SANDBOX_INTERACTIVE "$DEV_SANDBOX_INTERACTIVE" \
--setenv ELECTRON_DISABLE_SANDBOX 1 \
"${node_env[@]}" \
"${electron_env[@]}" \
-- "$DEV_SANDBOX_BASH" -ceu '
python3 /work/proxy.py /work/http /work/certs /work/certs/real-ca.pem >/work/logs/proxy.log 2>&1 &
proxy_pid=$!
cleanup() {
kill "$proxy_pid" 2>/dev/null || true
wait "$proxy_pid" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
# Bash opens /dev/tcp itself, so the readiness probe needs no netcat --
# one less binary the sandbox has to find on the host (GitHub runners
# ship no `nc`).
proxy_up() { (exec 3<>/dev/tcp/127.0.0.1/8080) 2>/dev/null; }
for _ in $(seq 1 100); do
proxy_up && break
sleep 0.05
done
if ! proxy_up; then
echo "error: the sandbox fake-internet proxy never came up" >&2
cat /work/logs/proxy.log >&2 || true
exit 1
fi
"$@"
' sandbox-command "$@"
@@ -0,0 +1,144 @@
# Behavioral tests for install.ps1 system Node/npm compatibility selection.
#
# The installer is dot-sourced without running its entry point, then external
# commands and downloads are replaced with deterministic in-process stubs.
# This exercises the shipped range parser and Test-Node acceptance gate without
# changing PATH, installing software, or touching the user's Hermes home.
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path))
$installScript = Join-Path $repoRoot 'scripts\install.ps1'
$testRoot = Join-Path $env:TEMP ("hermes-node-compatibility-test-" + [Guid]::NewGuid().ToString('N'))
$HermesHome = Join-Path $testRoot 'home'
$InstallDir = Join-Path $testRoot 'missing-checkout'
. $installScript -HermesHome $HermesHome -InstallDir $InstallDir
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$script:Failures = 0
function Assert-Equal {
param($Expected, $Actual, [string]$Label)
if ($Expected -ceq $Actual) {
Write-Host "PASS: $Label"
} else {
Write-Host "FAIL: $Label"
Write-Host " expected: [$Expected]"
Write-Host " actual: [$Actual]"
$script:Failures++
}
}
Write-Host '-- npm range evaluation --'
$supportedRange = Get-NpmRange
Assert-Equal '<11.10.0 || >=11.17.0' $supportedRange 'fresh-install fallback matches the supported npm range'
Assert-Equal $true (Test-NpmVersionOk '10.9.8') 'bundled npm 10.9.8 is accepted before clone'
Assert-Equal $true (Test-NpmVersionOk '11.9.9') 'lower alternative is accepted'
Assert-Equal $false (Test-NpmVersionOk '11.10.0') 'excluded band starts at 11.10.0'
Assert-Equal $false (Test-NpmVersionOk '11.16.0') 'reported npm 11.16.0 is rejected'
Assert-Equal $true (Test-NpmVersionOk '11.17.0') 'upper alternative starts at 11.17.0'
Assert-Equal $false (Test-NpmVersionOk 'not-a-version') 'malformed version fails closed'
Assert-Equal $false (Test-NpmVersionOk '12.0.0' '^12.0.0') 'unsupported range syntax fails closed'
# Controlled command surface used by the real Test-Node function.
$script:FakeNpmAvailable = $true
$script:FakeNpmVersion = '11.16.0'
$script:FakeNodeVersion = 'v24.18.0'
$script:DownloadAttempts = 0
$script:HasNode = $null
$NodeVersion = '22'
function node { $script:FakeNodeVersion }
function npm.cmd { $script:FakeNpmVersion }
function Get-Command {
[CmdletBinding()]
param([string]$Name)
switch ($Name) {
'node' {
return Microsoft.PowerShell.Core\Get-Command node -CommandType Function
}
'npm.cmd' {
if ($script:FakeNpmAvailable) {
return Microsoft.PowerShell.Core\Get-Command npm.cmd -CommandType Function
}
return $null
}
'npm' { return $null }
'winget' { return $null }
default { return $null }
}
}
function Ensure-NodeExeOnPath { $true }
function Get-WindowsArch { 'x64' }
function Invoke-WebRequest {
$script:DownloadAttempts++
throw 'network disabled by test'
}
function Write-Info { param([string]$Message) }
function Write-Warn { param([string]$Message) }
function Write-Success { param([string]$Message) }
function Invoke-SystemNodeProbe {
param(
[string]$NodeVersion,
[string]$NpmVersion,
[bool]$NpmAvailable = $true
)
$script:FakeNodeVersion = $NodeVersion
$script:FakeNpmVersion = $NpmVersion
$script:FakeNpmAvailable = $NpmAvailable
$script:DownloadAttempts = 0
$script:HasNode = $null
[void](Test-Node)
return [pscustomobject]@{
HasNode = $script:HasNode
DownloadAttempts = $script:DownloadAttempts
}
}
Write-Host ''
Write-Host '-- system Node acceptance --'
$result = Invoke-SystemNodeProbe 'v24.18.0' '11.17.0'
Assert-Equal $true $result.HasNode 'compatible system Node/npm is accepted'
Assert-Equal 0 $result.DownloadAttempts 'compatible system npm avoids managed download'
$result = Invoke-SystemNodeProbe 'v22.22.0' '10.9.8'
Assert-Equal $true $result.HasNode 'minimum Node with bundled npm is accepted'
Assert-Equal 0 $result.DownloadAttempts 'bundled npm avoids managed download'
$result = Invoke-SystemNodeProbe 'v24.18.0' '11.16.0'
Assert-Equal $false $result.HasNode 'incompatible system npm is not accepted'
Assert-Equal 1 $result.DownloadAttempts 'incompatible system npm falls through to managed Node'
$result = Invoke-SystemNodeProbe 'v24.18.0' '' $false
Assert-Equal $false $result.HasNode 'missing system npm is not accepted'
Assert-Equal 1 $result.DownloadAttempts 'missing system npm falls through to managed Node'
Write-Host ''
Write-Host '-- managed npm reuse --'
$managedDir = Join-Path $testRoot 'managed-node'
New-Item -ItemType Directory -Force -Path $managedDir | Out-Null
$managedNpm = Join-Path $managedDir 'npm.cmd'
@'
@echo off
if "%~1"=="--version" (
echo 10.9.8
exit /b 0
)
exit /b 42
'@ | Set-Content -LiteralPath $managedNpm -Encoding Ascii
Assert-Equal $true (Update-ManagedNpm $managedDir) 'compatible managed npm skips the upgrade command'
if ($script:Failures -gt 0) {
Write-Host ''
Write-Host "$script:Failures assertion(s) failed"
exit 1
}
Write-Host ''
Write-Host 'all assertions passed'
if (Test-Path $testRoot) {
Remove-Item -LiteralPath $testRoot -Recurse -Force
}
+1 -1
View File
@@ -366,7 +366,7 @@ def run_one_scenario(scenario: Dict[str, Any], enabled: bool, out_dir: Path) ->
n_registered = register_fake_tools()
# Capture tool calls via a hook on the registry dispatch path. We use the
# registry hook (rather than the run_agent.handle_function_call binding,
# registry hook (rather than the model_tools.handle_function_call binding,
# which is already cached by tool_executor) because the dispatch call is
# the one place every underlying tool call lands. Bridge calls are
# extracted from the message transcript after the run.
+3 -3
View File
@@ -107,10 +107,10 @@ def run_one(scenario: Dict[str, Any], mode: str, rep: int, out_dir: Path) -> Dic
discover_plugins() # idempotent; ensures no later clear wipes our hook
pm = get_plugin_manager()
pm._hooks.setdefault("post_api_request", []).append(usage_hook)
# Belt-and-braces: normalize_usage in the conversation loop is called
# Belt-and-braces: normalize_usage in agent.turn_usage (the conversation loop's usage recorder) is called
# exactly once per API response (streaming AND non-streaming). Wrap it
# to capture canonical usage the hook path may miss.
import agent.conversation_loop as _cl
import agent.turn_usage as _cl
_orig_norm = _cl.normalize_usage
def _norm_spy(raw, **kw):
cu = _orig_norm(raw, **kw)
@@ -140,7 +140,7 @@ def run_one(scenario: Dict[str, Any], mode: str, rep: int, out_dir: Path) -> Dic
finally:
registry.dispatch = original_dispatch
try:
import agent.conversation_loop as _cl2
import agent.turn_usage as _cl2
if "_orig_norm" in dir() or True:
try:
_cl2.normalize_usage = _orig_norm # type: ignore[name-defined]
+2 -2
View File
@@ -194,7 +194,7 @@ def run_one(scenario, mode, scale, rep, out_dir: Path):
skip_context_files=True, skip_memory=True,
platform="cli", max_iterations=15,
)
import agent.conversation_loop as _cl
import agent.turn_usage as _cl
_orig_norm = _cl.normalize_usage
def _norm_spy(raw, **kw):
cu = _orig_norm(raw, **kw)
@@ -224,7 +224,7 @@ def run_one(scenario, mode, scale, rep, out_dir: Path):
registry.dispatch = original_dispatch
if _orig_norm is not None:
try:
import agent.conversation_loop as _cl2
import agent.turn_usage as _cl2
_cl2.normalize_usage = _orig_norm
except Exception:
pass
+2 -2
View File
@@ -154,7 +154,7 @@ def run_one(scenario, mode, rep, out_dir: Path):
agent = AIAgent(provider="openrouter", model=model, quiet_mode=True,
save_trajectories=False, skip_context_files=True,
skip_memory=True, platform="cli", max_iterations=15)
import agent.conversation_loop as _cl
import agent.turn_usage as _cl
_orig_norm = _cl.normalize_usage
def _norm_spy(raw, **kw):
cu = _orig_norm(raw, **kw)
@@ -180,7 +180,7 @@ def run_one(scenario, mode, rep, out_dir: Path):
registry.dispatch = original_dispatch
if _orig_norm is not None:
try:
import agent.conversation_loop as _cl2
import agent.turn_usage as _cl2
_cl2.normalize_usage = _orig_norm
except Exception:
pass
+2 -2
View File
@@ -234,7 +234,7 @@ def run_one(scenario, mode, rep, out_dir: Path):
agent = AIAgent(provider="openrouter", model=model, quiet_mode=True,
save_trajectories=False, skip_context_files=True,
skip_memory=True, platform="cli", max_iterations=15)
import agent.conversation_loop as _cl
import agent.turn_usage as _cl
_orig_norm = _cl.normalize_usage
def _norm_spy(raw, **kw):
cu = _orig_norm(raw, **kw)
@@ -261,7 +261,7 @@ def run_one(scenario, mode, rep, out_dir: Path):
registry.dispatch = original_dispatch
if _orig_norm is not None:
try:
import agent.conversation_loop as _cl2
import agent.turn_usage as _cl2
_cl2.normalize_usage = _orig_norm
except Exception:
pass
+48
View File
@@ -0,0 +1,48 @@
# Local harness for windows-e2e.ps1's non-GUI logic, runnable under linux
# pwsh (nix run nixpkgs#powershell). Exercises the pieces that cost CI
# round-trips when wrong: parameter surface, ref-flag probing, and the
# install/update dispatch reaching the right arm names.
param()
$ErrorActionPreference = "Stop"
$driver = Join-Path $PSScriptRoot "..\tests\install\windows-e2e.ps1"
# 1. Parse cleanly.
$tok = $null; $err = $null
[System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $driver), [ref]$tok, [ref]$err) | Out-Null
if ($err -and $err.Count) { $err | ForEach-Object { Write-Host $_.Message }; exit 1 }
Write-Host "parse: OK"
# 2. Parameter surface: both axes present with the generator's ids.
$ast = [System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $driver), [ref]$tok, [ref]$err)
$params = $ast.ParamBlock.Parameters
$byName = @{}
foreach ($p in $params) { $byName[$p.Name.VariablePath.UserPath] = $p }
foreach ($required in @("Phase", "InstallMethod", "Route", "InstallRef")) {
if (-not $byName.ContainsKey($required)) { Write-Host "missing param: $required"; exit 1 }
}
$imSet = ($byName["InstallMethod"].Attributes | Where-Object { $_.TypeName.Name -eq "ValidateSet" }).PositionalArguments.Value
foreach ($m in @("desktop-installer@latest", "installer-script", "installer-script+desktop")) {
if ($imSet -notcontains $m) { Write-Host "InstallMethod ValidateSet missing $m"; exit 1 }
}
$rSet = ($byName["Route"].Attributes | Where-Object { $_.TypeName.Name -eq "ValidateSet" }).PositionalArguments.Value
foreach ($m in @("open-app-update", "hermes-desktop-app-update", "hermes-update", "installer-script", "installer-script+desktop", "desktop-installer@latest")) {
if ($rSet -notcontains $m) { Write-Host "Route ValidateSet missing $m"; exit 1 }
}
Write-Host "parameter surface: OK"
# 3. Dispatch bodies reference the right arms (AST-level: the switch on
# InstallMethod contains the three arms; the switch on Route contains all
# six, with desktop-installer@latest re-running the GUI installer).
$text = Get-Content -LiteralPath $driver -Raw
foreach ($needle in @(
'function Invoke-PhaseInstall',
'function Invoke-PhaseUpdate',
'Invoke-RefInstaller $state.old "old" -IncludeDesktop',
'Invoke-HermesDesktopAppUpdate $state.current',
'Invoke-HermesUpdate',
'Invoke-PhaseInstallGui -Mode "update"'
)) {
if ($text.IndexOf($needle) -lt 0) { Write-Host "dispatch missing: $needle"; exit 1 }
}
Write-Host "dispatch arms: OK"
Write-Host "ALL HARNESS CHECKS PASSED"