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:
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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()),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Executable
+80
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
|
||||
@@ -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', '✅', 'known', 'running', 'cancelled', '❌'];
|
||||
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 `✅${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 `❌${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('✅')).length;
|
||||
const failed = cells.filter((c) => c.startsWith('❌')).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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user