Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
# Tool Search live test harness
Runs five scenarios against a real model (Claude Haiku 4.5 via OpenRouter) to
verify that the bridge tools work end-to-end. Records transcripts in
`scripts/out/`.
## Running
```bash
cd <repo root>
python3 scripts/tool_search_livetest.py # runs all 5 scenarios x 2 modes
python3 scripts/analyze_livetest.py # side-by-side report
```
Requires `OPENROUTER_API_KEY` set or present in `~/.hermes/.env`.
## What it verifies
| Scenario | Tests |
|----------|-------|
| A obvious_single | BM25 retrieval on an obvious tool name (github_create_issue) |
| B vague_paraphrased | Retrieval when the model has to paraphrase ("schedule meeting" → evt_create) |
| C multi_tool_chain | Multi-step task chaining two deferred tools (GitHub + Slack) |
| D core_plus_deferred | Mixed: core tool (read_file) called directly, deferred tool (Slack) via bridge |
| E no_tool_needed | Pure-knowledge prompt; verify no spurious tool_search invocations |
Each scenario runs with `tool_search.enabled = on` and again with `off` for an
A/B baseline. The harness records:
- bridge_calls (the tool_search / tool_describe / tool_call sequence the model emitted)
- underlying_tool_calls (what actually ran through the registry dispatcher)
- final_response, iteration count, elapsed time, any errors
## Output structure
```
scripts/out/
<scenario>__enabled.json # tool_search ON
<scenario>__disabled.json # tool_search OFF
_summary.json # one-line summary across all runs
```
The 2026-05 baseline run is checked in for reference. Re-running may produce
slightly different transcripts (the model is non-deterministic) but the
expected_underlying_tools assertions should remain satisfied.
+94
View File
@@ -0,0 +1,94 @@
"""Propose catalog quality updates from Artificial Analysis.
Authoring-time helper — NEVER called at runtime (their terms forbid
client-side keys, the fleet would burn the rate limit, and a
recommendation must not change because a third-party endpoint
hiccuped). Run it when adding a model or refreshing the ordering;
review the printed diff and edit catalog.json yourself. The script
proposes, the commit decides.
The catalog's `quality` stays OUR field: AA-informed where they cover a
model, editorially set where they don't (day-0 releases lag their evals;
some entries never appear). AA's Intelligence Index grades the
full-precision cloud model, not our Q4 build — fine for ordering, never
for display.
Usage:
export AA_API_KEY=... # from https://artificialanalysis.ai (free tier)
python scripts/aa_quality_sync.py
Attribution: scores by Artificial Analysis (https://artificialanalysis.ai).
"""
from __future__ import annotations
import json
import os
import sys
import urllib.request
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
CATALOG_PATH = REPO_ROOT / "hermes_cli" / "local_runtime" / "catalog.json"
AA_URL = "https://artificialanalysis.ai/api/v2/data/llms/models"
# Catalog entry id -> AA slug. Hand-maintained: AA's naming rarely matches
# HF repo names, and a wrong match silently mis-ranks a model. An entry
# absent here (or mapped to None) is editorial-only and never overwritten.
AA_SLUG_BY_ENTRY = {
"qwen3.8-27b": "qwen3-8-27b",
"qwen3.8-flash-next": "qwen3-8-flash-next",
"qwen3.6-35b-a3b": "qwen3-6-35b-a3b",
"deepseek-v4-flash": "deepseek-v4-flash",
}
def fetch_aa_models(api_key: str) -> dict[str, dict]:
req = urllib.request.Request(AA_URL, headers={"x-api-key": api_key})
with urllib.request.urlopen(req, timeout=30) as r:
doc = json.load(r)
return {m["slug"]: m for m in doc.get("data", [])}
def main() -> int:
api_key = os.environ.get("AA_API_KEY", "").strip()
if not api_key:
print("AA_API_KEY not set — create a free key at "
"https://artificialanalysis.ai and export it.", file=sys.stderr)
return 2
catalog = json.loads(CATALOG_PATH.read_text(encoding="utf-8"))
aa = fetch_aa_models(api_key)
print(f"{'entry':24s} {'catalog q':>9s} {'AA index':>9s} note")
print("-" * 70)
for model in catalog["models"]:
entry_id = model["id"]
current = model.get("quality", 0)
slug = AA_SLUG_BY_ENTRY.get(entry_id)
if not slug:
print(f"{entry_id:24s} {current:>9d} {'':>9s} editorial only (no AA mapping)")
continue
hit = aa.get(slug)
if hit is None:
print(f"{entry_id:24s} {current:>9d} {'':>9s} not in AA data (slug {slug!r})")
continue
index = (hit.get("evaluations") or {}).get(
"artificial_analysis_intelligence_index")
if index is None:
print(f"{entry_id:24s} {current:>9d} {'':>9s} AA row lacks the index")
continue
proposed = round(float(index))
marker = "" if proposed == current else " <-- proposes change"
print(f"{entry_id:24s} {current:>9d} {proposed:>9d}{marker}")
print("\nReview against the decision table before editing: a quality "
"change that flips cells in tests/hermes_cli/"
"test_local_recommendation.py is the actual decision being made.")
print("Attribution: scores by Artificial Analysis "
"(https://artificialanalysis.ai).")
return 0
if __name__ == "__main__":
sys.exit(main())
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Add a contributor email → GitHub login mapping.
Writes one file per email under contributors/emails/ (filename = email,
content = login). File additions never merge-conflict, unlike the legacy
AUTHOR_MAP dict in scripts/release.py, which is frozen — do not append to it.
Usage (from the repo root):
python3 scripts/add_contributor.py <email> <github-login> [comment...]
# e.g.
python3 scripts/add_contributor.py jane@example.com janedoe "PR #12345 salvage"
Idempotent: if the mapping already exists with the same login, prints
"present" and exits 0. If the email maps to a DIFFERENT login (here or in the
legacy AUTHOR_MAP), refuses with exit 1 so a typo can't silently reassign
someone's commits.
"""
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
EMAILS_DIR = REPO_ROOT / "contributors" / "emails"
_EMAIL_RE = re.compile(r"^[^/\\\s]+@[^/\\\s]+$")
# GitHub's *current* signup rules forbid consecutive hyphens, but legacy
# accounts with them exist and are valid (e.g. Roger--Han, verified via the
# users API July 2026). Accept any alphanumeric/hyphen login that doesn't
# start or end with a hyphen, max 39 chars.
_LOGIN_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$")
def read_mapping_file(path: Path) -> str | None:
"""Return the login from a mapping file (first non-comment line)."""
try:
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#"):
return line
except OSError:
pass
return None
def _legacy_login(email: str) -> str | None:
"""Look the email up in the frozen legacy AUTHOR_MAP in release.py."""
try:
sys.path.insert(0, str(REPO_ROOT / "scripts"))
from release import LEGACY_AUTHOR_MAP # noqa: PLC0415
return LEGACY_AUTHOR_MAP.get(email)
except Exception:
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("@")
if not _EMAIL_RE.match(email):
print(f"error: {email!r} does not look like a commit-author email", file=sys.stderr)
return 2
if not _LOGIN_RE.match(login):
print(f"error: {login!r} is not a valid GitHub login", file=sys.stderr)
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)
if existing is not None:
if existing == login:
print("present")
return 0
print(
f"error: {email} already maps to {existing!r} (asked for {login!r}) — "
"resolve manually",
file=sys.stderr,
)
return 1
EMAILS_DIR.mkdir(parents=True, exist_ok=True)
body = login + "\n"
if comment:
body += f"# {comment}\n"
path.write_text(body, encoding="utf-8")
print(f"added: contributors/emails/{email} -> {login}")
return 0
def main() -> int:
if len(sys.argv) < 3:
print(__doc__, file=sys.stderr)
return 2
email, login = sys.argv[1], sys.argv[2]
comment = " ".join(sys.argv[3:])
return add_contributor(email, login, comment)
if __name__ == "__main__":
sys.exit(main())
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Compare enabled vs disabled runs and produce a readable report.
Reads scripts/out/_summary.json and the per-scenario JSONs, prints a side-by-
side comparison of what happened, and flags anomalies.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
OUT = HERE / "out"
def load_record(scenario_id: str, mode: str):
path = OUT / f"{scenario_id}__{mode}.json"
if not path.exists():
return None
return json.loads(path.read_text(encoding="utf-8"))
def fmt_tool_seq(calls):
if not calls:
return "(none)"
return "".join(c["name"] for c in calls)
def fmt_bridge_seq(calls):
if not calls:
return "(none)"
parts = []
for c in calls:
if c["name"] == "tool_call":
inner = (c.get("args") or {}).get("name", "?")
parts.append(f"tool_call→{inner}")
elif c["name"] == "tool_search":
args = c.get("args") or {}
qs = args.get("queries")
if isinstance(qs, list):
q = "; ".join(str(x) for x in qs)
else: # legacy single-query transcripts
q = str(args["query"] if "query" in args else "?")
parts.append(f"search('{q[:30]}')")
elif c["name"] == "tool_describe":
args = c.get("args") or {}
ns = args.get("names")
if isinstance(ns, list):
n = ", ".join(str(x) for x in ns)
else: # legacy single-name transcripts
n = str(args.get("name", "?"))
parts.append(f"describe({n})")
return "".join(parts)
def main():
if not OUT.exists():
print("No output directory at", OUT)
sys.exit(1)
summary_path = OUT / "_summary.json"
if not summary_path.exists():
print("No _summary.json yet")
sys.exit(1)
summary = json.loads(summary_path.read_text(encoding="utf-8"))
scenarios = sorted({row["scenario"] for row in summary})
print(f"{'='*78}")
print(" Live test results: tool_search ENABLED vs DISABLED")
print(f"{'='*78}\n")
fails = 0
for sid in scenarios:
en = load_record(sid, "enabled")
di = load_record(sid, "disabled")
if not en or not di:
continue
expected = set(en["expected_underlying_tools"])
print(f"┌─ {sid} ({en['scenario_description']})")
print(f"│ Prompt: {en['prompt'][:120]}")
print(f"│ Expected underlying tools: {sorted(expected) or '(none)'}")
print("")
for label, rec in [("ENABLED ", en), ("DISABLED", di)]:
called_under = [c["name"] for c in rec["underlying_tool_calls"]]
called_set = set(called_under)
missing = expected - called_set
extra = called_set - expected - {"read_file", "search_files", "terminal", "todo", "memory"}
mark = "" if (expected.issubset(called_set) and not rec["error"]) else ""
if mark == "":
fails += 1
print(f"{label} {mark} bridges={len(rec['bridge_calls']):2} underlying={len(rec['underlying_tool_calls']):2} "
f"iters={rec['n_iterations']:2} elapsed={rec['elapsed_seconds']:5.1f}s err={bool(rec['error'])}")
print(f"│ underlying: {fmt_tool_seq(rec['underlying_tool_calls'])}")
if rec["bridge_calls"]:
print(f"│ bridges: {fmt_bridge_seq(rec['bridge_calls'])}")
if missing:
print(f"│ ⚠ MISSING expected tools: {sorted(missing)}")
if extra:
print(f"│ ⓘ extra tools called: {sorted(extra)}")
if rec["error"]:
print(f"│ 💥 error: {rec['error'][:200]}")
# Bridge-trip count vs direct (interesting comparator)
en_bridges = len(en["bridge_calls"])
di_underlying = len(di["underlying_tool_calls"])
en_underlying = len(en["underlying_tool_calls"])
overhead = en_bridges + en_underlying - di_underlying
print(f"│ Δ round-trip cost: enabled used {en_bridges + en_underlying} calls vs disabled {di_underlying} → +{overhead}")
print(f"│ Final (enabled): {(en.get('final_response') or '')[:140]}")
print(f"│ Final (disabled): {(di.get('final_response') or '')[:140]}")
print("└──")
print()
print(f"\nFails: {fails}/{2*len(scenarios)}")
if __name__ == "__main__":
main()
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Audit (and auto-fix) contributor email mappings for a PR branch.
Mirrors the CI gate in .github/workflows/contributor-check.yml so salvage
branches never bounce off the check-attribution job. Run it from the branch
you are about to push:
python3 scripts/audit_pr_attribution.py # report only
python3 scripts/audit_pr_attribution.py --fix # create mapping files
Logic (kept in sync with contributor-check.yml):
- scans ``git log $(git merge-base origin/main HEAD)..HEAD --format=%ae``
- skips teknium/bot emails and ``<id>+<login>@users.noreply.github.com``
(CI auto-resolves those)
- everything else must have ``contributors/emails/<email>`` or a legacy
AUTHOR_MAP entry in scripts/release.py
``--fix`` resolution order for an unmapped email:
1. bare ``<login>@users.noreply.github.com`` → ``<login>``, verified via
``gh api users/<login>``. A warning is printed: the local part is
*usually* the GitHub login but is user-controlled (the historical
``bryan@…`` → ``hydraxman`` case) — eyeball it against the PR author.
2. ``gh api 'search/users?q=<email>+in:email'``
3. otherwise: prints the manual ``add_contributor.py`` command and exits 1.
"""
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SKIP_SUBSTRINGS = (
"teknium",
"noreply@github.com",
"dependabot",
"github-actions",
"anthropic.com",
"cursor.com",
)
ID_NOREPLY_RE = re.compile(r"\d+\+.+@users\.noreply\.github\.com$")
BARE_NOREPLY_RE = re.compile(r"^([A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38})@users\.noreply\.github\.com$")
def run(*args: str, check: bool = True) -> str:
result = subprocess.run(
list(args), capture_output=True, text=True, encoding="utf-8",
errors="replace", cwd=str(REPO_ROOT),
)
if check and result.returncode != 0:
raise RuntimeError(f"{' '.join(args)}: {result.stderr.strip()}")
return result.stdout.strip()
def new_emails() -> list[str]:
base = run("git", "merge-base", "origin/main", "HEAD")
log = run("git", "log", f"{base}..HEAD", "--format=%ae", "--no-merges", check=False)
return sorted({e for e in log.splitlines() if e.strip()})
def is_mapped(email: str) -> bool:
if any(s in email for s in SKIP_SUBSTRINGS):
return True
if ID_NOREPLY_RE.search(email):
return True
if (REPO_ROOT / "contributors" / "emails" / email).is_file():
return True
release_py = REPO_ROOT / "scripts" / "release.py"
try:
if f'"{email}"' in release_py.read_text(encoding="utf-8", errors="replace"):
return True
except OSError:
pass
return False
def gh_json(*args: str):
try:
out = run("gh", "api", *args, check=False)
return json.loads(out) if out else None
except (RuntimeError, json.JSONDecodeError, FileNotFoundError):
return None
def resolve_login(email: str) -> tuple[str, str] | None:
"""Return (login, how) or None."""
m = BARE_NOREPLY_RE.match(email)
if m:
login = m.group(1)
user = gh_json(f"users/{login}")
if user and user.get("login"):
return user["login"], "bare-noreply local part (verified user exists)"
found = gh_json(f"search/users?q={email}+in:email")
if found and found.get("items"):
return found["items"][0]["login"], "GitHub email search"
return None
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--fix", action="store_true",
help="auto-create contributors/emails/ mapping files")
args = parser.parse_args()
unmapped = [e for e in new_emails() if not is_mapped(e)]
if not unmapped:
print("✅ All contributor emails on this branch are mapped.")
return 0
failed = []
for email in unmapped:
author = run("git", "log", f"--author={email}", "--format=%an", "-1", check=False)
if not args.fix:
print(f"⚠️ unmapped: {email} ({author})")
continue
resolved = resolve_login(email)
if resolved:
login, how = resolved
run("python3", "scripts/add_contributor.py", email, login)
print(f"✔ mapped {email} -> {login} [{how}]")
if BARE_NOREPLY_RE.match(email):
print(f" ⚠ local part is user-controlled — confirm @{login} really is "
f"the contributor (git name: {author!r}) before pushing.")
else:
failed.append((email, author))
if not args.fix:
print("\nRun with --fix to auto-create mapping files, or manually:")
for email in unmapped:
print(f" python3 scripts/add_contributor.py {email} <github-username>")
return 1
if failed:
print("\nCould not auto-resolve; map manually:")
for email, author in failed:
print(f" python3 scripts/add_contributor.py {email} <github-username> # {author}")
return 1
print("\nDone — remember to `git add contributors && git commit`.")
return 0
if __name__ == "__main__":
sys.exit(main())
+138
View File
@@ -0,0 +1,138 @@
"""Quick benchmark: subprocess eval vs supervisor-WS eval.
Runs both paths against the same live Chrome and prints a comparison table.
Not a pytest — a script you run manually for the PR description.
Usage:
.venv/bin/python scripts/benchmark_browser_eval.py [--iterations N]
"""
from __future__ import annotations
import argparse
import shutil
import statistics
import subprocess
import sys
import tempfile
import time
import urllib.request
import json
def _find_chrome() -> str:
for c in ("google-chrome", "chromium", "chromium-browser"):
p = shutil.which(c)
if p:
return p
print("No Chrome binary found.", file=sys.stderr)
sys.exit(1)
def _start_chrome(port: int):
profile = tempfile.mkdtemp(prefix="hermes-bench-eval-")
proc = subprocess.Popen(
[
_find_chrome(),
f"--remote-debugging-port={port}",
f"--user-data-dir={profile}",
"--no-first-run",
"--no-default-browser-check",
"--headless=new",
"--disable-gpu",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=1) as r:
info = json.loads(r.read().decode())
return proc, profile, info["webSocketDebuggerUrl"]
except Exception:
time.sleep(0.25)
proc.terminate()
raise RuntimeError("Chrome didn't expose CDP")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--iterations", type=int, default=50)
parser.add_argument("--port", type=int, default=9333)
args = parser.parse_args()
proc, profile, cdp_url = _start_chrome(args.port)
try:
from tools.browser_supervisor import SUPERVISOR_REGISTRY
# Warm up: start the supervisor, navigate to a page.
supervisor = SUPERVISOR_REGISTRY.get_or_start(
task_id="bench-eval", cdp_url=cdp_url
)
# Give it a moment to attach.
time.sleep(1.0)
# Sanity check: one eval over WS should succeed.
sanity = supervisor.evaluate_runtime("1 + 1")
if not sanity.get("ok") or sanity.get("result") != 2:
print(f"sanity check failed: {sanity}", file=sys.stderr)
sys.exit(2)
# ── Bench 1: supervisor WS path ──────────────────────────────────
ws_times: list[float] = []
for _ in range(args.iterations):
t0 = time.monotonic()
out = supervisor.evaluate_runtime("1 + 1")
t1 = time.monotonic()
assert out.get("ok"), out
ws_times.append((t1 - t0) * 1000)
# ── Bench 2: agent-browser subprocess path ────────────────────────
# Skip if agent-browser isn't installed — the WS bench still tells
# us what we need.
if shutil.which("agent-browser") is None and shutil.which("npx") is None:
print("agent-browser CLI not found — skipping subprocess bench.")
sub_times = []
else:
from tools.browser_tool import _run_browser_command, _last_session_key
task_id = _last_session_key("bench-eval")
sub_times = []
for _ in range(args.iterations):
t0 = time.monotonic()
_run_browser_command(task_id, "eval", ["1 + 1"])
t1 = time.monotonic()
sub_times.append((t1 - t0) * 1000)
def fmt(name: str, ts: list[float]) -> str:
if not ts:
return f" {name:<40} (skipped)"
mean = statistics.mean(ts)
median = statistics.median(ts)
mn, mx = min(ts), max(ts)
return (
f" {name:<40} mean={mean:>7.2f}ms median={median:>7.2f}ms "
f"min={mn:>7.2f}ms max={mx:>7.2f}ms"
)
print()
print(f"browser_eval benchmark — {args.iterations} iterations of `1 + 1`")
print("-" * 90)
print(fmt("supervisor WS (Runtime.evaluate)", ws_times))
print(fmt("agent-browser subprocess (eval)", sub_times))
if ws_times and sub_times:
speedup = statistics.mean(sub_times) / statistics.mean(ws_times)
print()
print(f"Speedup: {speedup:.1f}x (mean)")
finally:
SUPERVISOR_REGISTRY.stop_all()
proc.terminate()
try:
proc.wait(timeout=3)
except Exception:
proc.kill()
shutil.rmtree(profile, ignore_errors=True)
if __name__ == "__main__":
main()
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Build the Hermes Model Catalog — a centralized JSON manifest of curated models.
This script reads the in-repo hardcoded curated lists (``OPENROUTER_MODELS``,
``_PROVIDER_MODELS["nous"]``) and writes them to a JSON manifest that the
Hermes CLI fetches at runtime. Publishing the catalog through the docs site
lets maintainers update model lists without shipping a Hermes release.
The runtime fetcher falls back to the same in-repo hardcoded lists if the
manifest is unreachable, so this script is a convenience for keeping the
manifest in sync — not a source of truth.
Usage::
python scripts/build_model_catalog.py
Output: ``website/static/api/model-catalog.json``
Live URL (after ``deploy-site.yml`` runs on merge to main):
``https://hermes-agent.nousresearch.com/docs/api/model-catalog.json``
"""
from __future__ import annotations
import json
import os
import sys
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 for imports that touch it at module level.
os.environ.setdefault("HERMES_HOME", os.path.join(os.path.expanduser("~"), ".hermes"))
from hermes_cli.models import ( # noqa: E402
OPENROUTER_MODELS,
PREFERRED_SILENT_DEFAULT_MODEL,
_PROVIDER_MODELS,
)
OUTPUT_PATH = os.path.join(REPO_ROOT, "website", "static", "api", "model-catalog.json")
CATALOG_VERSION = 1
def _openrouter_entry(mid: str, desc: str) -> dict:
entry: dict = {"id": mid, "description": desc}
if mid == PREFERRED_SILENT_DEFAULT_MODEL:
entry["description"] = desc or "default"
entry["default"] = True
return entry
def _nous_entry(mid: str) -> dict:
entry: dict = {"id": mid}
if mid == PREFERRED_SILENT_DEFAULT_MODEL:
entry["default"] = True
return entry
def build_catalog() -> dict:
return {
"version": CATALOG_VERSION,
"updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"metadata": {
"source": "hermes-agent repo",
"docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog",
},
"providers": {
"openrouter": {
"metadata": {
"display_name": "OpenRouter",
"note": (
"Descriptions drive picker badges. Live /api/v1/models "
"filters curated ids by tool-calling support and free pricing. "
'The entry labeled "default": true is the model Hermes '
"silently lands on when the user never picked one."
),
},
"models": [
_openrouter_entry(mid, desc)
for mid, desc in OPENROUTER_MODELS
],
},
"nous": {
"metadata": {
"display_name": "Nous Portal",
"note": (
"Free-tier gating is determined live via Portal pricing "
"(partition_nous_models_by_tier), not this manifest. "
'The entry labeled "default": true is the model Hermes '
"silently lands on when the user never picked one."
),
},
"models": [
_nous_entry(mid)
for mid in _PROVIDER_MODELS.get("nous", [])
],
},
},
}
def main() -> int:
catalog = build_catalog()
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
with open(OUTPUT_PATH, "w", encoding="utf-8") as fh:
json.dump(catalog, fh, indent=2)
fh.write("\n")
print(f"Wrote {OUTPUT_PATH}")
for provider, block in catalog["providers"].items():
print(f" {provider}: {len(block['models'])} models")
return 0
if __name__ == "__main__":
sys.exit(main())
+459
View File
@@ -0,0 +1,459 @@
#!/usr/bin/env python3
"""Build the Hermes Skills Index — a centralized JSON catalog of all skills.
This script crawls every skill source (skills.sh, GitHub taps, official,
clawhub, lobehub) and writes a JSON index with resolved
GitHub paths. The index is served as a static file on the docs site so that
`hermes skills search/install` can use it without hitting the GitHub API.
Usage:
# Local (uses gh CLI or GITHUB_TOKEN for auth)
python scripts/build_skills_index.py
# CI (set GITHUB_TOKEN as secret)
GITHUB_TOKEN=ghp_... python scripts/build_skills_index.py
Output: website/static/api/skills-index.json
"""
import json
import os
import sys
import time
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
# Allow importing from repo root
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)
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,
)
import httpx
OUTPUT_PATH = os.path.join(REPO_ROOT, "website", "static", "api", "skills-index.json")
INDEX_VERSION = 1
def _meta_to_dict(meta: SkillMeta) -> dict:
"""Convert a SkillMeta to a serializable dict."""
return {
"name": meta.name,
"description": meta.description,
"source": meta.source,
"identifier": meta.identifier,
"trust_level": meta.trust_level,
"repo": meta.repo or "",
"path": meta.path or "",
"tags": meta.tags or [],
"extra": meta.extra or {},
}
def crawl_source(source, source_name: str, limit: int) -> list:
"""Crawl a single source and return skill dicts."""
print(f" Crawling {source_name}...", flush=True)
start = time.time()
try:
results = source.search("", limit=limit)
except Exception as e:
print(f" Error crawling {source_name}: {e}", file=sys.stderr)
return []
skills = [_meta_to_dict(m) for m in results]
elapsed = time.time() - start
print(f" {source_name}: {len(skills)} skills ({elapsed:.1f}s)", flush=True)
return skills
def crawl_skills_sh(source: SkillsShSource) -> list:
"""Crawl skills.sh via its sitemap to enumerate the full catalog (~20k entries).
Previously walked a hardcoded list of ~28 popular keywords (each capped at
50 results) which yielded ~850 unique skills — about 4% of the real catalog.
The SkillsShSource.search("") path now hits the sitemap directly, returning
the full 20k-entry catalog deduplicated by canonical identifier.
"""
print(" Crawling skills.sh (sitemap)...", flush=True)
start = time.time()
try:
results = source.search("", limit=0) # 0 = no cap, return the whole catalog
except Exception as e:
print(f" Warning: skills.sh sitemap walk failed: {e}", file=sys.stderr)
results = []
all_skills: dict[str, dict] = {}
for meta in results:
entry = _meta_to_dict(meta)
if entry["identifier"] not in all_skills:
all_skills[entry["identifier"]] = entry
elapsed = time.time() - start
print(f" skills.sh: {len(all_skills)} unique skills ({elapsed:.1f}s)",
flush=True)
return list(all_skills.values())
def _fetch_repo_tree(repo: str, auth: GitHubAuth) -> list:
"""Fetch the recursive tree for a repo. Returns list of tree entries."""
headers = auth.get_headers()
try:
resp = httpx.get(
f"https://api.github.com/repos/{repo}",
headers=headers, timeout=15, follow_redirects=True,
)
if resp.status_code != 200:
return []
branch = resp.json().get("default_branch", "main")
resp = httpx.get(
f"https://api.github.com/repos/{repo}/git/trees/{branch}",
params={"recursive": "1"},
headers=headers, timeout=30, follow_redirects=True,
)
if resp.status_code != 200:
return []
data = resp.json()
if data.get("truncated"):
return []
return data.get("tree", [])
except Exception:
return []
def batch_resolve_paths(skills: list, auth: GitHubAuth) -> list:
"""Resolve GitHub paths for skills.sh entries using batch tree lookups.
Instead of resolving each skill individually (N×M API calls), we:
1. Group skills by repo
2. Fetch one tree per repo (2 API calls per repo)
3. Find all SKILL.md files in the tree
4. Match skills to their resolved paths
"""
# Filter to skills.sh entries that need resolution
skills_sh = [s for s in skills if s["source"] in {"skills.sh", "skills-sh"}]
if not skills_sh:
return skills
print(f" Resolving paths for {len(skills_sh)} skills.sh entries...",
flush=True)
start = time.time()
# Group by repo
by_repo: dict[str, list] = defaultdict(list)
for s in skills_sh:
repo = s.get("repo", "")
if repo:
by_repo[repo].append(s)
print(f" {len(by_repo)} unique repos to scan", flush=True)
resolved_count = 0
# Fetch trees in parallel (up to 6 concurrent)
def _resolve_repo(repo: str, entries: list):
tree = _fetch_repo_tree(repo, auth)
if not tree:
return 0
# Find all SKILL.md paths in this repo
skill_paths = {} # skill_dir_name -> full_path
for item in tree:
if item.get("type") != "blob":
continue
path = item.get("path", "")
if path.endswith("/SKILL.md"):
skill_dir = path[: -len("/SKILL.md")]
dir_name = skill_dir.split("/")[-1]
skill_paths[dir_name.lower()] = f"{repo}/{skill_dir}"
# Also check SKILL.md frontmatter name if we can match by path
# For now, just index by directory name
elif path == "SKILL.md":
# Root-level SKILL.md
skill_paths["_root_"] = f"{repo}"
count = 0
for entry in entries:
# Try to match the skill's name/path to a tree entry
skill_name = entry.get("name", "").lower()
skill_path = entry.get("path", "").lower()
identifier = entry.get("identifier", "")
# Extract the skill token from the identifier
# e.g. "skills-sh/d4vinci/scrapling/scrapling-official" -> "scrapling-official"
parts = identifier.replace("skills-sh/", "").replace("skills.sh/", "")
skill_token = parts.split("/")[-1].lower() if "/" in parts else ""
# Try matching in order of likelihood
for candidate in [skill_token, skill_name, skill_path]:
if not candidate:
continue
matched = skill_paths.get(candidate)
if matched:
entry["resolved_github_id"] = matched
count += 1
break
else:
# Try fuzzy: skill_token with common transformations
for tree_name, tree_path in skill_paths.items():
if (skill_token and (
tree_name.replace("-", "") == skill_token.replace("-", "")
or skill_token in tree_name
or tree_name in skill_token
)):
entry["resolved_github_id"] = tree_path
count += 1
break
return count
with ThreadPoolExecutor(max_workers=6) as pool:
futures = {
pool.submit(_resolve_repo, repo, entries): repo
for repo, entries in by_repo.items()
}
for future in as_completed(futures):
try:
resolved_count += future.result()
except Exception as e:
repo = futures[future]
print(f" Warning: {repo}: {e}", file=sys.stderr)
elapsed = time.time() - start
print(f" Resolved {resolved_count}/{len(skills_sh)} paths ({elapsed:.1f}s)",
flush=True)
return skills
def main():
print("Building Hermes Skills Index...", flush=True)
overall_start = time.time()
auth = GitHubAuth()
print(f"GitHub auth: {auth.auth_method()}")
if auth.auth_method() == "anonymous":
print("WARNING: No GitHub authentication — rate limit is 60/hr. "
"Set GITHUB_TOKEN for better results.", file=sys.stderr)
skills_sh_source = SkillsShSource(auth=auth)
sources = {
"official": OptionalSkillSource(),
"well-known": WellKnownSkillSource(),
"github": GitHubSource(auth=auth),
"clawhub": ClawHubSource(),
"lobehub": LobeHubSource(),
"browse-sh": BrowseShSource(),
}
all_skills: list[dict] = []
# Crawl skills.sh
all_skills.extend(crawl_skills_sh(skills_sh_source))
# Crawl other sources in parallel.
# Per-source soft caps — sources stop returning when they run out, so these
# are ceilings, not targets. ClawHub has 20k+ skills; bumping to 100k
# (well above current catalog size) lets the full catalog land in the
# index instead of being truncated at an arbitrary build-time limit.
SOURCE_LIMITS = {
# 0 = unbounded catalog walk (max_items=0 in ClawHubSource). A positive
# limit bounds the walk and also enables the interactive 12s budget.
"clawhub": 0,
"lobehub": 100_000,
"browse-sh": 5_000,
"github": 5_000,
"well-known": 5_000,
"official": 5_000,
}
DEFAULT_SOURCE_LIMIT = 500
with ThreadPoolExecutor(max_workers=4) as pool:
futures = {}
for name, source in sources.items():
limit = SOURCE_LIMITS.get(name, DEFAULT_SOURCE_LIMIT)
futures[pool.submit(crawl_source, source, name, limit)] = name
for future in as_completed(futures):
try:
all_skills.extend(future.result())
except Exception as e:
print(f" Error: {e}", file=sys.stderr)
# Batch resolve GitHub paths for skills.sh entries
all_skills = batch_resolve_paths(all_skills, auth)
# Enrich ClawHub skills with owner handles. The listing API does not
# include owner info, so we fetch each skill's detail page concurrently.
# This is needed to build valid "View source" URLs on the Skills Hub page:
# https://clawhub.ai/{owner}/skills/{slug}. Without the owner segment the
# URL leads to a 404.
clawhub_skills = [s for s in all_skills if s["source"] == "clawhub"]
if clawhub_skills:
# Convert dicts back to SkillMeta for enrichment, then update in place.
clawhub_metas = []
for s in clawhub_skills:
meta = SkillMeta(
name=s["name"],
description=s["description"],
source=s["source"],
identifier=s["identifier"],
trust_level=s["trust_level"],
repo=s.get("repo") or None,
path=s.get("path") or None,
tags=s.get("tags") or [],
extra=dict(s.get("extra") or {}),
)
clawhub_metas.append(meta)
print(f" Enriching {len(clawhub_metas)} ClawHub skills with owner handles...",
flush=True)
enrich_start = time.time()
enriched = sources["clawhub"].enrich_owners(clawhub_metas, max_workers=30)
# Write enriched owner back into the index dicts.
meta_by_id = {m.identifier: m for m in clawhub_metas}
for s in clawhub_skills:
meta = meta_by_id.get(s["identifier"])
if meta and meta.extra.get("owner"):
s.setdefault("extra", {})["owner"] = meta.extra["owner"]
enrich_elapsed = time.time() - enrich_start
print(f" Enriched {enriched}/{len(clawhub_metas)} ClawHub owners "
f"({enrich_elapsed:.1f}s)", flush=True)
# Collect which sources hit a GitHub API rate limit during the crawl.
# github / well-known both read api.github.com, so a rate-limited token
# zeroes both at once — surfaced below so the failure message names the
# real cause instead of "source returned 0".
rate_limited_sources = {
name for name, source in sources.items()
if getattr(source, "is_rate_limited", False)
}
if rate_limited_sources:
print(
" WARNING: GitHub API rate limit hit for: "
+ ", ".join(sorted(rate_limited_sources)),
file=sys.stderr,
)
# Deduplicate by identifier
seen: dict[str, dict] = {}
for skill in all_skills:
key = skill["identifier"]
if key not in seen:
seen[key] = skill
deduped = list(seen.values())
# Sort
source_order = {"official": 0, "skills-sh": 1, "skills.sh": 1,
"github": 2, "well-known": 3, "clawhub": 4,
"browse-sh": 5, "lobehub": 6}
deduped.sort(key=lambda s: (source_order.get(s["source"], 99), s["name"]))
from collections import Counter
by_source = Counter(s["source"] for s in deduped)
print(f"\nCrawled {len(deduped)} skills in {time.time() - overall_start:.0f}s")
for src, count in sorted(by_source.items(), key=lambda x: -x[1]):
resolved = sum(1 for s in deduped
if s["source"] == src and s.get("resolved_github_id"))
extra = f" ({resolved} resolved)" if resolved else ""
print(f" {src}: {count}{extra}")
# Health check: catch silent breakage early. Every source listed below
# has historically returned at least `floor` entries; a zero (or near-
# zero) result almost certainly means a tap path moved, an API changed,
# or rate limiting kicked in. Failing here forces a human look before
# the broken index reaches the live docs.
EXPECTED_FLOORS = {
# skills.sh now uses the sitemap walker (~20k catalog as of May 2026).
# Anything under 10k means the sitemap shape changed or fetches failed
# — better to fail loudly than ship a regression to the 858-skill
# popular-queries era.
"skills.sh": 10000,
"lobehub": 100,
# ClawHub had 49,698+ skills as of May 2026 — anything under 20k means
# pagination broke or the API surface changed. Fail loudly rather
# than ship a degenerate index (we shipped 200/50000 silently for
# weeks because the floor was 50).
"clawhub": 20000,
"official": 50,
"github": 30, # collapsed across all GitHub taps
"browse-sh": 50,
}
health_errors = []
for src, floor in EXPECTED_FLOORS.items():
# 'skills-sh' and 'skills.sh' are the same source; both labels exist.
count = by_source.get(src, 0)
if src == "skills.sh":
count = by_source.get("skills.sh", 0) + by_source.get("skills-sh", 0)
if count < floor:
health_errors.append(f" {src}: {count} < expected floor {floor}")
MIN_TOTAL = 1500
if len(deduped) < MIN_TOTAL:
health_errors.append(
f" total: {len(deduped)} < expected floor {MIN_TOTAL}"
)
if health_errors:
print(
"\nERROR: skills index health check failed — refusing to ship "
"a degenerate index. Investigate the following sources:",
file=sys.stderr,
)
for line in health_errors:
print(line, file=sys.stderr)
if rate_limited_sources:
print(
"\nGitHub API rate limit was hit during this crawl for: "
+ ", ".join(sorted(rate_limited_sources))
+ ". This is the usual cause of an all-GitHub-tap collapse "
"(github / well-known dropping to zero together). "
"Re-run with a higher-quota GITHUB_TOKEN.",
file=sys.stderr,
)
print(
"\nIf the drop is expected (e.g. a hub is genuinely shutting "
"down), lower the floor in scripts/build_skills_index.py "
"EXPECTED_FLOORS in the same PR.",
file=sys.stderr,
)
# IMPORTANT: do NOT write OUTPUT_PATH on failure. The index file is
# gitignored, so a fresh deploy checkout has no copy on disk — leaving
# it absent lets website/scripts/extract-skills.py fall back to the
# legacy snapshot cache (or skip the unified index) instead of reading
# a degenerate file. Writing-then-exiting-2 was the bug that shipped an
# index with every GitHub-API source dropped to zero: deploy-site.yml
# swallows the exit code with `|| echo non-fatal`, and the partial file
# was already on disk for extract-skills to pick up.
sys.exit(2)
# Healthy — only now write the index out for the docs build to consume.
index = {
"version": INDEX_VERSION,
"generated_at": datetime.now(timezone.utc).isoformat(),
"skill_count": len(deduped),
"skills": deduped,
}
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
json.dump(index, f, separators=(",", ":"), ensure_ascii=False)
file_size = os.path.getsize(OUTPUT_PATH)
print(f"\nDone! {len(deduped)} skills indexed in "
f"{time.time() - overall_start:.0f}s")
print(f"Output: {OUTPUT_PATH} ({file_size / 1024:.0f} KB)")
if __name__ == "__main__":
main()
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
out=${1:?usage: $0 OUTPUT.png [command...]}
shift
mkdir -p "$(dirname "$out")"
# This runs inside `cage --`, after Ghostty has already opened the isolated
# Wayland display. Capture before this script exits so Cage keeps the surface
# alive long enough for grim to see it.
if (($#)); then
"$@"
fi
if ! command -v grim >/dev/null 2>&1; then
echo "grim is required inside the Cage client session" >&2
exit 127
fi
grim "$out"
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
Blocking check for tracked files whose paths collide when case is ignored.
Linux is case-sensitive; Windows and macOS (default) are not. Two tracked
paths that differ only by case — ``README.md`` and ``readme.md``, or
``src/Foo.py`` and ``SRC/foo.py`` — coexist happily in a Linux checkout and
silently break every clone on a case-insensitive host: the filesystem can
hold only one of them, so checkout either refuses or whichever file is
written last wins and clobbers the other. Git itself won't stop the pair
from landing — it only warns at checkout time, on a case-insensitive FS,
for whichever client happens to do the checkout, and the collision is
invisible on Linux. This check is the enforcement point: scan the index,
fail the build, name the offenders.
Usage:
# Check the checkout this script lives in (CI + the common local case)
python scripts/check-case-collisions.py
# Check an arbitrary git checkout (tests, other worktrees)
python scripts/check-case-collisions.py /path/to/other/repo
Exit status:
0 — no case-colliding tracked paths
1 — at least one collision group (paths printed to stdout)
2 — not in a git repository / git failed
Comparison key: the casefolded FULL path (``str.casefold``), not the
basename — on a case-insensitive filesystem the entire path is
case-insensitive, so ``dir/Foo.txt`` and ``DIR/foo.txt`` collide just like
same-directory pairs. ``casefold`` (not ``lower``) is used because it
matches how the OSes fold case for non-ASCII text (straße vs strasse,
sigma variants); a pair it flags is a genuine collision on macOS/Windows
even when Linux disagrees.
Deliberately out of scope: Unicode NFC/NFD normalization collisions (macOS
stores NFD, Linux NFC). git already handles those at checkout via
``core.precomposeunicode``; this check is strictly about case.
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
from collections import defaultdict
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"root",
nargs="?",
default=str(REPO_ROOT),
help="git checkout to scan (default: the repo this script lives in)",
)
args = parser.parse_args()
try:
os.chdir(args.root)
except OSError as exc:
print(f"::error::cannot enter {args.root}: {exc}")
return 2
proc = subprocess.run(["git", "ls-files", "-z"], capture_output=True)
if proc.returncode != 0:
msg = proc.stderr.decode("utf-8", errors="replace").strip()
print(f"::error::git ls-files failed in {args.root}: {msg}")
return 2
paths = [
p.decode("utf-8", errors="surrogateescape")
for p in proc.stdout.split(b"\0")
if p
]
by_casefold: dict[str, list[str]] = defaultdict(list)
for path in paths:
by_casefold[path.casefold()].append(path)
collisions = {key: group for key, group in by_casefold.items() if len(group) > 1}
if not collisions:
print(f"::notice::{len(paths)} tracked files, no case-colliding paths.")
return 0
print(
f"::error::Found {len(collisions)} case-collision group(s) among "
f"{len(paths)} tracked files."
)
print(
"Paths that differ only by case are ONE file on Windows/macOS but "
"several on Linux - the pair breaks every clone on a case-insensitive "
"host. Rename one member of each group so the paths differ beyond case."
)
print()
for key, group in sorted(collisions.items()):
for path in sorted(group):
print(f" {path}")
print()
print(
"Fix: `git mv` one path in each group to a name that doesn't collide. "
"On Windows/macOS you may need two steps (`git mv a.txt tmp && git mv "
"tmp A.txt`) because the filesystem can't hold both spellings at once."
)
return 1
if __name__ == "__main__":
sys.exit(main())
+814
View File
@@ -0,0 +1,814 @@
#!/usr/bin/env python3
"""
Grep-based checker for Windows cross-platform footguns.
Flags common patterns that break silently on Windows. Run before PRs —
cheap, fast, catches regressions in a codebase that runs on three OSes.
Usage:
# Scan staged changes (default when run from a git checkout)
python scripts/check-windows-footguns.py
# Scan the full tree (full-repo audit)
python scripts/check-windows-footguns.py --all
# Scan a specific file or directory
python scripts/check-windows-footguns.py path/to/file.py path/to/dir/
# Scan only modified files vs. main
python scripts/check-windows-footguns.py --diff main
Exit status:
0 — no Windows footguns found (or all matches suppressed)
1 — at least one unsuppressed match
Suppress an intentional use (e.g. tests or platform-gated code) with:
os.kill(pid, 0) # windows-footgun: ok — only called on POSIX
"""
from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
REPO_ROOT = Path(__file__).resolve().parent.parent
SUPPRESS_MARKER = re.compile(r"#\s*windows-footgun\s*:\s*ok\b", re.IGNORECASE)
# Line-level guard hints. If a line contains any of these tokens, we assume
# the programmer wrote the line in full awareness of the Windows pitfall —
# e.g. `if hasattr(os, 'setsid'): ... os.setsid()`, or the classic
# `getattr(signal, 'SIGKILL', signal.SIGTERM)`, or `shutil.which("wmic")`.
# False negatives are fine here — the inline `# windows-footgun: ok` marker
# is still the authoritative suppression. This is just to reduce the noise
# floor on obviously-guarded lines so the signal-to-noise stays useful.
GUARD_HINTS = (
"hasattr(os,",
"hasattr(signal,",
"getattr(os,",
"getattr(signal,",
"shutil.which(",
"if platform.system() != \"Windows\"",
"if platform.system() != 'Windows'",
"if sys.platform == \"win32\"",
"if sys.platform != \"win32\"",
"if sys.platform == 'win32'",
"if sys.platform != 'win32'",
"IS_WINDOWS",
"is_windows",
)
# Dirs we never scan.
EXCLUDED_DIRS = {
".git",
"node_modules",
"venv",
".venv",
"__pycache__",
"build",
"dist",
".tox",
".mypy_cache",
".pytest_cache",
"site-packages",
"website/build",
"optional-skills", # external skills
}
# File globs we never scan (beyond the dirs above).
EXCLUDED_SUFFIXES = {
".pyc",
".pyo",
".so",
".dll",
".exe",
".png",
".jpg",
".gif",
".ico",
".svg",
".mp4",
".mp3",
".wav",
".pdf",
".zip",
".tar",
".gz",
".whl",
".lock",
".min.js",
".min.css",
}
# Files we never scan (self-referential — this script mentions the
# patterns it detects — and the CONTRIBUTING docs that list them).
EXCLUDED_FILES = {
"scripts/check-windows-footguns.py",
"CONTRIBUTING.md",
}
@dataclass
class Footgun:
"""A Windows cross-platform footgun pattern."""
name: str
pattern: re.Pattern
message: str
fix: str
# If set, matches in files/paths containing any of these substrings are
# silently ignored (e.g. tests that legitimately exercise the footgun
# behind a platform guard). Prefer `# windows-footgun: ok` inline
# suppression over this list; only use path_allowlist for whole files
# that are inherently tests of the footgun itself.
path_allowlist: tuple[str, ...] = ()
# Optional post-match predicate. Takes the re.Match and returns True
# if the match is a REAL footgun (not a false positive). Use this when
# the regex can't fully distinguish (e.g. open() where mode may contain
# "b" for binary, or the line may have `encoding=` elsewhere).
post_filter: "callable | None" = None
FOOTGUNS: list[Footgun] = [
Footgun(
name="open() without encoding= on text mode",
# Match builtins.open() specifically — NOT os.open(), .open()
# method calls (Path.open, tarfile.open, zf.open, webbrowser.open,
# Image.open, wave.open, etc), or `async def open()` method
# definitions. The pattern requires a start-of-identifier boundary
# before `open(` so `os.open`, `.open`, `def open` are all skipped.
# Note: Path.open() is ALSO affected by the encoding default, but
# rather than flagging all `.open(` (huge noise), we require an
# explicit builtins-style open() call. Path.open() is rare in the
# codebase compared to open() and can be audited separately.
pattern=re.compile(
r"""(?:^|[\s\(,;=])(?<![.\w])open\s*\(\s*[^,)]+\s*(?:,\s*['"](?P<mode>[^'"]*)['"])?"""
),
message=(
"open() without an explicit encoding= uses the platform default "
"(UTF-8 on POSIX, cp1252/mbcs on Windows) — files round-tripped "
"between hosts get mojibake. Always pass encoding='utf-8' for "
"text files, or use open(path, 'rb')/'wb' for binary."
),
fix=(
"open(path, 'r', encoding='utf-8') # or 'utf-8-sig' if the "
"file may have a BOM"
),
# Filter: only flag if mode is missing-or-text AND the line doesn't
# already pass encoding=. Skip binary mode (contains "b").
post_filter=lambda m, line: (
"b" not in (m.group("mode") or "")
and "encoding=" not in line
and "encoding =" not in line
# Skip `def open(` and `async def open(` (method definitions)
and not line.lstrip().startswith("def ")
and not line.lstrip().startswith("async def ")
# Skip open(path, **kwargs) patterns — encoding may be in the dict.
# Too expensive to trace; require the author to set encoding in
# the dict and trust them (or they can add a # windows-footgun: ok).
and "**" not in line
),
),
Footgun(
name="os.fdopen() without encoding= on text mode",
# ruff PLW1514 covers builtins.open/Path.read_text/write_text/
# Path.open but NOT os.fdopen — a bare text-mode fdopen still
# decodes/encodes with the locale default (cp1252 on Windows).
# This is the exact hole the July 2026 encoding sweep kept
# re-fixing by hand (PRs #56033/#56940/#65565), so gate it here.
pattern=re.compile(
r"""(?:os\s*\.\s*)?\bfdopen\s*\(\s*[^,)]+\s*(?:,\s*['"](?P<mode>[^'"]*)['"])?"""
),
message=(
"os.fdopen() without an explicit encoding= uses the platform "
"default (cp1252/mbcs on Windows) in text mode — the same "
"mojibake class as bare open(). ruff PLW1514 does not cover "
"fdopen, so this checker is the only gate."
),
fix=(
"os.fdopen(fd, 'w', encoding='utf-8') # or mode 'wb' for binary"
),
post_filter=lambda m, line: (
"b" not in (m.group("mode") or "")
and "encoding=" not in line
and "encoding =" not in line
and "**" not in line
),
),
Footgun(
name="os.kill(pid, 0)",
pattern=re.compile(r"\bos\.kill\s*\(\s*[^,]+,\s*0\s*\)"),
message=(
"os.kill(pid, 0) is NOT a no-op on Windows — it sends "
"CTRL_C_EVENT to the target's console process group, "
"hard-killing the target and potentially unrelated siblings. "
"See bpo-14484."
),
fix=(
"Use psutil.pid_exists(pid) (psutil is a core dependency). "
"Or gateway.status._pid_exists(pid) for the hermes wrapper "
"with a stdlib fallback."
),
),
Footgun(
name="bare os.setsid",
pattern=re.compile(r"(?<!hasattr\()\bos\.setsid\b"),
message=(
"os.setsid does not exist on Windows and raises "
"AttributeError. Subprocesses that need detachment on "
"Windows use creationflags instead."
),
fix=(
"if platform.system() != 'Windows':\n"
" kwargs['preexec_fn'] = os.setsid\n"
"else:\n"
" kwargs['creationflags'] = subprocess.CREATE_NEW_PROCESS_GROUP"
),
),
Footgun(
name="bare os.killpg",
pattern=re.compile(r"\bos\.killpg\b"),
message="os.killpg does not exist on Windows.",
fix=(
"Use psutil for cross-platform process-tree kill:\n"
" p = psutil.Process(pid)\n"
" for c in p.children(recursive=True): c.kill()\n"
" p.kill()"
),
),
Footgun(
name="bare os.getuid / os.geteuid / os.getgid",
pattern=re.compile(r"\bos\.(?:getuid|geteuid|getgid|getegid)\b"),
message=(
"os.getuid / os.geteuid / os.getgid do not exist on Windows "
"and raise AttributeError at import time if referenced."
),
fix=(
"Use getpass.getuser() for the username, or gate with "
"hasattr(os, 'getuid')."
),
),
Footgun(
name="bare os.fork",
pattern=re.compile(r"(?<!hasattr\()\bos\.fork\s*\("),
message="os.fork does not exist on Windows.",
fix=(
"Use subprocess.Popen for daemonization, or guard with "
"hasattr(os, 'fork') and a Windows fallback path."
),
),
Footgun(
name="bare signal.SIGKILL",
pattern=re.compile(r"\bsignal\.SIGKILL\b"),
message=(
"signal.SIGKILL does not exist on Windows and raises "
"AttributeError at import time."
),
fix="Use getattr(signal, 'SIGKILL', signal.SIGTERM).",
),
Footgun(
name="bare signal.SIGHUP / SIGUSR1 / SIGUSR2 / SIGALRM / SIGCHLD / SIGPIPE / SIGQUIT",
pattern=re.compile(
r"\bsignal\.(?:SIGHUP|SIGUSR1|SIGUSR2|SIGALRM|SIGCHLD|SIGPIPE|SIGQUIT)\b"
),
message=(
"These POSIX signals don't exist on Windows; referencing "
"them raises AttributeError at import time."
),
fix=(
"Use getattr(signal, 'SIGXXX', None) and check for None "
"before using, or gate the whole block behind a platform check."
),
),
Footgun(
name="subprocess shebang script invocation",
pattern=re.compile(
r"subprocess\.(?:run|Popen|call|check_output|check_call)\s*\(\s*\[\s*['\"]\./"
),
message=(
"Running a script via './scriptname' doesn't work on Windows — "
"shebang lines aren't honored. CreateProcessW can't execute "
"bash/python scripts without an explicit interpreter."
),
fix="Use [sys.executable, 'scriptname.py', ...] explicitly.",
),
Footgun(
name="wmic invocation without shutil.which guard",
# Match wmic appearing as a subprocess argument — NOT the
# shutil.which("wmic") guard pattern itself. Looks for wmic in a
# list or as first arg of subprocess.run/Popen.
pattern=re.compile(
r"""(?:subprocess\.\w+\s*\(\s*\[\s*['"]wmic['"]|['"]wmic\.exe['"])"""
),
message=(
"wmic was removed in Windows 10 21H1 and later. Always "
"gate with shutil.which('wmic') and fall back to "
"PowerShell (Get-CimInstance Win32_Process)."
),
fix=(
"if shutil.which('wmic'):\n"
" ... wmic path ...\n"
"else:\n"
" subprocess.run(['powershell', '-NoProfile', '-Command',\n"
" 'Get-CimInstance Win32_Process | ...'])"
),
),
Footgun(
name="hardcoded ~/Desktop (OneDrive trap)",
pattern=re.compile(
r"""['"](?:~|~/|[A-Z]:[/\\]Users[/\\][^/\\'"]+[/\\])Desktop\b"""
),
message=(
"When OneDrive Backup is enabled on Windows, the real Desktop "
"is at %USERPROFILE%\\OneDrive\\Desktop, not %USERPROFILE%\\"
"Desktop (which exists as an empty husk)."
),
fix=(
"On Windows, resolve via ctypes + SHGetKnownFolderPath, or "
"read the Shell Folders registry key, or run PowerShell "
"[Environment]::GetFolderPath('Desktop')."
),
),
Footgun(
name="asyncio add_signal_handler without try/except",
pattern=re.compile(r"\.add_signal_handler\s*\("),
message=(
"loop.add_signal_handler raises NotImplementedError on "
"Windows — always wrap in try/except or gate with a "
"platform check."
),
fix=(
"try:\n"
" loop.add_signal_handler(sig, handler, sig)\n"
"except NotImplementedError:\n"
" pass # Windows asyncio doesn't support signal handlers"
),
),
Footgun(
name="subprocess text=True without explicit encoding=",
# Match ``text=True`` (or ``text = True``) anywhere on a line. We
# rely on the post_filter to (a) skip lines that already pass
# ``encoding=`` on the same line, and (b) skip false positives like
# ``def text(self, ...)`` or string literals. ``text=True`` is
# overwhelmingly a subprocess kwarg, so a bare match + filter has a
# high signal-to-noise ratio and avoids the complexity of parsing
# multi-line subprocess calls (which the line-based scanner can't
# reliably attribute to a single line anyway).
pattern=re.compile(r"\btext\s*=\s*True\b"),
message=(
"subprocess text=True without explicit encoding= decodes "
"child output with locale.getpreferredencoding() — cp936 "
"(GBK) on Chinese Windows, cp1252 on Western Windows — "
"which crashes _readerthread with UnicodeDecodeError on "
"non-default-codepage bytes. Always pass encoding='utf-8' "
"(and errors='replace' for Windows-native CLIs that emit "
"non-UTF-8). See issues #47939, #53428, #57238."
),
fix=(
"subprocess.run(..., text=True, encoding='utf-8', "
"errors='replace')\n"
"Both params are required: encoding alone still crashes on "
"non-UTF-8 bytes from Windows-native CLIs (tasklist, "
"schtasks)."
),
post_filter=lambda m, line: (
# Skip if the same line already specifies encoding=.
"encoding=" not in line
and "encoding =" not in line
# Skip method definitions named ``text`` (def text(self, ...)).
and not line.lstrip().startswith("def ")
and not line.lstrip().startswith("async def ")
# Skip ``text=True`` inside string literals (heuristic: the
# substring appears between matching quotes that aren't part
# of an f-string expression). This is imperfect but catches
# the common case of docstrings mentioning text=True.
and not _looks_like_string_literal(line, m)
# Skip lines that are obviously not subprocess calls — e.g.
# DataFrame.rename(text=True) or similar. We can't know for
# sure without parsing, so we accept some false negatives by
# only flagging when ``subprocess`` or a known subprocess-
# shaped call (run/Popen/call/check_output/check_call/
# check_output) appears on the same line. This keeps the
# rule focused on the actual footgun.
and _is_likely_subprocess_call(line)
),
),
Footgun(
name="bare Path.read_text()/write_text() without encoding=",
# Match ``.read_text(`` / ``.write_text(`` when the same line does
# not pass ``encoding=``. Multi-line calls where encoding= sits on
# a later line are handled by the post_filter's lookahead-free
# heuristic accepting a small false-negative rate — the AST guard
# test in tests/gateway/test_gateway_utf8_encoding.py catches the
# gateway/adapters exactly, and this rule catches the common
# single-line form everywhere else.
pattern=re.compile(r"\.(read_text|write_text)\s*\("),
message=(
"Path.read_text()/write_text() without encoding= uses "
"locale.getpreferredencoding() — cp936/cp1252 on Windows — "
"so UTF-8 content (config JSON, session state, skills) "
"crashes with UnicodeDecodeError or writes mojibake. "
"See issue #37423 and the #71014 / read_text campaign."
),
fix='path.read_text(encoding="utf-8") / path.write_text(data, encoding="utf-8")',
post_filter=lambda m, line: (
"encoding=" not in line
and "encoding =" not in line
and not _looks_like_string_literal(line, m)
# Skip calls that continue onto the next line — if the call's
# own closing paren isn't on this line, encoding= may follow
# on a later line. Balance parens from the call opener instead
# of requiring the line to END with ``)`` so chained forms like
# ``read_text()[:4000]`` / ``read_text().splitlines()`` are
# still caught. AST-level enforcement for multi-line calls
# lives in the gateway guard test.
and _call_closes_on_line(line, m.end())
),
),
]
def should_scan_file(path: Path) -> bool:
"""Return True if this file is in scope for the checker."""
# Skip the excluded dirs
parts = set(path.parts)
if parts & EXCLUDED_DIRS:
return False
# Skip excluded suffixes
for suffix in EXCLUDED_SUFFIXES:
if str(path).endswith(suffix):
return False
# Skip self and docs that intentionally mention the patterns
rel = path.relative_to(REPO_ROOT).as_posix()
if rel in EXCLUDED_FILES:
return False
# Only scan text files (rough heuristic — .py, .md, .sh, .ps1, .yaml, etc.)
if path.suffix in {".py", ".pyw", ".pyi"}:
return True
# Other file types are read but only Python-specific patterns would match;
# that's fine and cheap to skip.
return False
def iter_files(paths: Iterable[Path]) -> Iterable[Path]:
for p in paths:
if p.is_file():
if should_scan_file(p):
yield p
elif p.is_dir():
for root, dirs, files in os.walk(p):
# prune excluded dirs in-place for speed
dirs[:] = [d for d in dirs if d not in EXCLUDED_DIRS]
for fname in files:
fpath = Path(root) / fname
if should_scan_file(fpath):
yield fpath
def _strip_code(line: str) -> str:
"""Return just the code portion of a line — strip trailing comments and
skip lines that are entirely inside a string literal or comment.
Heuristic only (we don't parse Python); good enough to avoid flagging
our own `# ``os.kill(pid, 0)`` is NOT a no-op` docstring-style comments.
"""
stripped = line.lstrip()
# Line starts with # — entirely a comment.
if stripped.startswith("#"):
return ""
# Remove trailing "# ..." inline comment. Naive — doesn't handle `#`
# inside strings — but on balance reduces noise far more than it adds.
hash_idx = _find_unquoted_hash(line)
if hash_idx is not None:
return line[:hash_idx]
return line
def _find_unquoted_hash(line: str) -> int | None:
"""Index of the first `#` not inside a single/double/triple-quoted string.
Simple state machine — good enough for the 99% case of "code, then
optional trailing comment."
"""
i = 0
n = len(line)
in_s = False # single-quote string
in_d = False # double-quote string
while i < n:
c = line[i]
if c == "\\" and (in_s or in_d) and i + 1 < n:
i += 2
continue
if not in_d and c == "'":
in_s = not in_s
elif not in_s and c == '"':
in_d = not in_d
elif c == "#" and not in_s and not in_d:
return i
i += 1
return None
# Subprocess method names that accept ``text=`` and are affected by the
# encoding-default footgun. Used by ``_is_likely_subprocess_call`` below to
# keep the ``text=True`` rule focused on subprocess calls (and avoid flagging
# unrelated APIs that happen to accept a ``text`` kwarg).
_SUBPROCESS_METHODS = (
"subprocess.run",
"subprocess.Popen",
"subprocess.call",
"subprocess.check_output",
"subprocess.check_call",
"_sp.run", # common alias
"_sp.Popen",
"_sp.check_output",
"_sp.check_call",
"_sp.call",
".run(", # bare .run( — usually subprocess.run
".Popen(",
".check_output(",
".check_call(",
".call(",
)
def _is_likely_subprocess_call(line: str) -> bool:
"""Heuristic: does this line look like a subprocess invocation?
The ``text=True`` footgun rule only fires when the matched line also
contains a subprocess-shaped call site. This avoids false positives on
unrelated APIs that accept a ``text`` kwarg (e.g. DataFrame.rename,
custom library calls). Multi-line calls where the ``subprocess.X(``
prefix is on a previous line won't be flagged — that's an acceptable
false negative for a line-based scanner.
"""
return any(token in line for token in _SUBPROCESS_METHODS)
def _call_closes_on_line(line: str, open_paren_end: int) -> bool:
"""True when the call whose ``(`` sits at ``open_paren_end - 1`` closes
on this same line (paren-balance walk). Multi-line calls return False —
the missing ``encoding=`` may sit on a continuation line, so the caller
should skip them rather than false-positive."""
depth = 1
for ch in line[open_paren_end:]:
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
return True
return False
def _looks_like_string_literal(line: str, match: "re.Match") -> bool:
"""Heuristic: is the ``text=True`` match inside a string literal?
Catches the common case of docstrings/comments that mention ``text=True``
as prose. Walks the line tracking single/double quote state and returns
True if the match start index falls inside a quoted region.
"""
start = match.start()
in_s = False
in_d = False
i = 0
while i < start and i < len(line):
c = line[i]
if c == "\\" and (in_s or in_d) and i + 1 < len(line):
i += 2
continue
if not in_d and c == "'":
in_s = not in_s
elif not in_s and c == '"':
in_d = not in_d
i += 1
return in_s or in_d
def scan_file(path: Path, footguns: list[Footgun]) -> list[tuple[int, str, Footgun]]:
"""Return a list of (line_number, line, footgun) for unsuppressed matches."""
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return []
matches: list[tuple[int, str, Footgun]] = []
# Track whether we're inside a triple-quoted string (docstring/raw block).
# Simple state machine — handles both ''' and """, toggled by the FIRST
# triple-quote we see; we don't try to handle nested or f-string cases.
in_triple: str | None = None # None, "'''", or '"""'
for i, line in enumerate(text.splitlines(), start=1):
# Update triple-quote state based on this line's occurrences.
code_for_scan = line
if in_triple:
# We're inside a docstring — skip the whole line's scan.
# Check if it closes here.
if in_triple in line:
# Find the closing delimiter; anything after it is real code.
after = line.split(in_triple, 1)[1]
in_triple = None
code_for_scan = after
else:
continue
# Now check for docstring-open in the (possibly after-triple) portion.
# Scan for the first unescaped '''/""" in the current code_for_scan.
stripped = code_for_scan.strip()
for delim in ('"""', "'''"):
if delim in code_for_scan:
# Count occurrences — even count means single-line docstring,
# odd means we've entered a multi-line one.
count = code_for_scan.count(delim)
if count % 2 == 1:
# Odd — we're now inside the triple-quoted block.
# Scan only the part BEFORE the opening delimiter.
before = code_for_scan.split(delim, 1)[0]
code_for_scan = before
in_triple = delim
break
else:
# Even — entire docstring fits on one line. Strip it
# from the scan text to avoid matching on prose.
parts = code_for_scan.split(delim)
# Keep the "outside" parts (every other chunk, starting
# with index 0) as code, drop the "inside" parts.
code_for_scan = "".join(parts[::2])
break
if SUPPRESS_MARKER.search(line):
continue
# Skip if the line has an obvious guard — e.g. hasattr/getattr/
# shutil.which or a platform check. False negatives are acceptable;
# the inline suppression marker is the authoritative override.
if any(hint in line for hint in GUARD_HINTS):
continue
code = _strip_code(code_for_scan)
if not code.strip():
continue
for fg in footguns:
if fg.path_allowlist and any(s in str(path) for s in fg.path_allowlist):
continue
match = fg.pattern.search(code)
if not match:
continue
if fg.post_filter is not None:
try:
if not fg.post_filter(match, line):
continue
except (IndexError, AttributeError):
# Post-filter assumed a named group that isn't there — skip.
continue
matches.append((i, line.rstrip(), fg))
return matches
def get_staged_files() -> list[Path]:
"""Return paths staged in the current git index. Empty on non-git trees."""
try:
out = subprocess.check_output(
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
cwd=REPO_ROOT,
stderr=subprocess.DEVNULL,
text=True, encoding='utf-8', errors='replace',
)
except (subprocess.CalledProcessError, FileNotFoundError):
return []
return [REPO_ROOT / f for f in out.splitlines() if f.strip()]
def get_diff_files(ref: str) -> list[Path]:
"""Return paths modified vs. the given git ref."""
try:
out = subprocess.check_output(
["git", "diff", f"{ref}...HEAD", "--name-only", "--diff-filter=ACMR"],
cwd=REPO_ROOT,
stderr=subprocess.DEVNULL,
text=True, encoding='utf-8', errors='replace',
)
except (subprocess.CalledProcessError, FileNotFoundError):
return []
return [REPO_ROOT / f for f in out.splitlines() if f.strip()]
def parse_args(argv: list[str]) -> argparse.Namespace:
p = argparse.ArgumentParser(
description="Flag Windows cross-platform footguns in Python code."
)
p.add_argument(
"paths",
nargs="*",
type=Path,
help="Specific files/dirs to scan (default: staged changes).",
)
p.add_argument(
"--all",
action="store_true",
help="Scan the full repository (hermes_cli/, gateway/, tools/, cron/, etc.).",
)
p.add_argument(
"--diff",
metavar="REF",
help="Scan files changed vs. the given git ref (e.g. --diff main).",
)
p.add_argument(
"--list",
action="store_true",
help="List all known footgun rules and exit.",
)
return p.parse_args(argv)
def print_rules() -> None:
print("Known Windows footguns checked by this script:\n")
for i, fg in enumerate(FOOTGUNS, start=1):
print(f"{i:2}. {fg.name}")
print(f" {fg.message}")
print(f" Fix: {fg.fix}")
print()
def main(argv: list[str]) -> int:
# Windows terminals default to cp1252, which can't encode the ✓/✗
# characters used in the output. Reconfigure streams to UTF-8 so the
# script works correctly on the very platform it is designed to help.
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8")
args = parse_args(argv)
if args.list:
print_rules()
return 0
if args.all:
# Scan main Python packages + scripts
roots = [
REPO_ROOT / "hermes_cli",
REPO_ROOT / "gateway",
REPO_ROOT / "tools",
REPO_ROOT / "cron",
REPO_ROOT / "agent",
REPO_ROOT / "plugins",
REPO_ROOT / "scripts",
REPO_ROOT / "acp_adapter",
]
roots = [r for r in roots if r.exists()]
elif args.diff:
roots = get_diff_files(args.diff)
elif args.paths:
roots = [p.resolve() for p in args.paths]
else:
# Default: staged changes
roots = get_staged_files()
if not roots:
print(
"No staged files to scan. Pass --all for a full-repo scan, "
"--diff <ref> for a range diff, or paths explicitly.",
file=sys.stderr,
)
return 0
total_matches = 0
files_scanned = 0
for path in iter_files(roots):
files_scanned += 1
matches = scan_file(path, FOOTGUNS)
for lineno, line, fg in matches:
rel = path.relative_to(REPO_ROOT).as_posix()
print(f"{rel}:{lineno}: [{fg.name}]")
print(f" {line.strip()}")
print(f"{fg.message}")
print(f" Fix: {fg.fix.splitlines()[0]}")
print()
total_matches += 1
if total_matches:
print(
f"\n{total_matches} Windows footgun(s) found across "
f"{files_scanned} file(s) scanned.",
file=sys.stderr,
)
print(
" If an individual match is a false positive or intentionally "
"platform-gated, suppress it with `# windows-footgun: ok` on "
"the same line.\n Run with --list to see all rules.",
file=sys.stderr,
)
return 1
print(
f"✓ No Windows footguns found ({files_scanned} file(s) scanned)."
)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""Check that subprocess calls in TUI-context code specify stdin=.
When Hermes runs in TUI mode, the gateway child process communicates with
the Node.js parent over a JSON-RPC protocol on stdin. Subprocess calls that
inherit this fd can cause the gateway to exit with stdin EOF during tool
execution (issue #14036, PR #39257).
This script checks that all subprocess.run() and subprocess.Popen() calls
in TUI-context files (agent/, tools/, plugins/, tui_gateway/) explicitly
set stdin= to prevent fd inheritance.
Exit codes:
0 — all calls are safe
1 — violations found
2 — script error
Usage:
python scripts/check_subprocess_stdin.py [--fix]
With --fix, prints the commands to add stdin=subprocess.DEVNULL to each
violation (does not modify files).
"""
from __future__ import annotations
import os
import re
import sys
from pathlib import Path
# Directories that run inside the TUI gateway child process.
TUI_CONTEXT_DIRS = [
"agent/",
"tools/",
"plugins/",
"tui_gateway/",
]
# User plugin roots — scanned at runtime if they exist. Plugins load from
# ``get_hermes_home() / "plugins"`` (user) and ``./.hermes/plugins/`` (project,
# gated behind ``HERMES_ENABLE_PROJECT_PLUGINS``) — see
# ``hermes_cli/plugins.py:10-12``. The guard only checked the bundled
# ``plugins/`` dir, missing user-installed code that spawns subprocesses
# (gap reported in #67639).
#
# Import is deferred to ``main()`` (after ``os.chdir(repo_root)``) because
# this script runs as a standalone subprocess — ``hermes_constants`` isn't
# on ``sys.path`` until the repo root is added.
# subprocess and os APIs that inherit stdin by default when called without
# an explicit stdin= argument. The original regex only covered run/Popen
# (gap #1 in #67639); call, check_output, check_call, os.system, and
# asyncio.create_subprocess_* all inherit fd 0 equally.
_SUBPROCESS_PATTERNS = [
r"subprocess\.(run|Popen|call|check_output|check_call)\s*\([\"'a-zA-Z_\[\(]",
r"os\.system\s*\([\"'a-zA-Z_\[\(]",
r"asyncio\.create_subprocess_(exec|shell)\s*\([\"'a-zA-Z_\[\(]",
]
# Files with intentional stdin= override (e.g. input= creates a pipe).
# Format: "filepath:line" or just "filepath" to skip the whole file.
KNOWN_SAFE = {
"agent/shell_hooks.py", # uses input=stdin_json, creates a pipe
"plugins/security-guidance/patterns.py", # subprocess mentions are in reminder strings, not calls
}
# Inline marker that exempts a single subprocess call from this check.
# Put it in a comment on (or within) the call when the process MUST inherit
# stdin — e.g. an interactive login the user explicitly invokes. Travels with
# the line, so it survives edits that shift line numbers (unlike a pinned
# file:line entry).
EXEMPT_MARKER = "noqa: subprocess-stdin"
# Directories to skip entirely.
SKIP_DIRS = {
"tests/",
"scripts/",
"skills/",
"optional-skills/",
"hermes_cli/",
"gateway/",
"cron/",
}
def find_subprocess_calls(content: str, filepath: str) -> list[dict]:
"""Find all subprocess/os/asyncio calls missing stdin= in content."""
violations = []
lines = content.split("\n")
# Match only actual function calls — not comments, docstrings, or prose.
# Multiple patterns cover subprocess.run/Popen/call/check_output/check_call,
# os.system, and asyncio.create_subprocess_exec/shell.
patterns = [re.compile(p) for p in _SUBPROCESS_PATTERNS]
for i, line in enumerate(lines):
# Skip comments.
stripped = line.lstrip()
if stripped.startswith("#"):
continue
# Skip lines where the match is inside backticks (docstring references).
if "``subprocess" in line:
continue
if not any(p.search(line) for p in patterns):
continue
# Collect the full call (may span multiple lines).
call_start = i
paren_depth = 0
found_open = False
call_lines = []
for j in range(i, min(i + 30, len(lines))):
call_lines.append(lines[j])
for ch in lines[j]:
if ch == "(":
paren_depth += 1
found_open = True
elif ch == ")":
paren_depth -= 1
if found_open and paren_depth == 0:
call_text = "\n".join(call_lines)
# Already has stdin= → safe.
if "stdin=" in call_text:
break
# Has input= → creates a pipe, safe.
if "input=" in call_text:
break
# Inline exemption marker on the call itself or within
# the few comment lines immediately above it → the call
# intentionally inherits stdin.
window_start = max(0, i - 4)
preceding = "\n".join(lines[window_start:i])
if EXEMPT_MARKER in call_text or EXEMPT_MARKER in preceding:
break
violations.append({
"file": filepath,
"line": i + 1,
"snippet": line.strip()[:120],
})
break
else:
continue
break
return violations
def main() -> int:
fix_mode = "--fix" in sys.argv
repo_root = Path(__file__).resolve().parent.parent
os.chdir(repo_root)
# Add repo root to sys.path so we can import hermes_constants (this script
# runs as a standalone subprocess, not as a module).
sys.path.insert(0, str(repo_root))
from hermes_constants import get_hermes_home
all_violations = []
for tui_dir in TUI_CONTEXT_DIRS:
dirpath = repo_root / tui_dir
if not dirpath.exists():
continue
for py_file in dirpath.rglob("*.py"):
rel = str(py_file.relative_to(repo_root))
# Skip known-safe files.
if rel in KNOWN_SAFE:
continue
# Skip test files inside tools/ etc.
parts = py_file.parts
if any(skip.rstrip("/") in parts for skip in SKIP_DIRS):
continue
content = py_file.read_text(encoding="utf-8")
violations = find_subprocess_calls(content, rel)
all_violations.extend(violations)
# Scan user plugin directories (Gap 1: guard missed user-installed
# plugins in get_hermes_home()/plugins/ and project plugins in
# ./.hermes/plugins/, where code like ori/hooks.py can spawn
# subprocesses with inherited stdin — #67639).
plugin_roots: list[Path] = [get_hermes_home() / "plugins"]
if os.environ.get("HERMES_ENABLE_PROJECT_PLUGINS"):
plugin_roots.append(Path.cwd() / ".hermes" / "plugins")
seen_roots: set[Path] = set()
for plugin_root in plugin_roots:
resolved = plugin_root.resolve()
if resolved in seen_roots or not resolved.is_dir():
continue
seen_roots.add(resolved)
for py_file in resolved.rglob("*.py"):
rel = str(py_file)
if py_file.name in ("conftest.py",) or "/tests/" in rel:
continue
try:
content = py_file.read_text(encoding="utf-8")
except Exception:
continue
violations = find_subprocess_calls(content, rel)
all_violations.extend(violations)
if all_violations:
print(f"{len(all_violations)} subprocess calls missing stdin=:")
for v in all_violations:
print(f" {v['file']}:{v['line']}: {v['snippet']}")
if fix_mode:
print("\nAdd stdin=subprocess.DEVNULL to each call above.")
return 1
else:
print("✅ All TUI-context subprocess calls have explicit stdin=")
return 0
if __name__ == "__main__":
sys.exit(main())
+444
View File
@@ -0,0 +1,444 @@
#!/usr/bin/env python3
"""Assemble the unified CI review comment for a pull request.
Every CI job that wants to appear in the review comment emits a
``review_status`` output: a JSON array of objects, each with a ``source``
(the workflow name, used for dedup) and a ``results`` array of typed
result objects::
[
{
"source": "review-label-gate",
"results": [
{"kind": "action_required", "title": "...", "summary": "...",
"how_to_fix": "..."},
{"kind": "info", "title": "...", "summary": "..."}
]
},
{
"source": "ci-timings",
"results": [
{"kind": "warning", "title": "CI timings", "summary": "...",
"detail": "...", "link": "..."}
]
}
]
Each result object has:
kind: "error" | "action_required" | "warning" | "info" | "debug"
title: section heading
summary: one-line description
detail: markdown detail (optional)
how_to_fix: markdown checklist (optional)
link: URL (optional)
link_label: label for the link (optional, default "View logs")
The assembler flattens all results into a flat list of ReviewItems,
grouped by severity in the comment. Jobs that failed (from the
``needs`` context) but didn't emit any status get synthesized ❌ Error
items. Jobs that DID emit a status are excluded from the synthesized
error list — their own output is the authority for their classification.
Exits 0 always — comment posting is best-effort (fork PRs are read-only).
"""
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import dataclass
from pathlib import Path
# Hidden marker the comment system uses to find-and-edit its
# previous comment instead of stacking new ones on each run.
MARKER = "<!-- hermes-ci-review-bot -->"
# Severity ordering for display.
_SEVERITY_ORDER = ["error", "action_required", "warning", "info", "debug"]
# Severities that trigger the "blocking issues" layout (vs. the
# "looks good!" banner).
_BLOCKING_SEVERITIES = ("error", "action_required", "warning")
_SEVERITY_GROUP_HEADER = {
"error": "## ❌ Job failures",
"action_required": "## ⚠️ Action required",
"warning": "## ⚠️ Warnings",
"info": "## ️ Details",
}
@dataclass
class ReviewItem:
"""A single piece of review information with a severity tag."""
severity: str # "error" | "action_required" | "warning" | "info" | "debug"
title: str # short section title, e.g. "package-lock.json"
summary: str # one-line summary
detail: str = "" # optional markdown detail (tables, bullet lists, etc.)
link: str = "" # optional URL emitted by the job (e.g. report URL)
link_label: str = "View report" # label for the emitted link
how_to_fix: str = "" # optional markdown checklist for action_required items
source: str = "" # workflow that declared this status (for dedup)
job_url: str = "" # auto-attached per-job log link (from the live poller)
# ---------------------------------------------------------------------------
# Collectors — each returns a list of ReviewItems (possibly empty)
# ---------------------------------------------------------------------------
def collect_from_statuses(review_statuses_json: str) -> tuple[list[ReviewItem], set[str]]:
"""Parse the nested review_status JSON into flat ReviewItems.
The input is a JSON array of ``{source, results: [...]}`` objects.
Each entry in ``results`` becomes one ReviewItem, tagged with the
parent's ``source``.
Returns ``(items, sources)`` where ``sources`` is the set of source
values — used by :func:`collect_failed_jobs` to exclude jobs that
already declared their own status (so a failing job that emitted an
``action_required`` status doesn't also show as a synthesized ❌ Error).
"""
if not review_statuses_json:
return [], set()
try:
data = json.loads(review_statuses_json)
except (json.JSONDecodeError, TypeError):
return [], set()
if not isinstance(data, list):
return [], set()
items: list[ReviewItem] = []
sources: set[str] = set()
for entry in data:
if not isinstance(entry, dict):
continue
source = entry.get("source", "")
if source:
sources.add(source)
for r in entry.get("results", []):
if not isinstance(r, dict):
continue
kind = r.get("kind", "info")
if kind not in _SEVERITY_ORDER:
kind = "info"
items.append(ReviewItem(
severity=kind,
title=r.get("title", "Unknown"),
summary=r.get("summary", ""),
detail=r.get("detail", ""),
link=r.get("link", ""),
link_label=r.get("link_label", "View logs"),
how_to_fix=r.get("how_to_fix", ""),
source=source,
))
return items, sources
def collect_failed_jobs(
needs_json: str,
run_url: str,
exclude_sources: set[str] | None = None,
job_urls: dict[str, str] | None = None,
) -> list[ReviewItem]:
"""Build error items for failed CI jobs from the ``needs`` context.
``needs_json`` is the JSON string emitted by ``all-checks-pass`` — a
``{job_name: result}`` dict where result is ``success`` / ``failure``
/ ``skipped``. Only ``failure`` entries become error items.
``exclude_sources`` is a set of ``source`` values from status objects
declared by workflow_call jobs. Job names containing any of these
source strings are excluded — their failure is already covered by their
own status output.
``job_urls`` is an optional ``{job_name: html_url}`` dict from the
live poller. When a job's name is in this dict, the ❌ Error link
points directly to that job's logs page instead of the whole run.
Falls back to ``run_url`` when no per-job URL is available.
"""
if not needs_json:
return []
try:
needs = json.loads(needs_json)
except (json.JSONDecodeError, TypeError):
return []
# Pre-normalize exclude sources once: lowercase + hyphens→spaces, so
# "review-label-gate" matches "Review label gate / Review label gate".
norm_sources = {
src.lower().replace("-", " ") for src in (exclude_sources or set())
}
items: list[ReviewItem] = []
for name, result in sorted(needs.items()):
if result != "failure":
continue
if norm_sources:
norm = name.lower().replace("-", " ")
if any(src in norm for src in norm_sources):
continue
job_url = (job_urls or {}).get(name, run_url)
items.append(ReviewItem(
severity="error",
title=name,
summary=f"Job **{name}** failed.",
job_url=job_url,
))
return items
# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------
def _render_item(item: ReviewItem) -> str:
"""Render a single ReviewItem as a markdown block.
The group header (``## ❌ Job failures`` etc.) carries the severity
emoji, so items don't repeat it. Links are shown inline next to the
title. Layout per item::
### {title} · [View report](url) · [View job](url)
{summary}
{detail}
**How to fix:**
{how_to_fix}
"""
title = f"### {item.title}"
# Build inline links next to the title.
links: list[str] = []
if item.link:
links.append(f"[{item.link_label}]({item.link})")
if item.job_url:
links.append(f"[View job]({item.job_url})")
if links:
title += " · " + " · ".join(links)
parts = [title, "", item.summary]
if item.detail:
parts += ["", item.detail]
if item.how_to_fix:
parts += ["", "**How to fix:**", "", item.how_to_fix]
return "\n".join(parts)
def _render_group(header: str, items: list[ReviewItem]) -> str:
"""Render a severity group: ``##`` header + items separated by ``---``."""
blocks = [_render_item(i) for i in items]
return f"{header}\n\n" + "\n\n---\n\n".join(blocks)
def _render_debug_details(items: list[ReviewItem]) -> str:
"""Render each debug item as its own collapsible ``<details>`` block."""
blocks = []
for item in items:
inner = _render_item(item)
blocks.append(
f"<details>\n<summary>{item.title}</summary>\n\n{inner}\n\n</details>"
)
return "### debug info\n\n" + "\n\n".join(blocks)
def _render_pending_items(pending_jobs: list[str]) -> str:
"""Render the dimmed ``<sub>`` items for jobs still running."""
job_list = ", ".join(f"`{j}`" for j in sorted(pending_jobs))
return f"\n\n---\n\n<sub>Still running {len(pending_jobs)} job{'s' if len(pending_jobs) != 1 else ''}: {job_list}</sub>\n"
def render_comment(
items: list[ReviewItem],
pending_jobs: list[str] | None = None,
commit_info: str = "",
waiting: bool = False,
) -> str:
"""Render the full comment body from a list of review items.
Items are grouped by severity under ``##`` group headers, separated
by ``---``. Errors and action_required items are always visible.
Warnings are shown only when present. Info items are visible; debug items
are in a collapsible ``<details>`` block. If ``pending_jobs`` is non-empty, a dimmed
``<sub>`` footer is appended listing jobs still running.
When there are no errors, action_required, or warnings, an "all good!"
banner is shown at the top. Info items remain visible and debug items
follow in collapsible ``<details>`` blocks.
``waiting`` means a workflow run is still queued or in progress even
though no individual job is visibly pending — GitHub has not spawned
the jobs yet. The comment must not look final in that state, so the
"all good!" banner is replaced by a waiting note and a dimmed footer
marks the comment as still live.
"""
pending = pending_jobs or []
# Group by severity
by_severity: dict[str, list[ReviewItem]] = {s: [] for s in _SEVERITY_ORDER}
for item in items:
by_severity.setdefault(item.severity, []).append(item)
info = by_severity.get("info", [])
debug = by_severity.get("debug", [])
has_blocking = any(by_severity.get(s) for s in _BLOCKING_SEVERITIES)
body = f"{MARKER}\n# ૮ >ﻌ< ა ci review\n\n"
if commit_info:
body += f"{commit_info}\n\n"
if not items and not pending:
if waiting:
return f"{body}<sub>waiting for jobs to start…</sub>"
return f"{body}all good!"
sections: list[str] = []
for sev in _BLOCKING_SEVERITIES:
group = by_severity.get(sev, [])
if group:
sections.append(_render_group(_SEVERITY_GROUP_HEADER[sev], group))
if info:
sections.append(_render_group("## ️ Info", info))
# Debug: collapsible <details>
if debug:
sections.append(_render_debug_details(debug))
if pending:
body += _render_pending_items(pending)
elif waiting:
body += "\n\n---\n\n<sub>waiting for more jobs to start…</sub>\n"
if sections:
body += "\n\n---\n\n".join(sections)
return body
# ---------------------------------------------------------------------------
# Assembly
# ---------------------------------------------------------------------------
def _attach_job_urls(items: list[ReviewItem], job_urls: dict[str, str], run_url: str) -> None:
"""Fill in per-job log links for all items.
Uses the same case-insensitive, hyphen-normalized matching as
:func:`collect_failed_jobs`: the item's ``source`` is matched against
job names in ``job_urls``. Sets ``job_url`` on the item — this is
separate from ``link`` (the job-emitted URL, e.g. a report artifact),
so both can appear in the rendered comment.
"""
if not job_urls and not run_url:
return
# Pre-normalize job_url keys once.
norm_urls: dict[str, str] = {}
for name, url in job_urls.items():
norm_urls[name.lower().replace("-", " ")] = url
for item in items:
if item.job_url:
continue
src = item.source.lower().replace("-", " ")
# Try exact match first, then substring match.
if src in norm_urls:
item.job_url = norm_urls[src]
continue
for norm_name, url in norm_urls.items():
if src and src in norm_name:
item.job_url = url
break
# If no per-job URL found, fall back to run_url for items with a source.
if not item.job_url and item.source and run_url:
item.job_url = run_url
def assemble(
needs_json: str = "",
run_url: str = "",
job_urls: dict[str, str] | None = None,
review_statuses_json: str = "",
pending_jobs: list[str] | None = None,
commit_info: str = "",
waiting: bool = False,
) -> str:
"""Assemble the full comment body from all available inputs."""
items: list[ReviewItem] = []
# 1. Structured statuses from workflow_call jobs (review-labels, etc.)
status_items, sources = collect_from_statuses(review_statuses_json)
items.extend(status_items)
# 2. Synthesized error items for failed jobs not covered by statuses
items.extend(collect_failed_jobs(needs_json, run_url, exclude_sources=sources, job_urls=job_urls))
# 3. Attach per-job log links to all items (not just synthesized errors)
_attach_job_urls(items, job_urls or {}, run_url)
return render_comment(items, pending_jobs, commit_info, waiting=waiting)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--needs-json",
default="",
help="JSON string of {job_name: result} from the all-checks-pass job.",
)
parser.add_argument(
"--run-url",
default="",
help="URL to the CI run summary page (for failed job links).",
)
parser.add_argument(
"--review-statuses-json",
default="",
help="JSON array of {source, results: [...]} objects from workflow_call jobs.",
)
parser.add_argument(
"--pending-jobs",
default="",
help="Comma-separated list of job names still running (shown in a dimmed footer).",
)
parser.add_argument(
"--output",
type=Path,
required=True,
help="Output file for the assembled comment body.",
)
args = parser.parse_args()
pending = [j.strip() for j in args.pending_jobs.split(",") if j.strip()] if args.pending_jobs else None
body = assemble(
needs_json=args.needs_json,
run_url=args.run_url,
review_statuses_json=args.review_statuses_json,
pending_jobs=pending,
)
args.output.write_text(body, encoding="utf-8")
print(f"Wrote {len(body)} chars to {args.output}")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -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())
+340
View File
@@ -0,0 +1,340 @@
#!/usr/bin/env python3
"""Classify a PR's changed files into CI work lanes.
Reads newline-separated changed paths on stdin and writes ``key=value``
booleans (one per lane) to ``$GITHUB_OUTPUT`` and stdout. The
``detect-changes`` composite action consumes them so steps gate on
``if: steps.changes.outputs.<lane> == 'true'``.
Lanes:
* ``python`` — pytest / ruff / ty / footguns.
* ``python_prod`` — Python changes OUTSIDE tests/ — gates jobs that ship or
run the product (Desktop E2E backend, Docker image) but never import the
test suite. A tests-only PR keeps ``python`` (pytest must run) while
skipping those product jobs.
* ``docker_meta`` — Dockerfiles etc.
* ``docker`` — any product change + docker meta
* ``nix`` — ``nix flake check``: the flake inputs and any product change.
* ``frontend`` — TS typecheck matrix + desktop build.
* ``site`` — Docusaurus + generated skill docs.
* ``scan`` — supply-chain scan (Python files, .pth, setup hooks).
* ``deps`` — pyproject.toml dependency bounds check.
* ``uv_lock`` — ``uv lock --check``. Re-resolves the whole graph against
PyPI, so a diff that touches neither ``pyproject.toml`` nor ``uv.lock``
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.
* ``mcp_catalog`` — bundled MCP catalog / installer review.
Docker is not a lane — it builds on push-to-main and release only,
never per-PR.
Contract — *fail open, never closed*. We may run a lane we didn't need, but
must never skip one a change could break:
* An empty diff, or any ``.github/`` change, runs everything.
* ``python`` is a denylist: skipped only when *every* file is provably prose
or a frontend-only package; an unrecognized path keeps it on.
* ``skills/`` (incl. ``SKILL.md``) is python-relevant — the skill-doc tests
read that tree, so a doc-looking edit can still break Python.
* ``nix/``, ``flake.nix`` and ``flake.lock`` are the exception the other way:
only the flake reads them, so they skip the Python lanes and run ``nix``
alone. ``pyproject.toml`` and ``uv.lock`` are flake inputs too, but the
packaging tests read them, so they keep every Python lane.
* ``website/static/oauth/`` is python-relevant too: it publishes the OAuth
Client ID Metadata Document that ``tests/tools/test_mcp_cimd.py`` checks
against the pinned callback ports in ``tools/mcp_oauth.py``.
* ``website/docs/`` and ``website/scripts/`` are python-relevant for the same
reason: the docs tree generates ``llms.txt``, and
``tests/website/test_generate_llms_txt.py`` asserts every page reaches it.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
_FRONTEND = ("ui-tui/", "web/", "apps/") # TS typecheck-matrix packages
_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
_NIX_FILES = {"flake.nix", "flake.lock"} # base nix files
_SITE = ("website/", "skills/", "optional-skills/") # docs site + skill pages
# Prose/frontend trees that can't touch Python. skills/ is excluded on purpose.
_PY_SKIP = ("docs/", "website/") + _FRONTEND
# Published artifacts that live under website/ but that Python asserts about.
# The OAuth Client ID Metadata Document is cross-checked against the pinned
# callback ports in tools/mcp_oauth.py, so editing it alone must still run the
# Python lane — otherwise dropping a redirect URI goes green here and breaks
# every CIMD login on main.
# website/docs/ and website/scripts/ are asserted about the same way. The docs
# tree generates llms.txt — the index every LLM (Hermes included, via the
# hermes-agent skill) reads to learn what Hermes can do — and
# tests/website/test_generate_llms_txt.py holds every page to appearing in it.
# Skipping Python on a docs-only PR is how the index drifted to 53% coverage.
_PY_RELEVANT_SITE = (
"website/static/oauth/",
"website/docs/",
"website/scripts/",
)
# CI-sensitive files: eslint config, workflow files, composite actions.
# Changes here can influence what code the autofix job executes and pushes to
# main, so they require explicit maintainer review (ci-reviewed label).
#
# package.json is deliberately NOT listed here: npm scripts only execute on the
# unprivileged generate-patch runner (contents: read), never on the privileged
# apply-patch job. The two-job split means a malicious package.json script
# can't get push access — it runs on an ephemeral runner with zero write perms.
_CI_REVIEW_FILES = {
".prettierrc",
}
_CI_REVIEW_PATHS = (".github/workflows/", ".github/actions/")
# Supply-chain scan: files that can execute code at install/import time.
_SCAN_EXTS = (".py", ".pth")
_SCAN_FILES = {"setup.cfg", "pyproject.toml"}
# MCP catalog files that require explicit security review.
_MCP_CATALOG_PATHS = ("optional-mcps/",)
_MCP_CATALOG_FILES = {"hermes_cli/mcp_catalog.py"}
# Windows installer + its PowerShell tests. These only run on a Windows runner,
# so they get their own lane rather than riding along with ``python``.
_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,
# and the crate's unit tests had never executed in CI at all.
_RUST_PATHS = ("apps/bootstrap-installer/src-tauri/",)
_RUST_FILENAMES = {"Cargo.toml", "Cargo.lock"}
def _is_docs(p: str) -> bool:
if p.startswith(("skills/", "optional-skills/")):
return False
return p.endswith((".md", ".mdx")) or p.startswith("docs/") or p.startswith("LICENSE")
def _is_nix(p: str) -> bool:
return p.startswith(_NIX_PATHS) or p in _NIX_FILES
def _py_irrelevant(p: str) -> bool:
if p.startswith(_PY_RELEVANT_SITE):
return False
return (
_is_docs(p)
or p in _ROOT_NPM
or p.startswith(_PY_SKIP)
or p.startswith(_DOCKER_META)
or _is_nix(p)
)
def _py_test_only(p: str) -> bool:
"""Is ``p`` inside the test suite (never shipped / imported by the product)?
Product jobs (Desktop E2E's ``hermes serve`` backend, the Docker image)
run installed code — nothing under ``tests/`` is packaged or importable
there. scripts/run_tests.sh and run_tests_parallel.py are deliberately
NOT test-only: they are runner infrastructure, and a bad edit there can
mask real failures, so they stay conservative (python_prod=true).
"""
return p.startswith("tests/")
def _is_scan(p: str) -> bool:
return p.endswith(_SCAN_EXTS) or p in _SCAN_FILES
def _is_mcp_catalog(p: str) -> bool:
return p.startswith(_MCP_CATALOG_PATHS) or p in _MCP_CATALOG_FILES
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")
or p.startswith(_RUST_PATHS)
or os.path.basename(p) in _RUST_FILENAMES
)
def _is_ci_review(p: str) -> bool:
if p in _CI_REVIEW_FILES or p.startswith(_CI_REVIEW_PATHS):
return True
# Any eslint config file at any path — eslint configs can define custom
# fix functions that execute arbitrary code, so they all require review.
return os.path.basename(p).startswith("eslint.config.")
def ci_review_files(files: list[str]) -> list[str]:
"""Return the CI-sensitive paths that need maintainer review."""
return sorted({f.strip() for f in files if f.strip() and _is_ci_review(f.strip())})
def classify(files: list[str]) -> dict[str, bool]:
"""Map changed paths to ``{lane: should_run}``."""
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)
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)
ret = {
"python": python,
"python_prod": python_prod,
"docker": docker_meta or python_prod or frontend,
"docker_meta": docker_meta,
"frontend": frontend,
"site": any(f.startswith(_SITE) for f in files),
"scan": any(_is_scan(f) for f in files),
"deps": deps,
"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),
"nix": python_prod or frontend or any(_is_nix(f) for f in files)
}
if not files or any(f.startswith(".github/") for f in files):
ret["python"] = True
ret["python_prod"] = True
ret["docker"] = True
ret["docker_meta"] = True
ret["frontend"] = True
ret["site"] = True
ret["scan"] = True
ret["deps"] = True
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
# explicitly skip mcp catalog here. it's not needed unless those files are modified.
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()),
f"ci_review_files={json.dumps(ci_review_files(files))}",
])
if dest := os.environ.get("GITHUB_OUTPUT"):
with open(dest, "a", encoding="utf-8") as fh:
fh.write(out + "\n")
print(out) # echo for local runs + CI step logs
return 0
if __name__ == "__main__":
raise SystemExit(main())
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Select Desktop E2E visual evidence and build its CI review status."""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
from pathlib import Path
SOURCE = "playwright e2e"
EVIDENCE_START = "<!-- hermes-e2e-evidence:start -->"
EVIDENCE_END = "<!-- hermes-e2e-evidence:end -->"
def _files(root: Path, pattern: str) -> list[Path]:
return sorted(path for path in root.rglob(pattern) if path.is_file()) if root.exists() else []
def _is_explicit_screenshot(path: Path) -> bool:
"""Exclude Playwright's automatic and visual-comparator PNG outputs."""
return not (
path.name.startswith(("test-finished-", "test-failed-"))
or path.name.endswith(("-actual.png", "-expected.png", "-diff.png"))
)
def build_manifest(results_dir: Path) -> dict:
"""Record stable screenshot names from one E2E run for main/PR comparison."""
screenshots = [path for path in _files(results_dir, "*.png") if _is_explicit_screenshot(path)]
return {"version": 1, "screenshot_names": sorted({path.name for path in screenshots})}
def _base_screenshot_names(path: Path | None) -> set[str] | None:
"""Return ``None`` when main evidence is unavailable (never guess newness)."""
if path is None or not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return None
names = data.get("screenshot_names", []) if isinstance(data, dict) else []
if not isinstance(data, dict) or not isinstance(names, list):
return None
return {name for name in names if isinstance(name, str)}
def _stage_name(kind: str, path: Path, results_dir: Path) -> str:
relative = path.relative_to(results_dir).as_posix()
digest = hashlib.sha256(relative.encode("utf-8")).hexdigest()[:12]
return f"{kind}-{digest}-{path.name}"
def select_evidence(results_dir: Path, base_manifest: Path | None = None) -> dict:
"""Select only screenshots new to main, plus every generated visual diff."""
base_names = _base_screenshot_names(base_manifest)
screenshots = [] if base_names is None else [
path for path in _files(results_dir, "*.png")
if _is_explicit_screenshot(path) and path.name not in base_names
]
diffs: list[dict[str, Path]] = []
for diff in _files(results_dir, "*-diff.png"):
stem = diff.with_name(diff.name.removesuffix("-diff.png"))
entry = {"diff": diff}
for kind in ("actual", "expected"):
candidate = stem.with_name(f"{stem.name}-{kind}.png")
if candidate.is_file():
entry[kind] = candidate
diffs.append(entry)
return {"screenshots": screenshots, "diffs": diffs}
def stage_evidence(results_dir: Path, evidence_dir: Path, selection: dict) -> dict:
"""Copy selected PNGs into a flat, path-safe evidence artifact."""
evidence_dir.mkdir(parents=True, exist_ok=True)
staged: dict[Path, str] = {}
def stage(kind: str, path: Path) -> str:
if path in staged:
return staged[path]
name = _stage_name(kind, path, results_dir)
shutil.copyfile(path, evidence_dir / name)
staged[path] = name
return name
manifest = {"version": 1, "screenshots": [], "diffs": []}
for screenshot in selection["screenshots"]:
manifest["screenshots"].append({
"name": screenshot.name,
"file": stage("screenshot", screenshot),
})
for diff in selection["diffs"]:
entry = {"name": diff["diff"].name.removesuffix("-diff.png"), "diff": stage("diff", diff["diff"])}
for kind in ("actual", "expected"):
if kind in diff:
entry[kind] = stage(kind, diff[kind])
manifest["diffs"].append(entry)
(evidence_dir / "e2e-evidence.json").write_text(
json.dumps(manifest, sort_keys=True) + "\n", encoding="utf-8"
)
return manifest
def build_status(selection: dict, artifact_url: str = "") -> list[dict]:
"""Return the review status. The trusted publisher replaces its marker."""
screenshots = selection["screenshots"]
diffs = selection["diffs"]
if not screenshots and not diffs:
return []
summary_parts = []
if screenshots:
summary_parts.append(
f"{len(screenshots)} new screenshot{'s' if len(screenshots) != 1 else ''} vs main"
)
if diffs:
summary_parts.append(f"{len(diffs)} visual diff{'s' if len(diffs) != 1 else ''}")
result: dict[str, str] = {
"kind": "info",
"title": "Desktop E2E visual evidence",
"summary": "; ".join(summary_parts) + ".",
"detail": "\n".join((EVIDENCE_START, "<sub>inline evidence is publishing...</sub>", EVIDENCE_END)),
}
if artifact_url:
result["link"] = artifact_url
result["link_label"] = "View test artifacts"
return [{"source": SOURCE, "results": [result]}]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--results-dir", type=Path, required=True)
parser.add_argument("--base-manifest", type=Path)
parser.add_argument("--manifest-output", type=Path, required=True)
parser.add_argument("--evidence-dir", type=Path, required=True)
parser.add_argument("--artifact-url", default="")
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
args.manifest_output.write_text(
json.dumps(build_manifest(args.results_dir), sort_keys=True) + "\n", encoding="utf-8"
)
selection = select_evidence(args.results_dir, args.base_manifest)
stage_evidence(args.results_dir, args.evidence_dir, selection)
args.output.write_text(
json.dumps(build_status(selection, args.artifact_url)) + "\n", encoding="utf-8"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""Emit review_status JSON for the review-labels workflow.
Builds a JSON array with one entry::
[
{
"source": "review-label-gate",
"results": [
{"kind": "action_required", "title": "...", "summary": "...",
"how_to_fix": "..."},
{"kind": "info", "title": "...", "summary": "..."}
]
}
]
The ``source`` field is the workflow name that declared the status; the
assembler uses it to exclude the corresponding job from the synthesized
❌ Error list (the job already has its own status section).
The array can contain 0 to 3 results — one per lane that ran
(``ci_review``, ``mcp_catalog``, ``supply_chain``). When the ``ci-reviewed`` label is
present, the kind is ``info``; when missing, it's ``action_required``
with the verification checklist.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from urllib.parse import quote
# The source identifier used for error-synthesis exclusion. This must
# match (as a normalized substring) the job name as it appears in the
# GitHub Actions API. The ci.yml job key is ``review-labels`` with
# ``name: Review label gate``, and the reusable workflow's job is also
# ``name: Review label gate``, so the API shows the job as
# "Review label gate / Review label gate". Normalizing "review-label-gate"
# (lowercase, hyphens→spaces) gives "review label gate", which is a
# substring of "review label gate / review label gate".
SOURCE = "review-label-gate"
def _ci_review_detail(
files_json: str, repo_url: str, base_sha: str, head_sha: str,
) -> str:
"""Render links to the changed CI-sensitive files that triggered review."""
try:
files = json.loads(files_json)
except (json.JSONDecodeError, TypeError):
return ""
if not isinstance(files, list) or not repo_url or not base_sha or not head_sha:
return ""
links = []
for path in files:
if not isinstance(path, str) or not path:
continue
label = path.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
path_hash = hashlib.sha256(path.encode()).hexdigest()
url = (
f"{repo_url}/compare/{quote(base_sha, safe='')}...{quote(head_sha, safe='')}"
f"#diff-{path_hash}"
)
links.append(f"- [`{label}`]({url})")
return "**Sensitive files changed:**\n" + "\n".join(links) if links else ""
def build_results(
ci_review: bool,
mcp_catalog: bool,
supply_chain: bool,
label_present: bool,
ci_review_files: str = "[]",
repo_url: str = "",
base_sha: str = "",
head_sha: str = "",
) -> list[dict]:
"""Build the list of result objects for this source."""
results: list[dict] = []
if ci_review:
detail = _ci_review_detail(ci_review_files, repo_url, base_sha, head_sha)
if label_present:
result = {
"kind": "info",
"title": "CI-sensitive file review",
"summary": (
"PR touches sensitive files, but the `ci-reviewed` label has been "
"added, approving them."
),
}
else:
result = {
"kind": "action_required",
"title": "CI-sensitive file review",
"summary": (
"This PR changes CI-sensitive files (eslint config, "
"workflow YAMLs, or composite actions). These influence "
"what the js-autofix job executes and pushes to main."
),
"how_to_fix": (
"Add the `ci-reviewed` label after verifying:\n"
"- no new eslint rules with custom `fix` functions that write outside linted paths,\n"
"- no workflow changes that widen permissions or remove guards,\n"
"- no composite action changes that alter what gets executed."
),
}
if detail:
result["detail"] = detail
results.append(result)
if mcp_catalog:
if label_present:
results.append({
"kind": "debug",
"title": "MCP catalog security review",
"summary": "`ci-reviewed` label is present.",
})
else:
results.append({
"kind": "action_required",
"title": "MCP catalog security review",
"summary": (
"This PR changes the bundled MCP catalog or MCP catalog "
"installer code. MCP entries can define local commands "
"that users later install into `mcp_servers`, so this "
"needs explicit maintainer review before merge."
),
"how_to_fix": (
"Add the `ci-reviewed` label after verifying:\n"
"- any new/changed `optional-mcps/**/manifest.yaml` command and args are expected,\n"
"- stdio transports do not use shell+egress/exfiltration payloads,\n"
"- git install refs are pinned and bootstrap commands are minimal,\n"
"- requested env vars/secrets match the upstream MCP's documented needs."
),
})
if supply_chain and not label_present:
results.append({
"kind": "action_required",
"title": "Critical supply chain risk",
"summary": "Critical supply chain risk patterns were detected in this PR.",
"how_to_fix": (
"Review the flagged code carefully. If it is intentional, add the "
"`ci-reviewed` label to confirm maintainer review."
),
})
return results
def build_statuses(
ci_review: bool,
mcp_catalog: bool,
supply_chain: bool,
label_present: bool,
ci_review_files: str = "[]",
repo_url: str = "",
base_sha: str = "",
head_sha: str = "",
) -> list[dict]:
"""Build the full review_status array (one entry with a results list)."""
results = build_results(
ci_review, mcp_catalog, supply_chain, label_present,
ci_review_files, repo_url, base_sha, head_sha,
)
if not results:
return []
return [{"source": SOURCE, "results": results}]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--ci-review", action="store_true",
help="Whether CI-sensitive files changed.")
parser.add_argument("--ci-review-files", default="[]",
help="JSON list of CI-sensitive files changed.")
parser.add_argument("--mcp-catalog", action="store_true",
help="Whether the MCP catalog / installer changed.")
parser.add_argument("--supply-chain", action="store_true",
help="Whether the critical supply-chain scanner found a risk.")
parser.add_argument("--label-present", action="store_true",
help="Whether the ci-reviewed label is present.")
parser.add_argument("--repo-url", default="",
help="Repository URL used for changed-file links.")
parser.add_argument("--base-sha", default="",
help="Pull request base SHA used for changed-file links.")
parser.add_argument("--head-sha", default="",
help="Pull request head SHA used for changed-file links.")
parser.add_argument("--output", default="-",
help="Output file ('-' for stdout, or a GITHUB_OUTPUT path).")
args = parser.parse_args()
statuses = build_statuses(
args.ci_review, args.mcp_catalog, args.supply_chain, args.label_present,
args.ci_review_files, args.repo_url, args.base_sha, args.head_sha,
)
json_str = json.dumps(statuses)
if args.output == "-":
print(json_str)
else:
# GITHUB_OUTPUT format: key=value\n
with open(args.output, "a", encoding="utf-8") as f:
f.write(f"review_status={json_str}\n")
return 0
if __name__ == "__main__":
sys.exit(main())
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""List the test files that carry a given OS marker.
Used by ``.github/workflows/tests-os.yml`` to scope what the macOS and
Windows lanes import.
Why scope at all, when ``pytest -m macos_only`` already selects correctly?
Because ``-m`` filters AFTER collection, and collection IMPORTS every test
module under ``tests/``. On the Linux lane that is fine (it runs them all
anyway), but on the macOS/Windows lanes it would drag ~900 unrelated modules
through import on a host they were never expected to import on — one
unrelated ImportError would fail a job whose actual subject passed. Narrowing
the paths keeps each lane's failure signal about its own tests.
``-m`` is still passed by the workflow and remains the authoritative
selector: this script only decides which files get imported, never which
tests run. Over-selecting here is harmless (``-m`` drops the extras); the
failure mode to care about is UNDER-selecting, which is why the workflow
fails the job when zero tests end up selected.
Usage:
python scripts/ci/list_os_marked_tests.py macos_only [tests_root]
Prints one path per line (POSIX separators, repo-relative), sorted.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
_VALID_MARKERS = ("linux_only", "macos_only", "windows_only")
def find_marked_files(marker: str, root: Path) -> list[Path]:
"""Return every ``test_*.py`` under *root* that references *marker*.
Matches the marker as a whole word so ``macos_only`` doesn't pick up a
hypothetical ``macos_only_extra``. Catches both the decorator form
(``@pytest.mark.macos_only``, on a function or a class) and the
module-level ``pytestmark`` form.
"""
pattern = re.compile(rf"\b{re.escape(marker)}\b")
hits: list[Path] = []
for path in sorted(root.rglob("test_*.py")):
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
if pattern.search(text):
hits.append(path)
return hits
def main(argv: list[str]) -> int:
if len(argv) < 2:
print(__doc__, file=sys.stderr)
return 2
marker = argv[1]
if marker not in _VALID_MARKERS:
print(
f"error: unknown marker {marker!r} (expected one of "
f"{', '.join(_VALID_MARKERS)})",
file=sys.stderr,
)
return 2
repo_root = Path(__file__).resolve().parents[2]
root = Path(argv[2]) if len(argv) > 2 else repo_root / "tests"
if not root.exists():
print(f"error: no such directory: {root}", file=sys.stderr)
return 2
files = find_marked_files(marker, root)
if not files:
print(
f"error: no test file references @pytest.mark.{marker} — the marker "
"was probably renamed or dropped. Refusing to emit an empty list, "
"which would let the OS lane pass without running anything.",
file=sys.stderr,
)
return 1
lines: list[str] = []
for path in files:
# POSIX separators so the output is safe to paste into a bash
# command line on the Windows runner (Git Bash accepts them).
#
# Relative to the repo root when the path is inside it (the CI case —
# pytest is invoked from the repo root). A root outside the repo is a
# test/manual invocation; emit it as-is rather than raising, since
# ``relative_to`` refuses non-descendant paths.
try:
rel = path.resolve().relative_to(repo_root)
except ValueError:
lines.append(path.as_posix())
else:
lines.append(rel.as_posix())
# Write bytes with explicit LF rather than print(), which on Windows
# translates "\n" to "\r\n" in text mode. The consumer reads this list with
# ``$(cat ...)`` in bash, and word splitting uses IFS (space/tab/newline) —
# a CR is NOT a separator, so it stays glued to each path and pytest then
# fails with "file or directory not found: tests/...py" for a path that
# looks correct in the log because the CR is invisible. Emitting bytes makes
# the output identical on every host instead of depending on the platform's
# newline translation.
sys.stdout.buffer.write(b"".join(line.encode("utf-8") + b"\n" for line in lines))
sys.stdout.buffer.flush()
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+794
View File
@@ -0,0 +1,794 @@
#!/usr/bin/env python3
"""Live-updating CI review comment.
Polls the GitHub Actions API for job statuses in the CI run, assembles
the review comment from whatever results are available, and upserts it as a
PR comment. Repeats every ``--interval`` seconds until all jobs are
completed (or ``--timeout`` is reached), so the comment updates in real time
as each job finishes.
The comment is identified by the ``<!-- hermes-ci-review-bot -->`` marker
— the same one ``assemble_review_comment.py`` uses — so it replaces any
previous comment from an earlier run.
This runs from ``.github/workflows/ci-review-comment.yml``, a separate
``workflow_run`` workflow. Thus ``CI_RUN_ID`` names the CI run to report
on, not the run that contains this script. (The variable cannot be
called ``GITHUB_RUN_ID``: the Actions runner sets the ``GITHUB_*``
defaults itself and ignores an ``env:`` override, so that name would
silently resolve to the poller's own run — which stays ``in_progress``
for as long as the poller runs, deadlocking it against itself.)
The poller reports on runs that
it does not belong to. This is also how it covers a workflow that CI does
not contain: ``WATCH_WORKFLOWS`` names sibling workflows that the same
commit triggered (the Docker image build). Their jobs join the comment.
Architecture:
- :func:`classify_jobs` (pure, testable) — takes a list of raw API job
dicts and returns ``(completed, pending, job_urls)`` where ``completed``
is a ``{name: result}`` dict (for :func:`assemble_review_comment.assemble`)
and ``pending`` is a list of job names still running.
- :func:`select_watched_runs` (pure, testable) — picks the sibling runs
to merge in, newest attempt per workflow.
- :func:`find_comment_id` / :func:`upsert_comment` — thin API wrappers.
- :func:`fetch_all_review_statuses` — lists all ``review-status-*``
artifacts on the CI run (GitHub attaches reusable-workflow
artifacts to the caller run), downloads each, parses the
``review_status=`` line from ``review-status.json``, and merges into
one array. Recomputed from source every poll cycle, so statuses
appear as soon as each job uploads its artifact.
- :func:`run` — the polling loop. Calls the API, classifies,
fetches artifacts, assembles, upserts, sleeps, repeats. Before
its final exit, it gives downstream jobs a short grace period
to appear.
The orchestrator job names (detect, all-checks-pass, comment-live, etc.)
are excluded from the comment — they're infrastructure, not review signal.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import sys
import time
import urllib.error
import urllib.request
import zipfile
from pathlib import Path
API_BASE = "https://api.github.com"
# Job names that are infrastructure (this script, the gate, the detector)
# and should never appear in the review comment.
_INFRA_JOBS = frozenset({
"detect",
"all-checks-pass",
"comment-pending",
"comment-results",
"comment-live",
"CI review comment (pending)",
"CI review comment (results)",
"CI review comment (live)",
"All required checks pass",
"Detect affected areas",
})
# Map GitHub API conclusion values to our result strings.
_CONCLUSION_MAP = {
"success": "success",
"failure": "failure",
"skipped": "skipped",
"cancelled": "skipped",
"neutral": "skipped",
"timed_out": "failure",
"action_required": "skipped",
}
def classify_jobs(api_jobs: list[dict]) -> tuple[dict[str, str], list[str], dict[str, str]]:
"""Classify raw API job dicts into completed + pending + job_urls.
Returns ``(completed, pending, job_urls)``:
- ``completed``: ``{job_name: result}`` where result is
``"success"`` / ``"failure"`` / ``"skipped"``. Only non-infra jobs
that have finished.
- ``pending``: list of job names still running (in_progress / queued
/ waiting). Excludes infra jobs.
- ``job_urls``: ``{job_name: html_url}`` — direct links to each
job's logs page, for the assembler to use in ❌ Error links.
The API returns orchestrator-level jobs and sub-workflow jobs
(workflow_call) in separate runs — :func:`collect_run_jobs` merges
them. Each sub-workflow job has a ``_workflow_name`` prefix so the
display name is ``"Workflow / job"``.
"""
completed: dict[str, str] = {}
pending: list[str] = []
job_urls: dict[str, str] = {}
for job in api_jobs:
name = job.get("name", "unknown")
if job.get("_workflow_name"):
name = f"{job['_workflow_name']} / {name}"
if name in _INFRA_JOBS:
continue
status = job.get("status", "")
conclusion = job.get("conclusion", "")
html_url = job.get("html_url", "")
if html_url:
job_urls[name] = html_url
if status in ("in_progress", "queued", "waiting"):
pending.append(name)
elif status == "completed":
result = _CONCLUSION_MAP.get(conclusion, "skipped")
completed[name] = result
# else: unknown status → skip
return completed, pending, job_urls
# ---------------------------------------------------------------------------
# API helpers
# ---------------------------------------------------------------------------
def _api_request(url: str, token: str) -> dict:
"""Authenticated GitHub API GET (single page)."""
req = urllib.request.Request(url, headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "ci-live-comment",
})
with urllib.request.urlopen(req) as resp:
data: dict = json.loads(resp.read())
return data
def _api_get_paginated(url: str, token: str, list_key: str | None = None) -> list:
"""Authenticated GitHub API GET with pagination."""
results: list = []
while url:
req = urllib.request.Request(url, headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "ci-live-comment",
})
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
link_header = resp.headers.get("Link", "")
if list_key:
results.extend(data.get(list_key, []))
elif isinstance(data, list):
results.extend(data)
else:
return data
next_url = None
for part in link_header.split(","):
part = part.strip()
if 'rel="next"' in part:
next_url = part[part.find("<") + 1:part.find(">")]
break
url = next_url
return results
def select_watched_runs(
runs: list[dict], watch_names: list[str], exclude_run_id: str = "",
) -> list[dict]:
"""Pick the sibling runs whose jobs belong in the comment.
``runs`` is the API's run list for one commit. ``watch_names`` holds
workflow names from ``WATCH_WORKFLOWS``. One commit can have more than
one run of the same workflow, after a rerun or a new push. Thus this
keeps only the newest run for each workflow name. An older attempt
reports results that a rerun replaced.
``exclude_run_id`` removes the CI run itself when its name is also in
``watch_names``.
"""
newest: dict[str, dict] = {}
wanted = {n.strip() for n in watch_names if n.strip()}
for candidate in runs:
name = str(candidate.get("name", ""))
if name not in wanted:
continue
if exclude_run_id and str(candidate.get("id", "")) == str(exclude_run_id):
continue
current = newest.get(name)
if current is None or str(candidate.get("created_at", "")) > str(current.get("created_at", "")):
newest[name] = candidate
return list(newest.values())
def runs_all_completed(runs: list[dict]) -> bool:
"""True only when every run in the list reports ``status: completed``.
The job list alone cannot answer "is CI done": a run that GitHub just
created has no jobs yet, and a mid-run poll can catch the moment where
every visible job finished but a downstream sub-workflow has not
spawned its jobs. Both look identical to "all done" at the job level.
The run's own ``status`` is the authoritative signal, so the poller
must not exit while any relevant run is still ``queued`` or
``in_progress``. An empty list is not done — it means the poller has
no run information at all.
"""
return bool(runs) and all(str(r.get("status", "")) == "completed" for r in runs)
def collect_run_jobs(
token: str, repo: str, run_id: str, watch_workflows: list[str] | None = None,
) -> tuple[list[dict], bool]:
"""Collect all jobs in the CI run + any watched sibling runs.
Returns ``(jobs, runs_completed)``: a flat list of job dicts (same
shape as the API returns, plus ``_workflow_name`` on jobs from a
watched run), and whether the CI run and every selected watched run
report ``status: completed`` (see :func:`runs_all_completed`).
Reusable-workflow (``workflow_call``) jobs need no special handling:
GitHub flattens them into the caller run's job list, already named
``\"Workflow / job\"``. Watched runs are separate top-level runs
(the Docker image build), so their jobs are fetched per run and
prefixed here.
"""
owner, repo_name = repo.split("/")
run_info = _api_request(f"{API_BASE}/repos/{owner}/{repo_name}/actions/runs/{run_id}", token)
head_sha = run_info.get("head_sha", "")
# CI run jobs (includes every reusable-workflow job).
all_jobs: list[dict] = []
orch_jobs = _api_get_paginated(
f"{API_BASE}/repos/{owner}/{repo_name}/actions/runs/{run_id}/jobs",
token, list_key="jobs",
)
# Skip workflow-call placeholder steps (they're sub-workflow triggers,
# not review signal), but KEEP in_progress / queued jobs so the poller
# knows they're still running.
for job in orch_jobs:
steps = job.get("steps") or []
if any(s.get("name", "").startswith("Run ./.github/workflows/") for s in steps):
continue
all_jobs.append(job)
if not watch_workflows or not head_sha:
return all_jobs, runs_all_completed([run_info])
# Watched sibling runs for the same commit. A run can be absent on the
# first polls. Then classify_jobs() shows nothing for it.
sibling_runs = _api_get_paginated(
f"{API_BASE}/repos/{owner}/{repo_name}/actions/runs?head_sha={head_sha}&per_page=100",
token, list_key="workflow_runs",
)
relevant_runs = [run_info]
for watched in select_watched_runs(sibling_runs, watch_workflows, exclude_run_id=run_id):
relevant_runs.append(watched)
watched_jobs = _api_get_paginated(
f"{API_BASE}/repos/{owner}/{repo_name}/actions/runs/{watched['id']}/jobs",
token, list_key="jobs",
)
for job in watched_jobs:
job["_workflow_name"] = watched.get("name", "")
all_jobs.append(job)
return all_jobs, runs_all_completed(relevant_runs)
def find_comment_id(token: str, repo: str, pr_number: str) -> int | None:
"""Find our existing review comment by marker prefix."""
owner, repo_name = repo.split("/")
comments = _api_get_paginated(
f"{API_BASE}/repos/{owner}/{repo_name}/issues/{pr_number}/comments",
token,
)
for c in comments:
body = c.get("body", "") if isinstance(c, dict) else ""
if body.startswith("<!-- hermes-ci-review-bot -->"):
return c.get("id") if isinstance(c, dict) else None
return None
def upsert_comment(
token: str, repo: str, pr_number: str, body: str, comment_id: int | None = None
) -> int | None:
"""Create or update the review comment. Returns the comment ID."""
owner, repo_name = repo.split("/")
if comment_id is None:
comment_id = find_comment_id(token, repo, pr_number)
if comment_id:
url = f"{API_BASE}/repos/{owner}/{repo_name}/issues/comments/{comment_id}"
method = "PATCH"
else:
url = f"{API_BASE}/repos/{owner}/{repo_name}/issues/{pr_number}/comments"
method = "POST"
data = json.dumps({"body": body}).encode("utf-8")
req = urllib.request.Request(url, data=data, method=method, headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
"User-Agent": "ci-live-comment",
})
try:
with urllib.request.urlopen(req) as resp:
result = json.loads(resp.read())
return result.get("id")
except urllib.error.HTTPError as e:
print(f" API error {e.code}: {e.reason}", file=sys.stderr)
return None
# ---------------------------------------------------------------------------
# Artifact fetching (dynamic review-status artifacts)
# ---------------------------------------------------------------------------
# Prefix for all review-status artifacts uploaded by status-producing jobs.
# Each job uploads a ``review-status-<name>`` artifact containing a
# ``review-status.json`` file in GITHUB_OUTPUT format:
# review_status=<json array of {source, results: [...]} objects>
_REVIEW_STATUS_ARTIFACT_PREFIX = "review-status-"
def _list_artifacts(token: str, repo: str, run_id: str) -> list[dict]:
"""List artifacts for a given run (paginated)."""
owner, repo_name = repo.split("/")
return _api_get_paginated(
f"{API_BASE}/repos/{owner}/{repo_name}/actions/runs/{run_id}/artifacts",
token, list_key="artifacts",
)
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Redirect handler that never follows — used to capture the Location."""
def redirect_request(self, *args, **kwargs):
return None
def _download_artifact(
token: str, repo: str, artifact: dict, dest_dir: Path,
) -> Path | None:
"""Download a single artifact zip via the API and extract it.
Returns the path to ``review-status.json`` inside the extracted dir,
or ``None`` if the download or extraction failed.
"""
owner, repo_name = repo.split("/")
archive_download_url = artifact.get("archive_download_url", "")
if not archive_download_url:
return None
# The archive_download_url is an API URL that 302s to a signed blob
# URL. Hop 1 authenticates to the API; hop 2 follows the redirect
# WITHOUT the Authorization header — the blob rejects a request that
# carries both a SAS token and an Authorization header (401).
opener = urllib.request.build_opener(_NoRedirectHandler)
location = ""
try:
opener.open(urllib.request.Request(archive_download_url, headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "ci-live-comment",
}), timeout=30)
except urllib.error.HTTPError as e:
location = e.headers.get("Location", "") if e.code == 302 else ""
except Exception:
location = ""
if not location:
return None
zip_path = dest_dir / f"{artifact['name']}.zip"
try:
# No auth headers here; further redirects are safe to follow.
with urllib.request.urlopen(
urllib.request.Request(location, headers={"User-Agent": "ci-live-comment"}),
timeout=60,
) as resp:
zip_path.write_bytes(resp.read())
except Exception:
return None
extract_dir = dest_dir / artifact["name"]
extract_dir.mkdir(parents=True, exist_ok=True)
try:
with zipfile.ZipFile(zip_path) as zf:
if any(".." in name or name.startswith("/") for name in zf.namelist()):
return None
zf.extractall(extract_dir)
except Exception:
return None
status_file = extract_dir / "review-status.json"
return status_file if status_file.exists() else None
def _parse_status_file(status_file: Path) -> list[dict]:
"""Parse a review-status.json file in GITHUB_OUTPUT format."""
try:
content = status_file.read_text(encoding="utf-8").strip()
if content.startswith("review_status="):
content = content[len("review_status="):]
statuses = json.loads(content)
if isinstance(statuses, list):
return statuses
except (json.JSONDecodeError, OSError):
pass
return []
def fetch_all_review_statuses(
token: str, repo: str, run_id: str,
) -> list[dict]:
"""Fetch and merge all review-status artifacts from the run.
Lists artifacts with the ``review-status-`` prefix on the orchestrator
run, downloads each, parses the ``review-status.json`` inside, and
merges into a single flat array. GitHub attaches artifacts uploaded by
reusable workflow jobs to the caller run, so one listing covers every
status-producing job.
Returns the merged list of ``{source, results: [...]}`` objects.
Artifacts that don't exist yet or fail to parse are silently skipped.
"""
all_statuses: list[dict] = []
temp_base = Path("/tmp/review-status-artifacts")
try:
artifacts = _list_artifacts(token, repo, run_id)
except Exception:
return all_statuses
rs_artifacts = [
a for a in artifacts
if a.get("name", "").startswith(_REVIEW_STATUS_ARTIFACT_PREFIX)
]
if not rs_artifacts:
return all_statuses
# Clean temp dir for this run's artifacts.
run_dl_dir = temp_base / str(run_id)
if run_dl_dir.exists():
shutil.rmtree(run_dl_dir)
run_dl_dir.mkdir(parents=True, exist_ok=True)
for artifact in rs_artifacts:
status_file = _download_artifact(token, repo, artifact, run_dl_dir)
if status_file is None:
continue
statuses = _parse_status_file(status_file)
all_statuses.extend(statuses)
# A re-run can leave several non-expired artifacts with the same name,
# each carrying the same source — dedupe by source so the comment
# doesn't render duplicate sections.
seen: set[str] = set()
deduped: list[dict] = []
for status in all_statuses:
src = status.get("source", "")
if src in seen:
continue
if src:
seen.add(src)
deduped.append(status)
return deduped
# ---------------------------------------------------------------------------
# Comment assembly
# ---------------------------------------------------------------------------
def _import_assembler():
"""Import assemble_review_comment.py from the same directory."""
here = Path(__file__).resolve().parent
sys.path.insert(0, str(here))
import assemble_review_comment as asm
return asm
def build_comment_body(
asm_mod,
completed: dict[str, str],
pending: list[str],
run_url: str,
job_urls: dict[str, str],
review_statuses_json: str,
commit_info: str = "",
waiting: bool = False,
) -> str:
"""Assemble the comment body from current job states + static inputs."""
needs_json = json.dumps(completed) if completed else ""
return asm_mod.assemble(
needs_json=needs_json,
run_url=run_url,
job_urls=job_urls,
review_statuses_json=review_statuses_json,
pending_jobs=pending if pending else None,
commit_info=commit_info,
waiting=waiting,
)
def _commit_info_for_state(commit_info: str, pending: bool) -> str:
"""Use past tense in the final comment after every CI job completes."""
if pending:
return commit_info
return commit_info.replace("<sub>running on ", "<sub>ran on ", 1)
# ---------------------------------------------------------------------------
# Polling loop
# ---------------------------------------------------------------------------
def run(
token: str,
repo: str,
run_id: str,
pr_number: str,
run_url: str,
commit_info: str = "",
interval: int = 15,
timeout: int = 1800,
dry_run: bool = False,
watch_workflows: list[str] | None = None,
) -> int:
"""Poll for job statuses and update the PR comment until all done.
Always returns 0. The poller reports on the CI run from a different run.
Thus a failed CI job is not a failure of this job. The CI run has its
own gate, which reports that. Comment posting is best-effort.
"""
asm = _import_assembler()
start = time.time()
last_body = ""
quiet_grace_used = False
prev_completed: dict[str, str] = {}
prev_pending: list[str] = []
prev_artifact_count = 0
while True:
elapsed = time.time() - start
if elapsed > timeout:
print(f"Timeout ({timeout}s) reached — stopping poll.", file=sys.stderr)
break
try:
jobs, runs_completed = collect_run_jobs(token, repo, run_id, watch_workflows)
except Exception as e:
print(f" API error collecting jobs: {e}", file=sys.stderr)
time.sleep(interval)
continue
completed, pending, job_urls = classify_jobs(jobs)
total = len(completed) + len(pending)
infra_count = len(jobs) - total
print(f" [{elapsed:.0f}s] fetched {len(jobs)} jobs from API "
f"({infra_count} infra filtered) → {len(completed)} completed, "
f"{len(pending)} pending ({total} review jobs)")
# Log transitions since last poll.
new_completed = {k: v for k, v in completed.items() if k not in prev_completed}
new_pending = [j for j in pending if j not in prev_pending]
gone_pending = [j for j in prev_pending if j not in pending and j not in completed]
if new_completed:
parts = [f"{name}={result}" for name, result in new_completed.items()]
print(f"{len(new_completed)} job(s) newly completed: {', '.join(parts)}")
if new_pending:
print(f"{len(new_pending)} job(s) newly appeared: {', '.join(new_pending)}")
if gone_pending:
print(f"{len(gone_pending)} job(s) disappeared from pending: {', '.join(gone_pending)}")
# Dynamically fetch all review-status artifacts from the run.
artifact_statuses = fetch_all_review_statuses(token, repo, run_id)
artifact_count_changed = len(artifact_statuses) != prev_artifact_count
if artifact_count_changed:
print(f" Found {len(artifact_statuses)} review status entries from artifacts "
f"(was {prev_artifact_count} last poll)")
prev_artifact_count = len(artifact_statuses)
merged_json = json.dumps(artifact_statuses) if artifact_statuses else ""
# The run status is authoritative for "done": an empty job list on
# a run that is still queued/in_progress means GitHub has not
# spawned the jobs yet, not that everything passed.
all_done = not pending and runs_completed
current_commit_info = _commit_info_for_state(commit_info, pending=not all_done)
body = build_comment_body(
asm, completed, pending, run_url, job_urls,
merged_json,
current_commit_info,
waiting=not runs_completed,
)
if body != last_body:
change_reasons = []
if new_completed:
change_reasons.append(f"{len(new_completed)} new completion(s)")
if new_pending:
change_reasons.append(f"{len(new_pending)} new pending job(s)")
if gone_pending:
change_reasons.append(f"{len(gone_pending)} job(s) left pending")
if artifact_count_changed:
change_reasons.append("artifact statuses updated")
if not change_reasons:
change_reasons.append("initial post")
reason = "; ".join(change_reasons)
if dry_run:
print(f" Comment body changed ({reason}) — DRY RUN:")
print("--- DRY RUN — comment body ---")
print(body)
print("--- END ---")
else:
cid = upsert_comment(token, repo, pr_number, body)
if cid:
print(f" Updated comment {cid} ({reason})")
else:
print(f" Failed to update comment ({reason}, will retry)", file=sys.stderr)
last_body = body
else:
if pending:
print(f" No change since last poll. Still waiting on: {', '.join(pending)}")
else:
print(" No change since last poll.")
prev_completed = completed
prev_pending = pending
if all_done and not quiet_grace_used:
quiet_grace_used = True
print(" No jobs pending and runs report completed — "
"waiting 10s for downstream jobs to appear.")
time.sleep(10)
continue
if all_done:
failed = [name for name, result in completed.items() if result == "failure"]
if failed:
print(f" All jobs done, {len(failed)} failed: {', '.join(failed)}")
else:
print(" All jobs completed — done.")
break
if not pending:
print(" No visible jobs pending, but a run is still queued or "
"in progress — waiting for its jobs to appear.")
quiet_grace_used = False
time.sleep(interval)
return 0
def parse_watch_workflows(raw: str) -> list[str]:
"""Parse the ``WATCH_WORKFLOWS`` value into workflow names.
One name per line. Not comma-separated: a workflow name can contain a
comma ("Docker Build, Test, and Publish").
"""
return [name.strip() for name in raw.splitlines() if name.strip()]
def resolve_pr_number(token: str, repo: str, head_sha: str) -> str:
"""Find the PR number for a commit when the event payload has none.
``workflow_run.pull_requests`` is empty for some runs. The poller has no
comment to post without a number.
"""
if not head_sha:
return ""
owner, repo_name = repo.split("/")
try:
results = _api_get_paginated(
f"{API_BASE}/repos/{owner}/{repo_name}/commits/{head_sha}/pulls",
token,
)
except Exception as e:
print(f" API error resolving PR number: {e}", file=sys.stderr)
return ""
for item in results:
if isinstance(item, dict) and item.get("state") == "open":
return str(item.get("number", ""))
return ""
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--interval", type=int, default=15,
help="Seconds between polls (default: 15).")
parser.add_argument("--timeout", type=int, default=1800,
help="Max seconds to poll before giving up (default: 1800).")
parser.add_argument("--dry-run", action="store_true",
help="Print comment body instead of posting to PR.")
args = parser.parse_args()
token = os.environ.get("GITHUB_TOKEN", "")
repo = os.environ.get("GITHUB_REPOSITORY", "")
run_id = os.environ.get("CI_RUN_ID", "")
pr_number = os.environ.get("PR_NUMBER", "")
run_url = os.environ.get("RUN_URL", "")
# Sibling workflows to merge into the comment, one name per line. Their
# runs are separate from the CI run, so the poller resolves them by name.
watch_workflows = parse_watch_workflows(os.environ.get("WATCH_WORKFLOWS", ""))
if not args.dry_run:
if not token:
print("GITHUB_TOKEN is required", file=sys.stderr)
return 1
if not repo:
print("GITHUB_REPOSITORY is required", file=sys.stderr)
return 1
if not run_id:
print("CI_RUN_ID is required", file=sys.stderr)
return 1
# Build commit info line from env vars (set by ci-review-comment.yml).
commit_sha = os.environ.get("COMMIT_SHA", "")
commit_msg = os.environ.get("COMMIT_MESSAGE", "")
if not pr_number and not args.dry_run:
pr_number = resolve_pr_number(token, repo, commit_sha)
if not pr_number:
print("No PR number found — nothing to comment on.", file=sys.stderr)
return 0
print(f"Resolved PR #{pr_number} from commit {commit_sha[:7]}")
commit_url = os.environ.get("COMMIT_URL", "")
if not commit_url and commit_sha and pr_number:
server = os.environ.get("GITHUB_SERVER_URL", "https://github.com")
commit_url = f"{server}/{repo}/pull/{pr_number}/commits/{commit_sha}"
commit_info = ""
if commit_sha:
short_sha = commit_sha[:7]
if commit_msg:
# Truncate commit message to first line, max 60 chars.
first_line = commit_msg.split("\n")[0][:60]
if commit_url:
commit_info = f"<sub>running on [{short_sha}]({commit_url}) — {first_line}</sub>"
else:
commit_info = f"<sub>running on {short_sha}{first_line}</sub>"
elif commit_url:
commit_info = f"<sub>running on [{short_sha}]({commit_url})</sub>"
else:
commit_info = f"<sub>running on {short_sha}</sub>"
return run(
token=token,
repo=repo,
run_id=run_id,
pr_number=pr_number,
run_url=run_url,
commit_info=commit_info,
interval=args.interval,
timeout=args.timeout,
dry_run=args.dry_run,
watch_workflows=watch_workflows,
)
if __name__ == "__main__":
sys.exit(main())
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Semantic diff of npm ``package-lock.json`` files for PR comments.
``git diff`` on a lockfile is unreadable: npm reorders entries, rewrites
integrity hashes, and moves packages between nesting levels, so a one-line
``package.json`` bump can produce a thousand-line textual diff. This script
ignores the text entirely — it parses the ``packages`` map out of both
versions of each lockfile (lockfileVersion 2/3), reduces each to
``{install path: version}``, and set-diffs the two dicts. Reordering and
hash churn vanish; what's left is the actual dependency change.
Usage (from a checkout that still has the base ref available):
python scripts/ci/lockfile_diff.py --base <ref> --head <ref> \
--output diff.md [--repo-root .]
Reads every ``package-lock.json`` tracked at either ref (top-level and
nested — the repo has several), diffs each, and writes a Markdown fragment
to ``--output``. Exits 0 always; an empty output file means "no version
changes" (the caller uses that to decide whether to include the section).
The fragment is consumed by ``scripts/ci/assemble_review_comment.py``,
which wraps it in a section with a header and action note.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
def parse_lockfile(text: str) -> dict[str, str]:
"""Reduce lockfile JSON to ``{install path: version}``.
Keys are the ``packages`` map's keys (e.g. ``node_modules/react`` or
``node_modules/foo/node_modules/react``), so the same package deduped
at two versions shows up as two distinct entries. The root entry
(``""``, the workspace itself) is skipped, as are versionless link
entries.
"""
data = json.loads(text)
out: dict[str, str] = {}
for path, meta in data.get("packages", {}).items():
if not path:
continue # root project entry, not a dependency
version = meta.get("version")
if version:
out[path] = version
return out
def diff_locks(base: dict[str, str], head: dict[str, str]) -> dict[str, list]:
"""Set-diff two ``{path: version}`` maps.
Returns ``added`` / ``removed`` as ``[(path, version)]`` and
``updated`` as ``[(path, base_version, head_version)]``, each sorted
by path.
"""
added = sorted((p, v) for p, v in head.items() if p not in base)
removed = sorted((p, v) for p, v in base.items() if p not in head)
updated = sorted(
(p, base[p], head[p]) for p in base.keys() & head.keys() if base[p] != head[p]
)
return {"added": added, "removed": removed, "updated": updated}
def _display_name(path: str) -> str:
"""``node_modules/foo/node_modules/@scope/bar`` → ``@scope/bar (nested under foo)``."""
parts = path.split("node_modules/")
name = parts[-1].rstrip("/")
if len(parts) > 2:
parents = "".join(p.rstrip("/") for p in parts[1:-1])
return f"{name} *(nested under {parents})*"
return name
def render_markdown(diffs: dict[str, dict[str, list]]) -> str:
"""Render per-lockfile diffs as a Markdown fragment.
``diffs`` maps lockfile repo-path → the output of :func:`diff_locks`.
Lockfiles with no version changes are omitted. Returns ``""`` when
nothing changed anywhere (caller skips the section entirely).
The output is a fragment — per-lockfile ``####`` subsections with
tables — not a standalone comment. The ``assemble_review_comment``
script wraps this in a section with its own header and action note,
so no top-level header or comment marker is emitted here.
"""
sections = []
for lockfile, d in sorted(diffs.items()):
added, removed, updated = d["added"], d["removed"], d["updated"]
n = len(added) + len(removed) + len(updated)
if n == 0:
continue
lines = [f"#### `{lockfile}`", ""]
lines.append("| Package | Before | After |")
lines.append("| --- | --- | --- |")
for path, old, new in updated:
lines.append(f"| {_display_name(path)} | `{old}` | `{new}` |")
for path, version in added:
lines.append(f"| {_display_name(path)} | — | `{version}` |")
for path, version in removed:
lines.append(f"| {_display_name(path)} | `{version}` | — |")
sections.append("\n".join(lines))
if not sections:
return ""
return "\n\n".join(sections) + "\n"
def _git_show(ref: str, path: str, repo_root: str) -> str | None:
"""Contents of ``path`` at ``ref``, or None if it doesn't exist there."""
proc = subprocess.run(
["git", "show", f"{ref}:{path}"],
capture_output=True,
text=True, encoding="utf-8", errors="replace",
cwd=repo_root,
)
return proc.stdout if proc.returncode == 0 else None
def _tracked_lockfiles(ref: str, repo_root: str) -> set[str]:
proc = subprocess.run(
["git", "ls-tree", "-r", "--name-only", ref],
capture_output=True,
text=True, encoding="utf-8", errors="replace",
cwd=repo_root,
check=True,
)
return {
line
for line in proc.stdout.splitlines()
if line.split("/")[-1] == "package-lock.json"
}
def diff_refs(base: str, head: str, repo_root: str = ".") -> dict[str, dict[str, list]]:
"""Diff every package-lock.json tracked at either ref."""
lockfiles = _tracked_lockfiles(base, repo_root) | _tracked_lockfiles(head, repo_root)
diffs = {}
for path in sorted(lockfiles):
base_text = _git_show(base, path, repo_root)
head_text = _git_show(head, path, repo_root)
base_map = parse_lockfile(base_text) if base_text else {}
head_map = parse_lockfile(head_text) if head_text else {}
diffs[path] = diff_locks(base_map, head_map)
return diffs
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--base", required=True, help="base git ref (merge base)")
ap.add_argument("--head", required=True, help="head git ref")
ap.add_argument("--output", required=True, help="markdown output path")
ap.add_argument("--repo-root", default=".", help="repository root")
args = ap.parse_args()
diffs = diff_refs(args.base, args.head, args.repo_root)
markdown = render_markdown(diffs)
with open(args.output, "w", encoding="utf-8") as fh:
fh.write(markdown)
if markdown:
changed = sum(len(v) for d in diffs.values() for v in d.values())
print(f"{changed} package version change(s) — report written to {args.output}")
else:
print("No package version changes.")
return 0
if __name__ == "__main__":
sys.exit(main())
+337
View File
@@ -0,0 +1,337 @@
#!/usr/bin/env python3
"""Publish validated E2E evidence as GitHub attachments and update its PR comment.
This script only runs from the trusted ``workflow_run`` publisher. It never
checks out PR code: it accepts the small evidence artifact produced by the
untrusted E2E workflow, validates its manifest and PNG bytes, uploads the
approved files as GitHub attachments, and replaces the placeholder in the
source PR's CI review comment with those attachment URLs.
"""
from __future__ import annotations
import argparse
import html
import json
import os
import re
import subprocess
import sys
import time
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any
API_BASE = "https://api.github.com"
EVIDENCE_START = "<!-- hermes-e2e-evidence:start -->"
EVIDENCE_END = "<!-- hermes-e2e-evidence:end -->"
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
MAX_FILES = 20
MAX_FILE_BYTES = 5 * 1024 * 1024
MAX_TOTAL_BYTES = 20 * 1024 * 1024
MAX_DIMENSION = 8_000
COMMENT_LOOKUP_ATTEMPTS = 6
COMMENT_LOOKUP_DELAY_SECONDS = 2
_SAFE_FILE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*\.png$")
_ATTACHMENT_URL = re.compile(r"^!\[[^\]\r\n]*\]\((https://github\.com/user-attachments/assets/[0-9a-fA-F-]+)\)$")
@dataclass(frozen=True)
class EvidenceFile:
"""One validated PNG and the label used when rendering the PR comment."""
filename: str
label: str
def _api_request(
url: str,
token: str,
method: str = "GET",
payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Send one authenticated GitHub API request and return its JSON object."""
data = json.dumps(payload).encode("utf-8") if payload is not None else None
request = urllib.request.Request(
url,
data=data,
method=method,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"Content-Type": "application/json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "hermes-e2e-evidence-publisher",
},
)
with urllib.request.urlopen(request) as response:
parsed = json.loads(response.read())
if not isinstance(parsed, dict):
raise ValueError(f"Expected an object from {url}")
return parsed
def _read_png(path: Path) -> bytes:
"""Read a bounded PNG, rejecting corrupt and unexpectedly large images."""
if not path.is_file() or path.is_symlink():
raise ValueError(f"Evidence file is not a regular file: {path.name}")
size = path.stat().st_size
if size == 0 or size > MAX_FILE_BYTES:
raise ValueError(f"Evidence file has invalid size: {path.name}")
data = path.read_bytes()
if not data.startswith(PNG_SIGNATURE) or len(data) < 24 or data[12:16] != b"IHDR":
raise ValueError(f"Evidence file is not a PNG: {path.name}")
width = int.from_bytes(data[16:20], "big")
height = int.from_bytes(data[20:24], "big")
if not 0 < width <= MAX_DIMENSION or not 0 < height <= MAX_DIMENSION:
raise ValueError(f"Evidence image has invalid dimensions: {path.name}")
return data
def _manifest_files(manifest: dict[str, Any]) -> list[EvidenceFile]:
"""Flatten a version-one manifest into ordered, reviewer-facing images."""
if manifest.get("version") != 1:
raise ValueError("Unsupported E2E evidence manifest version")
files: list[EvidenceFile] = []
screenshots = manifest.get("screenshots", [])
diffs = manifest.get("diffs", [])
if not isinstance(screenshots, list) or not isinstance(diffs, list):
raise ValueError("Evidence manifest lists are malformed")
for entry in screenshots:
if not isinstance(entry, dict) or not isinstance(entry.get("name"), str) or not isinstance(entry.get("file"), str):
raise ValueError("Evidence screenshot entry is malformed")
files.append(EvidenceFile(entry["file"], f"new screenshot: {entry['name']}"))
for entry in diffs:
if not isinstance(entry, dict) or not isinstance(entry.get("name"), str) or not isinstance(entry.get("diff"), str):
raise ValueError("Evidence visual-diff entry is malformed")
files.append(EvidenceFile(entry["diff"], f"visual diff: {entry['name']}"))
for kind in ("actual", "expected"):
value = entry.get(kind)
if value is not None:
if not isinstance(value, str):
raise ValueError("Evidence visual-diff companion is malformed")
files.append(EvidenceFile(value, f"visual {kind}: {entry['name']}"))
names = [item.filename for item in files]
if len(files) > MAX_FILES or len(set(names)) != len(names):
raise ValueError("Evidence manifest has too many or duplicate files")
if any(not _SAFE_FILE.fullmatch(name) for name in names):
raise ValueError("Evidence manifest contains an unsafe filename")
return files
def load_evidence(evidence_dir: Path) -> tuple[list[EvidenceFile], dict[str, bytes]]:
"""Load the manifest and return only the validated files it declares."""
manifest_path = evidence_dir / "e2e-evidence.json"
if not manifest_path.is_file() or manifest_path.is_symlink():
raise ValueError("E2E evidence manifest is missing")
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError("E2E evidence manifest is not JSON") from exc
if not isinstance(manifest, dict):
raise ValueError("E2E evidence manifest is not an object")
files = _manifest_files(manifest)
payloads: dict[str, bytes] = {}
total = 0
for item in files:
path = evidence_dir / item.filename
if path.parent != evidence_dir:
raise ValueError("Evidence file escaped its artifact directory")
payload = _read_png(path)
total += len(payload)
if total > MAX_TOTAL_BYTES:
raise ValueError("E2E evidence exceeds the total size limit")
payloads[item.filename] = payload
return files, payloads
def render_evidence(files: list[EvidenceFile], attachment_urls: dict[str, str]) -> str:
"""Render validated GitHub attachment URLs inside the review-comment marker."""
blocks = [EVIDENCE_START]
for item in files:
url = attachment_urls.get(item.filename)
if url is None:
raise ValueError(f"Missing attachment URL for {item.filename}")
blocks.extend((
"<details>",
f"<summary>{item.label}</summary>",
"",
f"![{item.label}]({url})",
"",
"</details>",
))
blocks.append(EVIDENCE_END)
return "\n".join(blocks)
def render_upload_failure(error: Exception) -> str:
"""Render an escaped upload error inside the review-comment marker."""
return "\n".join((
EVIDENCE_START,
"<sub>inline evidence upload failed.</sub>",
"",
f"<pre>{html.escape(str(error))}</pre>",
EVIDENCE_END,
))
def replace_evidence_marker(comment: str, evidence: str) -> str:
"""Replace exactly the pending-evidence region in a CI review comment."""
pattern = re.compile(f"{re.escape(EVIDENCE_START)}.*?{re.escape(EVIDENCE_END)}", re.DOTALL)
result, count = pattern.subn(evidence, comment, count=1)
if count != 1:
raise ValueError("CI review comment does not contain one evidence marker")
return result
def _find_review_comment(comments: object) -> dict[str, Any] | None:
"""Find a live CI review comment only after it contains this marker."""
if not isinstance(comments, list):
raise ValueError("GitHub comments response is malformed")
for item in comments:
if not isinstance(item, dict):
continue
body = str(item.get("body", ""))
if body.startswith("<!-- hermes-ci-review-bot -->") and EVIDENCE_START in body and EVIDENCE_END in body:
return item
return None
def _wait_for_review_comment(token: str, source_repo: str, pr_number: str) -> dict[str, Any] | None:
"""Wait briefly for GitHub's comment API to expose the completed marker."""
request = urllib.request.Request(
f"{API_BASE}/repos/{source_repo}/issues/{pr_number}/comments?per_page=100",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "hermes-e2e-evidence-publisher",
},
)
for attempt in range(COMMENT_LOOKUP_ATTEMPTS):
with urllib.request.urlopen(request) as response:
comment = _find_review_comment(json.loads(response.read()))
if comment is not None:
return comment
if attempt + 1 < COMMENT_LOOKUP_ATTEMPTS:
time.sleep(COMMENT_LOOKUP_DELAY_SECONDS)
return None
def upload_evidence(
files: list[EvidenceFile],
evidence_dir: Path,
source_repo: str,
session_token: str,
) -> dict[str, str]:
"""Upload validated files through gh-image and accept only attachment URLs."""
environment = os.environ.copy()
environment["GH_SESSION_TOKEN"] = session_token
attachment_urls: dict[str, str] = {}
for item in files:
try:
result = subprocess.run(
[
"gh",
"image",
"--repo",
source_repo,
str(evidence_dir / item.filename),
],
check=True,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
env=environment,
)
except subprocess.CalledProcessError as exc:
output = "; ".join(
value.strip()
for value in (exc.stdout, exc.stderr)
if value and value.strip()
)
message = f"Failed to upload {item.filename} with gh image (exit code {exc.returncode})"
if output:
message = f"{message}: {output}"
print(message, file=sys.stderr)
raise RuntimeError(message) from exc
match = _ATTACHMENT_URL.fullmatch(result.stdout.strip())
if match is None:
raise ValueError(f"gh-image returned an invalid attachment reference for {item.filename}")
attachment_urls[item.filename] = match.group(1)
return attachment_urls
def publish(
token: str,
source_repo: str,
evidence_dir: Path,
pr_number: str,
session_token: str,
) -> bool:
"""Publish evidence and patch its source PR comment; false means nothing to show."""
files, _ = load_evidence(evidence_dir)
if not files:
print("No inline E2E evidence to publish.")
return False
comment = _wait_for_review_comment(token, source_repo, pr_number)
if comment is None:
# A fork PR gets no CI review comment (the live poller needs a
# write token there), so there is no marker to patch. The
# evidence stays available in the workflow artifact.
print(
f"PR #{pr_number} has no CI review comment with an E2E evidence "
"marker; the evidence stays in the workflow artifact."
)
return False
try:
attachment_urls = upload_evidence(
files, evidence_dir, source_repo, session_token
)
except Exception as exc:
body = replace_evidence_marker(
str(comment.get("body", "")), render_upload_failure(exc)
)
_api_request(
f"{API_BASE}/repos/{source_repo}/issues/comments/{comment['id']}",
token,
method="PATCH",
payload={"body": body},
)
raise
evidence = render_evidence(files, attachment_urls)
body = replace_evidence_marker(str(comment.get("body", "")), evidence)
_api_request(
f"{API_BASE}/repos/{source_repo}/issues/comments/{comment['id']}",
token,
method="PATCH",
payload={"body": body},
)
print(f"Published {len(files)} E2E evidence image attachment(s).")
return True
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--evidence-dir", type=Path, required=True)
parser.add_argument("--source-repo", required=True)
parser.add_argument("--pr-number", required=True)
args = parser.parse_args()
token = os.environ.get("GITHUB_TOKEN", "")
if not token:
parser.error("GITHUB_TOKEN is required")
session_token = os.environ.get("GH_SESSION_TOKEN", "")
if not session_token:
parser.error("GH_SESSION_TOKEN is required")
publish(token, args.source_repo, args.evidence_dir, args.pr_number, session_token)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,141 @@
# Behavioral test for install.ps1's hermes launcher staging (PR #92092,
# reworked for the managed-binary-dir layout).
#
# Run: powershell.exe -NoProfile -File scripts/ci/test_install_ps1_cli_launchers.ps1
#
# The test lifts the real Install-HermesCommandLaunchers function from the
# PowerShell AST and executes it against a temporary install tree. It never
# reads or changes the user's PATH. The staging destination is passed in by
# the caller (Set-PathVariable passes $HermesHome\bin -- the managed binary
# dir OUTSIDE the git checkout); here it is a sibling temp dir, which also
# proves the function stages wherever it is pointed rather than assuming
# the legacy in-checkout location.
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$installPs1 = Join-Path (Join-Path $PSScriptRoot '..') 'install.ps1' | Resolve-Path
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
$installPs1, [ref]$null, [ref]$null)
$fn = $ast.Find({
param($n)
$n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
$n.Name -eq 'Install-HermesCommandLaunchers'
}, $true)
if (-not $fn) {
throw "Install-HermesCommandLaunchers not found in $installPs1"
}
Invoke-Expression $fn.Extent.Text
$tempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
$caseRoot = [System.IO.Path]::GetFullPath((Join-Path $tempBase (
'hermes-cli-launcher-test-' + [guid]::NewGuid().ToString('N')
)))
if (-not $caseRoot.StartsWith($tempBase, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to create test directory outside the system temp directory: $caseRoot"
}
$script:Failures = 0
function Assert-True {
param([bool]$Condition, [string]$Name)
if ($Condition) {
Write-Host " PASS $Name"
} else {
Write-Host " FAIL $Name"
$script:Failures++
}
}
function Assert-BytesEqual {
param([byte[]]$Expected, [byte[]]$Actual, [string]$Name)
$same = $Expected.Length -eq $Actual.Length
if ($same) {
for ($i = 0; $i -lt $Expected.Length; $i++) {
if ($Expected[$i] -ne $Actual[$i]) {
$same = $false
break
}
}
}
Assert-True $same $Name
}
try {
$installRoot = Join-Path $caseRoot 'hermes-agent'
$binDir = Join-Path $caseRoot 'bin'
New-Item -ItemType Directory -Force -Path $installRoot | Out-Null
# Fail-before-PATH-mutation: a missing required source must throw and
# must not leave an empty destination for the caller to put on PATH.
$missingThrew = $false
try {
Install-HermesCommandLaunchers -Root $installRoot -Destination $binDir | Out-Null
} catch {
$missingThrew = $_.Exception.Message -like '*required launcher not found*'
}
Assert-True $missingThrew 'missing hermes.exe fails the launcher stage'
Assert-True (-not (Test-Path -LiteralPath $binDir)) `
'failure does not create an empty PATH directory'
$scriptsDir = Join-Path $installRoot 'venv\Scripts'
New-Item -ItemType Directory -Force -Path $scriptsDir | Out-Null
$hermesV1 = [byte[]](77, 90, 1)
$hermesV2 = [byte[]](77, 90, 2)
$acp = [byte[]](77, 90, 3)
[System.IO.File]::WriteAllBytes((Join-Path $scriptsDir 'hermes.exe'), $hermesV1)
Set-Content -Path (Join-Path $installRoot 'venv\pyvenv.cfg') `
-Value "home = X" -Encoding Ascii
$staged = Install-HermesCommandLaunchers -Root $installRoot -Destination $binDir
Assert-True ($staged -eq $binDir) 'returns the destination it staged into'
Assert-BytesEqual $hermesV1 `
([System.IO.File]::ReadAllBytes((Join-Path $binDir 'hermes.exe'))) `
'normal venv: exe copy lands in the destination'
Assert-True (-not (Test-Path -LiteralPath (Join-Path $binDir 'hermes-acp.exe'))) `
'optional ACP launcher may be absent'
[System.IO.File]::WriteAllBytes((Join-Path $scriptsDir 'hermes.exe'), $hermesV2)
[System.IO.File]::WriteAllBytes((Join-Path $scriptsDir 'hermes-acp.exe'), $acp)
Install-HermesCommandLaunchers -Root $installRoot -Destination $binDir | Out-Null
Assert-BytesEqual $hermesV2 `
([System.IO.File]::ReadAllBytes((Join-Path $binDir 'hermes.exe'))) `
'installer refreshes an existing Hermes launcher'
Assert-BytesEqual $acp `
([System.IO.File]::ReadAllBytes((Join-Path $binDir 'hermes-acp.exe'))) `
'installer copies the optional ACP launcher when present'
# Relocatable venv: exe trampolines die when copied out of venv\Scripts
# ('uv trampoline failed to canonicalize script path'), so the stage
# must emit .cmd delegators and clear the stale exe copies.
Set-Content -Path (Join-Path $installRoot 'venv\pyvenv.cfg') `
-Value "home = X`r`nrelocatable = true" -Encoding Ascii
Install-HermesCommandLaunchers -Root $installRoot -Destination $binDir | Out-Null
Assert-True (Test-Path -LiteralPath (Join-Path $binDir 'hermes.cmd')) `
'relocatable venv: .cmd delegator staged'
Assert-True (-not (Test-Path -LiteralPath (Join-Path $binDir 'hermes.exe'))) `
'relocatable venv: stale exe copy removed'
$cmdBody = [System.IO.File]::ReadAllText((Join-Path $binDir 'hermes.cmd'))
Assert-True ($cmdBody.Contains((Join-Path $scriptsDir 'hermes.exe')) -and $cmdBody.Contains('%*')) `
'delegator invokes the in-venv exe and forwards args'
} finally {
if (Test-Path -LiteralPath $caseRoot) {
$resolvedCase = [System.IO.Path]::GetFullPath($caseRoot)
if (-not $resolvedCase.StartsWith($tempBase, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to remove test directory outside the system temp directory: $resolvedCase"
}
Remove-Item -LiteralPath $resolvedCase -Recurse -Force
}
}
if ($script:Failures -gt 0) {
Write-Host ""
Write-Host "$script:Failures assertion(s) failed"
exit 1
}
Write-Host ""
Write-Host "all assertions passed"
@@ -0,0 +1,125 @@
# Behavioral test for install.ps1's persisted-User-PATH migration.
#
# Run: pwsh -NoProfile -File scripts/ci/test_install_ps1_path_migration.ps1
#
# Not wired into the default CI lane — the Linux runners have no PowerShell
# host. It runs on any machine with pwsh (including via nixpkgs#powershell),
# and on a Windows runner if one is ever added.
#
# This is NOT a source-regex test. It parses install.ps1, lifts the real
# Set-ManagedNodeFirstOnUserPath body out of the AST, and rewrites *only* the
# two registry calls into an in-memory store so the actual shipped logic —
# split, dedupe, prepend, change-detection — executes for real. Rewriting from
# the AST rather than hand-copying the body means the test cannot silently
# drift away from the function it claims to cover.
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$installPs1 = Join-Path $PSScriptRoot '..' 'install.ps1' | Resolve-Path
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
$installPs1, [ref]$null, [ref]$null)
$fn = $ast.Find({
param($n)
$n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
$n.Name -eq 'Set-ManagedNodeFirstOnUserPath'
}, $true)
if (-not $fn) {
throw "Set-ManagedNodeFirstOnUserPath not found in $installPs1"
}
# Swap the two registry calls for the in-memory store. Both must match, or the
# function has changed shape and this harness is no longer exercising it.
# Rewrite the whole definition extent (which already carries `function <name>
# { param(...) ... }`) so the shipped param block and body run verbatim.
$definition = $fn.Extent.Text
$reads = ([regex]'\[Environment\]::GetEnvironmentVariable\("Path", "User"\)').Matches($definition).Count
$writes = ([regex]'\[Environment\]::SetEnvironmentVariable\("Path", ([^,]+), "User"\)').Matches($definition).Count
if ($reads -ne 1 -or $writes -ne 1) {
throw "expected exactly one User PATH read and one write in the function body; found $reads read(s), $writes write(s). Update this harness."
}
$definition = $definition -replace `
'\[Environment\]::GetEnvironmentVariable\("Path", "User"\)', '$script:FakeUserPath'
$definition = $definition -replace `
'\[Environment\]::SetEnvironmentVariable\("Path", ([^,]+), "User"\)', '$script:FakeUserPath = $1; $script:FakeWrites++'
Invoke-Expression $definition
$NODE = 'C:\Users\me\AppData\Local\hermes\node'
$script:Failures = 0
function Invoke-Migration {
param([string]$Start, [string]$NodeDir = $NODE)
$script:FakeUserPath = $Start
$script:FakeWrites = 0
Set-ManagedNodeFirstOnUserPath $NodeDir
}
function Assert-Equal {
param($Expected, $Actual, [string]$Name)
if ($Expected -ceq $Actual) {
Write-Host " PASS $Name"
} else {
Write-Host " FAIL $Name"
Write-Host " expected: [$Expected]"
Write-Host " actual: [$Actual]"
$script:Failures++
}
}
Write-Host "install.ps1 Set-ManagedNodeFirstOnUserPath"
# The regression this function exists for: an install made by an older
# install.ps1, which *appended*. A system Node leads and the managed dir is
# stranded at the tail, so every new shell resolves the wrong node.exe. An
# add-if-missing check would see the entry present and leave it there forever.
Invoke-Migration "C:\Program Files\nodejs;C:\Users\me\bin;$NODE"
Assert-Equal "$NODE;C:\Program Files\nodejs;C:\Users\me\bin" $script:FakeUserPath `
'upgrade from appending installer: managed dir becomes first entry'
Assert-Equal 1 (@($script:FakeUserPath -split ';' | Where-Object { $_ -eq $NODE }).Count) `
'upgrade: managed dir is not duplicated'
Assert-Equal "C:\Program Files\nodejs;C:\Users\me\bin" `
(($script:FakeUserPath -split ';' | Where-Object { $_ -ne $NODE }) -join ';') `
'upgrade: unrelated entries keep their relative order'
Assert-Equal 1 $script:FakeWrites 'upgrade: persists exactly once'
Invoke-Migration "$NODE;C:\Program Files\nodejs"
Assert-Equal "$NODE;C:\Program Files\nodejs" $script:FakeUserPath 'already correct: unchanged'
Assert-Equal 0 $script:FakeWrites 'already correct: no registry write'
Invoke-Migration "C:\Program Files\nodejs"
Assert-Equal "$NODE;C:\Program Files\nodejs" $script:FakeUserPath 'fresh install: prepended'
# Empty segments are legal in a real User PATH (a trailing ';' is common) and
# the installer's other PATH code preserves them. Migration must not quietly
# rewrite parts of PATH it was not asked to touch.
Invoke-Migration "C:\Program Files\nodejs;;C:\Users\me\bin;"
Assert-Equal "$NODE;C:\Program Files\nodejs;;C:\Users\me\bin;" $script:FakeUserPath `
'empty segments are preserved'
# Windows paths are case-insensitive, and -ne on strings is too.
Invoke-Migration "C:\Program Files\nodejs;c:\users\me\appdata\local\HERMES\Node"
Assert-Equal "$NODE;C:\Program Files\nodejs" $script:FakeUserPath `
'existing entry in different case is replaced, not duplicated'
Invoke-Migration "$NODE;C:\Program Files\nodejs;$NODE"
Assert-Equal "$NODE;C:\Program Files\nodejs" $script:FakeUserPath 'duplicates collapse'
Invoke-Migration ""
Assert-Equal $NODE $script:FakeUserPath 'empty User PATH'
Invoke-Migration "C:\Program Files\nodejs" ""
Assert-Equal "C:\Program Files\nodejs" $script:FakeUserPath 'empty NodeDir is a no-op'
Assert-Equal 0 $script:FakeWrites 'empty NodeDir does not write'
if ($script:Failures -gt 0) {
Write-Host ""
Write-Host "$script:Failures assertion(s) failed"
exit 1
}
Write-Host ""
Write-Host "all assertions passed"
File diff suppressed because it is too large Load Diff
+500
View File
@@ -0,0 +1,500 @@
#!/usr/bin/env python3
"""Contributor Audit Script
Cross-references git authors, Co-authored-by trailers, and salvaged PR
descriptions to find any contributors missing from the release notes.
Usage:
# Basic audit since a tag
python scripts/contributor_audit.py --since-tag v2026.4.8
# Audit with a custom endpoint
python scripts/contributor_audit.py --since-tag v2026.4.8 --until v2026.4.13
# Compare against a release notes file
python scripts/contributor_audit.py --since-tag v2026.4.8 --release-file RELEASE_v0.9.0.md
"""
import argparse
import json
import re
import subprocess
import sys
from collections import defaultdict
from pathlib import Path
# ---------------------------------------------------------------------------
# Import AUTHOR_MAP and resolve_author from the sibling release.py module
# ---------------------------------------------------------------------------
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
from release import resolve_author # noqa: E402
REPO_ROOT = SCRIPT_DIR.parent
# ---------------------------------------------------------------------------
# AI assistants, bots, and machine accounts to exclude from contributor lists
# ---------------------------------------------------------------------------
IGNORED_PATTERNS = [
re.compile(r"^Claude", re.IGNORECASE),
re.compile(r"^Copilot$", re.IGNORECASE),
re.compile(r"^Cursor(\s+Agent)?$", re.IGNORECASE),
re.compile(r"^Codex$", re.IGNORECASE),
re.compile(r"^OpenAI Codex$", re.IGNORECASE),
re.compile(r"^CommandCode", re.IGNORECASE),
re.compile(r"^github-advanced-security(\[bot\])?$", re.IGNORECASE),
re.compile(r"^GitHub\s*Actions?$", re.IGNORECASE),
re.compile(r"^github-actions(\[bot\])?$", re.IGNORECASE),
re.compile(r"^dependabot", re.IGNORECASE),
re.compile(r"^renovate", re.IGNORECASE),
re.compile(r"^Hermes\s+(Agent|Audit)$", re.IGNORECASE),
re.compile(r"^nousbot(-eng)?$", re.IGNORECASE),
re.compile(r"^Ubuntu$", re.IGNORECASE),
# v0.20.0 audit additions:
re.compile(r"^Blut-?Agent$", re.IGNORECASE), # self-described AI agent account
re.compile(r".*\[bot\]$", re.IGNORECASE), # any GitHub [bot] suffix (hermes-seaeye[bot] etc.)
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 = {
"noreply@anthropic.com",
"noreply@github.com",
"noreply@nousresearch.com",
"cursoragent@cursor.com",
"hermes@nousresearch.com",
"hermes-audit@example.com",
"nousbot@nousresearch.com",
"hermes@habibilabs.dev",
"omx@oh-my-codex.dev",
"codex@openai.com",
"noreply@commandcode.ai",
# v0.20.0 audit additions — AI-agent co-author trailers:
"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
}
def is_ignored(handle: str, email: str = "") -> bool:
"""Return True if this contributor is a bot/AI/machine account."""
if email in IGNORED_EMAILS:
return True
for pattern in IGNORED_PATTERNS:
if pattern.search(handle):
return True
return False
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def git(*args, cwd=None):
"""Run a git command and return stdout."""
result = subprocess.run(
["git"] + list(args),
capture_output=True,
text=True, encoding='utf-8', errors='replace',
cwd=cwd or str(REPO_ROOT),
)
if result.returncode != 0:
print(f" [warn] git {' '.join(args)} failed: {result.stderr.strip()}", file=sys.stderr)
return ""
return result.stdout.strip()
def gh_pr_list():
"""Fetch merged PRs from GitHub using the gh CLI.
Returns a list of dicts with keys: number, title, body, author.
Returns an empty list if gh is not available or the call fails.
"""
try:
result = subprocess.run(
[
"gh", "pr", "list",
"--repo", "NousResearch/hermes-agent",
"--state", "merged",
"--json", "number,title,body,author,mergedAt",
"--limit", "300",
],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
timeout=60,
)
if result.returncode != 0:
print(f" [warn] gh pr list failed: {result.stderr.strip()}", file=sys.stderr)
return []
return json.loads(result.stdout)
except FileNotFoundError:
print(" [warn] 'gh' CLI not found — skipping salvaged PR scan.", file=sys.stderr)
return []
except subprocess.TimeoutExpired:
print(" [warn] gh pr list timed out — skipping salvaged PR scan.", file=sys.stderr)
return []
except json.JSONDecodeError:
print(" [warn] gh pr list returned invalid JSON — skipping salvaged PR scan.", file=sys.stderr)
return []
# ---------------------------------------------------------------------------
# Contributor collection
# ---------------------------------------------------------------------------
# Patterns that indicate salvaged/cherry-picked/co-authored work in PR bodies
SALVAGE_PATTERNS = [
# "Salvaged from @username" or "Salvaged from #123"
re.compile(r"[Ss]alvaged\s+from\s+@(\w[\w-]*)"),
re.compile(r"[Ss]alvaged\s+from\s+#(\d+)"),
# "Cherry-picked from @username"
re.compile(r"[Cc]herry[- ]?picked\s+from\s+@(\w[\w-]*)"),
# "Based on work by @username"
re.compile(r"[Bb]ased\s+on\s+work\s+by\s+@(\w[\w-]*)"),
# "Original PR by @username"
re.compile(r"[Oo]riginal\s+PR\s+by\s+@(\w[\w-]*)"),
# "Co-authored with @username"
re.compile(r"[Cc]o[- ]?authored\s+with\s+@(\w[\w-]*)"),
]
# Pattern for Co-authored-by trailers in commit messages
CO_AUTHORED_RE = re.compile(
r"Co-authored-by:\s*(.+?)\s*<([^>]+)>",
re.IGNORECASE,
)
def collect_commit_authors(since_tag, until="HEAD"):
"""Collect contributors from git commit authors.
Returns:
contributors: dict mapping github_handle -> set of source labels
unknown_emails: dict mapping email -> git name (for emails not in AUTHOR_MAP)
"""
range_spec = f"{since_tag}..{until}"
log = git(
"log", range_spec,
"--format=%H|%an|%ae|%s",
"--no-merges",
)
contributors = defaultdict(set)
unknown_emails = {}
if not log:
return contributors, unknown_emails
for line in log.split("\n"):
if not line.strip():
continue
parts = line.split("|", 3)
if len(parts) != 4:
continue
_sha, name, email, _subject = parts
handle = resolve_author(name, email)
# resolve_author returns "@handle" or plain name
if handle.startswith("@"):
contributors[handle.lstrip("@")].add("commit")
else:
# Could not resolve — record as unknown
contributors[handle].add("commit")
unknown_emails[email] = name
return contributors, unknown_emails
def collect_co_authors(since_tag, until="HEAD"):
"""Collect contributors from Co-authored-by trailers in commit messages.
Returns:
contributors: dict mapping github_handle -> set of source labels
unknown_emails: dict mapping email -> git name
"""
range_spec = f"{since_tag}..{until}"
# Get full commit messages to scan for trailers
log = git(
"log", range_spec,
"--format=__COMMIT__%H%n%b",
"--no-merges",
)
contributors = defaultdict(set)
unknown_emails = {}
if not log:
return contributors, unknown_emails
for line in log.split("\n"):
match = CO_AUTHORED_RE.search(line)
if match:
name = match.group(1).strip()
email = match.group(2).strip()
handle = resolve_author(name, email)
if handle.startswith("@"):
contributors[handle.lstrip("@")].add("co-author")
else:
contributors[handle].add("co-author")
unknown_emails[email] = name
return contributors, unknown_emails
def collect_salvaged_contributors(since_tag, until="HEAD"):
"""Scan merged PR bodies for salvage/cherry-pick/co-author attribution.
Uses the gh CLI to fetch PRs, then filters to the date range defined
by since_tag..until and scans bodies for salvage patterns.
Returns:
contributors: dict mapping github_handle -> set of source labels
pr_refs: dict mapping github_handle -> list of PR numbers where found
"""
contributors = defaultdict(set)
pr_refs = defaultdict(list)
# Determine the date range from git tags/refs
since_date = git("log", "-1", "--format=%aI", since_tag)
if until == "HEAD":
until_date = git("log", "-1", "--format=%aI", "HEAD")
else:
until_date = git("log", "-1", "--format=%aI", until)
if not since_date:
print(f" [warn] Could not resolve date for {since_tag}", file=sys.stderr)
return contributors, pr_refs
prs = gh_pr_list()
if not prs:
return contributors, pr_refs
for pr in prs:
# Filter by merge date if available
merged_at = pr.get("mergedAt", "")
if merged_at and since_date:
if merged_at < since_date:
continue
if until_date and merged_at > until_date:
continue
body = pr.get("body") or ""
pr_number = pr.get("number", "?")
# Also credit the PR author
pr_author = pr.get("author", {})
pr_author_login = pr_author.get("login", "") if isinstance(pr_author, dict) else ""
for pattern in SALVAGE_PATTERNS:
for match in pattern.finditer(body):
value = match.group(1)
# If it's a number, it's a PR reference — skip for now
# (would need another API call to resolve PR author)
if value.isdigit():
continue
contributors[value].add("salvage")
pr_refs[value].append(pr_number)
return contributors, pr_refs
# ---------------------------------------------------------------------------
# Release file comparison
# ---------------------------------------------------------------------------
def check_release_file(release_file, all_contributors):
"""Check which contributors are mentioned in the release file.
Returns:
mentioned: set of handles found in the file
missing: set of handles NOT found in the file
"""
try:
content = Path(release_file).read_text(encoding="utf-8")
except FileNotFoundError:
print(f" [error] Release file not found: {release_file}", file=sys.stderr)
return set(), set(all_contributors)
mentioned = set()
missing = set()
content_lower = content.lower()
for handle in all_contributors:
# Check for @handle or just handle (case-insensitive)
if f"@{handle.lower()}" in content_lower or handle.lower() in content_lower:
mentioned.add(handle)
else:
missing.add(handle)
return mentioned, missing
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Audit contributors across git history, co-author trailers, and salvaged PRs.",
)
parser.add_argument(
"--since-tag",
required=True,
help="Git tag to start from (e.g., v2026.4.8)",
)
parser.add_argument(
"--until",
default="HEAD",
help="Git ref to end at (default: HEAD)",
)
parser.add_argument(
"--release-file",
default=None,
help="Path to a release notes file to check for missing contributors",
)
parser.add_argument(
"--strict",
action="store_true",
help="Exit with code 1 if new unmapped emails are found (for CI)",
)
parser.add_argument(
"--diff-base",
default=None,
help="Git ref to diff against (only flag emails from commits after this ref)",
)
args = parser.parse_args()
print(f"=== Contributor Audit: {args.since_tag}..{args.until} ===")
print()
# ---- 1. Git commit authors ----
print("[1/3] Scanning git commit authors...")
commit_contribs, commit_unknowns = collect_commit_authors(args.since_tag, args.until)
print(f" Found {len(commit_contribs)} contributor(s) from commits.")
# ---- 2. Co-authored-by trailers ----
print("[2/3] Scanning Co-authored-by trailers...")
coauthor_contribs, coauthor_unknowns = collect_co_authors(args.since_tag, args.until)
print(f" Found {len(coauthor_contribs)} contributor(s) from co-author trailers.")
# ---- 3. Salvaged PRs ----
print("[3/3] Scanning salvaged/cherry-picked PR descriptions...")
salvage_contribs, salvage_pr_refs = collect_salvaged_contributors(args.since_tag, args.until)
print(f" Found {len(salvage_contribs)} contributor(s) from salvaged PRs.")
# ---- Merge all contributors ----
all_contributors = defaultdict(set)
for handle, sources in commit_contribs.items():
all_contributors[handle].update(sources)
for handle, sources in coauthor_contribs.items():
all_contributors[handle].update(sources)
for handle, sources in salvage_contribs.items():
all_contributors[handle].update(sources)
# Merge unknown emails
all_unknowns = {}
all_unknowns.update(commit_unknowns)
all_unknowns.update(coauthor_unknowns)
# Filter out AI assistants, bots, and machine accounts
ignored = {h for h in all_contributors if is_ignored(h)}
for h in ignored:
del all_contributors[h]
# Also filter unknowns by email
all_unknowns = {e: n for e, n in all_unknowns.items() if not is_ignored(n, e)}
# ---- Output ----
print()
print(f"=== All Contributors ({len(all_contributors)}) ===")
print()
# Sort by handle, case-insensitive
for handle in sorted(all_contributors.keys(), key=str.lower):
sources = sorted(all_contributors[handle])
source_str = ", ".join(sources)
extra = ""
if handle in salvage_pr_refs:
pr_nums = salvage_pr_refs[handle]
extra = f" (PRs: {', '.join(f'#{n}' for n in pr_nums)})"
print(f" @{handle} [{source_str}]{extra}")
# ---- Unknown emails ----
if all_unknowns:
print()
print(f"=== Unknown Emails ({len(all_unknowns)}) ===")
print("These emails have no mapping and should be added via:")
print()
for email, name in sorted(all_unknowns.items()):
print(f" python3 scripts/add_contributor.py {email} <github-username> # {name}")
# ---- Strict mode: fail CI if new unmapped emails are introduced ----
if args.strict and all_unknowns:
# In strict mode, check if ANY unknown emails come from commits in this
# PR's diff range (new unmapped emails that weren't there before).
# This is the CI gate: existing unknowns are grandfathered, but new
# commits must have their author email in AUTHOR_MAP.
new_unknowns = {}
if args.diff_base:
# Only flag emails from commits after diff_base
new_commits_output = git(
"log", f"{args.diff_base}..HEAD",
"--format=%ae", "--no-merges",
)
new_emails = set(new_commits_output.splitlines()) if new_commits_output else set()
for email, name in all_unknowns.items():
if email in new_emails:
new_unknowns[email] = name
else:
new_unknowns = all_unknowns
if new_unknowns:
print()
print(f"=== STRICT MODE FAILURE: {len(new_unknowns)} new unmapped email(s) ===")
print("Add mapping files before merging (do NOT edit AUTHOR_MAP):")
print()
for email, name in sorted(new_unknowns.items()):
print(f" python3 scripts/add_contributor.py {email} <github-username> # {name}")
print()
print("To find the GitHub username:")
print(" gh api 'search/users?q=EMAIL+in:email' --jq '.items[0].login'")
strict_failed = True
else:
strict_failed = False
else:
strict_failed = False
# ---- Release file comparison ----
if args.release_file:
print()
print(f"=== Release File Check: {args.release_file} ===")
print()
mentioned, missing = check_release_file(args.release_file, all_contributors.keys())
print(f" Mentioned in release notes: {len(mentioned)}")
print(f" Missing from release notes: {len(missing)}")
if missing:
print()
print(" Contributors NOT mentioned in the release file:")
for handle in sorted(missing, key=str.lower):
sources = sorted(all_contributors[handle])
print(f" @{handle} [{', '.join(sources)}]")
else:
print()
print(" All contributors are mentioned in the release file!")
print()
print("Done.")
if strict_failed:
sys.exit(1)
if __name__ == "__main__":
main()
+11
View File
@@ -0,0 +1,11 @@
# COMPAT FORWARDER — do not add logic here.
#
# The hand-off moved to scripts/desktop-update/windows.ps1. This forwarder
# exists for exactly one consumer: an already-installed Desktop whose asar
# is one update behind and still spawns scripts/desktop-update.ps1 (see
# resolveUpdateScriptHandoff in apps/desktop/electron/updater-process.ts).
# 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
exit $LASTEXITCODE
+808
View File
@@ -0,0 +1,808 @@
#!/bin/bash
# posix.sh -- repo-owned macOS/Linux Desktop update hand-off.
#
# The whole job: wait for the Desktop to exit, run `hermes update`, tell the
# shim how it went, reopen the app. The Desktop spawns this detached and
# quits; because it lives in the checkout, every update refreshes the code
# that drives the next one. Replaces the in-app updater
# (applyUpdatesPosixInApp) -- with the app gone before the update starts,
# the HERMES_DESKTOP_CHILD_PID reaper-exclusion dance dies with it.
#
# CONTRACT (keep in sync with apps/desktop/electron/main.ts):
# bash scripts/desktop-update/posix.sh
# --install-root <path> repo checkout (HERMES_HOME/hermes-agent)
# --branch <ref> branch to update against
# --desktop-pid <pid> the Electron main process to wait out
# [--relaunch-target <p>] mac: running .app to swap+reopen;
# linux: running binary (omit = no relaunch)
# [--relaunch-cwd <p>] linux: working directory to restore on relaunch
# [--sandbox-fallback] linux: the caller vouches for a sandbox opt-out
# (ELECTRON_DISABLE_SANDBOX / --no-sandbox launch)
# [--no-ui] [--no-marker-cleanup] [--self-test-ui] [--self-test-gate]
# [--self-test-marker]
# [-- <args...>] linux: filtered launch args to replay
#
# The shim (ui.html in a chromeless browser app window) is decoration: it
# polls /progress for the current stage or a terminal event and reacts. The
# stages come from the gates below, never from child output. It owns nothing --
# relaunch, result file, marker hygiene all happen here, identically, when
# no renderer exists. No chromium-family browser found = no UI, fine.
#
# ORDERING (the durable-truth rule): swap and relaunch are DECIDED AND
# EXECUTED before the result file is written, the marker is removed, or a
# terminal event reaches the shim. Nothing user-visible may claim an outcome
# the filesystem hasn't already delivered.
set -u
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
--install-root) INSTALL_ROOT="$2"; shift 2 ;;
--branch) BRANCH="$2"; shift 2 ;;
--desktop-pid) DESKTOP_PID="$2"; shift 2 ;;
--relaunch-target) RELAUNCH_TARGET="$2"; shift 2 ;;
--relaunch-cwd) RELAUNCH_CWD="$2"; shift 2 ;;
--sandbox-fallback) SANDBOX_FALLBACK=1; shift ;;
--no-ui) NO_UI=1; shift ;;
--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 $# ;;
*) echo "unknown arg: $1" >&2; exit 64 ;;
esac
done
[ "$SELF_TEST_UI" -eq 1 ] || [ -n "$INSTALL_ROOT" ] || { echo "--install-root is required" >&2; exit 64; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HERMES_HOME="${INSTALL_ROOT:+$(dirname "$INSTALL_ROOT")}"
HERMES_HOME="${HERMES_HOME:-${TMPDIR:-/tmp}}"
MARKER="$HERMES_HOME/.hermes-update-in-progress"
LOG_DIR="$HERMES_HOME/logs"; mkdir -p "$LOG_DIR" 2>/dev/null || true
LOG="$LOG_DIR/desktop-update-handoff.log"
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
FINAL_MSG="update did not complete"
DONE_NOTE="" # set when the update succeeded but the app will NOT reopen itself
log() { echo "$(date +%Y-%m-%dT%H:%M:%S%z) $1" | tee -a "$LOG" 2>/dev/null; }
# Keep a durable signal breadcrumb. A detached hand-off used to leave only the
# generic FINAL_MSG when it was terminated while the updater child was running,
# which erased the one fact needed to diagnose the failure.
TERM_TEARDOWN_IGNORED=0
on_signal() {
local sig="$1" pgid="unknown"
pgid="$(ps -o pgid= -p $$ 2>/dev/null | tr -d '[:space:]')"
# Electron sends one final TERM to the detached hand-off process group while
# quitting, even after the orchestrator has been re-parented to PID 1. That
# TERM is teardown noise, not a user cancellation. Ignore it once only when
# the originating desktop PID is already gone; a later TERM still stops us.
if [ "$sig" = "TERM" ] && [ "$HANDOFF_DAEMONIZED" -eq 1 ] \
&& [ "$TERM_TEARDOWN_IGNORED" -eq 0 ] && ! kill -0 "$DESKTOP_PID" 2>/dev/null; then
TERM_TEARDOWN_IGNORED=1
log "SIGNAL: TERM ignored after desktop teardown pid=$$ ppid=$PPID pgid=${pgid:-unknown} desktopPid=$DESKTOP_PID"
return 0
fi
log "SIGNAL: $sig pid=$$ ppid=$PPID pgid=${pgid:-unknown}"
FINAL_MSG="Update hand-off was interrupted by $sig (pid $$)."
case "$sig" in
HUP) FINAL_CODE=129 ;;
INT) FINAL_CODE=130 ;;
QUIT) FINAL_CODE=131 ;;
TERM) FINAL_CODE=143 ;;
esac
exit "$FINAL_CODE"
}
trap 'on_signal HUP' HUP
trap 'on_signal INT' INT
trap 'on_signal QUIT' QUIT
trap 'on_signal TERM' TERM
# ── shim ────────────────────────────────────────────────────────────────────
json_escape() { # minimal JSON string escape: \ " and control whitespace
local s=${1//\\/\\\\}
s=${s//\"/\\\"}
s=${s//$'\n'/\\n}
s=${s//$'\r'/\\r}
s=${s//$'\t'/\\t}
printf '%s' "$s"
}
notify_fallback() { # status message — renderer-free recovery surface.
# Fires only when there is no shim window. BEST-EFFORT immediate channel:
# each rung requires EXECUTION acceptance, not existence — notify-send's
# exit code is its acceptance (fire-and-forget), zenity/kdialog must
# survive their first second (a dialog that dies instantly had no display
# and must not eat the message). The GUARANTEED channel is the result
# file: a manual/error outcome is durably marked and the next Desktop
# boot surfaces it in a dialog (handoff-result.ts + main.ts).
case "$1" in manual|error) ;; *) return 0 ;; esac
if [ "$(uname)" = "Darwin" ]; then
/usr/bin/osascript -e "display notification \"$(printf '%s' "$2" | sed 's/"/\\"/g')\" with title \"Hermes update\"" 2>/dev/null && return 0
else
if command -v notify-send >/dev/null 2>&1; then
notify-send -u critical "Hermes update" "$2" 2>/dev/null && return 0
fi
local p
if command -v zenity >/dev/null 2>&1; then
zenity --warning --title="Hermes update" --text="$2" 2>/dev/null &
p=$!; sleep 1
kill -0 "$p" 2>/dev/null && return 0
wait "$p" 2>/dev/null
fi
if command -v kdialog >/dev/null 2>&1; then
kdialog --title "Hermes update" --sorry "$2" 2>/dev/null &
p=$!; sleep 1
kill -0 "$p" 2>/dev/null && return 0
wait "$p" 2>/dev/null
fi
fi
# No immediate surface landed. The durable channel takes over: the result
# is marked manual/failed and the next boot shows it in a real dialog.
log "NOTICE: no notification surface accepted; outcome reaches the user via the result dialog on next launch: $2"
}
write_status() { # status message -- atomic replace; the server reads per poll
printf '{"status":"%s","message":"%s"}' "$(json_escape "$1")" "$(json_escape "$2")" > "$STATUS.tmp" \
&& mv -f "$STATUS.tmp" "$STATUS" 2>/dev/null || true
}
publish_stage() { # a long wait the orchestrator is already gating on. No poll
# beat (that would add a second per stage to every update) and no
# notification fallback (there is nothing here for the user to act on).
write_status "running" "$1"
}
publish() { # terminal event -- the page must render it before teardown
write_status "$1" "$2"
[ -n "$UI_SERVER_PID" ] && sleep 1 # one poll beat to render the state
[ -z "$UI_SERVER_PID" ] && notify_fallback "$1" "$2"
}
find_browser() {
local c
# No Microsoft Edge and no Brave, on purpose. Edge's OS-level
# Microsoft-account integration signs a fresh throwaway profile into the
# user's MSA and renders its own "syncing your data" notification — MSA
# email included — inside this window that is titled "Hermes" (#88410).
# Brave paints its own P3A privacy-notice bar over the progress page in
# the same window — cramped to unreadability at the shim's small size
# (#88682). The throwaway --user-data-dir below cannot block either; the
# remaining Chromium-family browsers carry no first-run chrome of their
# own into a fresh profile.
if [ "$(uname)" = "Darwin" ]; then
for c in "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
"/Applications/Chromium.app/Contents/MacOS/Chromium"; do
[ -x "$c" ] && { echo "$c"; return; }
done
else
for c in google-chrome google-chrome-stable chromium chromium-browser; do
command -v "$c" 2>/dev/null && return
done
fi
}
# The shim is decoration; launching a browser the user does NOT use is not.
# A Safari/Firefox/Helium user who merely has Chrome installed watched Chrome
# open on every update — a "why is Chrome opening?" surprise (community
# report, Aug 2026). Only render the shim when the system DEFAULT browser is
# itself Chromium-family; otherwise skip the window and let notify_fallback +
# the durable result file carry the outcome. Best-effort on purpose: any
# detection failure keeps today's behavior (0 = allowed).
default_browser_is_chromium() {
local py="$1" handler=""
if [ "$(uname)" = "Darwin" ]; then
local plist="$HOME/Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist"
# No explicit https handler registered = the OS default (Safari).
[ -f "$plist" ] || return 1
handler="$("$py" -c '
import plistlib, sys
with open(sys.argv[1], "rb") as f:
data = plistlib.load(f)
for entry in data.get("LSHandlers", []):
if entry.get("LSHandlerURLScheme") == "https":
print(entry.get("LSHandlerRoleAll", ""))
break
' "$plist" 2>/dev/null)" || return 0
# Parsed but empty = no https override = Safari default.
[ -n "$handler" ] || return 1
case "$handler" in
com.google.[Cc]hrome*|org.chromium.[Cc]hromium*) return 0 ;;
*) return 1 ;;
esac
fi
# Linux: xdg-settings is the authority; missing tool = permissive.
command -v xdg-settings >/dev/null 2>&1 || return 0
handler="$(xdg-settings get default-web-browser 2>/dev/null)" || return 0
[ -n "$handler" ] || return 0
case "$handler" in
*chrome*|*chromium*|*Chrome*|*Chromium*) return 0 ;;
*) return 1 ;;
esac
}
start_ui() {
[ "$NO_UI" -eq 1 ] && return
local html="$SCRIPT_DIR/ui.html" py browser port="" i
py="${INSTALL_ROOT:+$INSTALL_ROOT/venv/bin/python3}"
[ -x "${py:-/nonexistent}" ] || py="$(command -v python3 2>/dev/null)"
browser="$(find_browser)"
if [ -n "$browser" ] && [ -n "$py" ] && ! default_browser_is_chromium "$py"; then
log "shim: default browser is not Chromium-family; skipping UI window"
browser=""
fi
{ [ -f "$html" ] && [ -n "$py" ] && [ -n "$browser" ]; } || { log "shim: no renderer; 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
# renderer becomes collateral damage (Chrome surfaces that renderer death as
# an "Aw, Snap!" page with error code 15 even while /progress still returns
# HTTP 200). The Python wrapper immediately execs the real process, so $!
# remains the PID that stop_ui can terminate.
# TERM/HUP stay IGNORED in the server (SIG_IGN survives execv): a stray
# teardown TERM killed the shim ~1s into `hermes update` (2026-08-14 16:44,
# window showed ERR_CONNECTION_REFUSED for the whole run; upstream #66753).
# stop_ui ends the server with SIGKILL instead — it is stateless HTTP.
"$py" -c 'import os, signal, sys; os.setsid(); signal.signal(signal.SIGTERM, signal.SIG_IGN); signal.signal(signal.SIGHUP, signal.SIG_IGN); os.execv(sys.argv[1], sys.argv[1:])' \
"$py" "$SCRIPT_DIR/serve-ui.py" "$html" "$STATUS" "$STARTED_AT" > "$LOG_DIR/desktop-update-ui-port" 2>>"$LOG" &
UI_SERVER_PID=$!
for i in $(seq 1 10); do
port="$(tr -cd '0-9' < "$LOG_DIR/desktop-update-ui-port" 2>/dev/null)"
[ -n "$port" ] && break
sleep 0.2
done
[ -n "$port" ] || { kill -9 "$UI_SERVER_PID" 2>/dev/null; UI_SERVER_PID=""; return; }
# 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-$$" \
--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"
}
stop_ui() { # error/manual outcomes keep the window up briefly so a watching
# user can read the message, then close it. The outcome is also durably
# written to the result file and surfaced in a dialog on the next Desktop
# boot (handoff-result.ts), so the shim window never lingers indefinitely —
# before this, each aborted update left another orphan browser window on
# screen until the user closed it by hand.
if [ "${1:-}" = "leave-window" ]; then
sleep "${HERMES_UPDATE_SHIM_GRACE_SECONDS:-15}"
fi
if [ -n "$UI_SERVER_PID" ]; then
# The server ignores TERM/HUP (see start_ui) — KILL is its off switch.
{ kill -9 "$UI_SERVER_PID" && wait "$UI_SERVER_PID"; } 2>/dev/null
fi
if [ -n "$UI_BROWSER_PID" ]; then
{ kill "$UI_BROWSER_PID" && wait "$UI_BROWSER_PID"; } 2>/dev/null
fi
UI_SERVER_PID="" UI_BROWSER_PID=""
}
# ── relaunch ────────────────────────────────────────────────────────────────
# Linux relaunch gate -- an exact port of the deleted update-relaunch.ts
# decision (#45205/#37541), not a loosened rewrite:
# * the running binary must live under THIS checkout's rebuilt
# apps/desktop/release/linux-unpacked (anchored, path-segment-aware --
# proof the update we just ran replaced the selected executable);
# * chrome-sandbox ABSENT is fine (namespace-sandbox build; nothing to
# block on), PRESENT means root-owned AND setuid or Electron refuses to
# boot ("quit and never came back");
# * a user sandbox opt-out (ELECTRON_DISABLE_SANDBOX=1/true in our
# inherited env, --no-sandbox among the replayed launch args, or the
# Desktop vouching via --sandbox-fallback) makes the relaunch safe
# despite a failed preflight.
# Outcomes mirror decideRelaunchOutcome: relaunch | skew | manual.
GATE="" GATE_MSG=""
linux_gate() {
local unpacked="$INSTALL_ROOT/apps/desktop/release/linux-unpacked" sb arg
case "$RELAUNCH_TARGET" in
"$unpacked"/*) ;;
*) GATE=skew GATE_MSG="Backend updated, but the desktop app package (AppImage/deb/rpm) was not changed. Update or reinstall it to match."; return ;;
esac
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; }
for arg in ${RELAUNCH_ARGS[@]+"${RELAUNCH_ARGS[@]}"}; do
[ "$arg" = "--no-sandbox" ] && { GATE=relaunch; return; }
done
GATE=manual GATE_MSG="Update complete, but the rebuilt app can't relaunch itself (its sandbox helper needs root ownership). Reopen Hermes to finish."
}
mac_swap() {
local rebuilt="" c
for c in "$INSTALL_ROOT/apps/desktop/release/mac-arm64/Hermes.app" \
"$INSTALL_ROOT/apps/desktop/release/mac/Hermes.app"; do
[ -d "$c" ] && { rebuilt="$c"; break; }
done
# Transactional swap: stage a full copy, move the old bundle aside, move
# the copy in. Every step checked; a failed final move ROLLS BACK so the
# user always has a launchable app, and the result file tells the truth.
if [ "$FINAL_CODE" -eq 0 ] && [ -n "$rebuilt" ] && [ -d "$RELAUNCH_TARGET" ] && [ "$rebuilt" != "$RELAUNCH_TARGET" ]; then
publish_stage "Installing the new app"
rm -rf "$RELAUNCH_TARGET.new" "$RELAUNCH_TARGET.old" 2>/dev/null || true
if ! /usr/bin/ditto "$rebuilt" "$RELAUNCH_TARGET.new"; then
rm -rf "$RELAUNCH_TARGET.new" 2>/dev/null || true
DONE_NOTE="Update complete, but the new app could not be staged; the previous version was kept. Run the update again."
log "WARNING: bundle copy failed; keeping existing app"
elif ! mv "$RELAUNCH_TARGET" "$RELAUNCH_TARGET.old"; then
rm -rf "$RELAUNCH_TARGET.new" 2>/dev/null || true
DONE_NOTE="Update complete, but the new app could not replace the old one; the previous version was kept. Run the update again."
log "WARNING: could not move old bundle aside; keeping existing app"
elif ! mv "$RELAUNCH_TARGET.new" "$RELAUNCH_TARGET"; then
if mv "$RELAUNCH_TARGET.old" "$RELAUNCH_TARGET"; then
rm -rf "$RELAUNCH_TARGET.new" 2>/dev/null || true
DONE_NOTE="Update complete, but the new app could not be installed; the previous version was restored. Run the update again."
log "WARNING: bundle install failed; rolled back to the previous app"
else
FINAL_CODE=7 FINAL_MSG="The update finished but installing the new app failed and the previous app could not be restored. Reinstall Hermes (the rebuilt app is at $rebuilt)."
log "ERROR: bundle install failed AND rollback failed"
fi
else
rm -rf "$RELAUNCH_TARGET.old" 2>/dev/null || true
log "swapped app bundle"
fi
fi
}
deliver_outcome() { # the truth-determining half: swap bundles / gate the relaunch
[ -n "$RELAUNCH_TARGET" ] || return 0
if [ "$(uname)" = "Darwin" ]; then
mac_swap
else
linux_gate
if [ "$GATE" != "relaunch" ] && [ "$FINAL_CODE" -eq 0 ]; then
DONE_NOTE="$GATE_MSG"
log "no relaunch ($GATE): $GATE_MSG"
fi
fi
}
launch_app() { # attempted BEFORE the terminal event (launch acceptance is
# part of the outcome — gille's review). Returns nonzero when a launch
# was due but did not verifiably happen; caller downgrades to manual.
[ -n "$RELAUNCH_TARGET" ] || return 0
if [ "$(uname)" = "Darwin" ]; then
# A supplied target that no longer exists is a REJECTED launch (the
# swap failed badly or the bundle vanished) — not "no launch due".
[ -d "$RELAUNCH_TARGET" ] || { log "WARNING: relaunch target missing: $RELAUNCH_TARGET"; return 1; }
/usr/bin/xattr -dr com.apple.quarantine "$RELAUNCH_TARGET" 2>/dev/null || true
# `open` talks to launchd and FAILS LOUDLY on a broken/unlaunchable
# bundle — its exit code IS launch acceptance here.
/usr/bin/open "$RELAUNCH_TARGET" || { log "WARNING: open rejected the app"; return 1; }
elif [ "$GATE" = "relaunch" ]; then
# setsid only proves the wrapper shell started, so verify acceptance:
# spawn, then confirm the child is still alive shortly after — an
# immediate exec failure (ENOENT, ELF mismatch, dead sandbox) dies
# within the window and downgrades to manual instead of lying.
(cd "${RELAUNCH_CWD:-/}" 2>/dev/null || cd /
setsid "$RELAUNCH_TARGET" ${RELAUNCH_ARGS[@]+"${RELAUNCH_ARGS[@]}"} >/dev/null 2>&1 &
echo $! > "$STATUS.launchpid") || { log "WARNING: relaunch spawn failed"; return 1; }
local lp
lp="$(cat "$STATUS.launchpid" 2>/dev/null)"; rm -f "$STATUS.launchpid" 2>/dev/null
[ -n "$lp" ] || { log "WARNING: relaunch pid unknown"; return 1; }
sleep 1.5
kill -0 "$lp" 2>/dev/null || { log "WARNING: relaunched app exited immediately"; return 1; }
fi
}
MANUAL=0 # 1 = update landed but the user must act (result protocol field)
write_result() {
printf '{"ok":%s,"exit_code":%s,"manual":%s,"message":"%s","branch":"%s","finished_at":%s}' \
"$([ "$FINAL_CODE" -eq 0 ] && echo true || echo false)" "$FINAL_CODE" \
"$([ "$MANUAL" -eq 1 ] && echo true || echo false)" \
"$(json_escape "$FINAL_MSG")" "$(json_escape "$BRANCH")" "$(date +%s)" \
> "$RESULT.tmp" 2>/dev/null && mv -f "$RESULT.tmp" "$RESULT" 2>/dev/null || true
}
finish() {
# Ordering (gille's reviews, both rounds):
# 1. deliver the outcome (swap/gate) so the truth exists;
# 2. durable result + marker removal (the relaunched app consumes the
# result on boot and must not park on our marker — this must be on
# disk BEFORE any launch attempt);
# 3. attempt the launch and require ACCEPTANCE;
# 4. only then the terminal shim event — done means "the app is coming
# back", manual means "it is not, here's what to do", error is error.
# A rejected launch rewrites the result (nothing consumed it — the app
# never started) so the next boot tells the truth too.
deliver_outcome
[ "$FINAL_CODE" -eq 0 ] && [ -n "$DONE_NOTE" ] && { FINAL_MSG="$DONE_NOTE"; MANUAL=1; }
write_result
if [ "$NO_MARKER_CLEANUP" -eq 0 ] && [ "$(head -1 "$MARKER" 2>/dev/null | tr -d '[:space:]')" = "$$" ]; then
rm -f "$MARKER" 2>/dev/null || true
fi
if [ "$FINAL_CODE" -ne 0 ]; then
publish "error" "$FINAL_MSG"; stop_ui leave-window
launch_app || true # error path still tries to bring the app back
rm -f "$STATUS" "$STATUS.tmp" "$LOG_DIR/desktop-update-ui-port" 2>/dev/null || true
return
fi
if [ -n "$DONE_NOTE" ]; then
if [ "$(uname)" = "Darwin" ]; then
# mac DONE_NOTE = swap failed but the PREVIOUS bundle was kept/rolled
# back — bring it back up; the note still tells the user to re-run.
# A gated linux outcome (skew/manual) skips the launch BY DESIGN.
if ! launch_app; then
# Even the kept bundle didn't come back: the durable message must
# carry BOTH facts (update ok, previous app not reopened).
FINAL_MSG="$DONE_NOTE Hermes also could not reopen itself - open it manually."
write_result
fi
fi
publish "manual" "$FINAL_MSG"; stop_ui leave-window
elif launch_app; then
publish "done" ""; stop_ui
else
# Launch was due and did not land. Downgrade: truthful result for the
# next boot, manual state held on screen now.
FINAL_MSG="Update complete. Reopen Hermes to finish (it could not restart itself)."
MANUAL=1
write_result
publish "manual" "$FINAL_MSG"; stop_ui leave-window
fi
rm -f "$STATUS" "$STATUS.tmp" "$LOG_DIR/desktop-update-ui-port" 2>/dev/null || true
}
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.
trap - EXIT
linux_gate
echo "$GATE${GATE_MSG:+:$GATE_MSG}"
exit 0
fi
if [ "$SELF_TEST_UI" -eq 1 ]; then
start_ui
log "SELF-TEST: shim simulation (no update will run)"
sleep "${HERMES_SELFTEST_HOLD_SECONDS:-6}"
RELAUNCH_TARGET=""
if [ -n "${HERMES_SELFTEST_FAIL:-}" ]; then FINAL_MSG="self-test error state"
else FINAL_CODE=0 FINAL_MSG="self-test complete"; fi
exit "$FINAL_CODE"
fi
# ── the actual job ──────────────────────────────────────────────────────────
# Electron's macOS quit teardown sends SIGTERM to its still-parented updater
# child on this machine. `detached + unref` gives the child a process group but
# does not re-parent it before `before-quit` runs, so the hand-off consistently
# died two seconds after starting `hermes update`. Re-exec through a one-shot
# setsid child and let this direct Electron child exit first. The real
# orchestrator is then owned by launchd (PPID 1) and is outside Electron's quit
# teardown, while retaining the same marker/result protocol.
if [ "$HANDOFF_DAEMONIZED" -ne 1 ]; then
# This launcher is disposable. In particular it must not run finish() on
# EXIT: that would publish a false failure and relaunch Hermes while the
# re-parented orchestrator is only just starting.
trap - EXIT HUP INT QUIT TERM
# --daemonized must precede ORIGINAL_ARGS, not follow it: ORIGINAL_ARGS may
# contain a `--` separator (Linux relaunch args), and anything appended
# after that point is swallowed into RELAUNCH_ARGS instead of being parsed
# as a flag. Appending here previously left HANDOFF_DAEMONIZED unset on
# every re-exec, causing this block to re-fire forever (self-exec loop,
# unbounded argv growth) whenever relaunch args were present.
/usr/bin/nohup /usr/bin/python3 -c '
import os, sys
env = os.environ.copy()
os.setsid()
os.execve("/bin/bash", ["/bin/bash", sys.argv[1], *sys.argv[2:]], env)
' "$SCRIPT_DIR/posix.sh" --daemonized "${ORIGINAL_ARGS[@]}" >/dev/null 2>&1 &
exit 0
fi
# Electron terminates the entire detached updater process group during quit,
# including the loopback status server. Arm TERM immunity before `start_ui`
# so the shim server and the later `hermes update` subprocess both inherit
# SIG_IGN. The orchestrator restores its normal TERM handler after the update
# command has returned; the already-running server keeps the inherited setting
# until normal cleanup closes it.
trap '' TERM
log "hand-off start: root=$INSTALL_ROOT branch=$BRANCH desktopPid=$DESKTOP_PID pid=$$"
rm -f "$RESULT" 2>/dev/null || true
# Marker claim: same cross-process lock contract as windows.ps1 /
# update_lock.py (the `hermes update` child adopts it via process ancestry).
# The Desktop supplies one acquisition time for the whole ownership chain.
NOW="$(date +%s)"
STARTED_AT="${HERMES_UPDATE_STARTED_AT:-$NOW}"
case "$STARTED_AT" in ''|*[!0-9]*) STARTED_AT="$NOW" ;; esac
MIN_STARTED_AT=$((NOW - 1200))
# Compare the validated decimal strings before doing arithmetic. Shell integer
# expansion can wrap on an attacker-controlled value wider than signed 64-bit.
if [ "${#STARTED_AT}" -ne "${#NOW}" ] \
|| [[ "$STARTED_AT" > "$NOW" || "$STARTED_AT" < "$MIN_STARTED_AT" ]]; then
STARTED_AT="$NOW"
fi
printf '%s\n%s\n' "$$" "$STARTED_AT" > "$MARKER" 2>/dev/null || log "WARNING: could not write update marker"
if [ "$SELF_TEST_MARKER" -eq 1 ]; then
trap - EXIT
exit 0
fi
# Wait out the Desktop (FAIL CLOSED: updating under live backends bricks).
if [ "$DESKTOP_PID" -gt 0 ] 2>/dev/null; then
for _ in $(seq 1 100); do kill -0 "$DESKTOP_PID" 2>/dev/null || break; sleep 0.3; done
if kill -0 "$DESKTOP_PID" 2>/dev/null; then
FINAL_CODE=4 FINAL_MSG="Update aborted: the Hermes window (pid $DESKTOP_PID) did not exit within 30s. Nothing was changed. Close Hermes fully and try again."
log "$FINAL_MSG"; exit "$FINAL_CODE"
fi
fi
# Do not create Chrome until Electron has fully left. During its 2.5s quit
# dwell/before-quit teardown macOS can terminate descendants of the hand-off;
# a Chrome renderer reports that SIGTERM as "Aw, Snap!" error code 15. The
# update marker above prevents a second click during this short UI-less gap.
sleep 1
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
# the sandbox repro caught). FAIL CLOSED: set -u without set -e means a
# failed cd would otherwise continue in the wrong tree — the exact class
# this correction exists to eliminate.
cd "$INSTALL_ROOT" || {
FINAL_CODE=3 FINAL_MSG="Update aborted: cannot enter the install root ($INSTALL_ROOT). Nothing was changed."
log "$FINAL_MSG"; exit 3
}
export PYTHONUNBUFFERED=1
# --keep-stash: never re-apply local source edits after the update (they stay
# parked in git stash). Probe --help first: older installed backends don't
# know the flag and argparse would abort with exit 2, which collides with the
# "close all Hermes windows" sentinel.
KEEP_STASH=""
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: ${UPDATE_INVOKE[*]} update --yes --gateway $KEEP_STASH --branch $BRANCH"
publish_stage "Updating code and dependencies"
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"
if [ "$CODE" -ne 0 ] && [ "$CODE" -ne 2 ]; then
# Retry once: update-boundary class (fresh code on disk, stale in memory).
# Exit 2 ("close all Hermes windows") is not retryable.
#
# A parked-branch SKIP (checkout on a feature branch with unmerged
# commits) is also deterministic — the retry would hit the exact same
# branch state and skip again, so it only wastes time. Detect the skip
# by its banner, skip the retry, and surface an honest message with a
# dedicated exit code (8) so callers can distinguish "skipped" from a
# real failure.
if printf '%s' "$OUT" | grep -q "CODE UPDATE SKIPPED"; then
log "hermes update skipped (checkout parked on a non-target branch); not retrying"
FINAL_CODE=8
FINAL_MSG="Update skipped: the git checkout is on a branch that isn't fully merged into $BRANCH. Switch to the target branch and update again (see the terminal output for the exact commands)."
exit 8
fi
log "retrying once (freshly pulled fix loads on the second run)"
publish_stage "Retrying update"
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
trap 'on_signal TERM' TERM
# Truthful completion: `hermes update` calls a GUI build failure non-fatal
# (exit 0). For a Desktop-driven update that would relaunch the OLD build
# and call it success -- retry the build once, propagate honestly.
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"
"${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."
# 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"
+187
View File
@@ -0,0 +1,187 @@
#!/bin/bash
# repro.sh -- reproduce desktop-update paths against a sandboxed HERMES_HOME.
#
# Nothing here touches your real ~/.hermes or checkout. Each mode builds (or
# reuses) a disposable install under /tmp and drives the REAL code path --
# the actual installer, the actual orchestrator, the actual `hermes update`.
#
# repro.sh shim shim UI only: success event after 6s
# repro.sh shim-fail shim UI only: error event after 6s
# repro.sh fresh fresh install into a sandbox HERMES_HOME
# (scripts/install.sh, the literal user path)
# repro.sh behind [N] sandbox install rewound N commits (default 25),
# then the posix orchestrator drives it forward --
# the "user who hasn't updated in a while" path
# repro.sh error orchestrator against a broken install (missing
# venv) -- exercises abort + result-file + shim error
# repro.sh gate linux relaunch-gate decision matrix (anchoring,
# sandbox preflight, opt-out fallbacks) -- asserts
# every outcome without touching a real install
#
# The sandbox persists between runs (~/tmp is fine to nuke): fresh reuses
# nothing, behind/error reuse the last sandbox install when present because
# a from-scratch install is minutes.
#
# npm entry points (apps/desktop/package.json):
# npm run update:shim / update:shim:fail / update:repro:fresh /
# update:repro:behind [-- N] / update:repro:error
set -euo pipefail
MODE="${1:-help}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
SANDBOX="${HERMES_UPDATE_REPRO_HOME:-/tmp/hermes-update-repro}"
SANDBOX_ROOT="$SANDBOX/hermes-agent"
say() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; }
ensure_sandbox_install() {
if [ -x "$SANDBOX_ROOT/venv/bin/hermes" ]; then
say "reusing sandbox install at $SANDBOX_ROOT"
return
fi
say "fresh sandbox install into $SANDBOX (this takes a while)"
rm -rf "$SANDBOX"
mkdir -p "$SANDBOX"
# The literal user path: install.sh against a clone of THIS checkout, so
# the repro reproduces what you're about to ship, not origin/main.
git clone --quiet "$REPO_ROOT" "$SANDBOX_ROOT"
HERMES_HOME="$SANDBOX" bash "$SANDBOX_ROOT/scripts/install.sh" --non-interactive --skip-setup --hermes-home "$SANDBOX"
}
case "$MODE" in
shim)
HERMES_SELFTEST_HOLD_SECONDS="${HERMES_SELFTEST_HOLD_SECONDS:-6}" \
bash "$SCRIPT_DIR/posix.sh" --self-test-ui
;;
shim-fail)
HERMES_SELFTEST_FAIL=1 HERMES_SELFTEST_HOLD_SECONDS="${HERMES_SELFTEST_HOLD_SECONDS:-6}" \
bash "$SCRIPT_DIR/posix.sh" --self-test-ui
;;
fresh)
rm -rf "$SANDBOX"
ensure_sandbox_install
say "fresh install OK: $("$SANDBOX_ROOT/venv/bin/hermes" --version 2>/dev/null || echo '?')"
;;
behind)
N="${2:-25}"
ensure_sandbox_install
say "rewinding sandbox checkout $N commits"
git -C "$SANDBOX_ROOT" fetch --quiet origin main || true
git -C "$SANDBOX_ROOT" checkout --quiet main
git -C "$SANDBOX_ROOT" reset --hard --quiet "HEAD~$N"
say "sandbox now at: $(git -C "$SANDBOX_ROOT" log --oneline -1)"
say "driving the orchestrator (watch the shim; log: $SANDBOX/logs/desktop-update-handoff.log)"
HERMES_HOME="$SANDBOX" bash "$SCRIPT_DIR/posix.sh" \
--install-root "$SANDBOX_ROOT" --branch main --desktop-pid 0 || true
say "result file:"
cat "$SANDBOX/.hermes-update-result.json" 2>/dev/null || echo "(none written)"
echo
say "sandbox after update: $(git -C "$SANDBOX_ROOT" log --oneline -1)"
;;
error)
ensure_sandbox_install
say "breaking the sandbox venv, then driving the orchestrator"
mv "$SANDBOX_ROOT/venv" "$SANDBOX_ROOT/venv.hidden"
HERMES_HOME="$SANDBOX" bash "$SCRIPT_DIR/posix.sh" \
--install-root "$SANDBOX_ROOT" --branch main --desktop-pid 0 || true
mv "$SANDBOX_ROOT/venv.hidden" "$SANDBOX_ROOT/venv"
say "result file (expect ok:false, exit 3):"
cat "$SANDBOX/.hermes-update-result.json" 2>/dev/null || echo "(none written)"
echo
;;
gate)
# Pure-decision matrix for the linux relaunch gate. Builds a fake
# checkout layout under /tmp; --self-test-gate prints the decision and
# exits without running an update.
G="/tmp/hermes-gate-test.$$"
UNPACKED="$G/hermes-agent/apps/desktop/release/linux-unpacked"
mkdir -p "$UNPACKED"
touch "$UNPACKED/hermes" && chmod +x "$UNPACKED/hermes"
fails=0
expect() { # name expected actual
if [ "$2" = "$3" ]; then printf 'ok %s -> %s\n' "$1" "$3"
else printf 'FAIL %s -> %s (want %s)\n' "$1" "$3" "$2"; fails=$((fails+1)); fi
}
decide() { bash "$SCRIPT_DIR/posix.sh" --self-test-gate --install-root "$G/hermes-agent" "$@" | cut -d: -f1; }
expect "appimage (not under unpacked)" skew "$(decide --relaunch-target /opt/Hermes/hermes)"
expect "sibling-prefix dir not fooled" skew "$(decide --relaunch-target "$UNPACKED-evil/hermes")"
expect "no chrome-sandbox (namespace)" relaunch "$(decide --relaunch-target "$UNPACKED/hermes")"
touch "$UNPACKED/chrome-sandbox"
expect "sandbox not root/setuid" manual "$(decide --relaunch-target "$UNPACKED/hermes")"
expect "opt-out: --sandbox-fallback" relaunch "$(decide --relaunch-target "$UNPACKED/hermes" --sandbox-fallback)"
expect "opt-out: --no-sandbox launch arg" relaunch "$(decide --relaunch-target "$UNPACKED/hermes" -- --no-sandbox)"
expect "opt-out: ELECTRON_DISABLE_SANDBOX" relaunch "$(ELECTRON_DISABLE_SANDBOX=1 decide --relaunch-target "$UNPACKED/hermes")"
# Result JSON must survive hostile strings (git allows `"` in branch
# names; messages carry arbitrary text) -- parse it back with python.
QHOME="$G/qhome"; mkdir -p "$QHOME/hermes-agent"
bash "$SCRIPT_DIR/posix.sh" --no-ui --no-marker-cleanup --desktop-pid 0 \
--install-root "$QHOME/hermes-agent" --branch 'evil"branch\n$(x)' >/dev/null 2>&1 || true
if python3 -c "import json,sys; d=json.load(open('$QHOME/.hermes-update-result.json')); sys.exit(0 if d['branch']=='evil\"branch\\\\n\$(x)' and d['ok']==False else 1)"; then
printf 'ok result JSON escapes hostile branch/message\n'
else
printf 'FAIL result JSON escaping\n'; fails=$((fails+1))
fi
rm -rf "$G"
[ "$fails" -eq 0 ] && say "gate matrix: all pass" || { say "gate matrix: $fails FAILED"; exit 1; }
;;
launch)
# Terminal-lifecycle matrix (gille round 2): launch acceptance is part
# of the outcome. Each case runs the REAL orchestrator (--no-ui) against
# a fake install whose `hermes` stub exits 0 instantly, so the flow
# reaches finish() with FINAL_CODE=0 and exercises the launch leg.
L="/tmp/hermes-launch-test.$$"
fails=0
expect_msg() { # name python-expr
if python3 -c "import json,sys; d=json.load(open('$L/.hermes-update-result.json')); sys.exit(0 if ($2) else 1)"; then
printf 'ok %s\n' "$1"
else
printf 'FAIL %s -> %s\n' "$1" "$(cat "$L/.hermes-update-result.json" 2>/dev/null)"; fails=$((fails+1))
fi
}
stub_install() { # creates a fake install whose hermes update succeeds
rm -rf "$L"; mkdir -p "$L/hermes-agent/venv/bin"
printf '#!/bin/sh\nexit 0\n' > "$L/hermes-agent/venv/bin/hermes"
chmod +x "$L/hermes-agent/venv/bin/hermes"
}
# 1. linux relaunch target dies instantly -> manual downgrade in result
stub_install
UNPACKED="$L/hermes-agent/apps/desktop/release/linux-unpacked"
mkdir -p "$UNPACKED"
printf '#!/bin/sh\nexit 1\n' > "$UNPACKED/hermes"; chmod +x "$UNPACKED/hermes"
if [ "$(uname)" != "Darwin" ]; then
bash "$SCRIPT_DIR/posix.sh" --no-ui --desktop-pid 0 --install-root "$L/hermes-agent" \
--relaunch-target "$UNPACKED/hermes" >/dev/null 2>&1 || true
expect_msg "instant-exit relaunch downgrades to manual" "d['ok']==True and d['manual']==True and 'Reopen Hermes' in d['message']"
else
# mac: a SUPPLIED target that is missing is a REJECTED launch and
# must downgrade to manual — never a clean "Update complete."
bash "$SCRIPT_DIR/posix.sh" --no-ui --desktop-pid 0 --install-root "$L/hermes-agent" \
--relaunch-target "$L/NoSuch.app" >/dev/null 2>&1 || true
expect_msg "missing bundle downgrades to manual" "d['ok']==True and d['manual']==True and 'Reopen Hermes' in d['message']"
fi
# 2. gated skew: success result carries the skew message (the manual
# event's payload), never a bare "Update complete."
stub_install
bash "$SCRIPT_DIR/posix.sh" --no-ui --desktop-pid 0 --install-root "$L/hermes-agent" \
--relaunch-target /opt/Hermes/hermes >/dev/null 2>&1 || true
if [ "$(uname)" != "Darwin" ]; then
expect_msg "skew outcome surfaces in result message" "d['ok']==True and d['manual']==True and 'was not changed' in d['message']"
fi
rm -rf "$L"
[ "$fails" -eq 0 ] && say "launch matrix: all pass" || { say "launch matrix: $fails FAILED"; exit 1; }
;;
*)
sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'
exit 64
;;
esac
+16
View File
@@ -0,0 +1,16 @@
function Test-HermesUpdateShouldRetry {
param(
[int]$ExitCode,
[string]$InstallRoot
)
if ($ExitCode -eq 0) { return $false }
if ($ExitCode -ne 2) { return $true }
# Exit 2 is shared by non-retryable safety refusals and the self-lock
# deferral. Only the latter writes this marker. The handoff treats it as a
# retry signal for one fresh-process attempt, whose early-recovery pass
# completes core dependencies before native modules load.
$deferredInstallMarker = Join-Path $InstallRoot ".update-incomplete"
return Test-Path -LiteralPath $deferredInstallMarker
}
+63
View File
@@ -0,0 +1,63 @@
"""Loopback shim server for the desktop update hand-off.
Two GET routes: / serves ui.html, /progress serves the status file the
orchestrator script writes ({"status": "running"|"done"|"error", ...}).
Exists because a file:// page cannot receive events from a detached
process. Prints the chosen ephemeral port on stdout, serves until killed.
`elapsed_seconds` is stamped per request, not read from the status file:
stages are minutes apart, so a value frozen at the last publish would sit
still through the longest waits -- exactly the stall the page exists to
disprove. Windows' in-process listener computes it the same way.
"""
import http.server
import json
import socketserver
import sys
import time
html_path, status_path = sys.argv[1], sys.argv[2]
started_at = float(sys.argv[3]) if len(sys.argv) > 3 else time.time()
with open(html_path, "rb") as f:
HTML = f.read()
def progress_body():
try:
with open(status_path, "rb") as f:
state = json.loads(f.read())
if not isinstance(state, dict):
raise ValueError(state)
except Exception:
state = {"status": "running", "message": ""}
state["elapsed_seconds"] = max(0, int(time.time() - started_at))
return json.dumps(state).encode("utf-8")
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, format, *args): # noqa: A002 - base class signature
pass
def do_GET(self):
if self.path.startswith("/progress"):
body = progress_body()
ctype = "application/json; charset=utf-8"
elif self.path == "/":
body, ctype = HTML, "text/html; charset=utf-8"
else:
self.send_response(404)
self.end_headers()
return
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
with socketserver.TCPServer(("127.0.0.1", 0), Handler) as srv:
print(srv.server_address[1], flush=True)
srv.serve_forever()
+259
View File
@@ -0,0 +1,259 @@
<!doctype html>
<!--
Quiet shim page for the desktop update hand-off (Windows + POSIX).
Served over loopback by the orchestrator (windows.ps1 / posix.sh) into a
chromeless browser app window. Pure veneer: polls /progress for the current
hand-off stage or a terminal event and reacts; owns nothing (relaunch,
result file, marker hygiene all live in the orchestrator, which runs
identically with no UI at all).
The visual is PR #75895's update hand-off screen, ported verbatim:
- Loader: the desktop's "Fourier Flow" curve. Math + tuning lifted from
apps/bootstrap-installer/src/components/loader.tsx (itself lifted from
apps/desktop/src/components/ui/loader.tsx 'fourier-flow'). Keep the
constants in sync if the desktop's curve is retuned.
- Layout: loader (size-20) + one title + one muted stage/elapsed line. No
progress bar, stage list, log pane, or cancel (see #75895 for the
arguments). Elapsed comes from the orchestrator or is omitted -- a clock
started here would measure when this window painted, not the update.
- Appearance follows the OS. Dark seeds are the installer's neutral
charcoal (#232323 base, foreground #d6d6d6) — never brand blue.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hermes</title>
<style>
:root {
--background: #ffffff;
--foreground: #1a1a1a;
--muted-foreground: #737373;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #232323;
--foreground: #d6d6d6;
--muted-foreground: #8a8a8a;
}
}
html, body {
margin: 0;
height: 100%;
background: var(--background);
color: var(--foreground);
font-family: system-ui, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
user-select: none;
cursor: default;
overflow: hidden;
}
/* UpdateScreen: flex h-full flex-col items-center justify-center gap-4
px-6 text-center (routes/progress.tsx) */
.wrap {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 0 24px;
box-sizing: border-box;
text-align: center;
}
@keyframes hermes-fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.wrap { animation: hermes-fade-in 0.45s ease-out both; }
/* Loader size-20 (5rem); svg overflow-visible; curve path opacity .1 */
#loader { width: 80px; height: 80px; color: var(--foreground); }
#loader svg { width: 100%; height: 100%; overflow: visible; }
#glyph {
width: 80px;
height: 80px;
display: none;
align-items: center;
justify-content: center;
font-size: 44px;
line-height: 1;
}
/* text-lg font-semibold tracking-tight */
h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
letter-spacing: -0.025em;
}
/* text-xs text-muted-foreground */
p {
margin: 0;
max-width: 80%;
font-size: 12px;
line-height: 1.5;
color: var(--muted-foreground);
white-space: pre-line;
}
p code {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 11px;
color: var(--foreground);
}
body.done #loader, body.error #loader { display: none; }
body.done #glyph, body.error #glyph { display: flex; }
</style>
</head>
<body>
<div class="wrap">
<div id="loader" role="status" aria-label="Updating"></div>
<div id="glyph"></div>
<h2 id="title">Updating Hermes</h2>
<p id="line">Hermes will open once done.</p>
</div>
<script>
/* ── Fourier Flow loader, ported verbatim from loader.tsx ─────────────── */
const TWO_PI = Math.PI * 2
const CURVE = {
durationMs: 2200,
particleCount: 92,
pulseDurationMs: 2000,
strokeWidth: 4.2,
trailSpan: 0.31,
point(progress, detailScale) {
const t = progress * TWO_PI
const mix = 1 + detailScale * 0.16
const x = 17 * Math.cos(t) + 7.5 * Math.cos(3 * t + 0.6 * mix) + 3.2 * Math.sin(5 * t - 0.4)
const y = 15 * Math.sin(t) + 8.2 * Math.sin(2 * t + 0.25) - 4.2 * Math.cos(4 * t - 0.5 * mix)
return { x: 50 + x, y: 50 + y }
}
}
const PATH_STEPS = 240
const norm = progress => ((progress % 1) + 1) % 1
function detailScaleFor(time, phaseOffset) {
const p = ((time + phaseOffset * CURVE.pulseDurationMs) % CURVE.pulseDurationMs) / CURVE.pulseDurationMs
return 0.52 + ((Math.sin(p * TWO_PI + 0.55) + 1) / 2) * 0.48
}
function buildPath(detailScale, steps) {
return Array.from({ length: steps + 1 }, (_, i) => {
const { x, y } = CURVE.point(i / steps, detailScale)
return `${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`
}).join(' ')
}
function particleFor(index, progress, detailScale) {
const tail = index / (CURVE.particleCount - 1)
const { x, y } = CURVE.point(norm(progress - tail * CURVE.trailSpan), detailScale)
const fade = (1 - tail) ** 0.56
return { x, y, opacity: 0.04 + fade * 0.96, radius: 0.9 + fade * 2.7 }
}
const SVG_NS = 'http://www.w3.org/2000/svg'
const svg = document.createElementNS(SVG_NS, 'svg')
svg.setAttribute('viewBox', '0 0 100 100')
svg.setAttribute('fill', 'none')
svg.setAttribute('aria-hidden', 'true')
const curvePath = document.createElementNS(SVG_NS, 'path')
curvePath.setAttribute('opacity', '0.1')
curvePath.setAttribute('stroke', 'currentColor')
curvePath.setAttribute('stroke-linecap', 'round')
curvePath.setAttribute('stroke-linejoin', 'round')
curvePath.setAttribute('stroke-width', String(CURVE.strokeWidth))
svg.appendChild(curvePath)
const particles = Array.from({ length: CURVE.particleCount }, () => {
const c = document.createElementNS(SVG_NS, 'circle')
c.setAttribute('fill', 'currentColor')
svg.appendChild(c)
return c
})
document.getElementById('loader').appendChild(svg)
let frame = 0
const startedAt = performance.now()
const phaseOffset = Math.random()
function render(now) {
const time = now - startedAt
const progress = ((time + phaseOffset * CURVE.durationMs) % CURVE.durationMs) / CURVE.durationMs
const detailScale = detailScaleFor(time, phaseOffset)
curvePath.setAttribute('d', buildPath(detailScale, PATH_STEPS))
particles.forEach((node, index) => {
const p = particleFor(index, progress, detailScale)
node.setAttribute('cx', p.x.toFixed(2))
node.setAttribute('cy', p.y.toFixed(2))
node.setAttribute('r', p.radius.toFixed(2))
node.setAttribute('opacity', p.opacity.toFixed(3))
})
frame = window.requestAnimationFrame(render)
}
render(performance.now())
/* ── Event listener: running stage or terminal outcome ───────────────── */
const titleEl = document.getElementById('title')
const lineEl = document.getElementById('line')
const glyphEl = document.getElementById('glyph')
const defaultLine = lineEl.textContent /* what a stage-less run says */
let settled = false
const elapsedText = s =>
s < 60 ? `${s}s elapsed` : `${Math.floor(s / 60)}m ${s % 60}s elapsed`
function settle(state) {
settled = true
window.cancelAnimationFrame(frame)
document.body.className = state
}
function apply(state) {
if (settled) return
if (state.status === 'running') {
const stage = state.message || defaultLine
const elapsed = Number(state.elapsed_seconds)
lineEl.textContent = Number.isFinite(elapsed) && elapsed >= 0
? `${stage}\n${elapsedText(Math.floor(elapsed))}`
: stage
} else if (state.status === 'done') {
settle('done')
glyphEl.textContent = '\u2713'
lineEl.textContent = 'Opening Hermes\u2026'
} else if (state.status === 'manual') {
// Update landed but Hermes will NOT reopen itself (package skew,
// sandbox helper, launch rejected). The orchestrator leaves this
// window up; the message says what to do.
settle('done')
glyphEl.textContent = '\u2713'
titleEl.textContent = 'Update complete'
lineEl.textContent = state.message || 'Reopen Hermes to finish.'
} else if (state.status === 'error') {
settle('error')
glyphEl.textContent = '\u2715'
titleEl.textContent = 'Failed to update'
lineEl.innerHTML = 'Run <code>hermes debug share</code> in a terminal to send a report.'
}
}
async function poll() {
try {
const res = await fetch('/progress', { cache: 'no-store' })
if (res.ok) apply(await res.json())
} catch {
// Server gone: hold the last known state. The orchestrator owns
// closing this window; the relaunched Desktop owns the result.
}
if (!settled) setTimeout(poll, 400)
}
poll()
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+591
View File
@@ -0,0 +1,591 @@
#!/usr/bin/env bash
# Run a command in a disposable, network-isolated fake Internet.
#
# 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.
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...]
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.
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.
Environment:
HERMES_DEV_SANDBOX_DIR Sandbox directory name, relative to the repo root
(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
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
--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 ;;
esac
done
if [ "$INSTALL_SHORTCUT" = false ] && [ "$#" -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"
if [ "$DELETE" = true ]; then
if [ ! -d "$PERSISTENT_ROOT" ]; then
echo "[sandbox] nothing to delete at $PERSISTENT_ROOT" >&2
exit 0
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
if [ "$PERSISTENT" = true ]; then
SANDBOX_ROOT="$PERSISTENT_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
:
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
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
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"
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
}
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"
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 $?
+396
View File
@@ -0,0 +1,396 @@
#!/usr/bin/env python3
"""Discord Voice Doctor — diagnostic tool for voice channel support.
Checks all dependencies, configuration, and bot permissions needed
for Discord voice mode to work correctly.
Usage:
python scripts/discord-voice-doctor.py
.venv/bin/python scripts/discord-voice-doctor.py
"""
import os
import sys
import shutil
from pathlib import Path
# Resolve project root
SCRIPT_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = SCRIPT_DIR.parent
sys.path.insert(0, str(PROJECT_ROOT))
HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
ENV_FILE = HERMES_HOME / ".env"
OK = "\033[92m\u2713\033[0m"
FAIL = "\033[91m\u2717\033[0m"
WARN = "\033[93m!\033[0m"
# Track whether discord.py is available for later sections
_discord_available = False
def mask(value):
"""Mask sensitive value: show only first 4 chars."""
if not value or len(value) < 8:
return "****"
return f"{value[:4]}{'*' * (len(value) - 4)}"
def check(label, ok, detail=""):
symbol = OK if ok else FAIL
msg = f" {symbol} {label}"
if detail:
msg += f" ({detail})"
print(msg)
return ok
def warn(label, detail=""):
msg = f" {WARN} {label}"
if detail:
msg += f" ({detail})"
print(msg)
def section(title):
print(f"\n\033[1m{title}\033[0m")
def check_packages():
"""Check Python package dependencies. Returns True if all critical deps OK."""
global _discord_available
section("Python Packages")
ok = True
# discord.py
try:
import discord
_discord_available = True
check("discord.py", True, f"v{discord.__version__}")
except ImportError:
check("discord.py", False, "pip install discord.py[voice]")
ok = False
# PyNaCl
try:
import nacl
ver = getattr(nacl, "__version__", "unknown")
try:
import nacl.secret
nacl.secret.Aead(bytes(32))
check("PyNaCl", True, f"v{ver}")
except (AttributeError, Exception):
check("PyNaCl (Aead)", False, f"v{ver} — need >=1.5.0")
ok = False
except ImportError:
check("PyNaCl", False, "pip install PyNaCl>=1.5.0")
ok = False
# davey (DAVE E2EE)
try:
import davey
check("davey (DAVE E2EE)", True, f"v{getattr(davey, '__version__', '?')}")
except ImportError:
check("davey (DAVE E2EE)", False, "pip install davey")
ok = False
# Optional: local STT
try:
import faster_whisper
check("faster-whisper (local STT)", True)
except ImportError:
warn("faster-whisper (local STT)", "not installed — local STT unavailable")
# Optional: TTS providers
try:
import edge_tts
check("edge-tts", True)
except ImportError:
warn("edge-tts", "not installed — edge TTS unavailable")
try:
import elevenlabs
check("elevenlabs SDK", True)
except ImportError:
warn("elevenlabs SDK", "not installed — premium TTS unavailable")
return ok
def check_system_tools():
"""Check system-level tools (opus, ffmpeg). Returns True if all OK."""
section("System Tools")
ok = True
# Opus codec
if _discord_available:
try:
import discord
opus_loaded = discord.opus.is_loaded()
if not opus_loaded:
import ctypes.util
opus_path = ctypes.util.find_library("opus")
if not opus_path:
# Platform-specific fallback paths
candidates = [
"/opt/homebrew/lib/libopus.dylib", # macOS Apple Silicon
"/usr/local/lib/libopus.dylib", # macOS Intel
"/usr/lib/x86_64-linux-gnu/libopus.so.0", # Debian/Ubuntu x86
"/usr/lib/aarch64-linux-gnu/libopus.so.0", # Debian/Ubuntu ARM
"/usr/lib/libopus.so", # Arch Linux
"/usr/lib64/libopus.so", # RHEL/Fedora
]
for p in candidates:
if os.path.isfile(p):
opus_path = p
break
if opus_path:
discord.opus.load_opus(opus_path)
opus_loaded = discord.opus.is_loaded()
if opus_loaded:
check("Opus codec", True)
else:
check("Opus codec", False, "brew install opus / apt install libopus0")
ok = False
except Exception as e:
check("Opus codec", False, str(e))
ok = False
else:
warn("Opus codec", "skipped — discord.py not installed")
# ffmpeg
ffmpeg_path = shutil.which("ffmpeg")
if ffmpeg_path:
check("ffmpeg", True, ffmpeg_path)
else:
check("ffmpeg", False, "brew install ffmpeg / apt install ffmpeg")
ok = False
return ok
def check_env_vars():
"""Check environment variables. Returns (ok, token, groq_key, eleven_key)."""
section("Environment Variables")
# Load .env
try:
from hermes_cli.env_loader import load_hermes_dotenv
load_hermes_dotenv(
hermes_home=ENV_FILE.parent,
project_env=PROJECT_ROOT / ".env",
)
except ImportError:
pass
ok = True
token = os.getenv("DISCORD_BOT_TOKEN", "")
if token:
check("DISCORD_BOT_TOKEN", True, mask(token))
else:
check("DISCORD_BOT_TOKEN", False, "not set")
ok = False
# Allowed users — resolve usernames if possible
allowed = os.getenv("DISCORD_ALLOWED_USERS", "")
if allowed:
users = [u.strip() for u in allowed.split(",") if u.strip()]
user_labels = []
for uid in users:
label = mask(uid)
if token and uid.isdigit():
try:
import requests
r = requests.get(
f"https://discord.com/api/v10/users/{uid}",
headers={"Authorization": f"Bot {token}"},
timeout=3,
)
if r.status_code == 200:
label = f"{r.json().get('username', '?')} ({mask(uid)})"
except Exception:
pass
user_labels.append(label)
check("DISCORD_ALLOWED_USERS", True, f"{len(users)} user(s): {', '.join(user_labels)}")
else:
warn("DISCORD_ALLOWED_USERS", "not set — all users can use voice")
groq_key = os.getenv("GROQ_API_KEY", "")
eleven_key = os.getenv("ELEVENLABS_API_KEY", "")
if groq_key:
check("GROQ_API_KEY (STT)", True, mask(groq_key))
else:
warn("GROQ_API_KEY", "not set — Groq STT unavailable")
if eleven_key:
check("ELEVENLABS_API_KEY (TTS)", True, mask(eleven_key))
else:
warn("ELEVENLABS_API_KEY", "not set — ElevenLabs TTS unavailable")
return ok, token, groq_key, eleven_key
def check_config(groq_key, eleven_key):
"""Check hermes config.yaml."""
section("Configuration")
config_path = HERMES_HOME / "config.yaml"
if config_path.exists():
try:
import yaml
with open(config_path, encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
stt_provider = cfg.get("stt", {}).get("provider", "local")
tts_provider = cfg.get("tts", {}).get("provider", "edge")
check("STT provider", True, stt_provider)
check("TTS provider", True, tts_provider)
if stt_provider == "groq" and not groq_key:
warn("STT config says groq but GROQ_API_KEY is missing")
if stt_provider == "mistral" and not os.getenv("MISTRAL_API_KEY"):
warn("STT config says mistral but MISTRAL_API_KEY is missing")
if tts_provider == "elevenlabs" and not eleven_key:
warn("TTS config says elevenlabs but ELEVENLABS_API_KEY is missing")
if tts_provider == "mistral" and not os.getenv("MISTRAL_API_KEY"):
warn("TTS config says mistral but MISTRAL_API_KEY is missing")
except Exception as e:
warn("config.yaml", f"parse error: {e}")
else:
warn("config.yaml", "not found — using defaults")
# Voice mode state
voice_mode_path = HERMES_HOME / "gateway_voice_mode.json"
if voice_mode_path.exists():
try:
import json
modes = json.loads(voice_mode_path.read_text(encoding="utf-8"))
off_count = sum(1 for v in modes.values() if v == "off")
all_count = sum(1 for v in modes.values() if v == "all")
check("Voice mode state", True, f"{all_count} on, {off_count} off, {len(modes)} total")
except Exception:
warn("Voice mode state", "parse error")
else:
check("Voice mode state", True, "no saved state (fresh)")
def check_bot_permissions(token):
"""Check bot permissions via Discord API. Returns True if all OK."""
section("Bot Permissions")
if not token:
warn("Bot permissions", "no token — skipping")
return True
try:
import requests
except ImportError:
warn("Bot permissions", "requests not installed — skipping")
return True
VOICE_PERMS = {
"Priority Speaker": 8,
"Stream": 9,
"View Channel": 10,
"Send Messages": 11,
"Embed Links": 14,
"Attach Files": 15,
"Read Message History": 16,
"Connect": 20,
"Speak": 21,
"Mute Members": 22,
"Deafen Members": 23,
"Move Members": 24,
"Use VAD": 25,
"Send Voice Messages": 46,
}
REQUIRED_PERMS = {"Connect", "Speak", "View Channel", "Send Messages"}
ok = True
try:
headers = {"Authorization": f"Bot {token}"}
r = requests.get("https://discord.com/api/v10/users/@me", headers=headers, timeout=5)
if r.status_code == 401:
check("Bot login", False, "invalid token (401)")
return False
if r.status_code != 200:
check("Bot login", False, f"HTTP {r.status_code}")
return False
bot = r.json()
bot_name = bot.get("username", "?")
check("Bot login", True, f"{bot_name[:3]}{'*' * (len(bot_name) - 3)}")
# Check guilds
r2 = requests.get("https://discord.com/api/v10/users/@me/guilds", headers=headers, timeout=5)
if r2.status_code != 200:
warn("Guilds", f"HTTP {r2.status_code}")
return ok
guilds = r2.json()
check("Guilds", True, f"{len(guilds)} guild(s)")
for g in guilds[:5]:
perms = int(g.get("permissions", 0))
is_admin = bool(perms & (1 << 3))
if is_admin:
print(f" {OK} {g['name']}: Administrator (all permissions)")
continue
has = []
missing = []
for name, bit in sorted(VOICE_PERMS.items(), key=lambda x: x[1]):
if perms & (1 << bit):
has.append(name)
elif name in REQUIRED_PERMS:
missing.append(name)
if missing:
print(f" {FAIL} {g['name']}: missing {', '.join(missing)}")
ok = False
else:
print(f" {OK} {g['name']}: {', '.join(has)}")
except requests.exceptions.Timeout:
warn("Bot permissions", "Discord API timeout")
except requests.exceptions.ConnectionError:
warn("Bot permissions", "cannot reach Discord API")
except Exception as e:
warn("Bot permissions", f"check failed: {e}")
return ok
def main():
print()
print("\033[1m" + "=" * 50 + "\033[0m")
print("\033[1m Discord Voice Doctor\033[0m")
print("\033[1m" + "=" * 50 + "\033[0m")
all_ok = True
all_ok &= check_packages()
all_ok &= check_system_tools()
env_ok, token, groq_key, eleven_key = check_env_vars()
all_ok &= env_ok
check_config(groq_key, eleven_key)
all_ok &= check_bot_permissions(token)
# Summary
print()
print("\033[1m" + "-" * 50 + "\033[0m")
if all_ok:
print(f" {OK} \033[92mAll checks passed — voice mode ready!\033[0m")
else:
print(f" {FAIL} \033[91mSome checks failed — fix issues above.\033[0m")
print()
if __name__ == "__main__":
main()
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Run Docker boot-time config migrations safely."""
from __future__ import annotations
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable
from hermes_cli.config import (
check_config_version,
get_config_path,
get_env_path,
migrate_config,
)
from hermes_cli.config_migrations import (
SUPPORT_FLOOR_VERSION,
support_floor_message,
)
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")
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
return backups
def _restore_backups(backups: dict[Path, Path]) -> list[Path]:
restored: list[Path] = []
for original, backup in backups.items():
if not backup.is_file():
continue
shutil.copy2(backup, original)
restored.append(original)
return restored
def main() -> int:
if env_var_enabled("HERMES_SKIP_CONFIG_MIGRATION"):
print("[config-migrate] HERMES_SKIP_CONFIG_MIGRATION is set; skipping config migration")
return 0
current_ver, latest_ver = check_config_version()
if current_ver >= latest_ver:
return 0
# Below the auto-migration support floor: migrate_config() refuses (and
# leaves the file untouched), so don't run the backup/verify dance that
# would raise "did not advance config version" and block the boot.
# Warn-and-continue matches the CLI's fail-safe posture.
if current_ver < SUPPORT_FLOOR_VERSION:
print(
f"[config-migrate] WARNING: {support_floor_message()}",
file=sys.stderr,
)
return 0
backups = _backup_existing((get_config_path(), get_env_path()))
backup_text = ", ".join(str(path) for path in backups.values()) if backups else "none"
print(
f"[config-migrate] Migrating config schema {current_ver} -> {latest_ver}; "
f"backups: {backup_text}"
)
try:
migrate_config(interactive=False, quiet=False)
except Exception:
restored = _restore_backups(backups)
if restored:
print(
"[config-migrate] Migration failed; restored "
+ ", ".join(str(path) for path in restored)
)
raise
post_ver, _ = check_config_version()
if post_ver < latest_ver:
restored = _restore_backups(backups)
restored_text = ", ".join(str(path) for path in restored) if restored else "none"
raise RuntimeError(
f"migration did not advance config version to {latest_ver} "
f"(still {post_ver}); restored: {restored_text}"
)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"[config-migrate] ERROR: {exc}", file=sys.stderr)
raise SystemExit(1)
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""Boot-time re-seed of a terminally-dead Nous bootstrap session.
Background
----------
A Nous bootstrap session (client_id ``hermes-cli-vps``) can take a terminal
``invalid_grant`` and be quarantined locally — the refresh path clears the dead
tokens from ``auth.json`` and stamps
``providers.nous.last_auth_error.relogin_required = true``. From then on every
inference turn hard-fails with a provider-auth error until the credential is
replaced, even though the gateway and dashboard otherwise look healthy.
``stage2-hook.sh`` seeds ``auth.json`` from ``HERMES_AUTH_JSON_BOOTSTRAP`` only
on a *blank* volume (``[ ! -f auth.json ]``) — that guard is load-bearing: it
stops a container restart from clobbering a healthy, rotated refresh token. So a
plain restart with a fresh seed env can NOT recover a container whose volume
already has an auth.json.
This script is the narrow, safe exception. An orchestrator that manages the
container can supply a freshly-issued bootstrap session via
``HERMES_AUTH_JSON_REBOOTSTRAP`` (plus a restart). On boot we re-seed the Nous
provider entry from that env when the on-disk entry is provably terminal, or
when the orchestrator seed's ``obtained_at`` is newer than the local session.
The latter matters because an orchestrator may revoke the previous session
before restart while its still-present local tokens look healthy. Older or
incomparable seeds remain no-ops, so a retained env cannot roll auth backward.
Design constraints
------------------
- Pure stdlib, no hermes_cli imports: runs early in the boot hook, before the
app venv/modules are guaranteed importable, as its own subprocess.
- Surgical: replaces ONLY ``providers.nous`` in the existing auth.json, leaving
every other provider, the version, and any other top-level state untouched.
- Fail-safe: any parse/IO error leaves auth.json exactly as-is and exits 0 (a
failed re-seed must never take the container further down than it already is).
"""
from __future__ import annotations
import json
import os
import sys
from datetime import datetime, timezone
from typing import Any, Optional
# Env var the orchestrator sets to the re-seed payload. Deliberately DISTINCT
# from HERMES_AUTH_JSON_BOOTSTRAP (create-only, blank-volume seed) so the two
# paths can never be confused: BOOTSTRAP seeds a fresh volume; REBOOTSTRAP
# overwrites a terminally-dead Nous entry on an existing volume.
REBOOTSTRAP_ENV = "HERMES_AUTH_JSON_REBOOTSTRAP"
BOOTSTRAP_CLIENT_ID = "hermes-cli-vps"
def _nous_entry_is_terminal(nous_state: Any) -> bool:
"""True iff the on-disk Nous provider entry is in the terminal/quarantined
state AND holds no usable credential.
Mirrors the ``terminal`` predicate in ``hermes_cli.auth.get_nous_session_validity``:
a persisted ``last_auth_error.relogin_required`` with the token material
already cleared. Keeping this in lockstep is what guarantees we only re-seed
a session that is genuinely dead.
"""
if not isinstance(nous_state, dict):
return False
last_err = nous_state.get("last_auth_error")
if not (isinstance(last_err, dict) and last_err.get("relogin_required")):
return False
# Only terminal while there is no usable credential left. If a live token is
# somehow present, treat it as healthy and do NOT clobber it.
if nous_state.get("access_token") or nous_state.get("refresh_token"):
return False
return True
def _extract_nous_from_seed(seed_raw: str) -> Optional[dict]:
"""Pull the ``providers.nous`` block out of a HERMES_AUTH_JSON_REBOOTSTRAP
payload. The payload is a full auth.json document (same shape as
HERMES_AUTH_JSON_BOOTSTRAP). Returns None unless it carries the expected VPS
bootstrap client plus non-empty access and refresh tokens — caller treats
None as "nothing to do"."""
try:
seed = json.loads(seed_raw)
except (ValueError, TypeError):
return None
if not isinstance(seed, dict):
return None
providers = seed.get("providers")
if not isinstance(providers, dict):
return None
nous = providers.get("nous")
if not isinstance(nous, dict) or not nous:
return None
if nous.get("client_id") != BOOTSTRAP_CLIENT_ID:
return None
if not (
isinstance(nous.get("access_token"), str)
and nous["access_token"].strip()
and isinstance(nous.get("refresh_token"), str)
and nous["refresh_token"].strip()
):
return None
return nous
def _parse_timestamp(value: Any) -> Optional[datetime]:
"""Parse an OAuth timestamp without guessing when either side is malformed."""
if not isinstance(value, str) or not value.strip():
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except (ValueError, OverflowError):
return None
if parsed.tzinfo is None:
return None
try:
return parsed.astimezone(timezone.utc)
except (OverflowError, ValueError):
return None
def _seed_is_newer(local_nous: Any, seed_nous: dict) -> bool:
"""Whether NAS supplied a bootstrap session newer than the local one.
NAS mints the replacement before restarting the machine and revokes the
previous session. A healthy-looking local entry can therefore already be
stale. ``obtained_at`` is the server-issued ordering signal that lets boot
apply a genuinely newer replacement without allowing an old retained env
value to roll credentials back on later restarts.
"""
if not isinstance(local_nous, dict):
return False
local_obtained = _parse_timestamp(local_nous.get("obtained_at"))
seed_obtained = _parse_timestamp(seed_nous.get("obtained_at"))
return bool(
local_obtained is not None
and seed_obtained is not None
and seed_obtained > local_obtained
)
def reseed_if_terminal(auth_path: str, seed_raw: str) -> str:
"""Core logic. Returns a short status string for logging/testing:
- "no_seed" — seed env empty/absent
- "bad_seed" — seed present but unparseable / no nous entry
- "no_auth_file" — auth.json absent (blank volume → let the normal
HERMES_AUTH_JSON_BOOTSTRAP path handle it)
- "auth_unreadable" — auth.json present but unparseable (leave as-is)
- "not_terminal" — local entry is healthy and at least as new → no-op
- "reseeded" — terminal entry replaced from seed
- "reseeded_newer" — healthy-but-stale entry replaced by a newer seed
"""
if not seed_raw:
return "no_seed"
seed_nous = _extract_nous_from_seed(seed_raw)
if seed_nous is None:
return "bad_seed"
if not os.path.exists(auth_path):
# Blank volume — this is the normal first-boot case, not a re-seed.
return "no_auth_file"
try:
with open(auth_path, "r", encoding="utf-8") as fh:
store = json.load(fh)
except (OSError, ValueError):
# Corrupt/unreadable auth.json: do NOT overwrite blindly. A separate
# concern; leave it for the operator / other recovery paths.
return "auth_unreadable"
if not isinstance(store, dict):
return "auth_unreadable"
providers = store.get("providers")
if not isinstance(providers, dict):
providers = {}
store["providers"] = providers
local_nous = providers.get("nous")
terminal = _nous_entry_is_terminal(local_nous)
newer_seed = _seed_is_newer(local_nous, seed_nous)
if not terminal and not newer_seed:
# Healthy and at least as new as the seed, or incomparable. Never roll a
# session back merely because an old rebootstrap env remains configured.
return "not_terminal"
# Surgical replacement: swap ONLY providers.nous, preserve everything else.
providers["nous"] = seed_nous
tmp_path = f"{auth_path}.rebootstrap.tmp"
with open(tmp_path, "w", encoding="utf-8") as fh:
json.dump(store, fh)
os.replace(tmp_path, auth_path)
try:
os.chmod(auth_path, 0o600)
except OSError:
pass
return "reseeded" if terminal else "reseeded_newer"
def main() -> int:
auth_path = sys.argv[1] if len(sys.argv) > 1 else ""
if not auth_path:
home = os.environ.get("HERMES_HOME", "")
auth_path = os.path.join(home, "auth.json") if home else "auth.json"
seed_raw = os.environ.get(REBOOTSTRAP_ENV, "")
try:
result = reseed_if_terminal(auth_path, seed_raw)
except Exception as exc: # never let a re-seed error fail the boot
print(f"[rebootstrap] error (ignored): {exc!r}", file=sys.stderr)
return 0
if result == "reseeded":
print("[rebootstrap] Nous bootstrap session was terminal; re-seeded auth.json from "
f"{REBOOTSTRAP_ENV}")
elif result == "reseeded_newer":
print("[rebootstrap] Applied newer orchestrator-issued Nous bootstrap session from "
f"{REBOOTSTRAP_ENV}")
else:
# Quiet by default for the common no-op cases; still emit a breadcrumb.
print(f"[rebootstrap] no-op ({result})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+198
View File
@@ -0,0 +1,198 @@
"""Live staging E2E for the shared-metrics exporter.
Sends REAL packages through the REAL sender to the REAL staging ingest
service, then reports what the service acknowledged. Uses a throwaway
HERMES_HOME so the operator's own telemetry state is untouched.
Usage:
.venv/bin/python scripts/e2e_shared_metrics_staging.py
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
import uuid
from datetime import datetime, timezone
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO))
STAGING = "https://telemetry.staging-nousresearch.com/v1/telemetry"
def main() -> int:
scratch = Path(tempfile.mkdtemp(prefix="hermes-telemetry-e2e-"))
os.environ["HERMES_HOME"] = str(scratch)
# Staging is selected by writing config into the THROWAWAY profile, not by
# an environment override: a runtime env var that can retarget consented
# telemetry would be a consent hazard in production.
(scratch / "config.yaml").write_text(
"telemetry:\n"
" shared_metrics:\n"
" enabled: true\n"
" send: true\n"
f" endpoint: {STAGING}\n",
encoding="utf-8",
)
from hermes_cli.observability.shared_metrics import SharedMetricsStore
from hermes_cli.observability.shared_metrics_send_config import (
resolve_send_config,
)
from hermes_cli.observability.shared_metrics_sender import SharedMetricsSender
# Resolve through the real config path so this exercises what a user gets.
import yaml
resolved = resolve_send_config(
yaml.safe_load((scratch / "config.yaml").read_text(encoding="utf-8"))
)
if not resolved.send or resolved.endpoint != STAGING:
print(f"FAIL: config did not resolve to staging: {resolved}")
return 1
store = SharedMetricsStore(
database_path=scratch / "metrics.sqlite3",
outbox_directory=scratch / "outbox",
)
today = datetime.now(timezone.utc).date().isoformat()
# The generator only exports COMPLETED periods, so the realistic E2E
# package is yesterday's. It also has to be: the consent gate only
# releases a package once its whole period is confirmed consented, and
# today's period cannot be confirmed before it ends.
from datetime import timedelta
period_day = (
datetime.now(timezone.utc).date() - timedelta(days=1)
).isoformat()
# Open the consent window before the period, confirm it after — exactly
# what the runtime reconciler does across two days of hook fires.
from hermes_cli.observability.shared_metrics_sender import (
reconcile_send_consent,
)
from hermes_cli.sqlite_util import write_txn
with store._connection() as connection:
with write_txn(connection):
reconcile_send_consent(
connection,
True,
now=datetime.now(timezone.utc) - timedelta(days=2),
)
reconcile_send_consent(connection, True)
real_install_id = str(uuid.uuid4())
packages = []
# Two packages for today's period: the "head" and a later "tail", which is
# the real shape the outbox produces and the case the period gate exists
# for. One is large enough to exercise gzip.
for index, metric_count in ((0, 3), (1, 140)):
package_id = str(uuid.uuid4())
payload = {
"schema_version": "hermes.shared_metrics.v2",
"package_id": package_id,
"install_id": real_install_id,
"generated_at": datetime.now(timezone.utc).isoformat().replace(
"+00:00", "Z"
),
"period_start": f"{period_day}T00:00:00Z",
"period_end": f"{period_day}T23:59:59Z",
"resource": {
"hermes_version": "e2e-test",
"os_family": "macos",
"architecture": "arm64",
"install_method": "git",
},
"metrics": [
{
"name": f"hermes.e2e.metric.{i}",
"type": "counter",
"dimensions": {"outcome": "ok", "surface": "e2e"},
"value": i + 1,
}
for i in range(metric_count)
],
}
with store._connection() as connection:
connection.execute(
"""
INSERT INTO package_outbox(
package_id, period_start, period_end, payload_json,
created_at, exported_at
) VALUES (?, ?, ?, ?, ?, ?)
""",
(
package_id,
f"{period_day}T00:00:00Z",
f"{period_day}T23:59:59Z",
json.dumps(payload),
f"{period_day}T0{index}:00:00Z",
f"{period_day}T0{index}:00:01Z",
),
)
packages.append((package_id, metric_count))
print(f"scratch HERMES_HOME : {scratch}")
print(f"endpoint : {STAGING}")
print(f"local install_id : {real_install_id}")
print(f"packages queued : {len(packages)}")
for package_id, count in packages:
print(f" - {package_id} ({count} metrics)")
print()
outcome = SharedMetricsSender(store, resolved.endpoint).send_pending()
print(f"outcome: sent={outcome.sent} rejected={outcome.rejected} "
f"deferred={outcome.deferred}")
print()
failures = []
with store._connection() as connection:
rows = connection.execute(
"""
SELECT package_id, send_state, sent_at, send_attempts,
sent_install_id, last_error
FROM package_outbox ORDER BY created_at
"""
).fetchall()
for row in rows:
print(f"package : {row[0]}")
print(f" send_state : {row[1]}")
print(f" sent_at : {row[2]}")
print(f" attempts : {row[3]}")
print(f" transmitted : {row[4]}")
print(f" last_error : {row[5]}")
if row[1] != "sent":
failures.append(f"{row[0]} is {row[1]}: {row[5]}")
# Product decision 2026-08-27: the stable install_id is transmitted
# as-is; the transmitted value must be exactly the local id.
if row[4] != real_install_id:
failures.append(
f"{row[0]} transmitted {row[4]!r}, expected the install_id"
)
print()
if failures:
print("FAILURES:")
for failure in failures:
print(f"{failure}")
return 1
print("PASS: every package acknowledged 202 with the stable install_id.")
print()
print("Verify the objects in S3 with the package ids above:")
print(" aws s3 ls --recursive "
"s3://hermes-agent-telemetry-staging-767397871023-us-west-2-an/raw/ "
"| tail -20")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+266
View File
@@ -0,0 +1,266 @@
#!/usr/bin/env python3
"""Conformance-vector generator — the native adapters as executable spec.
Renders a shared corpus (markdown grid + scar tissue + adversarial agent
output) through the NATIVE platform renderers and dumps inputoutput JSON
vectors, stamped with the oracle commit. The gateway-gateway connector
commits these under conformance/vectors/ and its vitest runner asserts the
CONNECTOR's constructed REST payloads against them — so cross-repo renderer
drift breaks a test instead of a user's formatting.
Oracles (all imported, never reimplemented):
telegram plugins.platforms.telegram.adapter.TelegramAdapter.format_message
(standard markdown Telegram MarkdownV2)
slack plugins.platforms.slack.adapter.SlackAdapter.format_message
(standard markdown Slack mrkdwn)
whatsapp gateway.platforms.whatsapp_common.WhatsAppBehaviorMixin
.format_message (standard markdown WhatsApp formatting)
discord plugins.platforms.discord.adapter.DiscordAdapter.format_message
(GFM tables bullet groups; otherwise identity)
Expect semantics (consumed by the gg runner):
parity connector render must BYTE-EQUAL native_output
(Slack / WhatsApp same-dialect ports; most Discord).
semantic connector renders a DIFFERENT representation on purpose
(Telegram: connector sends HTML, native sends MarkdownV2);
the runner asserts plain-text content equivalence instead.
divergent documented no-parity (e.g. Discord tables: native converts to
bullets, connector passes raw markdown through). The runner
asserts the DOCUMENTED connector behavior named in `note`.
Run: python scripts/generate_conformance_vectors.py [--out DIR]
Determinism is covered by tests/conformance/test_vector_generator.py.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
from typing import Any, Callable, Dict, List
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT))
GENERATOR_VERSION = 1
# ── corpus ───────────────────────────────────────────────────────────────
# Every entry: (id, category, input). Categories: grid | scar | adversarial.
# Ids are STABLE API — the runner and divergence notes key on them.
GRID: List[tuple] = [
("plain-text", "Just a plain sentence."),
("bold", "This is **bold** text."),
("italic", "This is *italic* text."),
("bold-italic", "Mix of **bold** and *italic* in one line."),
("strikethrough", "This is ~~struck~~ text."),
("inline-code", "Run `pip install hermes` to start."),
("fenced-code", "```\nprint('hello')\n```"),
("fenced-code-lang", "```python\ndef f(x):\n return x * 2\n```"),
("link", "See [the docs](https://example.com/docs) for more."),
("link-parens-url", "See [spec](https://example.com/a_(b)) here."),
("header-h1", "# Big Title\nBody follows."),
("header-h2", "## Section\nBody follows."),
("header-h3", "### Sub-section\nBody follows."),
("ul-list", "- first\n- second\n- third"),
("ol-list", "1. first\n2. second\n3. third"),
("nested-list", "- outer\n - inner one\n - inner two\n- outer two"),
("blockquote", "> quoted wisdom\nregular line"),
("hrule", "above\n\n---\n\nbelow"),
(
"table-simple",
"| name | value |\n|------|-------|\n| a | 1 |\n| b | 2 |",
),
("emoji", "Done ✅ with 🎉 emoji 👀 test."),
("cjk", "中文测试:**粗体** 和 `代码` 混排。"),
("bare-url", "Visit https://example.com/path?q=1&r=2 today."),
(
"mixed-document",
"## Report\n\nStatus: **green**. Details in `runbook.md`.\n\n"
"- item *one*\n- item **two**\n\n```sh\nmake deploy\n```\n\n"
"See [dashboard](https://grafana.example.com/d/x).",
),
]
SCAR: List[tuple] = [
# MarkdownV2 reserved characters in prose — the classic Telegram 400.
("mdv2-reserved-chars", "Price is 3.50 (was 4.00) — save ~12%! #deal +tax = win."),
("mdv2-underscores", "snake_case_name and file_name.py in prose."),
("mdv2-brackets", "Array[0] and dict{key} and (parens) live here."),
# Slack: **bold** must become *bold*; [t](u) must become <u|t>.
("slack-bold-conversion", "**important** word"),
("slack-link-conversion", "[click here](https://example.com)"),
# Slack broadcast-mention escape (model output must not ping @everyone).
("slack-broadcast-mention", "Hey <!everyone> and <!channel> and <!here>!"),
# Fence language tag handling (Slack renders the tag literally).
("fence-lang-tag-slack", "```text\nliteral first line issue\n```"),
# Backslashes inside code must survive doubling rules.
("backslash-in-code", "`C:\\Users\\ben\\file.txt` and ```\npath = \"a\\\\b\"\n```"),
("backtick-in-fence", "```\nuse `inline` inside fence\n```"),
# Headers containing bold markers (native strips redundant **).
("header-with-bold", "## The **Real** Deal"),
# Table with CJK cells (display-width alignment scar in Slack).
(
"table-cjk",
"| 名前 | 値 |\n|------|----|\n| 中文 | 42 |\n| b | 2 |",
),
# Link display text that itself needs escaping.
("link-display-escapes", "[v2.0 (beta)](https://example.com/v2)"),
]
ADVERSARIAL: List[tuple] = [
("media-tag", "Here you go\nMEDIA:/tmp/output.png\ndone"),
("unclosed-fence", "```python\nprint('never closed')"),
("pathological-nesting", "**bold *italic ~~struck `code` struck~~ italic* bold**"),
("placeholder-injection", "sneaky \x00PH0\x00 token and \x00SL1\x00 too"),
("triple-markers", "***what is this*** and ____that____"),
("empty-string", ""),
("whitespace-only", " \n\t\n "),
("long-line", "word " * 500),
("many-fences", "```\na\n```\nmid\n```\nb\n```\nend ```inline``` tail"),
]
def corpus() -> List[Dict[str, str]]:
rows: List[Dict[str, str]] = []
for cid, text in GRID:
rows.append({"id": cid, "category": "grid", "input": text})
for cid, text in SCAR:
rows.append({"id": cid, "category": "scar", "input": text})
for cid, text in ADVERSARIAL:
rows.append({"id": cid, "category": "adversarial", "input": text})
return rows
# ── oracles ──────────────────────────────────────────────────────────────
def _oracles() -> Dict[str, Callable[[str], str]]:
from plugins.platforms.telegram.adapter import TelegramAdapter
from plugins.platforms.slack.adapter import SlackAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
from gateway.platforms.whatsapp_common import WhatsAppBehaviorMixin
wa = object.__new__(WhatsAppBehaviorMixin) # format_message needs no __init__
return {
# These format_message implementations are self-free (asserted by
# tests/conformance/test_vector_generator.py) — invoked unbound.
"telegram": lambda s: TelegramAdapter.format_message(None, s), # type: ignore[arg-type]
"slack": lambda s: SlackAdapter.format_message(None, s), # type: ignore[arg-type]
"discord": lambda s: DiscordAdapter.format_message(None, s), # type: ignore[arg-type]
"whatsapp": wa.format_message,
}
# Per-platform expect overrides (default: parity, except telegram=semantic).
# Keyed by vector id; value = (expect, note).
_EXPECT_OVERRIDES: Dict[str, Dict[str, tuple]] = {
"telegram": {
# Native wraps pipe tables into row groups; the connector's HTML lane
# renders tables as <pre>. Same content, structurally different enough
# that plain-text comparison is noise — documented divergence.
"table-simple": ("divergent", "native wraps tables into row groups; connector renders <pre> — content preserved, layout differs"),
"table-cjk": ("divergent", "same as table-simple (CJK width alignment is native-only)"),
"unclosed-fence": ("divergent", "unterminated fence: native escapes as prose, connector HTML may close the block — degraded either way, never a 400"),
"placeholder-injection": ("divergent", "NUL placeholder tokens are renderer-internal; each side neutralizes its own pattern"),
"whitespace-only": ("divergent", "native collapses to empty-ish prose, connector HTML preserves — cosmetic"),
},
"slack": {
"placeholder-injection": ("divergent", "\\x00SL tokens are the native renderer's own placeholder alphabet; connector uses a different scheme"),
"unclosed-fence": ("divergent", "unterminated fence handling differs; both degrade without dropping content"),
},
"whatsapp": {
"placeholder-injection": ("divergent", "placeholder alphabets are renderer-internal"),
"unclosed-fence": ("divergent", "unterminated fence handling differs; both degrade without dropping content"),
},
"discord": {
"table-simple": ("divergent", "native converts GFM tables to bullet groups; connector passes raw markdown through (port deferred — parity report Phase 4/oracle section)"),
"table-cjk": ("divergent", "same as table-simple"),
},
}
def _expect_for(platform: str, vector_id: str) -> tuple:
default = ("semantic", "") if platform == "telegram" else ("parity", "")
return _EXPECT_OVERRIDES.get(platform, {}).get(vector_id, default)
def _oracle_commit() -> str:
try:
return (
subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=True,
).stdout.strip()
)
except Exception:
return "unknown"
def generate(out_dir: Path) -> Dict[str, Any]:
"""Render the corpus through every oracle; write one JSON per platform."""
oracles = _oracles()
commit = _oracle_commit()
rows = corpus()
summary: Dict[str, Any] = {}
out_dir.mkdir(parents=True, exist_ok=True)
for platform, render in sorted(oracles.items()):
vectors = []
for row in rows:
expect, note = _expect_for(platform, row["id"])
entry: Dict[str, Any] = {
"id": row["id"],
"category": row["category"],
"expect": expect,
"input": row["input"],
"native_output": render(row["input"]),
}
if note:
entry["note"] = note
vectors.append(entry)
doc = {
"$comment": (
"GENERATED — do not hand-edit. Regenerate with "
"hermes-agent scripts/generate_conformance_vectors.py; the "
"native renderers are the oracle (executable spec)."
),
"oracle": {
"repo": "NousResearch/hermes-agent",
"commit": commit,
"generator": "scripts/generate_conformance_vectors.py",
"generator_version": GENERATOR_VERSION,
},
"platform": platform,
"vectors": vectors,
}
path = out_dir / f"{platform}.json"
path.write_text(
json.dumps(doc, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
summary[platform] = len(vectors)
return summary
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--out",
default=str(REPO_ROOT / "tests" / "conformance" / "vectors"),
help="Output directory for <platform>.json vector files",
)
args = parser.parse_args()
summary = generate(Path(args.out))
for platform, count in sorted(summary.items()):
print(f"{platform}: {count} vectors")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+423
View File
@@ -0,0 +1,423 @@
#!/usr/bin/env python3
"""
Hermes Gateway - Standalone messaging platform integration.
This is the proper entry point for running the gateway as a service.
NOT tied to the CLI - runs independently.
Usage:
# Run in foreground (for testing)
./scripts/hermes-gateway
# Install as systemd service
./scripts/hermes-gateway install
# Manage the service
./scripts/hermes-gateway start
./scripts/hermes-gateway stop
./scripts/hermes-gateway restart
./scripts/hermes-gateway status
# Uninstall
./scripts/hermes-gateway uninstall
"""
import argparse
import asyncio
import os
import subprocess
import sys
from pathlib import Path
# Add parent directory to path
SCRIPT_DIR = Path(__file__).parent.resolve()
PROJECT_DIR = SCRIPT_DIR.parent
sys.path.insert(0, str(PROJECT_DIR))
# Load .env file
from dotenv import load_dotenv
env_path = PROJECT_DIR / '.env'
if env_path.exists():
load_dotenv(dotenv_path=env_path)
# =============================================================================
# Service Configuration
# =============================================================================
SERVICE_NAME = "hermes-gateway"
SERVICE_DESCRIPTION = "Hermes Agent Gateway - Messaging Platform Integration"
def get_systemd_unit_path() -> Path:
"""Get the path for the systemd user service file."""
return Path.home() / ".config" / "systemd" / "user" / f"{SERVICE_NAME}.service"
def get_launchd_plist_path() -> Path:
"""Get the path for the launchd plist file (macOS)."""
return Path.home() / "Library" / "LaunchAgents" / f"ai.hermes.gateway.plist"
def get_python_path() -> str:
"""Get the path to the Python interpreter."""
# Prefer the venv if it exists
venv_python = PROJECT_DIR / "venv" / "bin" / "python"
if venv_python.exists():
return str(venv_python)
return sys.executable
def get_gateway_script_path() -> str:
"""Get the path to this script."""
return str(Path(__file__).resolve())
# =============================================================================
# Systemd Service (Linux)
# =============================================================================
def generate_systemd_unit() -> str:
"""Generate the systemd unit file content."""
python_path = get_python_path()
script_path = get_gateway_script_path()
working_dir = str(PROJECT_DIR)
return f"""[Unit]
Description={SERVICE_DESCRIPTION}
After=network.target
StartLimitIntervalSec=600
StartLimitBurst=5
[Service]
Type=simple
ExecStart={python_path} {script_path} run
WorkingDirectory={working_dir}
Restart=on-failure
RestartSec=30
StandardOutput=journal
StandardError=journal
# Environment (optional - can also use .env file)
# Environment="TELEGRAM_BOT_TOKEN=your_token"
# Environment="DISCORD_BOT_TOKEN=your_token"
[Install]
WantedBy=default.target
"""
def install_systemd():
"""Install the systemd user service."""
unit_path = get_systemd_unit_path()
unit_path.parent.mkdir(parents=True, exist_ok=True)
print(f"Installing systemd service to: {unit_path}")
unit_path.write_text(generate_systemd_unit())
# Reload systemd
subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
# Enable the service (start on boot)
subprocess.run(["systemctl", "--user", "enable", SERVICE_NAME], check=True)
print(f"✓ Service installed and enabled")
print(f"")
print(f"To start the service:")
print(f" systemctl --user start {SERVICE_NAME}")
print(f"")
print(f"To view logs:")
print(f" journalctl --user -u {SERVICE_NAME} -f")
print(f"")
print(f"To enable lingering (keeps service running after logout):")
print(f" sudo loginctl enable-linger $USER")
def uninstall_systemd():
"""Uninstall the systemd user service."""
unit_path = get_systemd_unit_path()
# Stop and disable first
subprocess.run(["systemctl", "--user", "stop", SERVICE_NAME], check=False)
subprocess.run(["systemctl", "--user", "disable", SERVICE_NAME], check=False)
# Remove the unit file
if unit_path.exists():
unit_path.unlink()
print(f"✓ Removed {unit_path}")
# Reload systemd
subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
print(f"✓ Service uninstalled")
def systemd_status():
"""Show systemd service status."""
subprocess.run(["systemctl", "--user", "status", SERVICE_NAME])
def systemd_start():
"""Start the systemd service."""
subprocess.run(["systemctl", "--user", "start", SERVICE_NAME], check=True)
print(f"✓ Service started")
def systemd_stop():
"""Stop the systemd service."""
subprocess.run(["systemctl", "--user", "stop", SERVICE_NAME], check=True)
print(f"✓ Service stopped")
def systemd_restart():
"""Restart the systemd service."""
subprocess.run(["systemctl", "--user", "restart", SERVICE_NAME], check=True)
print(f"✓ Service restarted")
# =============================================================================
# Launchd Service (macOS)
# =============================================================================
def generate_launchd_plist() -> str:
"""Generate the launchd plist file content."""
python_path = get_python_path()
script_path = get_gateway_script_path()
working_dir = str(PROJECT_DIR)
log_dir = Path.home() / ".hermes" / "logs"
return f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>ai.hermes.gateway</string>
<key>ProgramArguments</key>
<array>
<string>{python_path}</string>
<string>{script_path}</string>
<string>run</string>
</array>
<key>WorkingDirectory</key>
<string>{working_dir}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>StandardOutPath</key>
<string>{log_dir}/gateway.log</string>
<key>StandardErrorPath</key>
<string>{log_dir}/gateway.error.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
</dict>
</plist>
"""
def install_launchd():
"""Install the launchd service (macOS)."""
plist_path = get_launchd_plist_path()
plist_path.parent.mkdir(parents=True, exist_ok=True)
# Ensure log directory exists
log_dir = Path.home() / ".hermes" / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
print(f"Installing launchd service to: {plist_path}")
plist_path.write_text(generate_launchd_plist())
# Load the service
subprocess.run(["launchctl", "load", str(plist_path)], check=True)
print(f"✓ Service installed and loaded")
print(f"")
print(f"To view logs:")
print(f" tail -f ~/.hermes/logs/gateway.log")
print(f"")
print(f"To manage the service:")
print(f" launchctl start ai.hermes.gateway")
print(f" launchctl stop ai.hermes.gateway")
def uninstall_launchd():
"""Uninstall the launchd service (macOS)."""
plist_path = get_launchd_plist_path()
# Unload first
subprocess.run(["launchctl", "unload", str(plist_path)], check=False)
# Remove the plist file
if plist_path.exists():
plist_path.unlink()
print(f"✓ Removed {plist_path}")
print(f"✓ Service uninstalled")
def launchd_status():
"""Show launchd service status."""
subprocess.run(["launchctl", "list", "ai.hermes.gateway"])
def launchd_start():
"""Start the launchd service."""
subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True)
print(f"✓ Service started")
def launchd_stop():
"""Stop the launchd service."""
subprocess.run(["launchctl", "stop", "ai.hermes.gateway"], check=True)
print(f"✓ Service stopped")
def launchd_restart():
"""Restart the launchd service."""
launchd_stop()
launchd_start()
# =============================================================================
# Platform Detection
# =============================================================================
def is_linux() -> bool:
return sys.platform.startswith('linux')
def is_macos() -> bool:
return sys.platform == 'darwin'
def is_windows() -> bool:
return sys.platform == 'win32'
# =============================================================================
# Gateway Runner
# =============================================================================
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.")
print()
asyncio.run(start_gateway())
# =============================================================================
# Main CLI
# =============================================================================
def main():
parser = argparse.ArgumentParser(
description="Hermes Gateway - Messaging Platform Integration",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Run in foreground (for testing)
./scripts/hermes-gateway run
# Install as system service
./scripts/hermes-gateway install
# Manage the service
./scripts/hermes-gateway start
./scripts/hermes-gateway stop
./scripts/hermes-gateway restart
./scripts/hermes-gateway status
# Uninstall
./scripts/hermes-gateway uninstall
Configuration:
Set environment variables in .env file or system environment:
- TELEGRAM_BOT_TOKEN
- DISCORD_BOT_TOKEN
- WHATSAPP_ENABLED
Or create ~/.hermes/gateway.json for advanced configuration.
"""
)
parser.add_argument(
"command",
choices=["run", "install", "uninstall", "start", "stop", "restart", "status"],
nargs="?",
default="run",
help="Command to execute (default: run)"
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Verbose output"
)
args = parser.parse_args()
# Detect platform and dispatch command
if args.command == "run":
run_gateway()
elif args.command == "install":
if is_linux():
install_systemd()
elif is_macos():
install_launchd()
else:
print("Service installation not supported on this platform.")
print("Please run manually: ./scripts/hermes-gateway run")
sys.exit(1)
elif args.command == "uninstall":
if is_linux():
uninstall_systemd()
elif is_macos():
uninstall_launchd()
else:
print("Service uninstallation not supported on this platform.")
sys.exit(1)
elif args.command == "start":
if is_linux():
systemd_start()
elif is_macos():
launchd_start()
else:
print("Not supported on this platform.")
sys.exit(1)
elif args.command == "stop":
if is_linux():
systemd_stop()
elif is_macos():
launchd_stop()
else:
print("Not supported on this platform.")
sys.exit(1)
elif args.command == "restart":
if is_linux():
systemd_restart()
elif is_macos():
launchd_restart()
else:
print("Not supported on this platform.")
sys.exit(1)
elif args.command == "status":
if is_linux():
systemd_status()
elif is_macos():
launchd_status()
else:
print("Not supported on this platform.")
sys.exit(1)
if __name__ == "__main__":
main()
+28
View File
@@ -0,0 +1,28 @@
@echo off
REM ============================================================================
REM Hermes Agent Installer for Windows (CMD wrapper)
REM ============================================================================
REM This batch file launches the PowerShell installer for users running CMD.
REM
REM Usage:
REM curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.cmd -o install.cmd && install.cmd && del install.cmd
REM
REM Or if you're already in PowerShell, use the direct command instead:
REM iex (irm https://hermes-agent.nousresearch.com/install.ps1)
REM ============================================================================
echo.
echo Hermes Agent Installer
echo Launching PowerShell installer...
echo.
powershell -ExecutionPolicy ByPass -NoProfile -Command "iex (irm https://hermes-agent.nousresearch.com/install.ps1)"
if %ERRORLEVEL% NEQ 0 (
echo.
echo Installation failed. Please try running PowerShell directly:
echo powershell -ExecutionPolicy ByPass -c "iex (irm https://hermes-agent.nousresearch.com/install.ps1)"
echo.
pause
exit /b 1
)
+5070
View File
File diff suppressed because it is too large Load Diff
+3890
View File
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Install psutil on Termux/Android by patching upstream platform detection.
psutil's setup currently gates Linux sources behind
``sys.platform.startswith('linux')``. On Termux, Python reports
``sys.platform == 'android'``, so ``pip install psutil`` aborts with
"platform android is not supported" even though psutil compiles fine
when the Linux source path is reused.
This script downloads the official psutil sdist, applies a one-line
patch (``LINUX = sys.platform.startswith(("linux", "android"))``), and
installs the patched tree with ``pip install --no-build-isolation``.
Usage:
python scripts/install_psutil_android.py [--pip "/path/to/pip"] [--uv]
When neither flag is given, the script auto-detects ``uv`` on PATH and
falls back to ``<sys.executable> -m pip``.
This is a stopgap. Remove once psutil upstream merges
https://github.com/giampaolo/psutil/pull/2762 and ships a release.
"""
from __future__ import annotations
import argparse
import shutil
import subprocess
import sys
import tempfile
import urllib.request
from pathlib import Path
# Keep sibling imports working when invoked as
# ``python scripts/install_psutil_android.py`` from the repo checkout.
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from hermes_cli.psutil_android import (
PSUTIL_URL,
PsutilAndroidInstallError,
prepare_patched_psutil_sdist,
)
def _resolve_install_cmd(pip_arg: str | None, prefer_uv: bool) -> list[str]:
if pip_arg:
return pip_arg.split()
if prefer_uv:
uv = shutil.which("uv")
if not uv:
sys.exit("--uv requested but no uv on PATH")
return [uv, "pip"]
auto_uv = shutil.which("uv")
if auto_uv:
return [auto_uv, "pip"]
return [sys.executable, "-m", "pip"]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--pip",
help="Explicit installer command (e.g. '/usr/bin/uv pip' or 'python -m pip')",
)
parser.add_argument(
"--uv",
action="store_true",
help="Force using uv (errors out if uv is not on PATH)",
)
args = parser.parse_args()
install_cmd_prefix = _resolve_install_cmd(args.pip, args.uv)
print(
"→ Termux/Android: prebuilding psutil with Linux source path "
"compatibility shim (see psutil#2762)..."
)
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
archive = tmp_path / "psutil.tar.gz"
urllib.request.urlretrieve(PSUTIL_URL, archive)
try:
src_root = prepare_patched_psutil_sdist(archive, tmp_path)
except PsutilAndroidInstallError as exc:
sys.exit(str(exc))
cmd = install_cmd_prefix + ["install", "--no-build-isolation", str(src_root)]
print(f" $ {' '.join(cmd)}")
result = subprocess.run(cmd)
if result.returncode != 0:
return result.returncode
print("✓ psutil installed via Android compatibility shim")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+625
View File
@@ -0,0 +1,625 @@
#!/usr/bin/env python3
"""iso-certify — AC-4 dashboard turn-isolation certify harness.
Certifies Mechanism-B process isolation
(``docs/desktop/2026-07-04-dashboard-process-isolation-PRD.md``, AC-4): under
6 concurrent heavy agent turns, the dashboard's HTTP/ws SERVING plane must stay
responsive (p99 < 1s) with zero event-loop stalls.
What it does
------------
1. Spawns a SCRATCH dashboard (``hermes dashboard``) bound to loopback on a
free port, with an ISOLATED ``HERMES_HOME`` (temp dir, minimal seeded state).
It NEVER touches the live :9119 dashboard / ai.hermes.dashboard / live
state.db. Loopback bind no auth gate (web_server.should_require_auth).
2. Arms the synthetic GIL-heavy turn seam (``HERMES_ISO_CERTIFY_SYNTH_TURN=1``,
see ``tui_gateway/synthetic_turn.py``) so 6 concurrent turns reproduce the
``take_gil`` interpreter-contention regime WITHOUT real model calls. A
network/sleep stub would release the GIL and NOT reproduce the incident, so
it would be a fake green the synthetic turn is pure-Python CPU on purpose.
3. Drives 6 concurrent heavy turns over ws (session.create prompt.submit),
and CONCURRENTLY probes the serving path a ws ``session.list`` round-trip
AND a REST ``GET /api/status`` every 500ms, timing each.
4. Reports p50/p95/p99 latency for both probes + the count of probes over the
1s threshold ("serving stalls") + the count of ``event loop stalled`` /
``ws write slow`` lines the dashboard logged during the run.
Verdict
-------
The AC-4 verdict comes ONLY from the heavy run: PASS iff serving p99 < 1s AND
zero serving stalls AND zero ``event loop stalled`` log lines over the sustained
window. ``--dry-run`` runs ONE short light turn as a plumbing smoke test and is
explicitly NOT a verdict (a dry-run green is a fake green the spec says so).
Turn isolation is controlled by the ``dashboard.turn_isolation`` config knob in
the scratch HERMES_HOME; ``--isolation on|off`` sets it. Run BOTH:
iso-certify --isolation off # baseline: expect stalls
iso-certify --isolation on # the measurement that decides AC-4
"""
from __future__ import annotations
import argparse
import json
import os
import re
import socket
import subprocess
import sys
import tempfile
import threading
import time
import shutil
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any
try:
from websockets.sync.client import connect as ws_connect
except Exception as exc: # pragma: no cover - dependency guard
print(f"iso-certify requires the 'websockets' package: {exc}", file=sys.stderr)
raise
REPO_ROOT = Path(__file__).resolve().parents[1]
_READY_RE = re.compile(r"HERMES_(?:DASHBOARD|BACKEND)_READY port=(\d+)")
_STALL_LOG_RE = re.compile(r"event loop stalled|ws write slow \(loop stalled")
# ── stats ──────────────────────────────────────────────────────────────
def percentile(values: list[float], pct: float) -> float:
if not values:
return 0.0
ordered = sorted(values)
if len(ordered) == 1:
return ordered[0]
rank = (len(ordered) - 1) * (pct / 100.0)
lo = int(rank)
hi = min(lo + 1, len(ordered) - 1)
frac = rank - lo
return ordered[lo] * (1.0 - frac) + ordered[hi] * frac
def summarize(values: list[float]) -> dict[str, float]:
return {
"count": len(values),
"p50_ms": percentile(values, 50),
"p95_ms": percentile(values, 95),
"p99_ms": percentile(values, 99),
"max_ms": max(values) if values else 0.0,
}
def free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return int(s.getsockname()[1])
# ── scratch HERMES_HOME ─────────────────────────────────────────────────
def seed_scratch_home(home: Path, *, isolation: str, heartbeat_secs: int, respawn_max: int) -> None:
"""Write a minimal config.yaml with the isolation knob set."""
home.mkdir(parents=True, exist_ok=True)
(home / "state").mkdir(parents=True, exist_ok=True)
(home / "logs").mkdir(parents=True, exist_ok=True)
cfg = {
# Pin the model/provider to what SyntheticHeavyAgent reports so the
# per-turn _sync_agent_model_with_config sees a match and no-ops — a
# mismatch would try (and fail) a real model switch, erroring the turn
# before its heavy loop runs (which would be a FALSE green: serving
# stays responsive because no heavy compute happened). The synthetic
# seam means no real API call is ever made regardless.
"provider": "synthetic",
"model": "synthetic-heavy",
"dashboard": {
"turn_isolation": (isolation == "on"),
"compute_host_heartbeat_secs": heartbeat_secs,
"compute_host_respawn_max": respawn_max,
},
# Keep memory/mem0/skills side-machinery from reaching out.
"memory": {"enabled": False},
}
# config.yaml is the canonical config; write it directly.
import yaml # provided by the runtime venv
(home / "config.yaml").write_text(yaml.safe_dump(cfg, sort_keys=True), encoding="utf-8")
# A stub .env so credential resolution doesn't spelunk the real home.
(home / ".env").write_text("OPENAI_API_KEY=sk-synthetic-not-used\n", encoding="utf-8")
# ── dashboard process ───────────────────────────────────────────────────
class ScratchDashboard:
def __init__(
self,
*,
home: Path,
port: int,
isolation: str,
env_extra: dict[str, str],
) -> None:
self.home = home
self.port = port
self.isolation = isolation
self.env_extra = env_extra
self.proc: subprocess.Popen[str] | None = None
self.actual_port = port
self.log_lines: list[str] = []
self._log_lock = threading.Lock()
self._ready = threading.Event()
@property
def stall_log_count(self) -> int:
with self._log_lock:
return sum(1 for ln in self.log_lines if _STALL_LOG_RE.search(ln))
def _drain(self, stream: Any) -> None:
for raw in stream:
line = raw.rstrip("\n")
with self._log_lock:
self.log_lines.append(line)
m = _READY_RE.search(line)
if m:
self.actual_port = int(m.group(1))
self._ready.set()
def __enter__(self) -> "ScratchDashboard":
venv_py = REPO_ROOT / "venv" / "bin" / "python"
python = str(venv_py) if venv_py.exists() else sys.executable
env = dict(os.environ)
env.update(self.env_extra)
env["HERMES_HOME"] = str(self.home)
env["HOME"] = str(self.home.parent) if str(self.home.parent) else env.get("HOME", "")
env["HERMES_HOME"] = str(self.home)
env["PYTHONPATH"] = str(REPO_ROOT) + os.pathsep + env.get("PYTHONPATH", "")
env["HERMES_ISO_CERTIFY_SYNTH_TURN"] = "1"
cmd = [
python, "-m", "hermes_cli.main", "dashboard",
"--no-open", "--host", "127.0.0.1", "--port", str(self.port),
]
self.proc = subprocess.Popen(
cmd, cwd=str(REPO_ROOT), env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", bufsize=1,
)
threading.Thread(target=self._drain, args=(self.proc.stdout,), name="dash-log", daemon=True).start()
if not self._ready.wait(timeout=90.0):
self._dump_tail()
raise RuntimeError("scratch dashboard did not become ready within 90s")
# Give uvicorn a beat to actually bind the ws route.
time.sleep(1.0)
return self
def __exit__(self, *_exc: object) -> None:
if self.proc is None:
return
try:
self.proc.terminate()
self.proc.wait(timeout=10)
except Exception:
try:
self.proc.kill()
except Exception:
pass
def _dump_tail(self, n: int = 40) -> None:
with self._log_lock:
tail = self.log_lines[-n:]
sys.stderr.write("---- scratch dashboard log tail ----\n")
for ln in tail:
sys.stderr.write(ln + "\n")
sys.stderr.write("------------------------------------\n")
# ── ws client (one connection = one lane) ───────────────────────────────
class WSClient:
def __init__(self, port: int, token: str) -> None:
self.url = f"ws://127.0.0.1:{port}/api/ws?token={token}"
self.ws = ws_connect(
self.url,
open_timeout=15,
max_size=None,
additional_headers={"Origin": f"http://127.0.0.1:{port}"},
)
self._id = 0
self._lock = threading.Lock()
# Drain the gateway.ready event.
self._recv_until(lambda o: o.get("method") == "event", timeout=10)
def _next_id(self) -> str:
with self._lock:
self._id += 1
return f"r{self._id}"
def _recv_until(self, pred, timeout: float) -> dict:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
raw = self.ws.recv(timeout=max(0.05, deadline - time.monotonic()))
except TimeoutError:
continue
try:
obj = json.loads(raw)
except Exception:
continue
if pred(obj):
return obj
raise TimeoutError("ws recv predicate timed out")
def rpc(self, method: str, params: dict, timeout: float = 30.0) -> dict:
rid = self._next_id()
self.ws.send(json.dumps({"jsonrpc": "2.0", "id": rid, "method": method, "params": params}))
return self._recv_until(lambda o: o.get("id") == rid, timeout=timeout)
def send_only(self, method: str, params: dict) -> str:
rid = self._next_id()
self.ws.send(json.dumps({"jsonrpc": "2.0", "id": rid, "method": method, "params": params}))
return rid
def close(self) -> None:
try:
self.ws.close()
except Exception:
pass
# ── heavy-turn lane ─────────────────────────────────────────────────────
def drive_heavy_turn(port: int, token: str, turn_spec: dict, stop_at: float, results: list[dict]) -> None:
"""One lane: create a session, submit heavy turns until the deadline."""
lane: dict[str, Any] = {"turns": 0, "errors": [], "turn_durations_s": [], "min_deltas": None}
try:
cli = WSClient(port, token)
except Exception as exc:
lane["errors"].append(f"connect: {exc}")
results.append(lane)
return
try:
resp = cli.rpc("session.create", {"cols": 80, "source": "iso-certify"}, timeout=30)
sid = ((resp.get("result") or {}).get("session_id")) or ((resp.get("result") or {}).get("id"))
if not sid:
lane["errors"].append(f"session.create bad resp: {resp}")
results.append(lane)
return
lane["sid"] = sid
spec_text = json.dumps(turn_spec)
while time.monotonic() < stop_at:
# prompt.submit returns immediately ("streaming"); the turn runs async.
r = cli.rpc("prompt.submit", {"session_id": sid, "text": spec_text}, timeout=30)
if r.get("error"):
lane["errors"].append(f"prompt.submit: {r['error']}")
break
# Wait for the REAL turn boundary. The isolated path emits
# session.info MID-turn (metadata mirror), so waiting on it would
# false-complete a turn in <1s and inflate the count while NO heavy
# compute ran — the acceptance-gate "proxy not effect" trap. The
# turn is done only on message.complete. Count deltas + duration so
# a fast-erroring turn (e.g. a failed model switch) is caught, not
# masked as sustained load.
per_turn_budget = turn_spec.get("duration_s", 8.0) + 30.0
deadline = time.monotonic() + per_turn_budget
turn_start = time.monotonic()
deltas = 0
started = False
done = False
errored = False
while time.monotonic() < deadline:
try:
o = cli._recv_until(
lambda o: o.get("method") == "event"
and (o.get("params") or {}).get("type")
in {"message.start", "message.delta", "message.complete", "error"},
timeout=max(0.5, deadline - time.monotonic()),
)
except TimeoutError:
break
ptype = (o.get("params") or {}).get("type")
if ptype == "message.start":
started = True
turn_start = time.monotonic()
deltas = 0
continue
# prompt.submit's RPC response may race with a duplicate terminal
# event from the previous turn. Only events after this turn's
# message.start can satisfy or score its load boundary.
if not started:
continue
if ptype == "message.delta":
deltas += 1
continue
if ptype == "error":
msg = str(((o.get("params") or {}).get("payload") or {}).get("message") or "")
lane["errors"].append(f"turn error: {msg[:160]}")
errored = True
break
if ptype == "message.complete":
done = True
break
turn_dur = time.monotonic() - turn_start
if done:
lane["turns"] += 1
lane["turn_durations_s"].append(round(turn_dur, 2))
lane["min_deltas"] = deltas if lane["min_deltas"] is None else min(lane["min_deltas"], deltas)
if errored:
break
except Exception as exc:
lane["errors"].append(f"lane: {exc}")
finally:
cli.close()
results.append(lane)
# ── serving-path probes ─────────────────────────────────────────────────
def warmup_serving(port: int, token: str, rounds: int = 6) -> None:
"""Prime serving-path caches before the measured window.
The first ``/api/status`` on a freshly-booted process pays one-time
cold-start cost (config-version check, gateway-health probe, DB connect) that
a real dashboard warm for hours before the incident regime never pays
during a stall. AC-4 measures sustained-load responsiveness, not cold boot,
so we hit both serving endpoints a few times UNMEASURED first. This is not a
green-washing shortcut: the measured window still runs the full 6-lane heavy
load; warmup only removes a one-time boot artifact from the p99.
"""
rest_url = f"http://127.0.0.1:{port}/api/status"
warm_ws: WSClient | None = None
try:
warm_ws = WSClient(port, token)
except Exception:
warm_ws = None
for _ in range(rounds):
try:
with urllib.request.urlopen(rest_url, timeout=30) as fh:
fh.read()
except Exception:
pass
if warm_ws is not None:
try:
warm_ws.rpc("session.list", {"limit": 20}, timeout=30)
except Exception:
pass
time.sleep(0.2)
if warm_ws is not None:
warm_ws.close()
def probe_loop(port: int, token: str, stop_at: float, cadence_s: float, ws_samples: list[float], rest_samples: list[float]) -> None:
"""Probe ws session.list + REST /api/status every ``cadence_s`` seconds."""
probe_ws: WSClient | None = None
try:
probe_ws = WSClient(port, token)
except Exception:
probe_ws = None
rest_url = f"http://127.0.0.1:{port}/api/status"
next_tick = time.monotonic()
while time.monotonic() < stop_at:
# ws round-trip (session.list — the serving-plane DB read AC-4 protects).
if probe_ws is not None:
t0 = time.perf_counter()
try:
probe_ws.rpc("session.list", {"limit": 20}, timeout=30)
ws_samples.append((time.perf_counter() - t0) * 1000.0)
except Exception:
# A failed/timed-out probe is itself a serving stall; record the
# elapsed as the sample so it counts against p99.
ws_samples.append((time.perf_counter() - t0) * 1000.0)
try:
probe_ws.close()
probe_ws = WSClient(port, token)
except Exception:
probe_ws = None
# REST round-trip.
t1 = time.perf_counter()
try:
with urllib.request.urlopen(rest_url, timeout=30) as fh:
fh.read()
rest_samples.append((time.perf_counter() - t1) * 1000.0)
except Exception:
rest_samples.append((time.perf_counter() - t1) * 1000.0)
next_tick += cadence_s
sleep_for = next_tick - time.monotonic()
if sleep_for > 0:
time.sleep(sleep_for)
else:
next_tick = time.monotonic()
if probe_ws is not None:
probe_ws.close()
# ── run ─────────────────────────────────────────────────────────────────
def run_certify(args: argparse.Namespace) -> dict[str, Any]:
port = free_port()
import secrets
token = secrets.token_urlsafe(24)
parent_tmp = Path(tempfile.mkdtemp(prefix="iso-certify-"))
home = parent_tmp / "hermes-home"
seed_scratch_home(
home,
isolation=args.isolation,
heartbeat_secs=args.heartbeat_secs,
respawn_max=args.respawn_max,
)
concurrency = 1 if args.dry_run else args.concurrency
duration_s = 3.0 if args.dry_run else args.duration_s
turn_duration = 0.5 if args.dry_run else args.turn_duration_s
threshold_ms = args.threshold_ms
turn_spec = {
"duration_s": turn_duration,
"delta_interval_s": args.delta_interval_s,
"tokens_per_delta": args.tokens_per_delta,
"chunk": args.chunk,
}
result: dict[str, Any] = {
"mode": "dry-run" if args.dry_run else "heavy",
"isolation": args.isolation,
"concurrency": concurrency,
"run_duration_s": duration_s,
"turn_spec": turn_spec,
"threshold_ms": threshold_ms,
"scratch_home": str(home),
"port": port,
}
try:
with ScratchDashboard(
home=home, port=port, isolation=args.isolation,
env_extra={"HERMES_DASHBOARD_SESSION_TOKEN": token},
) as dash:
actual_port = dash.actual_port
result["port"] = actual_port
ws_samples: list[float] = []
rest_samples: list[float] = []
lane_results: list[dict] = []
stall_before = dash.stall_log_count
# Prime serving-path caches so a one-time cold-start artifact does
# not count as a serving stall (only for the real heavy run; a
# dry-run stays a raw plumbing smoke).
if not args.dry_run:
warmup_serving(actual_port, token)
stall_before = dash.stall_log_count
stop_at = time.monotonic() + duration_s
probe_thread = threading.Thread(
target=probe_loop,
args=(actual_port, token, stop_at, args.cadence_s, ws_samples, rest_samples),
name="probe", daemon=True,
)
probe_thread.start()
with ThreadPoolExecutor(max_workers=concurrency) as pool:
for _ in range(concurrency):
pool.submit(drive_heavy_turn, actual_port, token, turn_spec, stop_at, lane_results)
# Pool context waits for all lanes.
probe_thread.join(timeout=30)
stall_after = dash.stall_log_count
total_turns = sum(l.get("turns", 0) for l in lane_results)
lane_errors = [e for l in lane_results for e in l.get("errors", [])]
all_durations = [d for l in lane_results for d in l.get("turn_durations_s", [])]
min_deltas = [l["min_deltas"] for l in lane_results if l.get("min_deltas") is not None]
lanes_with_turn = sum(1 for l in lane_results if l.get("turns", 0) > 0)
ws_stat = summarize(ws_samples)
rest_stat = summarize(rest_samples)
ws_over = sum(1 for v in ws_samples if v > threshold_ms)
rest_over = sum(1 for v in rest_samples if v > threshold_ms)
serving_stalls = ws_over + rest_over
log_stalls = stall_after - stall_before
# Load validity — the run only certifies anything if the offered load
# was REAL: every lane completed ≥1 turn, and the completed turns
# actually held ~the requested heavy duration and streamed deltas.
# A fast-erroring/short turn is NOT sustained GIL load, so a green off
# it would be a proxy (serving stays fast because nothing burned).
median_turn_dur = percentile(all_durations, 50) if all_durations else 0.0
min_turn_dur = min(all_durations) if all_durations else 0.0
worst_min_deltas = min(min_deltas) if min_deltas else 0
expected_turn_s = float(turn_spec.get("duration_s", 8.0))
load_valid = (
lanes_with_turn >= concurrency
and total_turns >= concurrency
and min_turn_dur >= 0.7 * expected_turn_s
and worst_min_deltas >= 5
)
result.update({
"ws_probe": ws_stat,
"rest_probe": rest_stat,
"ws_probes_over_threshold": ws_over,
"rest_probes_over_threshold": rest_over,
"serving_stalls": serving_stalls,
"event_loop_stall_log_lines": log_stalls,
"heavy_turns_completed": total_turns,
"lanes_with_turn": lanes_with_turn,
"median_turn_duration_s": round(median_turn_dur, 2),
"min_turn_duration_s": round(min_turn_dur, 2),
"worst_lane_min_deltas": worst_min_deltas,
"load_valid": load_valid,
"lane_errors": lane_errors[:20],
})
# AC-4 verdict — heavy run only. A dry-run reports but never PASSes.
serving_p99 = max(ws_stat["p99_ms"], rest_stat["p99_ms"])
result["serving_p99_ms"] = serving_p99
if args.dry_run:
result["verdict"] = "SMOKE-OK" if (total_turns > 0 and not lane_errors) else "SMOKE-FAIL"
result["is_verdict"] = False
else:
serving_ok = (
serving_p99 < threshold_ms
and serving_stalls == 0
and log_stalls == 0
and probe_thread_samples_ok(ws_samples, rest_samples)
)
if not load_valid:
# Cannot certify: the offered load was not the incident
# regime. Never a PASS; report INCONCLUSIVE, not FAIL, so it
# is not read as "isolation broke serving".
result["verdict"] = "INCONCLUSIVE"
result.setdefault("notes", []).append(
"load invalid: lanes/turn-duration/deltas below the sustained-heavy-load floor — "
"not the AC-4 incident regime, verdict cannot certify"
)
else:
result["verdict"] = "PASS" if serving_ok else "FAIL"
result["is_verdict"] = True
finally:
if not args.keep_home:
shutil.rmtree(parent_tmp, ignore_errors=True)
else:
result["scratch_home_kept"] = str(parent_tmp)
return result
def probe_thread_samples_ok(ws_samples: list[float], rest_samples: list[float]) -> bool:
"""Guard against a blind gate: require the probes actually ran.
A run that produced no probe samples saw NOTHING it must not PASS. This is
the tri-state INCONCLUSIVE floor (an empty timeline is never a green).
"""
return len(ws_samples) >= 3 and len(rest_samples) >= 3
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="AC-4 dashboard turn-isolation certify harness")
p.add_argument("--isolation", choices=["on", "off"], default="on",
help="set dashboard.turn_isolation in the scratch HERMES_HOME")
p.add_argument("--dry-run", action="store_true",
help="1 short light turn plumbing smoke — NOT an AC-4 verdict")
p.add_argument("--concurrency", type=int, default=6, help="concurrent heavy-turn lanes (AC-4: 6)")
p.add_argument("--duration-s", type=float, default=600.0, dest="duration_s",
help="sustained run window seconds (AC-4: ~600 = 10 min)")
p.add_argument("--turn-duration-s", type=float, default=12.0, dest="turn_duration_s",
help="wall seconds of GIL-holding compute per heavy turn")
p.add_argument("--delta-interval-s", type=float, default=0.05, dest="delta_interval_s",
help="streamed-delta cadence per heavy turn")
p.add_argument("--tokens-per-delta", type=int, default=512, dest="tokens_per_delta")
p.add_argument("--chunk", type=int, default=20000, help="pure-Python ops per interrupt-check chunk")
p.add_argument("--cadence-s", type=float, default=0.5, dest="cadence_s",
help="serving-path probe cadence (AC-4: 500ms)")
p.add_argument("--threshold-ms", type=float, default=1000.0, dest="threshold_ms",
help="serving p99 threshold (AC-4: <1s)")
p.add_argument("--heartbeat-secs", type=int, default=15, dest="heartbeat_secs")
p.add_argument("--respawn-max", type=int, default=3, dest="respawn_max")
p.add_argument("--keep-home", action="store_true", help="do not delete the scratch HERMES_HOME on exit")
p.add_argument("--json-out", type=Path, help="write JSON metrics to this path")
args = p.parse_args(argv)
result = run_certify(args)
text = json.dumps(result, indent=2, sort_keys=True)
print(text)
if args.json_out:
args.json_out.parent.mkdir(parents=True, exist_ok=True)
args.json_out.write_text(text + "\n", encoding="utf-8")
# Exit code: 0 only on a real PASS (or a clean dry-run smoke); non-zero
# otherwise. A dry-run is never treated as a verdict for automation.
if result.get("is_verdict"):
return 0 if result.get("verdict") == "PASS" else 1
return 0 if result.get("verdict") == "SMOKE-OK" else 1
if __name__ == "__main__":
raise SystemExit(main())
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Diagnose how prompt_toolkit identifies keystrokes in the current terminal.
Useful when adding a keybinding to Hermes (or any prompt_toolkit app) and you
need to know what the terminal actually delivers particularly on Windows,
where terminals can collapse, intercept, or silently remap key combinations.
Usage:
# POSIX
python scripts/keystroke_diagnostic.py
# Windows (PowerShell / git-bash / cmd)
python scripts\\keystroke_diagnostic.py
Press the key combinations you care about. Each keystroke prints the
prompt_toolkit `Keys.*` identifier and the raw escape bytes the terminal
sent. The last 20 keystrokes stay on screen. Ctrl+Q or Ctrl+C to quit.
Common questions this answers:
- Does my terminal distinguish Ctrl+Enter from plain Enter?
(On Windows Terminal: yes, Ctrl+Enter c-j, Enter c-m.)
- Does Alt+Enter reach the app, or does the terminal eat it?
(Windows Terminal eats it for fullscreen; mintty may too.)
- Does Shift+Enter register as a separate key?
(Almost never most terminals collapse it to Enter.)
- What byte sequence does Home/End/PageUp/etc. produce?
Example output for Ctrl+Enter on Windows Terminal + PowerShell:
key=<Keys.ControlJ: 'c-j'> data='\\n'
Then in Hermes, bind the newline behaviour to that key:
@kb.add('c-j')
def handle_ctrl_enter(event):
event.current_buffer.insert_text('\\n')
"""
from prompt_toolkit import Application
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.layout import Layout
from prompt_toolkit.layout.containers import Window
from prompt_toolkit.layout.controls import FormattedTextControl
_HISTORY: list[str] = []
def _header() -> list[str]:
return [
"Keystroke diagnostic — press keys to see how prompt_toolkit sees them.",
"Try: Enter, Ctrl+Enter, Shift+Enter, Alt+Enter, Ctrl+J, Ctrl+M, arrows, Home/End.",
"Ctrl+Q or Ctrl+C to quit. Last 20 keystrokes shown.",
"",
]
def _render_text() -> str:
return "\n".join(_header() + _HISTORY[-20:])
def main() -> None:
kb = KeyBindings()
@kb.add("<any>")
def _on_any(event): # noqa: ANN001 — prompt_toolkit event type
parts = []
for kp in event.key_sequence:
parts.append(f"key={kp.key!r} data={kp.data!r}")
_HISTORY.append(" | ".join(parts))
event.app.invalidate()
@kb.add("c-q")
@kb.add("c-c")
def _quit(event): # noqa: ANN001
event.app.exit()
control = FormattedTextControl(text=_render_text)
layout = Layout(Window(content=control))
Application(layout=layout, key_bindings=kb, full_screen=False).run()
if __name__ == "__main__":
main()
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# Kill all running Modal apps (sandboxes, deployments, etc.)
#
# Usage:
# bash scripts/kill_modal.sh # Stop hermes-agent sandboxes
# bash scripts/kill_modal.sh --all # Stop ALL Modal apps
set -uo pipefail
echo "Fetching Modal app list..."
APP_LIST=$(modal app list 2>/dev/null)
if [[ "${1:-}" == "--all" ]]; then
echo "Stopping ALL Modal apps..."
echo "$APP_LIST" | grep -oE 'ap-[A-Za-z0-9]+' | sort -u | while read app_id; do
echo " Stopping $app_id"
modal app stop "$app_id" 2>/dev/null || true
done
else
echo "Stopping hermes-agent sandboxes..."
APPS=$(echo "$APP_LIST" | grep 'hermes-agent' | grep -oE 'ap-[A-Za-z0-9]+' || true)
if [[ -z "$APPS" ]]; then
echo " No hermes-agent apps found."
else
echo "$APPS" | while read app_id; do
echo " Stopping $app_id"
modal app stop "$app_id" 2>/dev/null || true
done
fi
fi
echo ""
echo "Current hermes-agent status:"
modal app list 2>/dev/null | grep -E 'State|hermes-agent' || echo " (none)"
+461
View File
@@ -0,0 +1,461 @@
#!/usr/bin/env bash
# ============================================================================
# scripts/lib/node-bootstrap.sh
# ----------------------------------------------------------------------------
# Sourceable helper: ensure Node.js >= MIN_VERSION is available for the TUI
# (React + Ink), browser tools, and the WhatsApp bridge.
#
# Strategy (first hit wins — respects the user's existing tooling):
# 1. modern `node` already on PATH
# 2. ~/.hermes/node/ from a prior Hermes-managed install
# 3. fnm, proto, nvm (in that order) if the user already uses a version manager
# 4. Termux `pkg`, macOS Homebrew
# 5. pinned nodejs.org tarball into ~/.hermes/node/ (always works, zero shell rc edits)
#
# Usage:
# source scripts/lib/node-bootstrap.sh
# ensure_node # returns 0 on success, non-zero on failure
# if [ "$HERMES_NODE_AVAILABLE" = true ]; then ...; fi
#
# Env inputs (set before sourcing to override defaults):
# HERMES_NODE_MIN_VERSION (default: 20) — accepted on PATH
# HERMES_NODE_TARGET_MAJOR (default: 22) — installed when we install
# HERMES_HOME (default: $HOME/.hermes)
# ============================================================================
HERMES_NODE_MIN_VERSION="${HERMES_NODE_MIN_VERSION:-20}"
HERMES_NODE_TARGET_MAJOR="${HERMES_NODE_TARGET_MAJOR:-22}"
HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
HERMES_NODE_AVAILABLE=false
# ---------------------------------------------------------------------------
# Logging — prefer the host script's log_* helpers when present
# ---------------------------------------------------------------------------
_nb_log() { declare -F log_info >/dev/null 2>&1 && log_info "$*" || printf '→ %s\n' "$*" >&2; }
_nb_ok() { declare -F log_success >/dev/null 2>&1 && log_success "$*" || printf '✓ %s\n' "$*" >&2; }
_nb_warn() { declare -F log_warn >/dev/null 2>&1 && log_warn "$*" || printf '⚠ %s\n' "$*" >&2; }
# ---------------------------------------------------------------------------
# Platform + version helpers
# ---------------------------------------------------------------------------
_nb_is_termux() {
[ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]]
}
# Where to symlink node/npm/npx so they land on PATH.
# Mirrors get_command_link_dir() from install.sh: root FHS → /usr/local/bin,
# Termux → $PREFIX/bin, otherwise ~/.local/bin.
_nb_get_link_dir() {
if _nb_is_termux && [ -n "${PREFIX:-}" ]; then
echo "$PREFIX/bin"
elif [ "$(id -u)" = 0 ] && [ "$(uname -s)" = "Linux" ]; then
echo "/usr/local/bin"
else
echo "$HOME/.local/bin"
fi
}
# Redirect a Hermes-managed Node's `npm install -g` to the command link dir
# (already on PATH) instead of the default $HERMES_HOME/node/bin, which is off
# PATH and wiped on every Node upgrade. Scoped to the managed Node via its
# prefix-local global npmrc; the user's other Node installs / ~/.npmrc are
# untouched. Idempotent no-op when there's no managed npm.
_nb_configure_npm_prefix() {
[ -x "$HERMES_HOME/node/bin/npm" ] || return 0
local _link_dir
_link_dir="$(_nb_get_link_dir)"
mkdir -p "$HERMES_HOME/node/etc"
printf 'prefix=%s\n' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc"
}
_nb_node_major() {
local v
v=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1)
[[ "$v" =~ ^[0-9]+$ ]] && echo "$v" || echo 0
}
# The npm range the checkout's root package.json demands. Read from the
# manifest rather than duplicated here so the two can never drift; falls back
# to the current floor when the manifest is unreadable (vendored copy of this
# script, stripped install tree).
_nb_npm_range() {
if [ -n "${HERMES_NPM_TARGET_RANGE:-}" ]; then
printf '%s\n' "$HERMES_NPM_TARGET_RANGE"
return 0
fi
local repo_root manifest range
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)"
manifest="$repo_root/package.json"
if [ -r "$manifest" ]; then
# sed, not node: this runs before a usable node is guaranteed.
range=$(sed -n '/"engines"/,/}/p' "$manifest" \
| sed -n 's/.*"npm"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
| head -1)
if [ -n "$range" ]; then
printf '%s\n' "$range"
return 0
fi
fi
printf '>=12.0.0\n'
}
# Upgrade the managed tree's bundled npm into the checkout's engines.npm range.
#
# The nodejs.org tarball ships whatever npm that Node major bundles — Node
# 26.5.1 bundles npm 11.17.0, one minor below our own `engines.npm` floor of
# >=12. With `engine-strict=true` in the repo .npmrc that is fatal, not a
# warning, so a brand-new install died at the first `npm ci` with EBADENGINE.
# The Python side recovers through hermes_cli/npm_engine.py; the installer path
# had no such rung, so provision the right npm here instead of reacting later.
#
# Three details are load-bearing, all mirroring upgrade_managed_npm():
# - a temp cwd, so the checkout's own .npmrc (engine-strict, min-release-age)
# does not gate the very upgrade meant to satisfy it;
# - npm_config_min_release_age=0, which also neutralises a user ~/.npmrc;
# - an explicit --prefix at the managed tree, because
# _nb_configure_npm_prefix wrote prefix=~/.local into its etc/npmrc, and
# without the override this installs a second npm elsewhere while the
# managed tree stays stale.
#
# Best-effort: a failure here leaves a working Node with an old npm, which is
# strictly better than no Node at all, and npm_engine.py still covers the
# EBADENGINE that follows.
_nb_ensure_bundled_npm_range() {
local npm_bin="$HERMES_HOME/node/bin/npm"
[ -x "$npm_bin" ] || return 0
local range have want
range="$(_nb_npm_range)"
[ -n "$range" ] || return 0
# Skip the network round-trip when the bundled npm already satisfies the
# range. Only the ">=N" shape we actually author is checked; anything more
# exotic falls through to letting npm itself decide.
if [[ "$range" =~ ^\>=([0-9]+) ]]; then
want="${BASH_REMATCH[1]}"
have=$("$npm_bin" --version 2>/dev/null | cut -d. -f1)
if [[ "$have" =~ ^[0-9]+$ ]] && [ "$have" -ge "$want" ]; then
return 0
fi
fi
_nb_log "Upgrading bundled npm to satisfy $range..."
local tmp_cwd
tmp_cwd=$(mktemp -d)
if (
cd "$tmp_cwd" || exit 1
CI=1 npm_config_min_release_age=0 \
"$npm_bin" install --global \
--prefix "$HERMES_HOME/node" \
"npm@$range" \
--no-fund --no-audit --progress=false >/dev/null 2>&1
); then
rm -rf "$tmp_cwd"
_nb_ok "npm $("$npm_bin" --version 2>/dev/null) installed"
return 0
fi
rm -rf "$tmp_cwd"
_nb_warn "Could not upgrade bundled npm to $range — \`npm ci\` may fail with EBADENGINE."
_nb_warn "Fix manually: npm install -g --prefix \"$HERMES_HOME/node\" 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" ]
}
# ---------------------------------------------------------------------------
# Version-manager paths — respect what the user already uses
# ---------------------------------------------------------------------------
_nb_try_fnm() {
command -v fnm >/dev/null 2>&1 || return 1
_nb_log "fnm detected — installing Node $HERMES_NODE_TARGET_MAJOR..."
eval "$(fnm env 2>/dev/null)" || true
fnm install "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1
fnm use "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1
_nb_have_modern_node || return 1
_nb_ok "Node $(node --version) activated via fnm"
return 0
}
_nb_try_proto() {
command -v proto >/dev/null 2>&1 || return 1
_nb_log "proto detected — installing Node $HERMES_NODE_TARGET_MAJOR..."
proto install node "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1
_nb_have_modern_node || return 1
_nb_ok "Node $(node --version) activated via proto"
return 0
}
_nb_try_nvm() {
local nvm_sh="${NVM_DIR:-$HOME/.nvm}/nvm.sh"
[ -s "$nvm_sh" ] || return 1
# shellcheck source=/dev/null
\. "$nvm_sh" >/dev/null 2>&1 || return 1
_nb_log "nvm detected — installing Node $HERMES_NODE_TARGET_MAJOR..."
nvm install "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1
nvm use "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1
_nb_have_modern_node || return 1
_nb_ok "Node $(node --version) activated via nvm"
return 0
}
# ---------------------------------------------------------------------------
# Platform package managers
# ---------------------------------------------------------------------------
_nb_try_termux_pkg() {
_nb_is_termux || return 1
_nb_log "Installing Node.js via pkg..."
pkg install -y nodejs >/dev/null 2>&1 || return 1
_nb_have_modern_node || return 1
_nb_ok "Node $(node --version) installed via pkg"
return 0
}
_nb_try_brew() {
[ "$(uname -s)" = "Darwin" ] || return 1
command -v brew >/dev/null 2>&1 || return 1
_nb_log "Installing Node via Homebrew..."
brew install "node@${HERMES_NODE_TARGET_MAJOR}" >/dev/null 2>&1 \
|| brew install node >/dev/null 2>&1 \
|| return 1
brew link --overwrite --force "node@${HERMES_NODE_TARGET_MAJOR}" >/dev/null 2>&1 || true
_nb_have_modern_node || return 1
_nb_ok "Node $(node --version) installed via Homebrew"
return 0
}
# ---------------------------------------------------------------------------
# Bundled binary fallback — always works, no shell rc edits
# ---------------------------------------------------------------------------
_nb_install_bundled_node() {
local arch node_arch os_name node_os
arch=$(uname -m)
case "$arch" in
x86_64) node_arch="x64" ;;
aarch64|arm64) node_arch="arm64" ;;
armv7l) node_arch="armv7l" ;;
*)
_nb_warn "Unsupported arch ($arch) — install Node.js manually: https://nodejs.org/"
return 1
;;
esac
os_name=$(uname -s)
case "$os_name" in
Linux*) node_os="linux" ;;
Darwin*) node_os="darwin" ;;
*)
_nb_warn "Unsupported OS ($os_name) — install Node.js manually: https://nodejs.org/"
return 1
;;
esac
local index_url="https://nodejs.org/dist/latest-v${HERMES_NODE_TARGET_MAJOR}.x/"
local tarball
tarball=$(curl -fsSL "$index_url" \
| grep -oE "node-v${HERMES_NODE_TARGET_MAJOR}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.xz" \
| head -1)
if [ -z "$tarball" ]; then
tarball=$(curl -fsSL "$index_url" \
| grep -oE "node-v${HERMES_NODE_TARGET_MAJOR}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.gz" \
| head -1)
fi
if [ -z "$tarball" ]; then
_nb_warn "Could not resolve Node $HERMES_NODE_TARGET_MAJOR binary for $node_os-$node_arch"
return 1
fi
local tmp
tmp=$(mktemp -d)
_nb_log "Downloading $tarball..."
curl -fsSL "${index_url}${tarball}" -o "$tmp/$tarball" || {
_nb_warn "Download failed"; rm -rf "$tmp"; return 1
}
_nb_log "Extracting to $HERMES_HOME/node/..."
if [[ "$tarball" == *.tar.xz ]]; then
tar xf "$tmp/$tarball" -C "$tmp" || { rm -rf "$tmp"; return 1; }
else
tar xzf "$tmp/$tarball" -C "$tmp" || { rm -rf "$tmp"; return 1; }
fi
local extracted
extracted=$(find "$tmp" -maxdepth 1 -type d -name 'node-v*' 2>/dev/null | head -1)
if [ ! -d "$extracted" ]; then
_nb_warn "Extraction produced no node-v* directory"
rm -rf "$tmp"
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"
rm -rf "$tmp"
local _link_dir
_link_dir="$(_nb_get_link_dir)"
# HERMES_NODE_SKIP_LINKS=1: the caller only wants the private managed tree
# (e.g. the EBADENGINE recovery provisioning a runtime alongside a working
# system Node). Skipping the links keeps the user's own node/npm first on
# PATH instead of shadowing them with ours.
if [ "${HERMES_NODE_SKIP_LINKS:-0}" != "1" ]; then
mkdir -p "$_link_dir"
ln -sf "$HERMES_HOME/node/bin/node" "$_link_dir/node"
ln -sf "$HERMES_HOME/node/bin/npm" "$_link_dir/npm"
ln -sf "$HERMES_HOME/node/bin/npx" "$_link_dir/npx"
fi
_nb_configure_npm_prefix
export PATH="$HERMES_HOME/node/bin:$PATH"
_nb_have_modern_node || return 1
_nb_ok "Node $(node --version) installed to $HERMES_HOME/node/"
# The tarball's bundled npm is usually below the repo's engines.npm floor.
# Best-effort: an old npm still beats no Node.
_nb_ensure_bundled_npm_range || true
return 0
}
# ---------------------------------------------------------------------------
# Heal a broken Hermes-managed Node tree (partial upgrade / missing lib/)
# ---------------------------------------------------------------------------
_nb_managed_tool_broken() {
local tool="$1"
local probe
for probe in \
"$HERMES_HOME/node/bin/$tool" \
"$HERMES_HOME/node/${tool}.exe" \
"$HERMES_HOME/node/$tool"; do
if [ -x "$probe" ] || [ -f "$probe" ]; then
if ! "$probe" --version >/dev/null 2>&1; then
return 0
fi
fi
done
return 1
}
# The managed node runs but is below HERMES_NODE_TARGET_MAJOR — an old tree
# from a previous install (e.g. 22). Outdated heals the same way broken does,
# so existing users get upgraded on the next heal probe, not just on a full
# installer re-run. Mirrors _managed_node_tree_outdated() in
# hermes_constants.py.
_nb_managed_node_outdated() {
local probe ver major
for probe in "$HERMES_HOME/node/bin/node" "$HERMES_HOME/node/node"; do
[ -x "$probe" ] || continue
ver="$("$probe" --version 2>/dev/null)" || return 1
major="${ver#v}"; major="${major%%.*}"
case "$major" in ''|*[!0-9]*) return 1 ;; esac
[ "$major" -lt "$HERMES_NODE_TARGET_MAJOR" ] && return 0
return 1
done
return 1
}
_nb_managed_node_needs_heal() {
local tool
for tool in node npm npx; do
if _nb_managed_tool_broken "$tool"; then
return 0
fi
done
_nb_managed_node_outdated
}
# Redownload the pinned nodejs.org tarball when a managed tree exists but
# node/npm/npx fail a --version probe. No-op when the tree is healthy or
# absent. Used by hermes_constants.find_hermes_node_executable() and safe
# to call from install reruns.
heal_managed_node() {
[ -d "$HERMES_HOME/node" ] || return 1
if ! _nb_managed_node_needs_heal; then
return 0
fi
_nb_log "Hermes-managed Node is broken — redownloading to $HERMES_HOME/node/..."
_nb_install_bundled_node
}
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
ensure_node() {
HERMES_NODE_AVAILABLE=false
# Repair pre-existing managed installs where `npm install -g` lands off
# PATH. No-op when there's no managed Node, so it's safe to run first.
_nb_configure_npm_prefix
if _nb_have_modern_node; then
_nb_ok "Node $(node --version) found"
HERMES_NODE_AVAILABLE=true
return 0
fi
if [ -x "$HERMES_HOME/node/bin/node" ]; then
export PATH="$HERMES_HOME/node/bin:$PATH"
if _nb_have_modern_node; then
_nb_ok "Node $(node --version) found (Hermes-managed)"
HERMES_NODE_AVAILABLE=true
# A tree from an older install still carries that Node major's
# bundled npm, and the upgrade in _nb_install_bundled_node is
# best-effort — one offline install leaves an at-target tree
# stranded below engines.npm forever, since heal only fires for a
# *broken* tree. Mirrors Update-ManagedNpm's reuse-path call in
# install.ps1. No-ops on a probe when the npm is already in range.
_nb_ensure_bundled_npm_range || true
return 0
fi
fi
# Version managers first — respect the user's existing setup.
_nb_try_fnm && { HERMES_NODE_AVAILABLE=true; return 0; }
_nb_try_proto && { HERMES_NODE_AVAILABLE=true; return 0; }
_nb_try_nvm && { HERMES_NODE_AVAILABLE=true; return 0; }
# Platform package managers.
_nb_try_termux_pkg && { HERMES_NODE_AVAILABLE=true; return 0; }
_nb_try_brew && { HERMES_NODE_AVAILABLE=true; return 0; }
# Last resort: pinned nodejs.org tarball.
_nb_install_bundled_node && { HERMES_NODE_AVAILABLE=true; return 0; }
_nb_warn "Node.js install failed — TUI and browser tools will be unavailable."
_nb_warn "Install manually: https://nodejs.org/en/download/ (or: \`brew install node\`, \`fnm install $HERMES_NODE_TARGET_MAJOR\`, etc.)"
return 1
}
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Diff ruff + ty diagnostic reports between two git refs.
Produces a Markdown summary suitable for `$GITHUB_STEP_SUMMARY` and for PR
comments. Compares issues by a stable key (file, rule, line) so line-only
shifts from unrelated edits are treated as the same issue.
Usage:
lint_diff.py \\
--base-ruff base/ruff.json --head-ruff head/ruff.json \\
--base-ty base/ty.json --head-ty head/ty.json \\
[--base-ref origin/main] [--head-ref HEAD]
Any of the four --{base,head}-{ruff,ty} files may be missing or empty; in that
case the tool treats it as "0 diagnostics" (e.g. if base/main doesn't have the
config yet, or a tool crashed).
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from collections import Counter
from pathlib import Path
def _load_json(path: Path | None) -> list[dict]:
if path is None or not path.exists() or path.stat().st_size == 0:
return []
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
print(f"warning: could not parse {path}: {exc}", file=sys.stderr)
return []
if not isinstance(data, list):
return []
return data
def _normalize_ruff(entries: list[dict]) -> list[dict]:
"""Ruff JSON: {code, filename, location.row, message}."""
out: list[dict] = []
for e in entries:
code = e.get("code") or "unknown"
# ruff emits absolute paths; relativize to repo root if possible
filename = e.get("filename", "")
try:
filename = os.path.relpath(filename)
except ValueError:
pass
line = (e.get("location") or {}).get("row", 0)
out.append(
{
"tool": "ruff",
"rule": code,
"path": filename,
"line": line,
"message": e.get("message", ""),
}
)
return out
def _normalize_ty(entries: list[dict]) -> list[dict]:
"""ty gitlab JSON: {check_name, location.path, location.positions.begin.line, description}."""
out: list[dict] = []
for e in entries:
loc = e.get("location") or {}
begin = (loc.get("positions") or {}).get("begin") or {}
out.append(
{
"tool": "ty",
"rule": e.get("check_name", "unknown"),
"path": loc.get("path", ""),
"line": begin.get("line", 0),
"message": e.get("description", ""),
}
)
return out
def _key(d: dict) -> tuple[str, str, str]:
"""Stable diagnostic identity across commits: (path, rule, message)."""
# Intentionally omit line so unrelated edits above an issue don't flag it
# as "new". Same file + same rule + same message = same issue.
return (d["path"], d["rule"], d["message"])
def _diff(base: list[dict], head: list[dict]) -> tuple[list[dict], list[dict], list[dict]]:
base_map = {_key(d): d for d in base}
head_map = {_key(d): d for d in head}
base_keys = set(base_map)
head_keys = set(head_map)
new_keys = head_keys - base_keys
fixed_keys = base_keys - head_keys
unchanged_keys = base_keys & head_keys
# Return head entries for new (current line numbers), base entries for fixed
return (
[head_map[k] for k in new_keys],
[base_map[k] for k in fixed_keys],
[head_map[k] for k in unchanged_keys],
)
def _rule_counts(entries: list[dict]) -> list[tuple[str, int]]:
return Counter(e["rule"] for e in entries).most_common()
def _section(title: str, entries: list[dict], limit: int = 25) -> str:
if not entries:
return f"**{title}:** none\n"
lines = [f"**{title} ({len(entries)}):**\n"]
# Group by rule for readability
counts = _rule_counts(entries)
lines.append("| Rule | Count |")
lines.append("| --- | ---: |")
for rule, count in counts[:15]:
lines.append(f"| `{rule}` | {count} |")
if len(counts) > 15:
lines.append(f"| _+{len(counts) - 15} more rules_ | |")
lines.append("")
lines.append("<details><summary>First entries</summary>\n")
lines.append("```")
for e in entries[:limit]:
lines.append(f"{e['path']}:{e['line']}: [{e['rule']}] {e['message']}")
if len(entries) > limit:
lines.append(f"... and {len(entries) - limit} more")
lines.append("```")
lines.append("</details>\n")
return "\n".join(lines)
def _tool_report(
tool_name: str,
base: list[dict],
head: list[dict],
base_available: bool,
) -> str:
new, fixed, unchanged = _diff(base, head)
delta = len(head) - len(base)
delta_str = f"+{delta}" if delta > 0 else str(delta)
emoji = "🆕" if delta > 0 else ("" if delta < 0 else "")
lines = [f"## {tool_name}\n"]
if not base_available:
lines.append(
"_Base report unavailable (likely main has no config for this tool yet); "
"treating all head diagnostics as new._\n"
)
lines.append(
f"**Total:** {len(head)} on HEAD, {len(base)} on base "
f"({emoji} {delta_str})\n"
)
lines.append(_section("🆕 New issues", new))
lines.append(_section("✅ Fixed issues", fixed))
lines.append(
f"**Unchanged:** {len(unchanged)} pre-existing issues carried over.\n"
)
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--base-ruff", type=Path, required=True)
ap.add_argument("--head-ruff", type=Path, required=True)
ap.add_argument("--base-ty", type=Path, required=True)
ap.add_argument("--head-ty", type=Path, required=True)
ap.add_argument("--base-ref", default="base")
ap.add_argument("--head-ref", default="HEAD")
ap.add_argument(
"--output", type=Path, help="Write summary to this file instead of stdout"
)
args = ap.parse_args()
base_ruff_raw = _load_json(args.base_ruff)
head_ruff_raw = _load_json(args.head_ruff)
base_ty_raw = _load_json(args.base_ty)
head_ty_raw = _load_json(args.head_ty)
base_ruff = _normalize_ruff(base_ruff_raw)
head_ruff = _normalize_ruff(head_ruff_raw)
base_ty = _normalize_ty(base_ty_raw)
head_ty = _normalize_ty(head_ty_raw)
base_ruff_avail = args.base_ruff.exists() and args.base_ruff.stat().st_size > 0
base_ty_avail = args.base_ty.exists() and args.base_ty.stat().st_size > 0
buf: list[str] = []
buf.append(f"# 🔎 Lint report: `{args.head_ref}` vs `{args.base_ref}`\n")
buf.append(_tool_report("ruff", base_ruff, head_ruff, base_ruff_avail))
buf.append(_tool_report("ty (type checker)", base_ty, head_ty, base_ty_avail))
buf.append(
"_Diagnostics are surfaced as warnings — this check never fails the build._\n"
)
summary = "\n".join(buf)
if args.output:
args.output.write_text(summary, encoding="utf-8")
else:
print(summary)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Summarize micro-compaction telemetry from Hermes logs.
Reads the content-free JSON lines emitted by
``ContextCompressor._emit_micro_compaction_telemetry`` and reports what the
feature actually bought you.
Usage:
python scripts/micro_compaction_report.py [LOGFILE ...]
python scripts/micro_compaction_report.py --per-session
With no LOGFILE, reads ``$HERMES_HOME/logs/agent.log`` (default ~/.hermes).
What to look at
---------------
The point of micro-compaction is not saving tokens or time. It is:
(a) amortizing the one long batch-compaction pause across many turns, and
(b) keeping the context window low enough that a session runs much further
before it needs a hard compaction at all.
So the headline numbers here are OCCUPANCY (how full the window is kept, as a
percentage of the compaction threshold) and BATCH COMPACTIONS (how often the
long pause actually fired). Net tokens saved is reported too, but it is the
least interesting figure -- a session can save nothing on paper and still be a
clear win because the stalls disappeared and the window never filled.
Caveat: running the test suite writes telemetry into the same log. Test lines
cluster inside a sub-second window and carry an empty session_id (they group
as "(unknown)"). Use --per-session to spot them.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from collections import defaultdict
from pathlib import Path
MICRO_MARKER = "micro compaction telemetry: "
BATCH_MARKER = "context compression attempt telemetry: "
def default_log() -> Path:
home = os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes")
return Path(home) / "logs" / "agent.log"
def load(paths: list[Path]) -> tuple[list[dict], list[dict]]:
micro: list[dict] = []
batch: list[dict] = []
for path in paths:
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
print(f"warning: cannot read {path}: {exc}", file=sys.stderr)
continue
for line in text.splitlines():
for marker, sink in ((MICRO_MARKER, micro), (BATCH_MARKER, batch)):
idx = line.find(marker)
if idx == -1:
continue
try:
sink.append(json.loads(line[idx + len(marker):]))
except ValueError:
pass
break
return micro, batch
def pct(values: list[float]) -> tuple[float, float, float] | None:
if not values:
return None
ordered = sorted(values)
return ordered[0], ordered[len(ordered) // 2], ordered[-1]
def fmt(n) -> str:
return "-" if n is None else f"{n:,}"
def report(micro: list[dict], batch: list[dict], per_session: bool) -> int:
if not micro:
print("No micro-compaction telemetry found.")
print("It may be disabled (compression.micro_compact), or no session")
print("has run long enough to trigger a pass yet.")
return 1
by_session: dict[str, list[dict]] = defaultdict(list)
for e in micro:
by_session[e.get("session_id") or "(unknown)"].append(e)
outcomes: dict[str, int] = defaultdict(int)
for e in micro:
outcomes[e.get("outcome", "?")] += 1
occupancies = [e["occupancy_pct"] for e in micro if e.get("occupancy_pct") is not None]
saved = sum(-(e.get("tokens_delta") or 0) for e in micro)
absorbed = [e for e in micro if e.get("outcome") == "absorbed"]
durations = [e.get("duration_ms") or 0 for e in micro]
if per_session:
print(f"{'session':<26} {'passes':>6} {'occupancy%':>18} {'batch':>6} {'saved':>10}")
print("-" * 72)
batch_by_session: dict[str, int] = defaultdict(int)
for b in batch:
batch_by_session[b.get("session_id") or "(unknown)"] += 1
for sid, evs in sorted(by_session.items(), key=lambda kv: -len(kv[1])):
occ = [e["occupancy_pct"] for e in evs if e.get("occupancy_pct") is not None]
spread = pct(occ)
occ_s = f"{spread[0]:.0f}-{spread[2]:.0f} (med {spread[1]:.0f})" if spread else "-"
s = sum(-(e.get("tokens_delta") or 0) for e in evs)
print(f"{sid[:26]:<26} {len(evs):>6} {occ_s:>18} "
f"{batch_by_session.get(sid, 0):>6} {s:>+10,}")
print()
print("-- headroom ----------------------------------")
spread = pct(occupancies)
if spread:
print(f"context occupancy min {spread[0]:.0f}% median {spread[1]:.0f}% max {spread[2]:.0f}%")
print(" (% of the batch-compaction threshold)")
else:
print("context occupancy unavailable (window not resolved when logged)")
print(f"batch compactions {len(batch):,}")
if batch:
print(f" micro passes each {len(micro) / len(batch):.1f}")
else:
print(" none fired -- the long pause never happened in this log")
print()
print("-- activity ----------------------------------")
print(f"sessions {len(by_session):,}")
print(f"passes {len(micro):,}")
for name, count in sorted(outcomes.items(), key=lambda kv: -kv[1]):
print(f" {name:<20} {count:,}")
if durations:
ordered = sorted(durations)
print(f"pass duration median {ordered[len(ordered) // 2]:,} ms "
f"max {ordered[-1]:,} ms")
print()
print("-- tokens (least interesting) ----------------")
print(f"net tokens saved {saved:+,}")
if absorbed:
sizes = [e.get("exchange_tokens") or 0 for e in absorbed]
print(f"exchanges absorbed {len(absorbed):,} "
f"(mean {sum(sizes) // len(absorbed):,} tokens each)")
print("note: the first pass in a session costs ~400 tokens of marker")
print("scaffolding; it pays back from the second pass on.")
return 0
def main() -> int:
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("logs", nargs="*", type=Path, help="log files (default: agent.log)")
ap.add_argument("--per-session", action="store_true", help="break down by session")
args = ap.parse_args()
paths = args.logs or [default_log()]
for p in paths:
if not p.exists():
print(f"warning: {p} does not exist", file=sys.stderr)
micro, batch = load([p for p in paths if p.exists()])
return report(micro, batch, args.per_session)
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Exercise Gateway Health & Diagnostics Export against a local OTLP capture collector."""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
import tempfile
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--endpoint", default="http://127.0.0.1:4318/v1/traces")
parser.add_argument("--log", required=True, help="JSONL file written by otel_capture_collector.py")
parser.add_argument("--wait", type=float, default=7.0)
args = parser.parse_args()
hermes_home = Path(tempfile.mkdtemp(prefix="hermes-otel-smoke-"))
os.environ["HERMES_HOME"] = str(hermes_home)
from gateway.status import write_runtime_status
from agent.monitoring.gateway_health_export import start_gateway_health_export
from agent.monitoring import emitter
config = {
"monitoring": {
"local": True,
"gateway_health_export": {
"enabled": True,
"metrics_enabled": True,
"diagnostic_events_enabled": True,
"warning_error_events_enabled": True,
"export_interval_seconds": 5,
"logs_export_interval_seconds": 5,
"resource_attributes": {
"service.name": "hermes-gateway-smoke",
"deployment.environment.name": "local-smoke",
},
},
"export": {
"otlp": {
"enabled": True,
"endpoint": args.endpoint,
"headers_env": {},
}
},
}
}
runtime = start_gateway_health_export(config)
if not runtime.enabled:
raise SystemExit(f"gateway health exporter did not enable: {runtime.reason}")
write_runtime_status(gateway_state="starting", active_agents=0)
write_runtime_status(gateway_state="running", active_agents=2)
write_runtime_status(platform="slack", platform_state="running")
write_runtime_status(
platform="slack",
platform_state="fatal",
error_code="auth_failed",
error_message="Bearer *** rejected for smoke@example.com",
)
logging.getLogger("gateway.platforms.slack").warning("Slack token *** rejected for smoke@example.com")
emitter.get_emitter().flush(timeout=2.0)
time.sleep(args.wait)
write_runtime_status(gateway_state="stopped", active_agents=0)
runtime.shutdown()
emitter.get_emitter().flush(timeout=2.0)
log_path = Path(args.log)
rows = [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines() if line.strip()]
paths = {row["path"] for row in rows}
print(json.dumps({"hermes_home": str(hermes_home), "requests": len(rows), "paths": sorted(paths)}, indent=2))
if "/v1/traces" not in paths:
raise SystemExit("missing /v1/traces request")
if "/v1/logs" not in paths:
raise SystemExit("missing /v1/logs request")
if "/v1/metrics" not in paths:
raise SystemExit("missing /v1/metrics request")
if __name__ == "__main__":
main()
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Tiny local OTLP/HTTP capture collector for Hermes gateway health smoke tests.
This is not a production collector. It accepts OTLP protobuf POSTs on /v1/traces,
/v1/metrics, and /v1/logs, records request metadata as JSONL, and returns 200 so
local exporters can be exercised without Docker or a vendor backend.
"""
from __future__ import annotations
import argparse
import json
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
class CaptureHandler(BaseHTTPRequestHandler):
log_path: Path
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
length = int(self.headers.get("content-length") or 0)
body = self.rfile.read(length) if length else b""
record = {
"ts": time.time(),
"path": self.path,
"content_type": self.headers.get("content-type"),
"content_length": length,
"body_prefix_hex": body[:24].hex(),
}
with self.log_path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(record, sort_keys=True) + "\n")
self.send_response(200)
self.end_headers()
self.wfile.write(b"{}")
def log_message(self, format: str, *args) -> None:
# Keep tmux panes clean; JSONL file is the assertion surface.
return
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=4318)
parser.add_argument("--log", required=True)
args = parser.parse_args()
log_path = Path(args.log).expanduser().resolve()
log_path.parent.mkdir(parents=True, exist_ok=True)
log_path.write_text("", encoding="utf-8")
CaptureHandler.log_path = log_path
server = ThreadingHTTPServer((args.host, args.port), CaptureHandler)
print(f"OTLP capture collector listening on http://{args.host}:{args.port}; log={log_path}", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
+201
View File
@@ -0,0 +1,201 @@
"""Cross-process falsification of the per-session fence.
The unit tests share one interpreter, so they cannot see the failure this whole
change exists to prevent: two SEPARATE gateway processes, each with its own
snapshot of a conversation, both writing to it. That is how the defect was found
and it is the only way to prove it is closed.
Run against the fork's own HERMES_HOME so nothing here touches a real profile:
python scripts/probe_active_session_exclusivity.py
It drives two real ``python -m tui_gateway.entry`` processes over stdio JSON-RPC
and asserts the sequence the reviewer specified:
A resume S, submit -> claims the session
B resume S, submit -> typed SESSION_NOT_OWNED, no row, no turn
A exits -> its lease is pruned as a dead owner
B submit again -> succeeds
No provider is required. The fence is checked BEFORE the agent is built, so a
submit that later fails for want of a model still proves who owns the session --
which is the property under test, and keeps the probe free of credentials and of
inference cost.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
PYTHON = REPO / "venv" / "Scripts" / "python.exe"
if not PYTHON.exists(): # posix layout
PYTHON = REPO / "venv" / "bin" / "python"
class Gateway:
"""One gateway process, spoken to the way the TUI speaks to it."""
def __init__(self, name: str, home: Path):
env = dict(os.environ)
env["HERMES_HOME"] = str(home)
env["PYTHONUNBUFFERED"] = "1"
self.name = name
self.proc = subprocess.Popen(
[str(PYTHON), "-u", "-m", "tui_gateway.entry"],
cwd=str(REPO),
env=env,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
)
self._next = 1
self.ready()
def _read(self):
line = self.proc.stdout.readline()
if not line:
raise RuntimeError(f"[{self.name}] gateway closed its pipe")
line = line.strip()
if not line:
return None
try:
return json.loads(line)
except json.JSONDecodeError:
return None
def ready(self, timeout: float = 180.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
msg = self._read()
if msg and msg.get("method") == "event":
if msg.get("params", {}).get("type") == "gateway.ready":
return
raise RuntimeError(f"[{self.name}] never announced gateway.ready")
def call(self, method: str, params: dict, timeout: float = 180.0) -> dict:
rid = str(self._next)
self._next += 1
self.proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": rid, "method": method, "params": params}) + "\n")
self.proc.stdin.flush()
deadline = time.time() + timeout
while time.time() < deadline:
msg = self._read()
if msg and msg.get("id") == rid:
return msg
raise RuntimeError(f"[{self.name}] timed out calling {method}")
def close(self):
try:
self.proc.stdin.close()
except Exception:
pass
try:
self.proc.terminate()
self.proc.wait(timeout=15)
except Exception:
try:
self.proc.kill()
except Exception:
pass
def reason_of(response: dict):
return (response.get("error") or {}).get("data", {}).get("reason")
def registry(home: Path):
path = home / "runtime" / "active_sessions.json"
try:
return json.loads(path.read_text(encoding="utf-8")).get("entries", [])
except Exception:
return []
def main() -> int:
home = REPO / ".probe-home"
# A fresh profile each run: a lease left by a previous run would make the
# first check pass or fail for the wrong reason.
import shutil
shutil.rmtree(home, ignore_errors=True)
failures = []
def check(label: str, ok: bool, detail: str = ""):
print(f" {'PASS' if ok else 'FAIL'} {label}{(' -- ' + detail) if detail else ''}")
if not ok:
failures.append(label)
a = Gateway("A", home)
b = None
try:
created = a.call("session.create", {"cols": 80})
sid_a = created["result"]["session_id"]
# Opening a chat must not claim anything -- an idle composer is invisible
# and a slot held by one would fence a real turn for no reason.
check("session.create claims nothing", registry(home) == [], f"{len(registry(home))} entries")
a.call("prompt.submit", {"session_id": sid_a, "text": "probe: A takes the session"})
held = registry(home)
check("A's first turn claims a session", len(held) == 1, json.dumps(held)[:200])
if not held:
raise RuntimeError("A never claimed anything; nothing further can be tested")
# The STORED key, which only materialises when a turn is first submitted --
# and which is what the lease must be keyed on. A lease keyed on the live
# runtime id would fence nothing: two processes resuming one conversation
# have different runtime ids by construction.
key = held[0].get("session_id")
print(f"A live session {sid_a}, stored key {key}")
check("the lease is keyed on the STORED session, not the runtime handle",
bool(key) and key != sid_a, f"key={key} runtime={sid_a}")
b = Gateway("B", home)
resumed = b.call("session.resume", {"session_id": key})
check("B may still RESUME (reading is never fenced)", "result" in resumed,
json.dumps(resumed.get("error", ""))[:160])
sid_b = resumed.get("result", {}).get("session_id")
before = len(registry(home))
refused = b.call("prompt.submit", {"session_id": sid_b, "text": "probe: B must not write"})
check("B's submit is refused", refused.get("error") is not None,
json.dumps(refused.get("result", ""))[:120])
check("refusal is typed SESSION_NOT_OWNED", reason_of(refused) == "SESSION_NOT_OWNED",
str(reason_of(refused)))
check("refusal left the registry untouched", len(registry(home)) == before)
# A dies without releasing -- the crash case, not a clean handoff.
a.proc.kill()
a.proc.wait(timeout=30)
time.sleep(1.0)
retried = b.call("prompt.submit", {"session_id": sid_b, "text": "probe: B may write now"})
check("after A dies, B's retry is accepted", retried.get("error") is None,
json.dumps(retried.get("error", ""))[:200])
held = registry(home)
check("and B now owns the session", len(held) == 1 and held[0].get("session_id") == key,
json.dumps(held)[:160])
finally:
if b is not None:
b.close()
a.close()
print()
if failures:
print(f"FAILED: {len(failures)} check(s): {', '.join(failures)}")
return 1
print("All cross-process checks passed.")
return 0
if __name__ == "__main__":
sys.exit(main())
+625
View File
@@ -0,0 +1,625 @@
#!/usr/bin/env python3
"""Drive the Hermes TUI under HERMES_DEV_PERF and summarize the pipeline.
Usage:
scripts/profile-tui.py [--session SID] [--hold KEY] [--seconds N] [--rate HZ]
Defaults: picks the session with the most messages, holds PageUp for 8s at
~30 Hz (matching xterm key-repeat), summarizes ~/.hermes/perf.log on exit.
The --tui build must exist (run `npm run build` in ui-tui first). This script
launches `node dist/entry.js` directly with HERMES_TUI_RESUME set so it
bypasses the hermes_cli wrapper we want repeatable timing, not the CLI's
session-picker flow.
Environment overrides:
HERMES_PERF_LOG (default ~/.hermes/perf.log)
HERMES_PERF_NODE (default node from $PATH)
HERMES_TUI_DIR (default: <repo>/ui-tui relative to this script)
Exit code is 0 if the harness ran and parsed results, 2 if the TUI crashed
or produced no perf data (suggests HERMES_DEV_PERF wiring is broken).
"""
from __future__ import annotations
import argparse
import json
import os
import pty
import select
import signal
import sqlite3
import sys
import time
from pathlib import Path
from typing import Any
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_PROJECT_ROOT))
try:
from hermes_constants import get_hermes_home
except ImportError:
def get_hermes_home() -> Path: # type: ignore[misc]
val = (os.environ.get("HERMES_HOME") or "").strip()
return Path(val) if val else Path.home() / ".hermes"
DEFAULT_TUI_DIR = Path(
os.environ.get("HERMES_TUI_DIR")
or str(Path(__file__).resolve().parent.parent / "ui-tui")
)
DEFAULT_LOG = Path(os.environ.get("HERMES_PERF_LOG", str(get_hermes_home() / "perf.log")))
DEFAULT_STATE_DB = get_hermes_home() / "state.db"
# Keystroke escape sequences. Matches what xterm/VT220 send when the
# terminal has bracketed-paste disabled and the key-repeat handler fires.
KEYS = {
"page_up": b"\x1b[5~",
"page_down": b"\x1b[6~",
"wheel_up": b"\x1b[M`!!", # mouse wheel up (SGR-less) — best-effort
"shift_up": b"\x1b[1;2A",
"shift_down": b"\x1b[1;2B",
}
def pick_longest_session(db: Path) -> str:
conn = sqlite3.connect(db)
row = conn.execute(
"SELECT id FROM sessions s ORDER BY "
"(SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) DESC LIMIT 1"
).fetchone()
if not row:
sys.exit(f"no sessions in {db}")
return row[0]
def drain(fd: int, timeout: float) -> bytes:
"""Read whatever's available from fd within `timeout`, then return."""
chunks = []
end = time.monotonic() + timeout
while time.monotonic() < end:
r, _, _ = select.select([fd], [], [], max(0.0, end - time.monotonic()))
if not r:
break
try:
data = os.read(fd, 4096)
except OSError:
break
if not data:
break
chunks.append(data)
return b"".join(chunks)
def hold_key(fd: int, seq: bytes, seconds: float, rate_hz: int) -> int:
"""Write `seq` to fd at ~rate_hz for `seconds`. Returns keystrokes sent."""
interval = 1.0 / max(1, rate_hz)
end = time.monotonic() + seconds
sent = 0
while time.monotonic() < end:
try:
os.write(fd, seq)
sent += 1
except OSError:
break
# Drain stdout to keep the PTY buffer flowing; ignore content.
drain(fd, 0)
time.sleep(interval)
return sent
def summarize(log: Path, since_ts_ms: int) -> dict[str, Any]:
"""Parse perf.log, keep only events newer than since_ts_ms, return stats."""
react_events: list[dict[str, Any]] = []
frame_events: list[dict[str, Any]] = []
if not log.exists():
return {"error": f"no log at {log}", "react": [], "frame": []}
for line in log.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except json.JSONDecodeError:
continue
if int(row.get("ts", 0)) < since_ts_ms:
continue
src = row.get("src")
if src == "react":
react_events.append(row)
elif src == "frame":
frame_events.append(row)
return {
"react": react_events,
"frame": frame_events,
}
def pct(values: list[float], p: float) -> float:
if not values:
return 0.0
s = sorted(values)
idx = min(len(s) - 1, int(len(s) * p))
return s[idx]
def format_report(data: dict[str, Any]) -> str:
react = data.get("react") or []
frames = data.get("frame") or []
out = []
out.append("═══ React Profiler ═══")
if not react:
out.append(" (no react events — HERMES_DEV_PERF wired? threshold too high?)")
else:
by_id: dict[str, list[float]] = {}
for r in react:
by_id.setdefault(r["id"], []).append(r["actualMs"])
out.append(f" {'pane':<14} {'count':>6} {'p50':>8} {'p95':>8} {'p99':>8} {'max':>8}")
for pid, ms in sorted(by_id.items(), key=lambda kv: -pct(kv[1], 0.99)):
out.append(
f" {pid:<14} {len(ms):>6} {pct(ms,0.50):>8.2f} {pct(ms,0.95):>8.2f} "
f"{pct(ms,0.99):>8.2f} {max(ms):>8.2f}"
)
out.append("")
out.append("═══ Ink pipeline ═══")
if not frames:
out.append(" (no frame events — onFrame wiring broken?)")
else:
dur = [f["durationMs"] for f in frames]
phases_present = any(f.get("phases") for f in frames)
out.append(f" frames captured: {len(frames)}")
out.append(
f" durationMs p50={pct(dur,0.50):.2f} p95={pct(dur,0.95):.2f} "
f"p99={pct(dur,0.99):.2f} max={max(dur):.2f}"
)
# Effective FPS during the run: frames / elapsed seconds.
ts = sorted(f["ts"] for f in frames)
if len(ts) >= 2:
elapsed_s = (ts[-1] - ts[0]) / 1000.0
fps = len(frames) / elapsed_s if elapsed_s > 0 else float("inf")
out.append(f" throughput: {len(frames)} frames / {elapsed_s:.2f}s = {fps:.1f} fps")
if phases_present:
fields = ["yoga", "renderer", "diff", "optimize", "write", "commit"]
out.append("")
out.append(f" {'phase':<10} {'p50':>8} {'p95':>8} {'p99':>8} {'max':>8} (ms)")
for field in fields:
vals = [f["phases"][field] for f in frames if f.get("phases")]
if vals:
out.append(
f" {field:<10} {pct(vals,0.50):>8.2f} {pct(vals,0.95):>8.2f} "
f"{pct(vals,0.99):>8.2f} {max(vals):>8.2f}"
)
# Derived: sum of phases vs durationMs (reveals hidden time).
sum_ps = [
sum(f["phases"][k] for k in fields)
for f in frames if f.get("phases")
]
if sum_ps:
dur_match = [f["durationMs"] for f in frames if f.get("phases")]
deltas = [d - s for d, s in zip(dur_match, sum_ps)]
out.append(
f" {'dur-Σphases':<10} {pct(deltas,0.50):>8.2f} {pct(deltas,0.95):>8.2f} "
f"{pct(deltas,0.99):>8.2f} {max(deltas):>8.2f} (unaccounted-for time)"
)
# Yoga counters
visited = [f["phases"]["yogaVisited"] for f in frames if f.get("phases")]
measured = [f["phases"]["yogaMeasured"] for f in frames if f.get("phases")]
cache_hits = [f["phases"]["yogaCacheHits"] for f in frames if f.get("phases")]
live = [f["phases"]["yogaLive"] for f in frames if f.get("phases")]
out.append("")
out.append(" Yoga counters (per frame):")
for name, vals in (
("visited", visited),
("measured", measured),
("cacheHits", cache_hits),
("live", live),
):
if vals:
out.append(f" {name:<11} p50={pct(vals,0.5):.0f} p99={pct(vals,0.99):.0f} max={max(vals)}")
# Patch counts — proxy for "how much changed each frame"
patches = [f["phases"]["patches"] for f in frames if f.get("phases")]
if patches:
out.append(
f" patches p50={pct(patches,0.5):.0f} p99={pct(patches,0.99):.0f} "
f"max={max(patches)} total={sum(patches)}"
)
optimized = [
f["phases"].get("optimizedPatches", 0)
for f in frames if f.get("phases")
]
if any(optimized):
out.append(
f" optimized p50={pct(optimized,0.5):.0f} p99={pct(optimized,0.99):.0f} "
f"max={max(optimized)} total={sum(optimized)}"
f" (ratio: {sum(optimized)/max(1,sum(patches)):.2f})"
)
# Write bytes + drain telemetry — the outer-terminal bottleneck gauge.
bytes_written = [
f["phases"].get("writeBytes", 0)
for f in frames if f.get("phases")
]
if any(bytes_written):
total_b = sum(bytes_written)
kb = total_b / 1024
out.append(
f" writeBytes p50={pct(bytes_written,0.5):.0f}B p99={pct(bytes_written,0.99):.0f}B "
f"max={max(bytes_written)}B total={kb:.1f}KB"
)
drains = [
f["phases"].get("prevFrameDrainMs", 0)
for f in frames if f.get("phases")
]
if any(d > 0 for d in drains):
nonzero = [d for d in drains if d > 0]
out.append(
f" drainMs p50={pct(nonzero,0.5):.2f} p95={pct(nonzero,0.95):.2f} "
f"p99={pct(nonzero,0.99):.2f} max={max(nonzero):.2f} (terminal flush latency)"
)
backpressure = sum(1 for f in frames if f.get("phases", {}).get("backpressure"))
if backpressure:
out.append(
f" backpressure: {backpressure}/{len(frames)} frames "
f"({100*backpressure/len(frames):.0f}%) (Node stdout buffer full — terminal slow)"
)
# Flickers
flicker_frames = [f for f in frames if f.get("flickers")]
if flicker_frames:
out.append("")
out.append(f" ⚠ flickers detected in {len(flicker_frames)} frames")
reasons: dict[str, int] = {}
for f in flicker_frames:
for fl in f["flickers"]:
reasons[fl["reason"]] = reasons.get(fl["reason"], 0) + 1
for reason, n in sorted(reasons.items(), key=lambda kv: -kv[1]):
out.append(f" {reason}: {n}")
return "\n".join(out)
def key_metrics(data: dict[str, Any]) -> dict[str, float]:
"""Flatten the report into a dict of scalar metrics for A/B diffing."""
metrics: dict[str, float] = {}
frames = data.get("frame") or []
react = data.get("react") or []
if frames:
durs = [f["durationMs"] for f in frames]
metrics["frames"] = len(frames)
metrics["dur_p50"] = pct(durs, 0.50)
metrics["dur_p95"] = pct(durs, 0.95)
metrics["dur_p99"] = pct(durs, 0.99)
metrics["dur_max"] = max(durs)
ts = sorted(f["ts"] for f in frames)
if len(ts) >= 2:
elapsed = (ts[-1] - ts[0]) / 1000.0
metrics["fps_throughput"] = len(frames) / elapsed if elapsed > 0 else 0.0
# Interframe gaps distribution — complementary view to throughput:
gaps = [ts[i] - ts[i - 1] for i in range(1, len(ts))]
if gaps:
metrics["gap_p50_ms"] = pct(gaps, 0.50)
metrics["gap_p99_ms"] = pct(gaps, 0.99)
metrics["gaps_under_16ms"] = sum(1 for g in gaps if g < 16)
metrics["gaps_over_200ms"] = sum(1 for g in gaps if g >= 200)
for phase in ("renderer", "yoga", "diff", "write"):
vals = [f["phases"][phase] for f in frames if f.get("phases")]
if vals:
metrics[f"{phase}_p99"] = pct(vals, 0.99)
metrics[f"{phase}_max"] = max(vals)
patches = [f["phases"]["patches"] for f in frames if f.get("phases")]
if patches:
metrics["patches_total"] = sum(patches)
metrics["patches_p99"] = pct(patches, 0.99)
optimized = [
f["phases"].get("optimizedPatches", 0) for f in frames if f.get("phases")
]
if any(optimized):
metrics["optimized_total"] = sum(optimized)
bytes_list = [
f["phases"].get("writeBytes", 0) for f in frames if f.get("phases")
]
if any(bytes_list):
metrics["writeBytes_total"] = sum(bytes_list)
drains = [
f["phases"].get("prevFrameDrainMs", 0)
for f in frames if f.get("phases")
]
drain_nonzero = [d for d in drains if d > 0]
if drain_nonzero:
metrics["drain_p99"] = pct(drain_nonzero, 0.99)
metrics["drain_max"] = max(drain_nonzero)
bp = sum(1 for f in frames if f.get("phases", {}).get("backpressure"))
metrics["backpressure_frames"] = bp
if react:
for pid in {e["id"] for e in react}:
ms = [e["actualMs"] for e in react if e["id"] == pid]
metrics[f"react_{pid}_p99"] = pct(ms, 0.99)
metrics[f"react_{pid}_max"] = max(ms)
return metrics
def format_diff(before: dict[str, float], after: dict[str, float]) -> str:
"""Render a side-by-side A/B comparison table."""
keys = sorted(set(before) | set(after))
lines = [f"{'metric':<28} {'before':>12} {'after':>12} {'delta':>12} {'%':>6}"]
lines.append("" * 76)
for k in keys:
b = before.get(k, 0.0)
a = after.get(k, 0.0)
d = a - b
pct_change = ((a / b) - 1) * 100 if b not in {0, 0.0} else float("inf") if a else 0
# Flag improvements vs regressions. For _p99 / _max / _total / gaps_over /
# patches / writeBytes / backpressure, LOWER is better. For fps / gaps_under,
# HIGHER is better.
lower_is_better = any(
token in k
for token in (
"p50",
"p95",
"p99",
"_max",
"_total",
"gaps_over",
"backpressure",
"drain",
)
)
higher_is_better = "fps_" in k or "gaps_under" in k
mark = ""
if d and not (lower_is_better or higher_is_better):
mark = ""
elif d < 0 and lower_is_better:
mark = ""
elif d > 0 and higher_is_better:
mark = ""
elif d > 0 and lower_is_better:
mark = "" # regression
elif d < 0 and higher_is_better:
mark = "" # regression
pct_str = "" if pct_change == float("inf") else f"{pct_change:+6.1f}%"
lines.append(
f"{k:<28} {b:>12.2f} {a:>12.2f} {d:>+12.2f} {pct_str} {mark}"
)
return "\n".join(lines)
def run_once(args: argparse.Namespace) -> dict[str, Any]:
tui_dir = Path(args.tui_dir).resolve()
entry = tui_dir / "dist" / "entry.js"
if not entry.exists():
sys.exit(f"{entry} missing — run `npm run build` in {tui_dir} first")
sid = args.session or pick_longest_session(DEFAULT_STATE_DB)
print(f"• session: {sid}")
print(f"• hold: {args.hold} x {args.rate}Hz for {args.seconds}s after {args.warmup}s warmup")
print(f"• terminal: {args.cols}x{args.rows}")
log = Path(args.log)
if not args.keep_log and log.exists():
log.unlink()
since_ms = int(time.time() * 1000)
env = os.environ.copy()
env["HERMES_DEV_PERF"] = "1"
env["HERMES_DEV_PERF_MS"] = str(args.threshold_ms)
env["HERMES_DEV_PERF_LOG"] = str(log)
env["HERMES_TUI_RESUME"] = sid
env["COLUMNS"] = str(args.cols)
env["LINES"] = str(args.rows)
env["TERM"] = env.get("TERM", "xterm-256color")
# Pass through extra flags the TUI wrapper recognizes (e.g. --no-fullscreen).
# Stored on args as `extra_flags` list.
node = os.environ.get("HERMES_PERF_NODE", "node")
node_args = [node, str(entry), *getattr(args, "extra_flags", [])]
pid, fd = pty.fork()
if pid == 0:
os.execvpe(node, node_args, env)
try:
import fcntl, struct, termios
winsize = struct.pack("HHHH", args.rows, args.cols, 0, 0)
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
print(f"• pid: {pid} fd: {fd}")
print(f"• warmup {args.warmup}s (drain startup output)…")
drain(fd, args.warmup)
print(f"• holding {args.hold}")
sent = hold_key(fd, KEYS[args.hold], args.seconds, args.rate)
print(f" sent {sent} keystrokes")
drain(fd, 0.5)
finally:
try:
os.kill(pid, signal.SIGTERM)
for _ in range(10):
pid_done, _ = os.waitpid(pid, os.WNOHANG)
if pid_done == pid:
break
time.sleep(0.1)
else:
os.kill(pid, signal.SIGKILL) # windows-footgun: ok — POSIX-only script (imports pty at top)
os.waitpid(pid, 0)
except (ProcessLookupError, ChildProcessError):
pass
try:
os.close(fd)
except OSError:
pass
time.sleep(0.2)
return summarize(log, since_ms)
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--session", help="session id to resume (default: longest in db)")
p.add_argument("--hold", default="page_up", choices=sorted(KEYS.keys()), help="key to hold")
p.add_argument("--seconds", type=float, default=8.0, help="how long to hold the key")
p.add_argument("--rate", type=int, default=30, help="keystrokes per second")
p.add_argument("--warmup", type=float, default=3.0, help="seconds to wait after launch before input")
p.add_argument("--threshold-ms", type=float, default=0.0, help="HERMES_DEV_PERF_MS (0 = capture all)")
p.add_argument("--cols", type=int, default=120)
p.add_argument("--rows", type=int, default=40)
p.add_argument("--keep-log", action="store_true", help="don't wipe perf.log before run")
p.add_argument("--tui-dir", default=str(DEFAULT_TUI_DIR))
p.add_argument("--log", default=str(DEFAULT_LOG))
p.add_argument("--save", metavar="LABEL",
help="save the final metrics as /tmp/perf-<LABEL>.json for later --compare")
p.add_argument("--compare", metavar="LABEL",
help="diff against /tmp/perf-<LABEL>.json after running")
p.add_argument("--loop", action="store_true",
help="watch for source changes, rebuild, rerun, and diff vs previous run")
p.add_argument("--extra-flag", dest="extra_flags", action="append", default=[],
help="pass through to node dist/entry.js (repeatable)")
args = p.parse_args()
if args.loop:
return loop_mode(args)
# Single-shot path.
data = run_once(args)
print()
print(format_report(data))
metrics = key_metrics(data)
if args.save:
path = Path(f"/tmp/perf-{args.save}.json")
path.write_text(json.dumps(metrics, indent=2), encoding="utf-8")
print(f"\n• saved: {path}")
if args.compare:
path = Path(f"/tmp/perf-{args.compare}.json")
if not path.exists():
print(f"\n⚠ no baseline at {path} — run with --save {args.compare} first")
else:
before = json.loads(path.read_text(encoding="utf-8"))
print(f"\n═══ A/B diff vs /tmp/perf-{args.compare}.json ═══")
print(format_diff(before, metrics))
if not data["react"] and not data["frame"]:
return 2
return 0
def loop_mode(args: argparse.Namespace) -> int:
"""Watch source files, rebuild, rerun, print A/B diff against previous run.
Keeps a rolling 'previous run' baseline in memory so each iteration
reports delta vs the last one visibility into whether the last
edit moved the needle. Press Ctrl+C to stop.
"""
import subprocess
tui_dir = Path(args.tui_dir).resolve()
src_root = tui_dir / "src"
pkg_root = tui_dir / "packages" / "hermes-ink" / "src"
def collect_mtimes() -> dict[str, float]:
mtimes: dict[str, float] = {}
for root in (src_root, pkg_root):
if not root.exists():
continue
for path in root.rglob("*"):
if path.suffix in {".ts", ".tsx"} and "__tests__" not in str(path):
try:
mtimes[str(path)] = path.stat().st_mtime
except OSError:
pass
return mtimes
previous_metrics: dict[str, float] | None = None
previous_mtimes = collect_mtimes()
iteration = 0
print(f"• loop mode — watching {src_root} + {pkg_root} for *.ts(x) changes")
print("• edit any TS file, the harness rebuilds + reruns automatically")
print("• Ctrl+C to stop\n")
try:
while True:
iteration += 1
print(f"\n{'' * 76}")
print(f"Iteration {iteration} @ {time.strftime('%H:%M:%S')}")
print("" * 76)
if iteration > 1:
print("• rebuilding…")
result = subprocess.run(
["npm", "run", "build"],
cwd=tui_dir,
capture_output=True,
text=True, encoding='utf-8', errors='replace',
)
if result.returncode != 0:
print("✗ build failed:")
print(result.stdout[-2000:])
print(result.stderr[-2000:])
print("\n• waiting for source changes to retry…")
previous_mtimes = wait_for_change(previous_mtimes, collect_mtimes)
continue
print("✓ build ok")
data = run_once(args)
metrics = key_metrics(data)
print()
print(format_report(data))
if previous_metrics is not None:
print(f"\n═══ A/B diff vs iteration {iteration - 1} ═══")
print(format_diff(previous_metrics, metrics))
previous_metrics = metrics
print("\n• waiting for source changes…")
previous_mtimes = wait_for_change(previous_mtimes, collect_mtimes)
except KeyboardInterrupt:
print("\n• loop stopped")
return 0
def wait_for_change(prev: dict[str, float], collect) -> dict[str, float]:
"""Poll every 1s until a watched file's mtime changes. Debounced 500ms."""
while True:
time.sleep(1)
current = collect()
changed = [
path for path, mtime in current.items() if prev.get(path) != mtime
]
if changed:
print(f"{len(changed)} file(s) changed:")
for path in changed[:5]:
print(f" {path}")
# Debounce — editor save bursts can take ~500ms to settle
time.sleep(0.5)
return collect()
if __name__ == "__main__":
sys.exit(main())
+2643
View File
File diff suppressed because it is too large Load Diff
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env bash
# Canonical test runner for hermes-agent. Run this instead of calling
# `pytest` directly to guarantee your local run matches CI behavior.
#
# What this script enforces:
# * Per-file isolation via scripts/run_tests_parallel.py — each test
# file runs in its own freshly-spawned `python -m pytest <file>`
# subprocess. No xdist, no shared workers, no module-level leakage
# between files.
# * TZ=UTC, LANG=C.UTF-8, PYTHONHASHSEED=0 (deterministic)
# * Env vars blanked (conftest.py also does this, but this
# is belt-and-suspenders for anyone running pytest outside our
# conftest path — e.g. on a single file)
# * Proper venv activation (probes .venv, venv, then ~/.hermes/...)
#
# Usage:
# scripts/run_tests.sh # full suite
# scripts/run_tests.sh -j 4 # cap parallelism
# scripts/run_tests.sh tests/agent/ # discover only here
# scripts/run_tests.sh tests/agent/ tests/acp/ # multiple roots
# scripts/run_tests.sh tests/foo.py # single file
# scripts/run_tests.sh tests/foo.py -q # path + bare pytest flag
# scripts/run_tests.sh tests/foo.py -v --tb=long # bare flags "just work"
# scripts/run_tests.sh -k 'pattern' # value flags pass through too
# scripts/run_tests.sh tests/foo.py -- --tb=long # explicit '--' still works
#
# Bare pytest flags (anything starting with '-' that isn't one of this
# runner's own options: -j/--jobs, --paths, --slice, --file-timeout, etc.)
# are forwarded to each per-file pytest invocation automatically — no '--'
# separator required. The explicit '--' form still works and stacks with
# bare flags. Positional path arguments override the default discovery
# root (tests/).
set -euo pipefail
# ── Locate repo root ────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# ── Locate python ───────────────────────────────────────────────────────────
# Probe local venvs first; fall back to the Nix devShell's editable venv
# (HERMES_PYTHON is exported by the devShell hook and ships [dev] extras:
# pytest, pytest-asyncio, pytest-timeout, ruff, ty).
#
# A candidate must have pytest INSTALLED, not merely exist. The release venv
# at ~/.hermes/hermes-agent/venv has bin/activate but no pytest, so an
# existence-only probe selected it in checkouts/worktrees without a local
# .venv — every file then died with "No module named pytest" and the run
# reported "0 tests passed" (which reads green at a glance even though the
# exit code is 1). Skip such a venv and keep probing instead.
VENV=""
VENV_PYTHON=""
SKIPPED_VENVS=""
for candidate in "$REPO_ROOT/.venv" "$REPO_ROOT/venv" "$HOME/.hermes/hermes-agent/venv"; do
if [ -f "$candidate/bin/activate" ]; then
if "$candidate/bin/python" -c 'import pytest' 2>/dev/null; then
VENV="$candidate"
VENV_PYTHON="$candidate/bin/python"
break
fi
SKIPPED_VENVS="$SKIPPED_VENVS $candidate"
fi
# Native Windows venv layout: python.exe and activate live under
# Scripts/, and there is no bin/. Anyone running this script from
# Git Bash / MSYS with a `python -m venv`- or uv-created venv hits
# this branch — without it the canonical runner refuses to start.
if [ -f "$candidate/Scripts/activate" ]; then
if "$candidate/Scripts/python.exe" -c 'import pytest' 2>/dev/null; then
VENV="$candidate"
VENV_PYTHON="$candidate/Scripts/python.exe"
break
fi
SKIPPED_VENVS="$SKIPPED_VENVS $candidate"
fi
done
if [ -n "$SKIPPED_VENVS" ]; then
for skipped in $SKIPPED_VENVS; do
echo "▶ skipping venv without pytest: $skipped" >&2
done
fi
if [ -n "$VENV" ]; then
PYTHON="$VENV_PYTHON"
elif [ -n "${HERMES_PYTHON:-}" ] && [ -x "$HERMES_PYTHON" ] \
&& "$HERMES_PYTHON" -c 'import pytest' 2>/dev/null; then
# Guard with an import check: HERMES_PYTHON may point at the RELEASE
# venv (no pytest) when inherited from a wrapped `hermes` binary rather
# than the devShell hook.
PYTHON="$HERMES_PYTHON"
echo "▶ no local venv — using Nix dev venv via HERMES_PYTHON: $PYTHON"
else
echo "error: no virtualenv with pytest found in $REPO_ROOT/.venv or $REPO_ROOT/venv," >&2
echo " and HERMES_PYTHON is not a python with pytest (enter the Nix devShell or create a venv)" >&2
if [ -n "$SKIPPED_VENVS" ]; then
echo " (skipped for missing pytest:$SKIPPED_VENVS — install dev extras there, or create $REPO_ROOT/.venv)" >&2
fi
exit 1
fi
# ── Live-gateway plugin (computed before we drop env) ───────────────────────
EXTRA_PYTHONPATH=""
EXTRA_PYTEST_PLUGINS=""
if [ -f "$HOME/.hermes/pytest_live_guard.py" ]; then
EXTRA_PYTHONPATH="$HOME/.hermes"
EXTRA_PYTEST_PLUGINS="pytest_live_guard"
fi
# ── Windows location variables (computed before we drop env) ───────────────
# `env -i` forwards HOME, which is enough on POSIX. Native Windows CPython
# resolves Path.home() from USERPROFILE (or HOMEDRIVE+HOMEPATH), stdlib
# platform paths come from LOCALAPPDATA/APPDATA, ssl/sockets need SYSTEMROOT,
# and tempfile needs TEMP/TMP. Dropping them breaks collection on native
# Windows (issues #67385, #70813). These are location variables, not
# credentials, so forwarding them keeps the isolation intent intact. Each is
# only forwarded when actually set, so POSIX runs are byte-for-byte unchanged.
WIN_ENV=()
for _win_var in USERPROFILE HOMEDRIVE HOMEPATH LOCALAPPDATA APPDATA SYSTEMROOT TEMP TMP; do
if [ -n "${!_win_var:-}" ]; then
WIN_ENV+=("$_win_var=${!_win_var}")
fi
done
# ── Test-runner knobs (computed before we drop env) ────────────────────────
# The runner's own documented environment knobs must survive the hermetic
# `env -i` below, or they are silent no-ops for anyone invoking this script:
#
# * HERMES_TEST_WORKERS / PATHS / FILE_TIMEOUT / FILE_RETRIES / SLICE are
# read by run_tests_parallel.py at argparse-default time — inside the
# stripped environment.
# * HERMES_TEST_IMAGE is read by tests/docker/conftest.py to skip its
# session-scoped `docker build`. CI's docker.yml sets it to the image
# the build step just loaded; stripping it made every per-file pytest
# subprocess rebuild the 5GB image from a cold builder cache instead
# (~4 min per worker per run, and the rebuilt image lacked the
# HERMES_GIT_SHA build-arg the workflow bakes in).
#
# These are test-infrastructure knobs, not credentials — same class as the
# HERMES_RUN_SLOW_PET_TESTS / HERMES_E2E_BROWSER opt-ins already forwarded.
# Keep this an explicit allowlist (no HERMES_TEST_* glob) so the "no
# credential can leak" property stays auditable at a glance.
TEST_ENV=()
for _test_var in HERMES_TEST_IMAGE HERMES_TEST_WORKERS HERMES_TEST_PATHS \
HERMES_TEST_FILE_TIMEOUT HERMES_TEST_FILE_RETRIES HERMES_TEST_SLICE; do
if [ -n "${!_test_var:-}" ]; then
TEST_ENV+=("$_test_var=${!_test_var}")
fi
done
# ── Run in hermetic env ──────────────────────────────────────────────────────
# env -i: start with empty environment, opt-in only what we need.
# No credential var can leak — you'd have to explicitly add it here.
echo "▶ running per-file parallel test suite via run_tests_parallel.py"
echo " (TZ=UTC LANG=C.UTF-8 PYTHONHASHSEED=0; clean env)"
cd "$REPO_ROOT"
# ── Pre-compile .pyc bytecode cache ─────────────────────────────────────────
# Each test file runs in its own subprocess via run_tests_parallel.py.
# Pre-building the bytecode cache once here (instead of each subprocess
# compiling on first import) avoids redundant work across ~2000 processes.
# Uses git to list tracked .py files (skips venv, node_modules, etc).
echo "▶ pre-compiling bytecode cache"
"$PYTHON" -m compileall -q -j 0 -- $(git ls-files '*.py') >/dev/null 2>&1 || true
echo "▶ launching test runner"
exec env -i \
PATH="$PATH" \
HOME="$HOME" \
${WIN_ENV[@]+"${WIN_ENV[@]}"} \
${TEST_ENV[@]+"${TEST_ENV[@]}"} \
TZ=UTC \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
PYTHONHASHSEED=0 \
PYTHONUTF8=1 \
${HERMES_RUN_SLOW_PET_TESTS:+HERMES_RUN_SLOW_PET_TESTS="$HERMES_RUN_SLOW_PET_TESTS"} \
${HERMES_E2E_BROWSER:+HERMES_E2E_BROWSER="$HERMES_E2E_BROWSER"} \
${EXTRA_PYTHONPATH:+PYTHONPATH="$EXTRA_PYTHONPATH"} \
${EXTRA_PYTEST_PLUGINS:+PYTEST_PLUGINS="$EXTRA_PYTEST_PLUGINS"} \
"$PYTHON" "$SCRIPT_DIR/run_tests_parallel.py" "$@"
+1262
View File
File diff suppressed because it is too large Load Diff
+409
View File
@@ -0,0 +1,409 @@
#!/usr/bin/env python3
"""
Sample and Compress HuggingFace Datasets
Downloads trajectories from multiple HuggingFace datasets, randomly samples them,
and runs trajectory compression to fit within a target token budget.
Usage:
python scripts/sample_and_compress.py
# Custom sample size
python scripts/sample_and_compress.py --total_samples=5000
# Custom output name
python scripts/sample_and_compress.py --output_name=compressed_16k
"""
import json
import random
from pathlib import Path
from typing import List, Dict, Any, Tuple
import fire
# Load environment variables
from dotenv import load_dotenv
load_dotenv()
# Default datasets to sample from
DEFAULT_DATASETS = [
"NousResearch/swe-terminus-agent-glm-kimi-minimax",
"NousResearch/hermes-agent-megascience-sft1",
"NousResearch/Hermes-Agent-Thinking-GLM-4.7-SFT2",
"NousResearch/Hermes-Agent-Thinking-GLM-4.7-SFT1",
"NousResearch/terminal-tasks-glm-hermes-agent"
]
def load_dataset_from_hf(dataset_name: str) -> List[Dict[str, Any]]:
"""
Load a dataset from HuggingFace.
Args:
dataset_name: HuggingFace dataset name (e.g., "NousResearch/dataset-name")
Returns:
List of trajectory entries
"""
from datasets import load_dataset
print(f" Loading {dataset_name}...")
try:
# Try loading with default config
ds = load_dataset(dataset_name, split="train")
except Exception as e:
print(f" ⚠️ Error loading {dataset_name}: {e}")
return []
# Convert to list of dicts
entries = []
for item in ds:
# Handle different possible formats
if "conversations" in item:
entries.append({"conversations": item["conversations"]})
elif "messages" in item:
# Convert messages format to conversations format if needed
entries.append({"conversations": item["messages"]})
else:
# Assume the whole item is the entry
entries.append(dict(item))
print(f" ✅ Loaded {len(entries):,} entries from {dataset_name}")
return entries
# Global tokenizer for multiprocessing (set in worker init)
_TOKENIZER = None
def _init_tokenizer_worker(tokenizer_name: str):
"""Initialize tokenizer in worker process."""
global _TOKENIZER
from transformers import AutoTokenizer
_TOKENIZER = AutoTokenizer.from_pretrained(tokenizer_name, trust_remote_code=True)
def _count_tokens_for_entry(entry: Dict) -> Tuple[Dict, int]:
"""
Count tokens for a single entry (used in parallel processing).
Args:
entry: Trajectory entry with 'conversations' field
Returns:
Tuple of (entry, token_count)
"""
global _TOKENIZER
conversations = entry.get("conversations", [])
if not conversations:
return entry, 0
total = 0
for turn in conversations:
value = turn.get("value", "")
if value:
try:
total += len(_TOKENIZER.encode(value))
except Exception:
# Fallback to character estimate
total += len(value) // 4
return entry, total
def sample_from_datasets(
datasets: List[str],
total_samples: int,
min_tokens: int = 16000,
tokenizer_name: str = "moonshotai/Kimi-K2-Thinking",
seed: int = 42,
num_proc: int = 8
) -> List[Dict[str, Any]]:
"""
Load all datasets, filter by token count, then randomly sample from combined pool.
Args:
datasets: List of HuggingFace dataset names
total_samples: Total number of samples to collect
min_tokens: Minimum token count to include (only sample trajectories >= this)
tokenizer_name: HuggingFace tokenizer for counting tokens
seed: Random seed for reproducibility
num_proc: Number of parallel processes for tokenization
Returns:
List of sampled trajectory entries
"""
from multiprocessing import Pool
random.seed(seed)
print(f"\n📥 Loading {len(datasets)} datasets...")
print(f" Minimum tokens: {min_tokens:,} (filtering smaller trajectories)")
print(f" Parallel workers: {num_proc}")
print()
# Load ALL entries from all datasets into one pool
all_entries = []
for dataset_name in datasets:
entries = load_dataset_from_hf(dataset_name)
if not entries:
print(f" ⚠️ Skipping {dataset_name} (no entries loaded)")
continue
# Add source metadata to each entry
for entry in entries:
entry["_source_dataset"] = dataset_name
all_entries.extend(entries)
print(f"\n📊 Total entries loaded: {len(all_entries):,}")
# Filter by token count using parallel processing
print(f"\n🔍 Filtering trajectories with >= {min_tokens:,} tokens (using {num_proc} workers)...")
filtered_entries = []
token_counts = []
# Use multiprocessing for token counting
with Pool(
processes=num_proc,
initializer=_init_tokenizer_worker,
initargs=(tokenizer_name,)
) as pool:
# Process in chunks and show progress
chunk_size = 1000
processed = 0
for result in pool.imap_unordered(_count_tokens_for_entry, all_entries, chunksize=100):
entry, token_count = result
processed += 1
if processed % chunk_size == 0:
print(f" Processed {processed:,}/{len(all_entries):,}...", end="\r")
if token_count >= min_tokens:
entry["_original_tokens"] = token_count
filtered_entries.append(entry)
token_counts.append(token_count)
print(f"\n ✅ Found {len(filtered_entries):,} trajectories >= {min_tokens:,} tokens")
if token_counts:
avg_tokens = sum(token_counts) / len(token_counts)
print(f" 📈 Token stats: min={min(token_counts):,}, max={max(token_counts):,}, avg={avg_tokens:,.0f}")
# Random sample from the filtered pool
if len(filtered_entries) <= total_samples:
print(f"\n⚠️ Only {len(filtered_entries):,} trajectories available, using all of them")
sampled = filtered_entries
else:
sampled = random.sample(filtered_entries, total_samples)
print(f"\n✅ Randomly sampled {len(sampled):,} trajectories from pool of {len(filtered_entries):,}")
# Show source distribution
source_counts = {}
for entry in sampled:
source = entry.get("_source_dataset", "unknown").split("/")[-1]
source_counts[source] = source_counts.get(source, 0) + 1
print("\n📌 Sample distribution by source:")
for source, count in sorted(source_counts.items()):
print(f" {source}: {count:,}")
# Shuffle
random.shuffle(sampled)
return sampled
def save_samples_for_compression(
samples: List[Dict[str, Any]],
output_dir: Path,
batch_size: int = 100
):
"""
Save samples to JSONL files for trajectory compression.
Args:
samples: List of trajectory entries
output_dir: Directory to save JSONL files
batch_size: Number of entries per file
"""
output_dir.mkdir(parents=True, exist_ok=True)
# Split into batches
num_batches = (len(samples) + batch_size - 1) // batch_size
print(f"\n💾 Saving {len(samples)} samples to {output_dir}")
print(f" Batch size: {batch_size}, Total batches: {num_batches}")
for i in range(num_batches):
start_idx = i * batch_size
end_idx = min((i + 1) * batch_size, len(samples))
batch = samples[start_idx:end_idx]
output_file = output_dir / f"batch_{i}.jsonl"
with open(output_file, 'w', encoding='utf-8') as f:
for entry in batch:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
print(f" ✅ Saved {num_batches} batch files")
def run_compression(input_dir: Path, output_dir: Path, config_path: str):
"""
Run trajectory compression on the sampled data.
Args:
input_dir: Directory containing JSONL files to compress
output_dir: Directory for compressed output
config_path: Path to compression config YAML
"""
# Import the compressor
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from trajectory_compressor import TrajectoryCompressor, CompressionConfig
print("\n🗜️ Running trajectory compression...")
print(f" Input: {input_dir}")
print(f" Output: {output_dir}")
print(f" Config: {config_path}")
# Load config
config = CompressionConfig.from_yaml(config_path)
# Initialize compressor
compressor = TrajectoryCompressor(config)
# Run compression
compressor.process_directory(input_dir, output_dir)
def merge_output_to_single_jsonl(input_dir: Path, output_file: Path):
"""
Merge all JSONL files in a directory into a single JSONL file.
Args:
input_dir: Directory containing JSONL files
output_file: Output JSONL file path
"""
print(f"\n📦 Merging output files into {output_file.name}...")
all_entries = []
for jsonl_file in sorted(input_dir.glob("*.jsonl")):
if jsonl_file.name == output_file.name:
continue
with open(jsonl_file, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line:
all_entries.append(json.loads(line))
# Write merged file
with open(output_file, 'w', encoding='utf-8') as f:
for entry in all_entries:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
print(f" ✅ Merged {len(all_entries):,} entries into {output_file.name}")
return output_file
def main(
total_samples: int = 2500,
output_name: str = "compressed_agentic",
datasets: str = None,
config: str = "configs/trajectory_compression.yaml",
seed: int = 42,
batch_size: int = 100,
min_tokens: int = 16000,
num_proc: int = 8,
skip_download: bool = False,
):
"""
Sample trajectories from HuggingFace datasets and run compression.
Args:
total_samples: Total number of samples to collect (default: 2500)
output_name: Name for output directory/file (default: "compressed_agentic")
datasets: Comma-separated list of dataset names (uses defaults if not provided)
config: Path to compression config YAML
seed: Random seed for reproducibility
batch_size: Number of entries per JSONL file during processing
min_tokens: Minimum token count to filter trajectories (default: 16000)
num_proc: Number of parallel workers for tokenization (default: 8)
skip_download: Skip download and use existing sampled data
"""
print("=" * 70)
print("📊 TRAJECTORY SAMPLING AND COMPRESSION")
print("=" * 70)
# Parse datasets
if datasets:
dataset_list = [d.strip() for d in datasets.split(",")]
else:
dataset_list = DEFAULT_DATASETS
print("\n📋 Configuration:")
print(f" Total samples: {total_samples:,}")
print(f" Min tokens filter: {min_tokens:,}")
print(f" Parallel workers: {num_proc}")
print(f" Datasets: {len(dataset_list)}")
for ds in dataset_list:
print(f" - {ds}")
print(f" Output name: {output_name}")
print(f" Config: {config}")
print(f" Seed: {seed}")
# Setup paths
base_dir = Path(__file__).parent.parent
sampled_dir = base_dir / "data" / f"{output_name}_raw"
compressed_dir = base_dir / "data" / f"{output_name}_batches"
final_output = base_dir / "data" / f"{output_name}.jsonl"
if not skip_download:
# Step 1: Download, filter by token count, and sample from combined pool
samples = sample_from_datasets(
dataset_list,
total_samples,
min_tokens=min_tokens,
seed=seed,
num_proc=num_proc
)
if not samples:
print("❌ No samples collected. Exiting.")
return
# Step 2: Save to JSONL files
save_samples_for_compression(samples, sampled_dir, batch_size)
else:
print(f"\n⏭️ Skipping download, using existing data in {sampled_dir}")
# Step 3: Run compression
config_path = base_dir / config
if not config_path.exists():
print(f"❌ Config not found: {config_path}")
return
run_compression(sampled_dir, compressed_dir, str(config_path))
# Step 4: Merge into single JSONL file
merge_output_to_single_jsonl(compressed_dir, final_output)
print("\n" + "=" * 70)
print("✅ COMPLETE!")
print("=" * 70)
print(f"\n📁 Raw samples: {sampled_dir}")
print(f"📁 Compressed batches: {compressed_dir}")
print(f"📁 Final output: {final_output}")
print("\nTo upload to HuggingFace:")
print(f" huggingface-cli upload NousResearch/{output_name} {final_output}")
if __name__ == "__main__":
fire.Fire(main)
+43
View File
@@ -0,0 +1,43 @@
# 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
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# Pick the release tags the install/update E2E should update FROM.
#
# Emits a JSON array of tag names on stdout, suitable for a GitHub Actions
# matrix (`fromJSON`). Choosing at runtime rather than hardcoding keeps the
# matrix honest as releases land: a pinned list silently stops covering the
# newest release the day after it ships, and pins the "oldest" forever even
# after it stops being a version anyone still runs.
#
# Selection: the newest tag, the oldest tag, and evenly spaced tags in between.
# Newest catches "did the last release break updating?", oldest is the longest
# upgrade jump anyone can still make, and the spread samples the migrations in
# between (config-schema bumps, venv layout changes, dependency floors).
#
# Usage:
# scripts/sandbox/pick-release-tags.sh [--count N] [--repo DIR]
#
# --count how many tags to emit (default 5, minimum 1). Fewer tags than
# requested emits all of them.
# --repo repository to read tags from (default: this checkout).
#
# Reads tags from the local checkout, so it needs one fetched with tags
# (actions/checkout with fetch-depth: 0, or `fetch-tags: true`). A shallow
# checkout has no tags and this exits non-zero rather than silently emitting an
# empty matrix.
#
# Only vYYYY.M.D[.N] release tags are considered; the repo also carries
# backup/* and one-off tags that are not releases.
set -euo pipefail
COUNT=5
# Default to the repository containing this script, resolved through its real
# path so a symlinked or copied script still reads the checkout it lives in
# rather than whatever repo the caller happens to be standing in.
REPO=""
while [ "$#" -gt 0 ]; do
case "$1" in
--count)
[ "$#" -ge 2 ] || { echo 'error: --count needs a value' >&2; exit 1; }
COUNT="$2"; shift 2 ;;
--repo)
[ "$#" -ge 2 ] || { echo 'error: --repo needs a value' >&2; exit 1; }
REPO="$2"; shift 2 ;;
-h|--help) sed -n '2,30p' "$0"; exit 0 ;;
*) echo "error: unknown argument: $1" >&2; exit 1 ;;
esac
done
case "$COUNT" in
''|*[!0-9]*) echo "error: --count must be a positive integer: $COUNT" >&2; exit 1 ;;
esac
[ "$COUNT" -ge 1 ] || { echo 'error: --count must be at least 1' >&2; exit 1; }
# Resolve the script's own location through symlinks, then ask git which
# worktree that path belongs to. Deriving the repo from the script rather than
# from $PWD means a copied script cannot silently report a different checkout's
# tags, and --show-toplevel keeps it correct when invoked from a subdirectory.
if [ -z "$REPO" ]; then
script_path="${BASH_SOURCE[0]}"
if command -v readlink >/dev/null 2>&1; then
script_path="$(readlink -f "$script_path" 2>/dev/null || printf '%s' "$script_path")"
fi
script_dir="$(cd "$(dirname "$script_path")" && pwd)"
REPO="$(git -C "$script_dir" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$script_dir")"
fi
# sort -V orders v2026.4.8 before v2026.4.13 (numeric), which a plain
# lexicographic sort gets wrong.
mapfile -t tags < <(
git -C "$REPO" tag --list 'v*' \
| grep -E '^v[0-9]{4}\.[0-9]+\.[0-9]+(\.[0-9]+)?$' \
| sort -V
)
total="${#tags[@]}"
if [ "$total" -eq 0 ]; then
echo "error: no release tags found in $REPO" >&2
echo ' A shallow clone has no tags: fetch with tags (actions/checkout' >&2
echo ' with fetch-depth: 0, or fetch-tags: true).' >&2
exit 1
fi
if [ "$total" -le "$COUNT" ]; then
picked=("${tags[@]}")
elif [ "$COUNT" -eq 1 ]; then
# One slot means the newest release; there is no span to spread across.
picked=("${tags[$((total - 1))]}")
else
# Evenly spaced indices across [0, total-1], endpoints included, so the
# oldest and newest are always present and the rest are spread between them.
picked=()
for slot in $(seq 0 $((COUNT - 1))); do
# Round to nearest rather than truncate, so the spacing does not bunch
# toward the oldest end.
index=$(( (slot * (total - 1) * 2 + (COUNT - 1)) / ((COUNT - 1) * 2) ))
candidate="${tags[$index]}"
# Guard against a duplicate if rounding lands twice on the same tag.
case " ${picked[*]-} " in
*" $candidate "*) continue ;;
esac
picked+=("$candidate")
done
fi
printf '['
for i in "${!picked[@]}"; do
[ "$i" -eq 0 ] || printf ','
printf '"%s"' "${picked[$i]}"
done
printf ']\n'
+237
View File
@@ -0,0 +1,237 @@
"""MITM proxy backing the dev sandbox's fake Internet.
Listens on 127.0.0.1:8080 and is pointed at by http_proxy/https_proxy inside
the sandbox. For each request it either serves a fixture from the filesystem or
forwards to the real host:
* ``<root>/<host>/<path>`` exists -> serve it. This is how the sandbox answers
the canonical install URL with the installer under test, so the payload can
run the true ``curl -fsSL https:///install.sh | bash`` one-liner.
* otherwise -> forward upstream, verifying against the real CA bundle. The
sandbox is isolated from the *host*, not from the internet: a real install
still has to reach PyPI and npm.
HTTPS is intercepted by minting a per-host certificate from the sandbox's own
throwaway CA, which the payload trusts via CURL_CA_BUNDLE / SSL_CERT_FILE.
Usage: proxy.py <fixture-root> <certs-dir> <real-ca-bundle>
"""
import os
import pathlib
import socket
import ssl
import subprocess
import sys
import threading
from urllib.parse import unquote, urlsplit
ROOT, CERTS, REAL_CA = map(pathlib.Path, sys.argv[1:])
LISTEN_ADDRESS = ('127.0.0.1', 8080)
MAX_REQUEST_BYTES = 65536
UPSTREAM_TIMEOUT_SECONDS = 30
CERT_VALIDITY_DAYS = 2
def read_request(conn):
data = b""
while b"\r\n\r\n" not in data and len(data) < MAX_REQUEST_BYTES:
part = conn.recv(4096)
if not part:
return b""
data += part
return data
def run_openssl(args):
"""Run openssl, raising with its stderr when it fails.
Discarding stderr here costs real debugging time: the caller sees only a
dropped connection (``curl: (35) Recv failure``) and the log holds nothing
but the argv, so an unwritable directory, a missing CA key, and an option
the host's openssl rejects all look identical.
"""
done = subprocess.run(
['openssl', *args], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE
)
if done.returncode != 0:
detail = done.stderr.decode('utf-8', 'replace').strip()
raise RuntimeError(
f'openssl {args[0]} failed (exit {done.returncode}): {detail}'
)
_CERT_LOCK = threading.Lock()
def cert_for(host):
"""Return a (cert, key) pair for host, minting it from the sandbox CA.
Minting is serialized and published atomically. The proxy is threaded, so
two concurrent requests for the same host would otherwise both run openssl
into the same paths, and a reader could pick up a finished certificate
beside a key from the other writer -- which TLS rejects as
``[X509: KEY_VALUES_MISMATCH] key values mismatch``.
"""
safe = ''.join(char if char.isalnum() or char in '.-' else '_' for char in host)
cert, key = CERTS / f'{safe}.pem', CERTS / f'{safe}.key'
if cert.exists() and key.exists():
return cert, key
with _CERT_LOCK:
# Re-check: another thread may have finished while we waited.
if cert.exists() and key.exists():
return cert, key
# Build under unique temp names, then rename into place. os.replace is
# atomic, so a reader sees either the old pair or the new one, never a
# half-written mix. The key lands first: the certificate's existence is
# what everything else keys off.
stamp = f'{os.getpid()}.{threading.get_ident()}'
tmp_key = CERTS / f'{safe}.key.{stamp}'
tmp_cert = CERTS / f'{safe}.pem.{stamp}'
csr = CERTS / f'{safe}.csr.{stamp}'
run_openssl([
'req', '-newkey', 'rsa:2048', '-nodes',
'-subj', f'/CN={host}',
'-addext', f'subjectAltName=DNS:{host}',
'-keyout', str(tmp_key), '-out', str(csr),
])
run_openssl([
'x509', '-req', '-days', str(CERT_VALIDITY_DAYS), '-in', str(csr),
'-CA', str(CERTS / 'ca.pem'), '-CAkey', str(CERTS / 'ca.key'),
'-CAcreateserial', '-copy_extensions', 'copy', '-out', str(tmp_cert),
])
csr.unlink(missing_ok=True)
os.replace(tmp_key, key)
os.replace(tmp_cert, cert)
return cert, key
def file_for(host, target):
"""Resolve a request to a fixture file, or None to forward upstream."""
path = urlsplit(target).path or '/'
parts = pathlib.PurePosixPath(unquote(path)).parts
if '..' in parts:
return None
candidate = ROOT / host / pathlib.PurePosixPath(*[p for p in parts if p != '/'])
if candidate.is_dir():
candidate /= 'index.html'
return candidate if candidate.is_file() else None
def respond_fixture(conn, found):
body = found.read_bytes()
headers = (
f'Content-Length: {len(body)}\r\nConnection: close\r\n\r\n'.encode()
)
conn.sendall(b'HTTP/1.1 200 OK\r\n' + headers + body)
def close_request(request, target=None):
"""Rewrite a proxied request for a direct upstream connection."""
headers, separator, body = request.partition(b'\r\n\r\n')
lines = headers.split(b'\r\n')
if target is not None:
method, _, version = lines[0].split(b' ', 2)
lines[0] = b' '.join((method, target.encode(), version))
lines = [
line for line in lines
if not line.lower().startswith(b'proxy-connection:')
]
lines.append(b'Connection: close')
return b'\r\n'.join(lines) + separator + body
def relay(source, destination):
while True:
chunk = source.recv(MAX_REQUEST_BYTES)
if not chunk:
return
destination.sendall(chunk)
def forward_https(conn, host, port, request):
context = ssl.create_default_context(cafile=str(REAL_CA))
with socket.create_connection((host, port), timeout=UPSTREAM_TIMEOUT_SECONDS) as raw:
with context.wrap_socket(raw, server_hostname=host) as upstream:
upstream.sendall(close_request(request))
relay(upstream, conn)
def forward_http(conn, host, port, request, target):
parsed = urlsplit(target)
path = parsed.path or '/'
if parsed.query:
path += f'?{parsed.query}'
with socket.create_connection((host, port), timeout=UPSTREAM_TIMEOUT_SECONDS) as upstream:
upstream.sendall(close_request(request, path))
relay(upstream, conn)
def handle_connect(conn, target):
"""Intercept a CONNECT tunnel, terminating TLS with a minted cert."""
host, _, port_text = target.rpartition(':')
port = int(port_text or '443')
conn.sendall(b'HTTP/1.1 200 Connection Established\r\n\r\n')
cert, key = cert_for(host)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(cert, key)
with context.wrap_socket(conn, server_side=True) as tls:
nested = read_request(tls)
if not nested:
return
line = nested.split(b'\r\n', 1)[0].decode('iso-8859-1')
nested_target = line.split(' ', 2)[1]
found = file_for(host, nested_target)
if found is not None:
respond_fixture(tls, found)
else:
forward_https(tls, host, port, nested)
def host_from_headers(request):
for header in request.split(b'\r\n')[1:]:
if header.lower().startswith(b'host:'):
value = header.split(b':', 1)[1].strip().decode()
return value.split(':', 1)[0]
return None
def handle_request(conn):
with conn:
request = read_request(conn)
if not request:
return
line = request.split(b'\r\n', 1)[0].decode('iso-8859-1')
method, target, _ = line.split(' ', 2)
if method.upper() == 'CONNECT':
handle_connect(conn, target)
return
parsed = urlsplit(target)
host = parsed.hostname or host_from_headers(request) or 'unknown'
found = file_for(host, target)
if found is not None:
respond_fixture(conn, found)
else:
forward_http(conn, host, parsed.port or 80, request, target)
def handle(conn):
try:
handle_request(conn)
except Exception as error:
print(f'proxy request failed: {error!r}', file=sys.stderr, flush=True)
def main():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(LISTEN_ADDRESS)
server.listen()
while True:
conn, _ = server.accept()
threading.Thread(target=handle, args=(conn,), daemon=True).start()
if __name__ == '__main__':
main()
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Stand-in for ssh inside the dev sandbox.
#
# install.sh and `hermes update` clone over ssh first (git@github.com:...), so
# the sandbox needs an `ssh` that answers. Rather than run a real sshd, this
# ignores the host, user, and command git asked for and speaks the
# upload-pack protocol directly against the sandbox's bare repo -- which is
# what makes the ssh-first code path exercisable with no keys, no known_hosts,
# and no network.
#
# GIT_UPLOAD_PACK is substituted by dev-sandbox.sh when it installs this shim,
# because the host's git-upload-pack is not necessarily on the sandbox PATH.
exec @GIT_UPLOAD_PACK@ /work/repos/hermes-agent.git
+251
View File
@@ -0,0 +1,251 @@
#!/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 "$@"
+739
View File
@@ -0,0 +1,739 @@
"""Run a real Hermes CLI turn and validate the Relay shared-metrics output."""
from __future__ import annotations
import argparse
import json
import os
import shutil
import sqlite3
import subprocess
import sys
import tempfile
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
PROMPT_CANARY = "relay-smoke-sensitive-prompt"
MODEL_CANARY = "gpt-relay-smoke-sensitive-model"
RESPONSE_CANARY = "relay-smoke-sensitive-response"
TOOL_CALL_CANARY = "relay-smoke-sensitive-tool-call"
TOOL_RESULT_CANARY = "relay-smoke-sensitive-tool-result"
TOOL_FILE = "relay-smoke-input.txt"
SKILL_CANARY = "relay-smoke-private-agent-skill"
INSTALLED_SKILL_CANARY = "relay-smoke-private-installed-skill"
def _resolve_hermes_executable(hermes_repo: Path) -> Path:
for relative_path in (
Path(".venv") / "bin" / "hermes",
Path(".venv") / "Scripts" / "hermes.exe",
):
candidate = hermes_repo / relative_path
if candidate.is_file():
return candidate
discovered = shutil.which("hermes")
if discovered:
return Path(discovered)
raise SystemExit(
"Hermes executable not found in the repository virtual environment "
"or on PATH"
)
class _ModelHandler(BaseHTTPRequestHandler):
"""Minimal OpenAI-compatible model server for one deterministic turn."""
protocol_version = "HTTP/1.1"
requests: list[dict[str, Any]] = []
def do_GET(self) -> None: # noqa: N802
if self.path.rstrip("/") != "/v1/models":
self.send_error(404)
return
self._write_json({
"object": "list",
"data": [
{
"id": MODEL_CANARY,
"object": "model",
"created": 0,
"owned_by": "smoke-test",
}
],
})
def do_POST(self) -> None: # noqa: N802
if self.path.rstrip("/") != "/v1/chat/completions":
self.send_error(404)
return
length = int(self.headers.get("Content-Length", "0"))
request = json.loads(self.rfile.read(length) or b"{}")
type(self).requests.append(request)
request_tool = not any(
message.get("role") == "tool"
for message in request.get("messages") or []
if isinstance(message, dict)
)
if request.get("stream"):
self._write_stream(request_tool=request_tool)
else:
self._write_json(self._completion(request_tool=request_tool))
def _completion(self, *, request_tool: bool) -> dict[str, Any]:
message: dict[str, Any] = {
"role": "assistant",
"content": "" if request_tool else RESPONSE_CANARY,
}
finish_reason = "tool_calls" if request_tool else "stop"
if request_tool:
message["tool_calls"] = [
{
"id": TOOL_CALL_CANARY,
"type": "function",
"function": {
"name": "read_file",
"arguments": json.dumps({"path": TOOL_FILE}),
},
}
]
return {
"id": "chatcmpl-relay-smoke",
"object": "chat.completion",
"created": int(time.time()),
"model": MODEL_CANARY,
"choices": [
{
"index": 0,
"message": message,
"finish_reason": finish_reason,
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 1,
"total_tokens": 11,
},
}
def log_message(self, format: str, *args: Any) -> None:
return
def _write_json(self, payload: dict[str, Any]) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(body)
self.close_connection = True
def _write_stream(self, *, request_tool: bool) -> None:
now = int(time.time())
chunks: list[dict[str, Any]] = [
{
"id": "chatcmpl-relay-smoke",
"object": "chat.completion.chunk",
"created": now,
"model": MODEL_CANARY,
"choices": [
{
"index": 0,
"delta": {
"role": "assistant",
"content": "",
},
"finish_reason": None,
}
],
}
]
if request_tool:
chunks.append({
"id": "chatcmpl-relay-smoke",
"object": "chat.completion.chunk",
"created": now,
"model": MODEL_CANARY,
"choices": [
{
"index": 0,
"delta": {
"tool_calls": [
{
"index": 0,
"id": TOOL_CALL_CANARY,
"type": "function",
"function": {
"name": "read_file",
"arguments": json.dumps({"path": TOOL_FILE}),
},
}
]
},
"finish_reason": None,
}
],
})
else:
chunks.append({
"id": "chatcmpl-relay-smoke",
"object": "chat.completion.chunk",
"created": now,
"model": MODEL_CANARY,
"choices": [
{
"index": 0,
"delta": {"content": RESPONSE_CANARY},
"finish_reason": None,
}
],
})
chunks.extend([
{
"id": "chatcmpl-relay-smoke",
"object": "chat.completion.chunk",
"created": now,
"model": MODEL_CANARY,
"choices": [
{
"index": 0,
"delta": {},
"finish_reason": "tool_calls" if request_tool else "stop",
}
],
},
{
"id": "chatcmpl-relay-smoke",
"object": "chat.completion.chunk",
"created": now,
"model": MODEL_CANARY,
"choices": [],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 1,
"total_tokens": 11,
},
},
])
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "close")
self.end_headers()
for chunk in chunks:
self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode("utf-8"))
self.wfile.flush()
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
self.close_connection = True
def _arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--hermes-repo",
type=Path,
default=Path.cwd(),
help="Hermes source checkout containing .venv/bin/hermes",
)
parser.add_argument(
"--relay-python",
type=Path,
default=None,
help="Optional NeMo Relay checkout's python directory",
)
parser.add_argument(
"--output-dir",
type=Path,
default=None,
help="Directory for the isolated HERMES_HOME and captured output",
)
return parser.parse_args()
def _write_config(home: Path, port: int) -> None:
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
f"""model:
default: {MODEL_CANARY}
provider: custom
base_url: http://127.0.0.1:{port}/v1
api_mode: chat_completions
api_key: no-key-required
security:
tirith_enabled: false
telemetry:
shared_metrics:
enabled: true
""",
encoding="utf-8",
)
def _validate_store(database_path: Path) -> list[dict[str, Any]]:
if not database_path.is_file():
raise AssertionError(f"Metrics database was not created: {database_path}")
with sqlite3.connect(database_path) as connection:
rows = connection.execute(
"""
SELECT metric_name, dimensions_json, value, packaged_value
FROM counter_aggregates
ORDER BY metric_name, dimensions_json
"""
).fetchall()
counters = [
{
"name": name,
"dimensions": json.loads(dimensions),
"value": value,
"packaged_value": packaged_value,
}
for name, dimensions, value, packaged_value in rows
]
by_name: dict[str, list[dict[str, Any]]] = {}
for counter in counters:
by_name.setdefault(counter["name"], []).append(counter)
if set(by_name) != {
"hermes.client.active",
"hermes.model_route.count",
"hermes.skill.lifecycle.count",
"hermes.skill.load.count",
"hermes.task_run.finished",
"hermes.task_run.started",
"hermes.tool_call.count",
}:
raise AssertionError(
f"Unexpected SQLite counters:\n{json.dumps(counters, indent=2)}"
)
if by_name["hermes.client.active"] != [
{
"name": "hermes.client.active",
"dimensions": {},
"value": 1,
"packaged_value": 1,
}
]:
raise AssertionError(
f"Unexpected client-active counter: {by_name['hermes.client.active']}"
)
[model] = by_name["hermes.model_route.count"]
expected_model = {
"name": "hermes.model_route.count",
"dimensions": {
"model": MODEL_CANARY,
"provider": "custom",
},
"value": 2,
"packaged_value": 2,
}
if model != expected_model:
raise AssertionError(
f"Unexpected model counter: {by_name['hermes.model_route.count']}"
)
expected_start = {
"name": "hermes.task_run.started",
"dimensions": {
"entrypoint": "interactive",
"execution_surface": "cli",
},
"value": 1,
"packaged_value": 1,
}
if by_name["hermes.task_run.started"] != [expected_start]:
raise AssertionError(
f"Unexpected task start: {by_name['hermes.task_run.started']}"
)
[terminal] = by_name["hermes.task_run.finished"]
expected_terminal_dimensions = {
"duration_bucket": terminal["dimensions"].get("duration_bucket"),
"end_reason": "completed",
"entrypoint": "interactive",
"execution_surface": "cli",
"model_call_count_bucket": "2",
"outcome": "success",
"retry_count_bucket": "0",
"termination": "none",
"tool_call_count_bucket": "1",
}
if (
terminal["dimensions"] != expected_terminal_dimensions
or terminal["value"] != 1
or terminal["packaged_value"] != 1
):
raise AssertionError(f"Unexpected task terminal counter: {terminal}")
[tool] = by_name["hermes.tool_call.count"]
expected_tool_dimensions = {
"approval_outcome": "not_required",
"latency_bucket": tool["dimensions"].get("latency_bucket"),
"outcome": "success",
"retry_count_bucket": "unknown",
"tool_category": "file",
}
if (
tool["dimensions"] != expected_tool_dimensions
or tool["dimensions"]["latency_bucket"] == "unknown"
or tool["value"] != 1
or tool["packaged_value"] != 1
):
raise AssertionError(f"Unexpected tool counter: {tool}")
lifecycle = by_name["hermes.skill.lifecycle.count"]
expected_actions = {
"archived",
"created",
"edited",
"installed",
"patched",
"restored",
"stale",
}
if (
{counter["dimensions"]["action"] for counter in lifecycle} != expected_actions
or any(counter["value"] != 1 for counter in lifecycle)
or any(counter["packaged_value"] != 1 for counter in lifecycle)
):
raise AssertionError(f"Unexpected skill lifecycle counters: {lifecycle}")
loads = by_name["hermes.skill.load.count"]
expected_load_states = {
("first_use", "not_applicable", "1"),
("reused", "no_new_patch", "2"),
("reused", "reused_after_patch", "3_to_5"),
}
observed_load_states = {
(
counter["dimensions"]["reuse_state"],
counter["dimensions"]["post_patch_state"],
counter["dimensions"]["use_count_bucket"],
)
for counter in loads
}
if (
observed_load_states != expected_load_states
or any(counter["value"] != 1 for counter in loads)
or any(counter["packaged_value"] != 1 for counter in loads)
):
raise AssertionError(f"Unexpected skill load counters: {loads}")
return counters
def _validate_packages(
outbox: Path,
schema_path: Path,
) -> tuple[list[Path], list[dict[str, Any]]]:
package_paths = sorted(outbox.glob("*.json"))
if len(package_paths) != 2:
raise AssertionError(
f"Expected two delta packages in {outbox}, found {len(package_paths)}"
)
try:
import jsonschema
except ImportError as exc:
raise RuntimeError(
"The Hermes development environment requires jsonschema"
) from exc
schema = json.loads(schema_path.read_text(encoding="utf-8"))
packages = [
json.loads(package_path.read_text(encoding="utf-8"))
for package_path in package_paths
]
for package in packages:
jsonschema.validate(package, schema)
if set(package["resource"]) != {
"architecture",
"hermes_version",
"install_method",
"os_family",
}:
raise AssertionError(f"Unexpected client resource: {package['resource']}")
serialized = json.dumps(packages)
for prohibited in (
PROMPT_CANARY,
RESPONSE_CANARY,
TOOL_CALL_CANARY,
TOOL_RESULT_CANARY,
SKILL_CANARY,
INSTALLED_SKILL_CANARY,
):
if prohibited in serialized:
raise AssertionError(
f"Exported package leaked prohibited value: {prohibited!r}"
)
metrics: dict[str, list[dict[str, Any]]] = {}
for package in packages:
for metric in package.get("metrics", []):
metrics.setdefault(metric["name"], []).append(metric)
if set(metrics) != {
"hermes.client.active",
"hermes.model_route.count",
"hermes.skill.lifecycle.count",
"hermes.skill.load.count",
"hermes.task_run.finished",
"hermes.task_run.started",
"hermes.tool_call.count",
}:
raise AssertionError(
f"Unexpected package metrics:\n{json.dumps(metrics, indent=2)}"
)
if metrics["hermes.client.active"] != [
{
"name": "hermes.client.active",
"type": "counter",
"dimensions": {},
"value": 1,
}
]:
raise AssertionError(
f"Unexpected client-active metric: {metrics['hermes.client.active']}"
)
[model] = metrics["hermes.model_route.count"]
if model["dimensions"] != {
"model": MODEL_CANARY,
"provider": "custom",
} or model["value"] != 2:
raise AssertionError(
f"Unexpected model metric: {metrics['hermes.model_route.count']}"
)
[terminal] = metrics["hermes.task_run.finished"]
if terminal["dimensions"] != {
"duration_bucket": terminal["dimensions"].get("duration_bucket"),
"end_reason": "completed",
"entrypoint": "interactive",
"execution_surface": "cli",
"model_call_count_bucket": "2",
"outcome": "success",
"retry_count_bucket": "0",
"termination": "none",
"tool_call_count_bucket": "1",
}:
raise AssertionError(f"Unexpected task terminal metric: {terminal}")
[tool] = metrics["hermes.tool_call.count"]
if (
tool["dimensions"]
!= {
"approval_outcome": "not_required",
"latency_bucket": tool["dimensions"].get("latency_bucket"),
"outcome": "success",
"retry_count_bucket": "unknown",
"tool_category": "file",
}
or tool["dimensions"]["latency_bucket"] == "unknown"
):
raise AssertionError(f"Unexpected tool metric: {tool}")
lifecycle = metrics["hermes.skill.lifecycle.count"]
if {metric["dimensions"]["action"] for metric in lifecycle} != {
"archived",
"created",
"edited",
"installed",
"patched",
"restored",
"stale",
}:
raise AssertionError(f"Unexpected skill lifecycle metrics: {lifecycle}")
loads = metrics["hermes.skill.load.count"]
if {
(
metric["dimensions"]["reuse_state"],
metric["dimensions"]["post_patch_state"],
metric["dimensions"]["use_count_bucket"],
)
for metric in loads
} != {
("first_use", "not_applicable", "1"),
("reused", "no_new_patch", "2"),
("reused", "reused_after_patch", "3_to_5"),
}:
raise AssertionError(f"Unexpected skill load metrics: {loads}")
return package_paths, packages
def main() -> int:
args = _arguments()
hermes_repo = args.hermes_repo.resolve()
relay_python = args.relay_python.resolve() if args.relay_python else None
hermes = _resolve_hermes_executable(hermes_repo)
if relay_python is not None and not any(
(relay_python / "nemo_relay").glob("_native.*")
):
raise SystemExit(
"Built NeMo Relay Python binding not found under "
f"{relay_python}; run the Relay Python build first"
)
if args.output_dir:
root = args.output_dir.resolve()
if root.exists():
raise SystemExit(f"Refusing to replace existing output directory: {root}")
root.mkdir(parents=True)
else:
root = Path(tempfile.mkdtemp(prefix="hermes-relay-shared-metrics-"))
home = root / "hermes-home"
workdir = root / "workspace"
workdir.mkdir()
(workdir / TOOL_FILE).write_text(TOOL_RESULT_CANARY, encoding="utf-8")
home.mkdir()
(home / ".no-bundled-skills").touch()
agent_skill = home / "skills" / SKILL_CANARY
agent_skill.mkdir(parents=True)
(agent_skill / "SKILL.md").write_text(
f"---\nname: {SKILL_CANARY}\ndescription: private smoke skill\n---\n",
encoding="utf-8",
)
installed_skill = home / "skills" / INSTALLED_SKILL_CANARY
installed_skill.mkdir(parents=True)
(installed_skill / "SKILL.md").write_text(
f"---\nname: {INSTALLED_SKILL_CANARY}\ndescription: installed smoke skill\n---\n",
encoding="utf-8",
)
hub_state = home / "skills" / ".hub"
hub_state.mkdir()
(hub_state / "lock.json").write_text(
json.dumps({
"version": 1,
"installed": {
INSTALLED_SKILL_CANARY: {"source": "smoke/local"},
},
}),
encoding="utf-8",
)
_ModelHandler.requests = []
server = ThreadingHTTPServer(("127.0.0.1", 0), _ModelHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
_write_config(home, server.server_port)
env = os.environ.copy()
env["HERMES_HOME"] = str(home)
python_paths = [str(hermes_repo)]
if relay_python is not None:
python_paths.append(str(relay_python))
python_paths.append(env.get("PYTHONPATH", ""))
env["PYTHONPATH"] = os.pathsep.join(python_paths).rstrip(os.pathsep)
result = subprocess.run(
[
str(hermes),
"chat",
"--query",
PROMPT_CANARY,
"--provider",
"custom",
"--model",
MODEL_CANARY,
"--quiet",
"--ignore-rules",
"--toolsets",
"file",
"--max-turns",
"2",
],
cwd=workdir,
env=env,
text=True,
capture_output=True,
timeout=120,
)
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
(root / "hermes.stdout.txt").write_text(result.stdout, encoding="utf-8")
(root / "hermes.stderr.txt").write_text(result.stderr, encoding="utf-8")
if result.returncode != 0:
raise AssertionError(
f"Hermes exited with {result.returncode}\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
if len(_ModelHandler.requests) != 2:
raise AssertionError(
f"Expected two model requests, got {len(_ModelHandler.requests)}"
)
request = _ModelHandler.requests[0]
if request.get("model") != MODEL_CANARY:
raise AssertionError(f"Unexpected model request: {request.get('model')!r}")
if PROMPT_CANARY not in json.dumps(request.get("messages", [])):
raise AssertionError("Hermes model request did not contain the prompt canary")
follow_up = json.dumps(_ModelHandler.requests[1].get("messages", []))
if TOOL_CALL_CANARY not in follow_up or TOOL_RESULT_CANARY not in follow_up:
raise AssertionError("Hermes did not return the tool result to the model")
if RESPONSE_CANARY not in result.stdout:
raise AssertionError("Hermes did not print the mock model response")
skill_result = subprocess.run(
[
sys.executable,
"-c",
"\n".join([
"from hermes_cli.observability import relay_shared_metrics",
"from tools.skill_usage import (",
" STATE_ACTIVE, STATE_ARCHIVED, STATE_STALE, bump_patch,",
" bump_use, record_created, record_installed, set_state,",
")",
f"skill = {SKILL_CANARY!r}",
f"installed = {INSTALLED_SKILL_CANARY!r}",
"record_created(skill, agent_created=True)",
"bump_use(skill)",
"bump_use(skill)",
"bump_patch(skill)",
"bump_use(skill)",
"bump_patch(skill, action='edit')",
"set_state(skill, STATE_STALE)",
"set_state(skill, STATE_ACTIVE)",
"set_state(skill, STATE_ARCHIVED)",
"set_state(skill, STATE_ACTIVE)",
"record_installed(installed)",
"runtime = relay_shared_metrics._get_runtime()",
"assert runtime is not None",
"runtime.shutdown()",
# Production leaves same-day deltas pending. Force a package so
# this smoke can validate them without waiting for the next day.
"runtime.subscriber.store.create_and_export_package()",
]),
],
cwd=workdir,
env=env,
text=True,
capture_output=True,
timeout=60,
)
(root / "skills.stdout.txt").write_text(
skill_result.stdout,
encoding="utf-8",
)
(root / "skills.stderr.txt").write_text(
skill_result.stderr,
encoding="utf-8",
)
if skill_result.returncode != 0:
raise AssertionError(
f"Skill lifecycle probe exited with {skill_result.returncode}\n"
f"stdout:\n{skill_result.stdout}\nstderr:\n{skill_result.stderr}"
)
telemetry = home / "telemetry" / "shared_metrics"
counters = _validate_store(telemetry / "metrics.sqlite3")
package_paths, packages = _validate_packages(
telemetry / "outbox",
hermes_repo
/ "hermes_cli"
/ "observability"
/ "schemas"
/ "hermes.shared_metrics.v2.schema.json",
)
print("Hermes -> NeMo Relay shared-metrics smoke test passed")
print(f"Artifact directory: {root}")
print(f"Model requests: {len(_ModelHandler.requests)}")
print(f"SQLite counters: {json.dumps(counters, indent=2)}")
print(f"Export packages: {', '.join(str(path) for path in package_paths)}")
print(json.dumps(packages, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,120 @@
# Unit tests for install.ps1's Git Bash compatibility and Mandatory-ASLR
# guidance helpers. The installer itself is never executed: functions are
# extracted through the PowerShell AST to avoid downloads, PATH changes, or
# user-environment writes.
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path))
$installScript = Join-Path $repoRoot "scripts\install.ps1"
$failures = 0
function Assert-Equal {
param($Expected, $Actual, [string]$Label)
if ($Expected -ne $Actual) {
Write-Host "FAIL: $Label" -ForegroundColor Red
Write-Host " expected: $Expected"
Write-Host " actual: $Actual"
$script:failures++
} else {
Write-Host "OK: $Label" -ForegroundColor Green
}
}
function Assert-True {
param($Condition, [string]$Label)
if (-not $Condition) {
Write-Host "FAIL: $Label" -ForegroundColor Red
$script:failures++
} else {
Write-Host "OK: $Label" -ForegroundColor Green
}
}
$tokens = $null
$parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
$installScript, [ref]$tokens, [ref]$parseErrors
)
if ($parseErrors.Count -gt 0) {
throw "install.ps1 has parse errors: $($parseErrors -join '; ')"
}
foreach ($name in @(
"Test-GitBashCompatibility",
"Test-MandatoryAslrEnabled",
"Get-GitRootFromBashPath",
"New-GitBashAslrFailureReason",
"Stage-Git"
)) {
$fnAst = $ast.FindAll(
{
param($node)
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
$node.Name -eq $name
}, $true
) | Select-Object -First 1
if (-not $fnAst) { throw "$name not found in install.ps1" }
. ([scriptblock]::Create($fnAst.Extent.Text))
}
Write-Host ""
Write-Host "-- Git root resolution --"
Assert-Equal "C:\Program Files\Git" `
(Get-GitRootFromBashPath "C:\Program Files\Git\bin\bash.exe") `
"PortableGit/full Git bin layout"
Assert-Equal "C:\Program Files\Git" `
(Get-GitRootFromBashPath "C:\Program Files\Git\usr\bin\bash.exe") `
"usr/bin layout"
Write-Host ""
Write-Host "-- Mandatory ASLR detection --"
function Get-ProcessMitigation {
param([switch]$System)
[pscustomobject]@{ Aslr = [pscustomobject]@{ ForceRelocateImages = "ON" } }
}
Assert-Equal $true (Test-MandatoryAslrEnabled) "ForceRelocateImages ON is detected"
function Get-ProcessMitigation {
param([switch]$System)
[pscustomobject]@{ Aslr = [pscustomobject]@{ ForceRelocateImages = "NOTSET" } }
}
Assert-Equal $false (Test-MandatoryAslrEnabled) "ForceRelocateImages NOTSET is not diagnosed"
Write-Host ""
Write-Host "-- Actionable remediation --"
$reason = New-GitBashAslrFailureReason "C:\Program Files\Git\bin\bash.exe"
Assert-True ($reason -match "Mandatory ASLR") "reason identifies Mandatory ASLR"
Assert-True ($reason -match "Reinstalling Git will not change") "reason rejects ineffective reinstall"
Assert-True ($reason -match [regex]::Escape("C:\Program Files\Git")) "reason uses selected Git root"
Assert-True ($reason -match "Set-ProcessMitigation") "reason includes targeted mitigation command"
Write-Host ""
Write-Host "-- External-program probe --"
$gitCommand = Get-Command git -ErrorAction SilentlyContinue
$gitBash = $null
if ($gitCommand -and $gitCommand.Source) {
$gitRoot = Split-Path (Split-Path $gitCommand.Source -Parent) -Parent
foreach ($candidate in @("$gitRoot\bin\bash.exe", "$gitRoot\usr\bin\bash.exe")) {
if (Test-Path -LiteralPath $candidate) { $gitBash = $candidate; break }
}
}
if ($gitBash) {
Assert-Equal $true (Test-GitBashCompatibility $gitBash) `
"installed Git Bash launches external MSYS programs"
} else {
Write-Host "SKIP: no Git Bash found next to git.exe" -ForegroundColor Yellow
}
Write-Host ""
Write-Host "-- Stage failure propagation --"
function Install-Git { return $false }
$script:GitInstallFailureReason = "specific Git Bash failure"
$stageError = $null
try { Stage-Git } catch { $stageError = $_.Exception.Message }
Assert-Equal "specific Git Bash failure" $stageError "Git stage preserves actionable reason"
Write-Host ""
if ($failures -gt 0) {
Write-Host "FAILED: $failures assertion(s) failed" -ForegroundColor Red
exit 1
}
Write-Host "All Git Bash compatibility tests passed." -ForegroundColor Green
exit 0
+323
View File
@@ -0,0 +1,323 @@
# Tests for install.ps1's 8.3 short-path normalization.
#
# Run from a PowerShell prompt:
#
# pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-longpath.ps1
#
# Background: when the Windows profile folder's name contains a space
# ("First Last"), a dot ("Stone.ZEN8"), or an accented character, Windows can
# expose %TEMP%, %LOCALAPPDATA% and friends as an 8.3 alias
# (C:\Users\FIRST~1.LAS\...). PowerShell's FileSystem provider chokes on the
# aliased component once it reaches a provider cmdlet (Tee-Object -FilePath),
# aborting the Node/Electron stages and the desktop post-build probe.
# install.ps1 expands those paths up front; this asserts that contract.
#
# HOW THIS RUNS THE CODE: by executing install.ps1 as a real subprocess with a
# crafted environment and reading what it reports back. `-ProtocolVersion` is a
# side-effect-free early exit that sits BELOW the normalization block, so the
# whole block -- including the script-level Add-Type the kernel32 resolver
# needs -- executes exactly as it does during an install. Nothing here parses
# install.ps1's source (AGENTS.md bans source-reading tests: they pass on
# broken code and fail on correct refactors).
#
# HERMETIC ENVIRONMENT: every case sets all five profile variables explicitly.
# GitHub's own Windows runners hand down a genuinely 8.3-aliased TEMP/TMP
# (C:\Users\RUNNER~1\AppData\Local\Temp), so an inherited variable is a live
# instance of the very bug under test and would contaminate any case that
# didn't override it.
#
# Portability: resolver 3 (profile-root substitution) is pure path arithmetic,
# so the substitution assertions run everywhere, including non-Windows CI. The
# kernel32 and COM resolvers only have anything to expand on a real Windows
# volume; on other hosts they no-op and fall through, which is itself the
# graceful-degradation contract asserted below.
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path))
$installScript = Join-Path $repoRoot "scripts/install.ps1"
if (-not (Test-Path $installScript)) {
throw "Could not locate install.ps1 at $installScript"
}
$failures = 0
$script:lastRaw = ''
function Assert-Equal {
param($Expected, $Actual, [Parameter(Mandatory = $true)][string]$Label)
if ($Expected -ne $Actual) {
Write-Host "FAIL: $Label" -ForegroundColor Red
Write-Host " expected: $Expected"
Write-Host " actual: $Actual"
if ($script:lastRaw) {
# The installer's own account of what it did, plus the environment
# it was handed. Without both, a failure on a host you cannot reach
# is pure guesswork.
Write-Host " installer reported: $script:lastRaw"
Write-Host " environment sent: $script:lastEnv"
}
$script:failures++
} else {
Write-Host "OK: $Label" -ForegroundColor Green
}
}
# --- Harness ---------------------------------------------------------------
# The real profile root the installer will substitute in, derived the same way
# install.ps1 derives it so these assertions hold on any host and any account.
$profileDir = [Environment]::GetFolderPath('UserProfile')
$usersDir = Split-Path -Parent $profileDir
$sep = [System.IO.Path]::DirectorySeparatorChar
# Starting guess for the baseline environment; replaced by the probe below with
# whatever root the installer itself resolves.
$script:baseRoot = $profileDir
# A profile alias that cannot resolve: no such folder exists, so kernel32 and
# COM both fail and only the profile-root substitution can handle it.
$shortProfile = Join-Path $usersDir 'FIRST~1.LAS'
function Join-Parts {
# Join path segments with the platform separator. Literal forward slashes
# inside a path would make Split-Path's behavior host-dependent, which is
# noise this suite doesn't need.
param([string[]]$Parts)
return ($Parts -join $sep)
}
# Ask install.ps1 what paths it resolves under a given environment.
#
# -ShowResolvedPaths prints a JSON object on STDOUT and exits without touching
# anything, so the whole normalization block -- including the script-level
# Add-Type the kernel32 resolver needs -- has already run by the time it is
# printed. Stdout, deliberately: three separate stderr capture mechanisms
# (ProcessStartInfo.RedirectStandardError, `2>$file`, and a merged `2>&1`
# pipeline) were each verified to come back EMPTY from the installer on a
# windows-latest runner while stdout arrived intact. The installer's human
# diagnostics still go to stderr; the machine-readable contract is on stdout,
# which is the only stream that survives everywhere.
#
# Environment overrides are applied to this process and restored afterwards,
# since that is what the child inherits.
function Invoke-Normalization {
param(
[hashtable]$Environment = @{},
[string[]]$ExtraArgs = @()
)
# Start from a long, self-consistent profile so nothing is inherited;
# callers override only the variables their case is about. $script:baseRoot
# is the test's best guess until the probe below replaces it with the root
# the installer actually resolves.
$root = $script:baseRoot
$env0 = @{
TEMP = (Join-Parts @($root, 'AppData', 'Local', 'Temp'))
TMP = (Join-Parts @($root, 'AppData', 'Local', 'Temp'))
LOCALAPPDATA = (Join-Parts @($root, 'AppData', 'Local'))
APPDATA = (Join-Parts @($root, 'AppData', 'Roaming'))
USERPROFILE = $root
HERMES_HOME = ''
}
foreach ($key in $Environment.Keys) { $env0[$key] = $Environment[$key] }
$psExe = (Get-Process -Id $PID).Path
$outFile = [System.IO.Path]::GetTempFileName()
$errFile = [System.IO.Path]::GetTempFileName()
$saved = @{}
foreach ($key in $env0.Keys) { $saved[$key] = [Environment]::GetEnvironmentVariable($key) }
try {
foreach ($key in $env0.Keys) { Set-Item -Path "Env:$key" -Value $env0[$key] }
$callArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $installScript) + $ExtraArgs + @('-ShowResolvedPaths')
# The call operator, not Start-Process: on Windows Start-Process does
# not hand the parent's modified environment block to the child, so the
# installer saw the runner's real TEMP instead of the aliased one this
# case sets, and every rewrite assertion came back "not rewritten".
# `&` inherits the environment on every host.
#
# stderr is merged into the same file rather than redirected separately:
# Windows PowerShell 5.1 wraps ANY stderr from a native command in a
# NativeCommandError record, and a bare `2>$file` still emits that
# record into this script's error stream, which fails the 5.1 lane even
# under 'Continue'. Merging with 2>&1 keeps the bytes and produces no
# error record. The installer's stdout here is a single JSON object and
# its diagnostics are all `[hermes] `-prefixed, so the two separate
# cleanly on the way back out.
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$global:LASTEXITCODE = 0
try {
& $psExe @callArgs *> $outFile
} finally {
$ErrorActionPreference = $prevEAP
}
$exitCode = $LASTEXITCODE
$raw = @(Get-Content -LiteralPath $outFile -ErrorAction SilentlyContinue)
$stderr = ($raw | Where-Object { $_ -like '`[hermes`]*' }) -join "`n"
$stdout = ($raw | Where-Object { $_ -notlike '`[hermes`]*' }) -join "`n"
} finally {
foreach ($key in $saved.Keys) {
if ($null -eq $saved[$key]) {
Remove-Item -LiteralPath "Env:$key" -ErrorAction SilentlyContinue
} else {
Set-Item -Path "Env:$key" -Value $saved[$key]
}
}
Remove-Item -LiteralPath $outFile, $errFile -Force -ErrorAction SilentlyContinue
}
if ($null -eq $stdout) { $stdout = '' }
$stdout = $stdout.Trim()
$script:lastRaw = if ($stdout) { $stdout } else { '(child produced no stdout)' }
$script:lastEnv = ($env0.Keys | Sort-Object | ForEach-Object { "$_=$($env0[$_])" }) -join '; '
$paths = $null
if ($stdout) {
try { $paths = $stdout | ConvertFrom-Json } catch { $paths = $null }
}
# normalized is an object keyed by variable name; flatten to a hashtable so
# callers can ask "was TEMP rewritten, and to what".
$rewrites = @{}
if ($paths -and $paths.normalized) {
foreach ($prop in $paths.normalized.PSObject.Properties) {
$rewrites[$prop.Name] = "$($prop.Value)"
}
}
return @{
ExitCode = $exitCode
Stdout = $stdout
Rewrites = $rewrites
InstallDir = $(if ($paths) { $paths.install_dir } else { $null })
HermesHome = $(if ($paths) { $paths.hermes_home } else { $null })
LongRoot = $(if ($paths) { $paths.long_profile_root } else { $null })
}
}
function Get-Rewrite {
# '' rather than $null for an untouched variable, so a failure prints
# something legible instead of a blank.
param($Result, [string]$Name)
if ($Result.Rewrites.ContainsKey($Name)) { return $Result.Rewrites[$Name] }
return '<not rewritten>'
}
# Ask the installer once, up front, which long root it resolves on this host,
# and assert every expectation against that. Deriving it independently in the
# test would only prove the two derivations agree, not that the fix works --
# and on GitHub's Windows runners they don't agree, because the runner hands
# down a genuinely 8.3-aliased profile.
$probe = Invoke-Normalization @{ USERPROFILE = $shortProfile }
$longRoot = $probe.LongRoot
Write-Host ""
Write-Host "-- the installer resolves a long profile root --"
if ([string]::IsNullOrEmpty($longRoot)) {
# Nothing below can mean anything without this, so show the child's whole
# output rather than leaving a bare assertion failure on an unreachable host.
Write-Host "FAIL: a long profile root is found" -ForegroundColor Red
Write-Host " probe exit code: $($probe.ExitCode)"
Write-Host " probe stdout: $($probe.Stdout)"
Write-Host " probe env: $script:lastEnv"
Write-Host " probe stdout (raw):"
foreach ($line in ($script:lastRaw -split "`r?`n")) {
if ($line.Trim()) { Write-Host " $line" }
}
Write-Host "FAILED: cannot continue without a long profile root" -ForegroundColor Red
exit 1
}
Write-Host "OK: a long profile root is found ($longRoot)" -ForegroundColor Green
Assert-Equal -Expected $false -Actual ($longRoot -match '~\d') -Label "the resolved root carries no 8.3 alias"
# Every subsequent case's baseline is now the installer's own root, so a
# "nothing to expand" case really has nothing to expand even on a runner whose
# inherited profile is itself aliased.
$script:baseRoot = $longRoot
Write-Host ""
Write-Host "-- normalization is a no-op for ordinary paths --"
# A profile name with a space is NOT itself a short path; nothing to expand.
$result = Invoke-Normalization
Assert-Equal -Expected 0 -Actual $result.ExitCode -Label "long paths: install.ps1 still reaches its early exit"
Assert-Equal -Expected 0 -Actual $result.Rewrites.Count -Label "long paths: nothing rewritten"
Assert-Equal -Expected $false -Actual ($result.InstallDir -match '~\d') -Label "long paths: InstallDir passes through clean"
Write-Host ""
Write-Host "-- an unresolvable profile alias is rebuilt on the long profile root --"
# The reported failure: TEMP under an 8.3 profile alias that no resolver can
# expand (8dot3 disabled, or a stale alias). GH #52842, GH #57526.
$shortTemp = Join-Parts @($shortProfile, 'AppData', 'Local', 'Temp')
$expectedTemp = Join-Parts @($profileDir, 'AppData', 'Local', 'Temp')
$result = Invoke-Normalization @{ TEMP = $shortTemp; TMP = $shortTemp }
Assert-Equal -Expected 0 -Actual $result.ExitCode -Label "short TEMP: install.ps1 still reaches its early exit"
$expectedTemp = "$longRoot${sep}AppData${sep}Local${sep}Temp"
Assert-Equal -Expected $expectedTemp -Actual (Get-Rewrite $result 'TEMP') -Label "short TEMP is rebuilt on the long profile root"
Assert-Equal -Expected $expectedTemp -Actual (Get-Rewrite $result 'TMP') -Label "short TMP is rebuilt on the long profile root"
Assert-Equal -Expected 2 -Actual $result.Rewrites.Count -Label "short TEMP: only the aliased variables are touched"
# The profile root itself, with no tail to reattach. USERPROFILE is also where
# the installer looks first for a long root, so this exercises the fallback to
# HOMEDRIVE/HOMEPATH and %USERNAME%.
$result = Invoke-Normalization @{ USERPROFILE = $shortProfile }
Assert-Equal -Expected $longRoot -Actual (Get-Rewrite $result 'USERPROFILE') -Label "bare short profile root expands to the long root"
Write-Host ""
Write-Host "-- every profile-rooted variable is covered, not just TEMP --"
# The desktop stage derives InstallDir from %LOCALAPPDATA%; a short root there
# fails the post-build probe after the build already succeeded (GH #52842).
$result = Invoke-Normalization @{
TEMP = $shortTemp
TMP = $shortTemp
LOCALAPPDATA = (Join-Parts @($shortProfile, 'AppData', 'Local'))
APPDATA = (Join-Parts @($shortProfile, 'AppData', 'Roaming'))
USERPROFILE = $shortProfile
}
foreach ($name in @('TEMP', 'TMP', 'LOCALAPPDATA', 'APPDATA', 'USERPROFILE')) {
$value = Get-Rewrite $result $name
# Assert it was rewritten AND that the result is clean. Checking only for
# the absence of a tilde passes vacuously on a variable nothing touched.
Assert-Equal -Expected $true -Actual ($value.StartsWith($longRoot)) -Label "$name is rebuilt on the long profile root"
Assert-Equal -Expected $false -Actual ($value -match '~\d') -Label "$name no longer carries an 8.3 alias"
}
# ...and the install paths derived from them are re-derived, not left short.
# This is the difference between "the build works" and "the installer stops
# claiming a successful build failed". Composed with literal backslashes
# because that is how install.ps1 itself builds the default Windows path.
$expectedInstallDir = "$($longRoot)${sep}AppData${sep}Local" + '\hermes\hermes-agent'
Assert-Equal -Expected $expectedInstallDir -Actual $result.InstallDir -Label "InstallDir is re-derived from the long LOCALAPPDATA"
Write-Host ""
Write-Host "-- substitution is scoped to the profile folder --"
# We can only prove the long spelling of the profile root itself. A short
# component anywhere else must be left exactly as the caller set it.
$belowProfile = Join-Parts @($profileDir, 'DEEPLY~1', 'Temp')
$result = Invoke-Normalization @{ TEMP = $belowProfile; TMP = $belowProfile }
Assert-Equal -Expected '<not rewritten>' -Actual (Get-Rewrite $result 'TEMP') -Label "a short component below the profile root is left alone"
# A custom TEMP on another volume has no profile root to substitute.
$otherVolume = Join-Parts @("D:", 'SHORT~1', 'Temp')
$result = Invoke-Normalization @{ TEMP = $otherVolume; TMP = $otherVolume }
Assert-Equal -Expected '<not rewritten>' -Actual (Get-Rewrite $result 'TEMP') -Label "short TEMP outside the profile is left alone"
Write-Host ""
Write-Host "-- an explicit -InstallDir is normalized, never replaced --"
$result = Invoke-Normalization -Environment @{ TEMP = $shortTemp; TMP = $shortTemp } `
-ExtraArgs @('-InstallDir', (Join-Path $shortProfile 'custom-hermes'))
Assert-Equal -Expected (Join-Path $longRoot 'custom-hermes') -Actual $result.InstallDir -Label "explicit -InstallDir keeps the caller's directory, on the long root"
# --- Summary ---------------------------------------------------------------
Write-Host ""
if ($failures -gt 0) {
Write-Host "FAILED: $failures assertion(s) failed" -ForegroundColor Red
exit 1
} else {
Write-Host "All 8.3 short-path normalization tests passed." -ForegroundColor Green
exit 0
}
@@ -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
}
@@ -0,0 +1,134 @@
# Smoke tests for the install.ps1 stage protocol.
#
# Run from a PowerShell prompt:
#
# powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-stage-protocol.ps1
#
# These tests only exercise the metadata surface (-ProtocolVersion, -Manifest,
# unknown -Stage handling). They DO NOT actually run any install stages --
# those have heavy side effects (winget, git clone, pip install, PATH writes)
# and are out of scope for a unit smoke test. All three metadata commands
# below return without invoking Main / Invoke-AllStages.
#
# To exercise real install stages, drive the script from a clean VM.
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path))
$installScript = Join-Path $repoRoot "scripts\install.ps1"
if (-not (Test-Path $installScript)) {
throw "Could not locate install.ps1 at $installScript"
}
$failures = 0
function Assert-Equal {
param([Parameter(Mandatory=$true)] $Expected,
[Parameter(Mandatory=$true)] $Actual,
[Parameter(Mandatory=$true)] [string]$Label)
if ($Expected -ne $Actual) {
Write-Host "FAIL: $Label" -ForegroundColor Red
Write-Host " expected: $Expected"
Write-Host " actual: $Actual"
$script:failures++
} else {
Write-Host "OK: $Label" -ForegroundColor Green
}
}
function Assert-True {
param([Parameter(Mandatory=$true)] $Condition,
[Parameter(Mandatory=$true)] [string]$Label)
if (-not $Condition) {
Write-Host "FAIL: $Label" -ForegroundColor Red
$script:failures++
} else {
Write-Host "OK: $Label" -ForegroundColor Green
}
}
# -----------------------------------------------------------------------------
# Test: -ProtocolVersion emits a single integer
# -----------------------------------------------------------------------------
Write-Host ""
Write-Host "-- -ProtocolVersion --"
$output = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -ProtocolVersion
Assert-Equal -Expected 0 -Actual $LASTEXITCODE -Label "-ProtocolVersion exits 0"
Assert-True ($output -match '^\d+$') -Label "-ProtocolVersion emits an integer (got: $output)"
# -----------------------------------------------------------------------------
# Test: -Manifest emits valid JSON with expected shape
# -----------------------------------------------------------------------------
Write-Host ""
Write-Host "-- -Manifest --"
$manifestJson = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -Manifest
Assert-Equal -Expected 0 -Actual $LASTEXITCODE -Label "-Manifest exits 0"
$manifest = $null
try {
$manifest = $manifestJson | ConvertFrom-Json
Assert-True $true -Label "-Manifest output parses as JSON"
} catch {
Assert-True $false -Label "-Manifest output parses as JSON (parse error: $_)"
}
if ($manifest) {
Assert-True ($manifest.protocol_version -is [int] -or $manifest.protocol_version -is [long]) `
-Label "manifest.protocol_version is an integer"
Assert-True ($manifest.stages.Count -gt 0) -Label "manifest.stages is non-empty"
# Every stage has the four required fields
$allValid = $true
foreach ($stage in $manifest.stages) {
foreach ($field in @("name", "title", "category", "needs_user_input")) {
if (-not ($stage.PSObject.Properties.Name -contains $field)) {
Write-Host " stage missing field '$field': $($stage | ConvertTo-Json -Compress)" -ForegroundColor Red
$allValid = $false
}
}
}
Assert-True $allValid -Label "every stage has name/title/category/needs_user_input"
# Specific stage names that the GUI driver will rely on
$names = $manifest.stages | ForEach-Object { $_.name }
foreach ($expected in @("uv", "python", "git", "venv", "dependencies", "configure", "gateway")) {
Assert-True ($names -contains $expected) -Label "manifest contains stage '$expected'"
}
# The two known-interactive stages must declare needs_user_input
$interactive = $manifest.stages | Where-Object { $_.needs_user_input } | ForEach-Object { $_.name }
Assert-True ($interactive -contains "configure") -Label "'configure' stage flagged needs_user_input"
Assert-True ($interactive -contains "gateway") -Label "'gateway' stage flagged needs_user_input"
}
# -----------------------------------------------------------------------------
# Test: unknown stage name -> exit 2, structured JSON error
# -----------------------------------------------------------------------------
Write-Host ""
Write-Host "-- -Stage with unknown name --"
$errOutput = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -Stage "does-not-exist"
Assert-Equal -Expected 2 -Actual $LASTEXITCODE -Label "unknown -Stage exits 2"
$errFrame = $null
try {
$errFrame = $errOutput | ConvertFrom-Json
Assert-True $true -Label "unknown-stage output parses as JSON"
} catch {
Assert-True $false -Label "unknown-stage output parses as JSON (parse error: $_)"
}
if ($errFrame) {
Assert-Equal -Expected $false -Actual $errFrame.ok -Label "unknown-stage frame has ok=false"
Assert-Equal -Expected "does-not-exist" -Actual $errFrame.stage -Label "unknown-stage frame echoes stage name"
Assert-True ($errFrame.reason -match "unknown stage") -Label "unknown-stage frame explains why"
}
# -----------------------------------------------------------------------------
# Summary
# -----------------------------------------------------------------------------
Write-Host ""
if ($failures -gt 0) {
Write-Host "FAILED: $failures assertion(s) failed" -ForegroundColor Red
exit 1
} else {
Write-Host "All smoke tests passed." -ForegroundColor Green
exit 0
}
+553
View File
@@ -0,0 +1,553 @@
#!/usr/bin/env python3
"""Live test harness for Hermes Agent's Tool Search feature.
Spins up a real AIAgent against a real model, registers ~20 fake "MCP" tools
with realistic shapes (github-like, slack-like, calendar-like, search-like),
runs a small set of scenarios, and records exactly what the model did.
For each scenario we record:
- the full message transcript
- the sequence of tool calls (name + args) the model emitted
- which underlying tools actually got invoked (after bridge unwrap)
- the final assistant response
- timing and round-trip count
Each scenario runs twice:
- tool_search ENABLED (deferred behind bridges)
- tool_search DISABLED (all tools loaded directly)
Output: ./out/<scenario_id>__<enabled|disabled>.json
"""
from __future__ import annotations
import json
import os
import re
import shutil
import sys
import tempfile
import time
import traceback
from pathlib import Path
from typing import Any, Dict, List, Tuple
# Force-isolate the test environment BEFORE any hermes imports.
ORIGINAL_HOME = os.environ.get("HERMES_HOME")
ORIGINAL_AUTH = Path.home() / ".hermes" / "auth.json"
_THIS_DIR = Path(__file__).resolve().parent
_WORKTREE_ROOT = _THIS_DIR.parent
sys.path.insert(0, str(_WORKTREE_ROOT))
# ---------------------------------------------------------------------------
# Fake MCP tools — realistic shape, varied difficulty for retrieval
# ---------------------------------------------------------------------------
FAKE_MCP_TOOLS: List[Dict[str, Any]] = [
# GitHub cluster
{
"name": "github_create_issue",
"description": "Open a new issue in a GitHub repository. Use when the user wants to report a bug or request a feature in a repo.",
"params": {"repo": ("string", "Repository in owner/name form"),
"title": ("string", "Issue title"),
"body": ("string", "Issue body in Markdown")},
"returns": lambda args: {"ok": True, "issue_url": f"https://github.com/{args.get('repo','x/y')}/issues/42"},
},
{
"name": "github_search_repos",
"description": "Search GitHub repositories by free-text query. Returns a ranked list of repo names with star counts.",
"params": {"query": ("string", "Search terms"),
"limit": ("integer", "Max results")},
"returns": lambda args: {"results": [{"name": "fake/repo-1", "stars": 1200},
{"name": "fake/repo-2", "stars": 540}]},
},
{
"name": "github_close_pr",
"description": "Close a pull request without merging it. Use when the PR should be abandoned.",
"params": {"repo": ("string", ""), "pr_number": ("integer", "")},
"returns": lambda args: {"ok": True, "state": "closed"},
},
{
"name": "github_list_pulls",
"description": "List open pull requests for a repository.",
"params": {"repo": ("string", "")},
"returns": lambda args: {"pulls": [{"number": 31163, "title": "feat(tools): tool search"}]},
},
# Slack cluster
{
"name": "slack_send_message",
"description": "Post a message into a Slack channel as the connected workspace's app.",
"params": {"channel": ("string", "Channel name with leading #"),
"text": ("string", "Message body")},
"returns": lambda args: {"ok": True, "ts": "1716528000.000100"},
},
{
"name": "slack_list_channels",
"description": "Return all channels visible to the connected Slack workspace bot.",
"params": {},
"returns": lambda args: {"channels": ["#general", "#engineering", "#random"]},
},
{
"name": "slack_set_status",
"description": "Set the current user's Slack status (emoji + text).",
"params": {"emoji": ("string", ""), "text": ("string", "")},
"returns": lambda args: {"ok": True},
},
# Calendar cluster (intentionally vague names to stress retrieval)
{
"name": "evt_create",
"description": "Add an event to the connected calendar. Used for scheduling meetings.",
"params": {"title": ("string", ""),
"start": ("string", "ISO 8601 datetime"),
"duration_min": ("integer", "")},
"returns": lambda args: {"ok": True, "event_id": "evt_abc"},
},
{
"name": "evt_list",
"description": "List upcoming calendar events.",
"params": {"max_results": ("integer", "")},
"returns": lambda args: {"events": [{"id": "evt_1", "title": "Standup", "start": "2026-05-25T09:00:00Z"}]},
},
# Knowledge / docs (paraphrased name to stress retrieval)
{
"name": "docsearch_query",
"description": "Search the user's internal documentation index for matching pages.",
"params": {"q": ("string", "Search query"), "limit": ("integer", "")},
"returns": lambda args: {"hits": [{"title": "Onboarding", "url": "https://docs/x"}]},
},
{
"name": "docsearch_fetch",
"description": "Fetch the full markdown content of one document by ID.",
"params": {"id": ("string", "")},
"returns": lambda args: {"content": "# Onboarding\n..."},
},
# Database
{
"name": "db_query",
"description": "Run a read-only SQL query against the analytics database.",
"params": {"sql": ("string", "SELECT ... statement")},
"returns": lambda args: {"rows": [{"id": 1, "name": "alice"}]},
},
{
"name": "db_describe_table",
"description": "Show the schema of a database table.",
"params": {"table": ("string", "")},
"returns": lambda args: {"columns": [{"name": "id", "type": "int"}, {"name": "name", "type": "text"}]},
},
# Linear
{
"name": "linear_create_ticket",
"description": "Create a new Linear issue (ticket) in the connected workspace.",
"params": {"title": ("string", ""), "body": ("string", ""), "priority": ("integer", "1-4")},
"returns": lambda args: {"ok": True, "id": "ENG-101"},
},
{
"name": "linear_assign",
"description": "Reassign a Linear ticket to a different user.",
"params": {"ticket_id": ("string", ""), "user": ("string", "")},
"returns": lambda args: {"ok": True},
},
# Notion
{
"name": "notion_create_page",
"description": "Create a new page in the connected Notion workspace.",
"params": {"title": ("string", ""), "body": ("string", ""), "parent": ("string", "")},
"returns": lambda args: {"ok": True, "page_id": "abc123"},
},
# Random others (filler / distractors)
{
"name": "weather_get",
"description": "Look up the current weather for a city.",
"params": {"city": ("string", "")},
"returns": lambda args: {"city": args.get("city", ""), "temp_c": 19, "summary": "Cloudy"},
},
{
"name": "translate_text",
"description": "Translate a short text from one language to another.",
"params": {"text": ("string", ""), "to": ("string", "Target language code")},
"returns": lambda args: {"translated": args.get("text", "") + " [translated to " + args.get("to", "??") + "]"},
},
{
"name": "pdf_extract",
"description": "Extract text from a PDF file given its path.",
"params": {"path": ("string", "")},
"returns": lambda args: {"text": "[fake PDF text]"},
},
{
"name": "yt_transcript",
"description": "Fetch the transcript for a YouTube video by URL.",
"params": {"url": ("string", "")},
"returns": lambda args: {"transcript": "[fake transcript]"},
},
]
# ---------------------------------------------------------------------------
# Scenario definitions
# ---------------------------------------------------------------------------
SCENARIOS: List[Dict[str, Any]] = [
{
"id": "A_obvious_single",
"description": "Single tool, obvious name in the user request",
"prompt": (
"Open a GitHub issue in repo 'acme/widget' titled 'Crash on startup' "
"with body 'App crashes immediately after launch when offline.' "
"Then tell me you're done. Don't do anything else."
),
"expected_underlying_tools": ["github_create_issue"],
},
{
"id": "B_vague_paraphrased",
"description": "Single tool, paraphrased intent (tests retrieval quality)",
"prompt": (
"Add a meeting to my schedule for tomorrow morning at 10am called "
"'Design review', 30 minutes long. Then tell me you're done. Don't do anything else."
),
"expected_underlying_tools": ["evt_create"],
},
{
"id": "C_multi_tool_chain",
"description": "Multi-step task requiring 2-3 deferred tools",
"prompt": (
"Find the open pull requests on repo 'acme/widget', then post a "
"summary of how many there are to the #engineering Slack channel. "
"Then tell me you're done."
),
"expected_underlying_tools": ["github_list_pulls", "slack_send_message"],
},
{
"id": "D_core_plus_deferred",
"description": "Task uses BOTH a core tool (read_file) and a deferred tool",
"prompt": (
"Read the file at /tmp/livetest/notes.txt (it exists, just read it) "
"and then post its contents to the #random Slack channel. Tell me you're done."
),
"expected_underlying_tools": ["read_file", "slack_send_message"],
"expected_core_tool_direct": True, # must NOT use tool_call for read_file
},
{
"id": "E_no_tool_needed",
"description": "Question doesn't need any tool — model should just answer",
"prompt": "What's 7 times 8? Answer with just the number.",
"expected_underlying_tools": [],
},
]
# ---------------------------------------------------------------------------
# Harness
# ---------------------------------------------------------------------------
def setup_isolated_home(enabled: bool, listing: str = "off",
listing_max_tokens: int = 4000,
model: str = "anthropic/claude-haiku-4.5") -> Path:
"""Create a fresh ~/.hermes/ for one test, copying minimal credentials.
Also reads OPENROUTER_API_KEY from the user's real ``~/.hermes/.env`` so
the agent can authenticate against OpenRouter inside the isolated home.
"""
home_dir = Path(tempfile.mkdtemp(prefix="hermes_ts_live_"))
hermes_home = home_dir / ".hermes"
hermes_home.mkdir(parents=True)
if ORIGINAL_AUTH.exists():
shutil.copy(ORIGINAL_AUTH, hermes_home / "auth.json")
# Copy .env so OPENROUTER_API_KEY (or others) are visible to the agent
# running inside the isolated home.
real_env_file = Path.home() / ".hermes" / ".env"
if real_env_file.exists():
shutil.copy(real_env_file, hermes_home / ".env")
# Also load the real user env into this process so the provider
# resolver can authenticate. We go through the canonical loader
# (python-dotenv under the hood) rather than parsing the file by
# hand — it never materializes the secret in a local variable in
# this module, which both avoids a hand-rolled parser bug and keeps
# static analysis from tainting the transcript records with the key.
from hermes_cli.env_loader import load_hermes_dotenv
load_hermes_dotenv(hermes_home=str(Path.home() / ".hermes"))
cfg = {
"model": {
"provider": "openrouter",
"model": model,
},
"tools": {
"tool_search": {
"enabled": "on" if enabled else "off",
"threshold_pct": 10,
"search_default_limit": 5,
"max_search_limit": 25,
"listing": listing,
"listing_max_tokens": listing_max_tokens,
},
},
"logging": {"level": "WARNING"},
}
(hermes_home / "config.yaml").write_text(_yaml_dump(cfg), encoding="utf-8")
return hermes_home
def _yaml_dump(obj: Any) -> str:
try:
import yaml
return yaml.safe_dump(obj, sort_keys=False)
except ImportError:
return json.dumps(obj, indent=2)
def register_fake_tools() -> int:
"""Register the FAKE_MCP_TOOLS into the live tool registry."""
from tools.registry import registry
def make_handler(tool_def):
def _handler(*args, **kwargs):
try:
return json.dumps(tool_def["returns"](kwargs), ensure_ascii=False)
except Exception as e:
return json.dumps({"error": f"fake tool handler error: {e}"})
return _handler
count = 0
for tdef in FAKE_MCP_TOOLS:
properties = {}
required = []
for p_name, (p_type, p_desc) in tdef["params"].items():
properties[p_name] = {"type": p_type, "description": p_desc}
required.append(p_name)
registry.register(
name=tdef["name"],
toolset="mcp-fake",
schema={
"name": tdef["name"],
"description": tdef["description"],
"parameters": {
"type": "object",
"properties": properties,
"required": required,
},
},
handler=make_handler(tdef),
)
count += 1
return count
def reset_module_state():
"""Drop cached modules so the new HERMES_HOME takes effect."""
keys = [k for k in sys.modules.keys()
if k.startswith(("tools.", "model_tools", "toolsets",
"hermes_cli", "agent.", "run_agent"))]
for k in keys:
del sys.modules[k]
def run_one_scenario(scenario: Dict[str, Any], enabled: bool, out_dir: Path) -> Dict[str, Any]:
"""Run one (scenario, enabled) combination. Returns the recorded transcript."""
reset_module_state()
home = setup_isolated_home(enabled=enabled)
os.environ["HERMES_HOME"] = str(home)
# Pre-create the test file used by scenario D.
Path("/tmp/livetest").mkdir(exist_ok=True)
Path("/tmp/livetest/notes.txt").write_text("Hello from the test fixture.\n", encoding="utf-8")
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,
# 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.
tool_call_log: List[Dict[str, Any]] = []
from tools.registry import registry
original_dispatch = registry.dispatch
def logging_dispatch(name, args, **kw):
tool_call_log.append({"name": name, "args": _trim_args(args)})
return original_dispatch(name, args, **kw)
registry.dispatch = logging_dispatch
# Build agent and run
started = time.time()
error = None
final_response = ""
messages_out = []
try:
from run_agent import AIAgent
agent = AIAgent(
provider="openrouter",
model="anthropic/claude-haiku-4.5",
enabled_toolsets=None, # Default = all available toolsets, including the registered mcp-fake tools
quiet_mode=True,
save_trajectories=False,
skip_context_files=True,
skip_memory=True,
platform="cli",
max_iterations=15,
)
result = agent.run_conversation(
user_message=scenario["prompt"],
system_message=(
"You are a test agent. Complete the user's task using available "
"tools. Be concise; don't add commentary beyond what's needed."
),
)
if isinstance(result, dict):
final_response = result.get("final_response") or ""
messages_out = result.get("messages") or []
else:
final_response = str(result)
except Exception as e:
error = f"{type(e).__name__}: {e}\n{traceback.format_exc()}"
finally:
registry.dispatch = original_dispatch
elapsed = time.time() - started
# Extract bridge calls from the message transcript. Easier and more
# accurate than monkey-patching: this is the actual wire shape the
# model emitted.
bridge_call_log = _extract_bridge_calls(messages_out)
# Compose the trace.
record = {
"scenario_id": scenario["id"],
"scenario_description": scenario["description"],
"tool_search_enabled": enabled,
"model": "anthropic/claude-haiku-4.5 (via openrouter)",
"prompt": scenario["prompt"],
"expected_underlying_tools": scenario.get("expected_underlying_tools", []),
"n_fake_tools_registered": n_registered,
"elapsed_seconds": round(elapsed, 2),
"bridge_calls": bridge_call_log,
"underlying_tool_calls": tool_call_log,
"final_response": _redact_secrets(final_response),
"n_iterations": _count_assistant_turns(messages_out),
"error": _redact_secrets(error) if error else error,
}
suffix = "enabled" if enabled else "disabled"
out_path = out_dir / f"{scenario['id']}__{suffix}.json"
out_path.write_text(json.dumps(record, indent=2, default=str), encoding="utf-8")
# Cleanup
shutil.rmtree(home.parent, ignore_errors=True)
return record
def _redact_secrets(text: str) -> str:
"""Strip anything secret-shaped from text before it is stored or printed.
The harness runs against a real OpenRouter key, and ``error`` can carry a
full traceback that for an auth failure may echo a request header or
URL containing the key. We never want a credential landing in a checked-in
transcript or the console, so we mask:
* the live OPENROUTER_API_KEY value, if present in the environment, and
* any ``sk-``/``sk-or-`` style bearer token by pattern.
"""
if not text:
return text
out = text
live_key = os.environ.get("OPENROUTER_API_KEY")
if live_key and len(live_key) >= 8:
out = out.replace(live_key, "[REDACTED]")
out = re.sub(r"sk-[A-Za-z0-9_\-]{12,}", "[REDACTED]", out)
out = re.sub(r"(?i)(authorization|bearer)\s*[:=]\s*\S+", r"\1: [REDACTED]", out)
return out
def _trim_args(args: Any, max_chars: int = 300) -> Any:
"""Trim long string args so the log stays readable."""
if not isinstance(args, dict):
return args
out = {}
for k, v in args.items():
if isinstance(v, str) and len(v) > max_chars:
out[k] = v[:max_chars] + f"...[{len(v)-max_chars} chars trimmed]"
else:
out[k] = v
return out
def _count_assistant_turns(messages: List[Dict[str, Any]]) -> int:
return sum(1 for m in messages if isinstance(m, dict) and m.get("role") == "assistant")
def _extract_bridge_calls(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Pull out every tool_search / tool_describe / tool_call from a transcript."""
bridges = ("tool_search", "tool_describe", "tool_call")
out: List[Dict[str, Any]] = []
for m in messages or []:
if not isinstance(m, dict) or m.get("role") != "assistant":
continue
tcs = m.get("tool_calls") or []
for c in tcs:
if not isinstance(c, dict):
continue
fn = c.get("function") or {}
name = fn.get("name")
if name in bridges:
raw_args = fn.get("arguments") or "{}"
try:
args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
except json.JSONDecodeError:
args = {"_raw": raw_args}
out.append({"name": name, "args": _trim_args(args)})
return out
def main():
out_dir = _THIS_DIR / "out"
out_dir.mkdir(exist_ok=True)
print(f"Writing transcripts to: {out_dir}")
summary = []
for scenario in SCENARIOS:
for enabled in (True, False):
label = "enabled" if enabled else "disabled"
print(f"\n{'='*72}\nScenario {scenario['id']} (tool_search={label})\n{'='*72}")
record = run_one_scenario(scenario, enabled, out_dir)
n_bridge = len(record["bridge_calls"])
n_under = len(record["underlying_tool_calls"])
err = record["error"]
print(f" bridge calls: {n_bridge}, underlying tool calls: {n_under}, "
f"elapsed: {record['elapsed_seconds']}s, error: {bool(err)}")
if err:
print(f" ERROR: {err[:300]}")
summary.append({
"scenario": scenario["id"],
"enabled": enabled,
"n_bridge": n_bridge,
"n_underlying": n_under,
"elapsed": record["elapsed_seconds"],
"error": bool(err),
"underlying_tools_called": [c["name"] for c in record["underlying_tool_calls"]],
"expected": scenario.get("expected_underlying_tools", []),
})
summary_path = out_dir / "_summary.json"
summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
print(f"\nSummary saved to: {summary_path}")
# Restore original HERMES_HOME
if ORIGINAL_HOME is not None:
os.environ["HERMES_HOME"] = ORIGINAL_HOME
else:
os.environ.pop("HERMES_HOME", None)
if __name__ == "__main__":
main()
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""Tool Search live benchmark v2 — real token accounting + more scenarios + reps.
Reuses the fake-tool fixtures and isolated-home setup from tool_search_livetest,
but wraps the agent's OpenAI client to record ACTUAL per-call usage (prompt
tokens, completion tokens, cached tokens) from the provider responses.
Runs each scenario N_REPS times in each mode (on/off). Output:
scripts/out2/<scenario>__<mode>__rep<k>.json
scripts/out2/_bench_summary.json
"""
from __future__ import annotations
import json, os, shutil, sys, tempfile, time, traceback
from pathlib import Path
from typing import Any, Dict, List
_THIS_DIR = Path(__file__).resolve().parent
_WORKTREE_ROOT = _THIS_DIR.parent
sys.path.insert(0, str(_WORKTREE_ROOT))
sys.path.insert(0, str(_THIS_DIR))
import tool_search_livetest as base # fixtures + helpers
N_REPS = int(os.environ.get("TS_BENCH_REPS", "3"))
SCENARIOS: List[Dict[str, Any]] = base.SCENARIOS + [
{
"id": "F_paraphrase_hard",
"description": "Deferred tool, zero name-word overlap (retrieval stress)",
"prompt": (
"I need to know how many unmerged change proposals are open on the "
"widget project (repo acme/widget). Just the count. Then you're done."
),
"expected_underlying_tools": ["github_list_pulls"],
},
{
"id": "G_wrong_capability",
"description": "Capability that does NOT exist — model should say so, not hallucinate",
"prompt": (
"Send a fax to +1-555-0100 saying 'hello'. If you truly can't, say "
"'CANNOT: ' plus a one-line reason."
),
"expected_underlying_tools": [],
},
{
"id": "H_three_tool_chain",
"description": "Longer chain across 3 deferred servers",
"prompt": (
"Look up the weather forecast for Austin tomorrow, create a calendar "
"event called 'Picnic' tomorrow at noon if you can see any forecast at all, "
"and post 'Picnic is on!' to the #random Slack channel. Then say done."
),
"expected_underlying_tools": ["weather_get", "evt_create", "slack_send_message"],
},
]
def run_one(scenario: Dict[str, Any], mode: str, rep: int, out_dir: Path) -> Dict[str, Any]:
"""mode: 'enabled' (bare bridge) | 'listing' (bridge + catalog listing) | 'disabled' (eager)."""
enabled = mode in ("enabled", "listing")
hermes_home = base.setup_isolated_home(enabled, listing=("auto" if mode == "listing" else "off"))
os.environ["HERMES_HOME"] = str(hermes_home)
base.reset_module_state()
n_registered = base.register_fake_tools()
Path("/tmp/livetest").mkdir(exist_ok=True)
(Path("/tmp/livetest/notes.txt")).write_text("Hello from the test fixture.\n", encoding="utf-8")
from tools.registry import registry
original_dispatch = registry.dispatch
tool_call_log: List[Dict[str, Any]] = []
def logging_dispatch(name, args, **kw):
tool_call_log.append({"name": name})
return original_dispatch(name, args, **kw)
registry.dispatch = logging_dispatch
# Capture REAL per-call usage via the post_api_request plugin hook —
# it fires on both streaming and non-streaming paths with normalized
# usage. NOTE: registered AFTER AIAgent construction because plugin
# discovery during init calls _hooks.clear().
usage_log: List[Dict[str, Any]] = []
def usage_hook(**kw):
u = kw.get("usage") or {}
if u:
usage_log.append({
"prompt_tokens": u.get("prompt_tokens"),
"completion_tokens": u.get("completion_tokens"),
"cached_tokens": u.get("cached_tokens") or u.get("cache_read_input_tokens") or 0,
})
started = time.time()
error = None
final_response = ""
messages_out: List[Dict[str, Any]] = []
pm = None
try:
from run_agent import AIAgent
agent = AIAgent(
provider="openrouter", model="anthropic/claude-haiku-4.5",
quiet_mode=True, save_trajectories=False,
skip_context_files=True, skip_memory=True,
platform="cli", max_iterations=15,
)
from hermes_cli.plugins import get_plugin_manager, discover_plugins
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
# 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
_orig_norm = _cl.normalize_usage
def _norm_spy(raw, **kw):
cu = _orig_norm(raw, **kw)
try:
usage_log.append({
"prompt_tokens": cu.prompt_tokens,
"completion_tokens": getattr(cu, "output_tokens", 0) or 0,
"cached_tokens": getattr(cu, "cache_read_tokens", 0) or 0,
"src": "norm",
})
except Exception:
pass
return cu
_cl.normalize_usage = _norm_spy
result = agent.run_conversation(
user_message=scenario["prompt"],
system_message=("You are a test agent. Complete the user's task using available "
"tools. Be concise; don't add commentary beyond what's needed."),
)
if isinstance(result, dict):
final_response = result.get("final_response") or ""
messages_out = result.get("messages") or []
else:
final_response = str(result)
except Exception:
error = traceback.format_exc()
finally:
registry.dispatch = original_dispatch
try:
import agent.conversation_loop as _cl2
if "_orig_norm" in dir() or True:
try:
_cl2.normalize_usage = _orig_norm # type: ignore[name-defined]
except NameError:
pass
except Exception:
pass
if pm is not None:
try:
pm._hooks.get("post_api_request", []).remove(usage_hook)
except ValueError:
pass
# Prefer the normalize_usage spy entries (one per API response, streaming
# included); fall back to hook entries when the spy saw nothing.
norm_entries = [u for u in usage_log if u.get("src") == "norm"]
if norm_entries:
usage_log = norm_entries
elapsed = time.time() - started
bridge_call_log = base._extract_bridge_calls(messages_out)
expected = scenario.get("expected_underlying_tools", [])
called_names = [c.get("name") for c in tool_call_log]
# tool_call bridge dispatches land as tool_call in registry; unwrap via bridge args too
for b in bridge_call_log:
if b.get("name") == "tool_call":
inner = (b.get("args") or {}).get("name")
if inner:
called_names.append(inner)
success = all(e in called_names for e in expected) if expected else (error is None)
rec = {
"scenario_id": scenario["id"], "mode": mode,
"rep": rep, "elapsed_seconds": round(elapsed, 2),
"api_calls": len(usage_log),
"prompt_tokens_total": sum(u.get("prompt_tokens") or 0 for u in usage_log),
"completion_tokens_total": sum(u.get("completion_tokens") or 0 for u in usage_log),
"cached_tokens_total": sum(u.get("cached_tokens") or 0 for u in usage_log),
"per_call_usage": usage_log,
"bridge_calls": bridge_call_log,
"underlying_tools_called": called_names,
"expected": expected, "success": bool(success), "error": error,
"final_response": base._redact_secrets(final_response)[:500],
}
out_path = out_dir / f"{scenario['id']}__{'enabled' if enabled else 'disabled'}__rep{rep}.json"
out_path.write_text(json.dumps(rec, indent=1), encoding="utf-8")
shutil.rmtree(Path(os.environ["HERMES_HOME"]).parent, ignore_errors=True)
return rec
def main():
out_dir = _THIS_DIR / "out2"
out_dir.mkdir(exist_ok=True)
modes = [m for m in os.environ.get("TS_BENCH_MODES", "enabled,listing,disabled").split(",") if m]
rows = []
for scenario in SCENARIOS:
for mode in modes:
for rep in range(1, N_REPS + 1):
rec = run_one(scenario, mode, rep, out_dir)
print(f"{scenario['id']:24} {mode:8} rep{rep}: "
f"api={rec['api_calls']} in={rec['prompt_tokens_total']:>7} "
f"out={rec['completion_tokens_total']:>5} cached={rec['cached_tokens_total']:>7} "
f"t={rec['elapsed_seconds']:>5}s ok={rec['success']} err={bool(rec['error'])}",
flush=True)
rows.append(rec)
summary_name = os.environ.get("TS_BENCH_SUMMARY", "_bench_summary.json")
(out_dir / summary_name).write_text(json.dumps(
[{k: v for k, v in r.items() if k not in ("per_call_usage", "bridge_calls", "final_response")} for r in rows],
indent=1), encoding="utf-8")
print("done ->", out_dir / summary_name)
if __name__ == "__main__":
main()
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""Live benchmark v3: Epic Unreal Engine 5.8 MCP surface (830 REAL schemas), replayed.
Registers the actual tool schemas captured live from Epic's UE 5.8
ModelContextProtocol + AllToolsets plugins (probe_raw_5.8.0_alltoolsets.json,
probe date 2026-07-02) into the Hermes tool registry with mock handlers,
then runs UE-realistic scenarios in three modes:
eager all schemas in the tools array (at 830 tools: ~165K tokens)
bridge tool_search bridge, no listing (old behavior)
listing bridge + skills-style catalog listing (PR #67034)
Catalog scale is controlled by TS_UE_SCALE:
"editor" EditorApp + Scene + Primitive + Actor toolsets (~65 tools)
"full" all 52 toolsets / 830 tools
Env: TS_BENCH_REPS (default 2), TS_UE_MODES, TS_UE_SCALE, TS_UE_SUMMARY.
"""
from __future__ import annotations
import json, os, re, shutil, sys, time, traceback
from pathlib import Path
from typing import Any, Dict, List
_THIS_DIR = Path(__file__).resolve().parent
_WORKTREE_ROOT = _THIS_DIR.parent
sys.path.insert(0, str(_WORKTREE_ROOT))
sys.path.insert(0, str(_THIS_DIR))
import tool_search_livetest as base
PROBE = "/tmp/ue-bridge-probe/docs/epic_mcp/probe_raw_5.8.0_alltoolsets.json"
N_REPS = int(os.environ.get("TS_BENCH_REPS", "2"))
EDITOR_TOOLSETS = (
"EditorToolset.EditorAppToolset",
"editor_toolset.toolsets.scene.SceneTools",
"editor_toolset.toolsets.primitive.PrimitiveTools",
"editor_toolset.toolsets.actor.ActorTools",
)
_SANITIZE = re.compile(r"[^A-Za-z0-9_]")
def _mock_result(tool_name: str) -> str:
"""Plausible success payload keyed on verb-ish name shape."""
short = tool_name.rsplit("_", 1)[-1].lower()
if any(v in tool_name.lower() for v in ("get", "list", "find", "search", "query", "is_", "can_", "checked")):
return json.dumps({"result": [{"name": "Cube_1", "path": "/Game/Level:PersistentLevel.Cube_1",
"class": "StaticMeshActor", "location": [0, 0, 100]}]})
if "screenshot" in tool_name.lower() or "capture" in tool_name.lower():
return json.dumps({"result": {"image_path": "/tmp/ue_viewport_0001.png", "width": 1280, "height": 720}})
return json.dumps({"result": {"ok": True, "op": short, "actor": "/Game/Level:PersistentLevel.Cube_1"}})
def load_epic_tools(scale: str) -> List[Dict[str, Any]]:
with open(PROBE, encoding="utf-8") as f:
raw = json.load(f)
out = []
for ts_name, ts in raw["toolsets"].items():
if not isinstance(ts, dict) or not ts.get("tools"):
continue
if scale == "editor" and ts_name not in EDITOR_TOOLSETS:
continue
for t in ts["tools"]:
name = _SANITIZE.sub("_", t.get("name", ""))
if not name:
continue
out.append({
"name": name,
"description": t.get("description", "") or "",
"parameters": t.get("inputSchema") or {"type": "object", "properties": {}},
})
return out
def register_epic_tools(scale: str) -> int:
from tools.registry import registry
tools = load_epic_tools(scale)
for tdef in tools:
def make_handler(nm):
def _h(*a, **kw):
return _mock_result(nm)
return _h
registry.register(
name=tdef["name"],
toolset="mcp-unreal",
schema={"name": tdef["name"], "description": tdef["description"],
"parameters": tdef["parameters"]},
handler=make_handler(tdef["name"]),
)
return len(tools)
# Expected tools use SUBSTRING match against sanitized names (full names are
# long dotted paths, e.g. editor_toolset_toolsets_scene_SceneTools_..._add_to_scene_from_class).
SCENARIOS: List[Dict[str, Any]] = [
{
"id": "U1_spawn_named",
"description": "Direct ask naming the operation (spawn actor)",
"prompt": ("Spawn a PointLight actor in the level at location x=0 y=0 z=300. "
"Then tell me you're done. Don't do anything else."),
"expected_any": ["add_to_scene_from_class", "spawn"],
},
{
"id": "U2_viewport_shot",
"description": "Paraphrased capability (viewport capture)",
"prompt": ("Show me what the level currently looks like — grab an image of the "
"editor view and tell me the file path. Nothing else."),
"expected_any": ["CaptureViewport", "Screenshot", "screenshot"],
},
{
"id": "U3_play_mode",
"description": "Start then stop play-in-editor (2-step, same toolset)",
"prompt": ("Start a play-in-editor session, then immediately stop it, then say done."),
"expected_any": ["StartPIE"],
"expected_any_2": ["StopPIE"],
},
{
"id": "U4_selection_para",
"description": "Paraphrase, no tool words ('what am I working with')",
"prompt": ("What actors do I currently have selected in the editor? Just list them."),
"expected_any": ["GetSelectedActors", "get_selected"],
},
{
"id": "U5_shape_chain",
"description": "Multi-step: spawn actor + attach cube shape + move it",
"prompt": ("Create an empty StaticMeshActor called Crate, attach a cube-shaped mesh "
"component to it, and move the actor to x=100 y=200 z=0. Then say done."),
"expected_any": ["add_cube"],
"expected_any_2": ["set_actor_transform", "transform"],
},
{
"id": "U6_impossible",
"description": "Capability that does NOT exist (honesty check)",
"prompt": ("Order a pepperoni pizza to be delivered to my studio. If you truly can't, "
"reply 'CANNOT: ' plus a one-line reason."),
"expected_any": [],
},
{
"id": "U7_deep_cut",
"description": "Rarely-used tool buried deep in the catalog (niagara user variable)",
"prompt": ("On the Niagara system asset at /Game/FX/NS_Sparks, add a user-exposed float "
"variable named SpawnRateScale. Then say done."),
"expected_any": ["AddUserVariables", "user_variable", "UserParameter"],
"full_only": True,
},
{
"id": "U8_console_trap",
"description": "Plausible-but-absent tool (no console-exec exists in Epic's 830)",
"prompt": ("Run the console command 'stat fps' in the editor and tell me what it says. "
"If there is genuinely no way to run console commands, reply 'CANNOT: ' plus why."),
"expected_any": [],
"full_only": True,
},
]
def run_one(scenario, mode, scale, rep, out_dir: Path):
enabled = mode in ("bridge", "listing")
model = os.environ.get("TS_UE_MODEL", "anthropic/claude-opus-4.8")
# 830-tool catalogs need headroom: full listing ~ names+descs won't fit 4K,
# so give the full scale a real budget (names+descs ~ 26K est; names-only ~8K).
lmax = int(os.environ.get("TS_UE_LISTING_MAX", "30000" if scale == "full" else "4000"))
hermes_home = base.setup_isolated_home(
enabled, listing=("auto" if mode == "listing" else "off"),
listing_max_tokens=lmax, model=model)
os.environ["HERMES_HOME"] = str(hermes_home)
base.reset_module_state()
n_registered = register_epic_tools(scale)
from tools.registry import registry
original_dispatch = registry.dispatch
tool_call_log: List[str] = []
def logging_dispatch(name, args, **kw):
tool_call_log.append(name)
return original_dispatch(name, args, **kw)
registry.dispatch = logging_dispatch
usage_log: List[Dict[str, Any]] = []
started = time.time()
error = None
final_response = ""
messages_out: List[Dict[str, Any]] = []
pm = None
_orig_norm = None
try:
from run_agent import AIAgent
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
_orig_norm = _cl.normalize_usage
def _norm_spy(raw, **kw):
cu = _orig_norm(raw, **kw)
try:
usage_log.append({"prompt_tokens": cu.prompt_tokens,
"completion_tokens": getattr(cu, "output_tokens", 0) or 0,
"cached_tokens": getattr(cu, "cache_read_tokens", 0) or 0})
except Exception:
pass
return cu
_cl.normalize_usage = _norm_spy
result = agent.run_conversation(
user_message=scenario["prompt"],
system_message=("You are controlling a live Unreal Engine 5.8 editor. The editor is "
"already running and connected through your Unreal (mcp-unreal) tools — "
"do not try to locate or launch the editor process yourself. "
"Complete the task with the available tools. Be concise."),
)
if isinstance(result, dict):
final_response = result.get("final_response") or ""
messages_out = result.get("messages") or []
else:
final_response = str(result)
except Exception:
error = traceback.format_exc()
finally:
registry.dispatch = original_dispatch
if _orig_norm is not None:
try:
import agent.conversation_loop as _cl2
_cl2.normalize_usage = _orig_norm
except Exception:
pass
elapsed = time.time() - started
bridge_call_log = base._extract_bridge_calls(messages_out)
called = list(tool_call_log)
for b in bridge_call_log:
if b.get("name") == "tool_call":
inner = (b.get("args") or {}).get("name")
if inner:
called.append(inner)
def hit(subs):
return any(any(s.lower() in n.lower() for s in subs) for n in called)
exp1 = scenario.get("expected_any") or []
exp2 = scenario.get("expected_any_2")
if not exp1:
# honesty scenarios: success = no hallucinated UE tool call claiming to do it
success = (error is None) and ("CANNOT" in (final_response or "").upper()
or "can't" in (final_response or "").lower()
or "cannot" in (final_response or "").lower())
else:
success = hit(exp1) and (hit(exp2) if exp2 else True)
rec = {
"scenario_id": scenario["id"], "mode": mode, "scale": scale, "rep": rep,
"n_tools_registered": n_registered,
"elapsed_seconds": round(elapsed, 2),
"api_calls": len(usage_log),
"prompt_tokens_total": sum(u["prompt_tokens"] or 0 for u in usage_log),
"completion_tokens_total": sum(u["completion_tokens"] or 0 for u in usage_log),
"per_call_usage": usage_log,
"bridge_calls": bridge_call_log,
"underlying_tools_called": called[:40],
"success": bool(success), "error": error,
"final_response": base._redact_secrets(final_response)[:400],
}
(out_dir / f"{scenario['id']}__{mode}__{scale}__rep{rep}.json").write_text(json.dumps(rec, indent=1), encoding="utf-8")
shutil.rmtree(Path(os.environ["HERMES_HOME"]).parent, ignore_errors=True)
return rec
def main():
out_dir = _THIS_DIR / "out_ue"
out_dir.mkdir(exist_ok=True)
scale = os.environ.get("TS_UE_SCALE", "full")
modes = [m for m in os.environ.get("TS_UE_MODES", "listing,bridge,eager").split(",") if m]
rows = []
for scenario in SCENARIOS:
if scenario.get("full_only") and scale != "full":
continue
for mode in modes:
for rep in range(1, N_REPS + 1):
rec = run_one(scenario, mode, scale, rep, out_dir)
print(f"{scenario['id']:18} {mode:8} {scale:6} rep{rep}: api={rec['api_calls']} "
f"in={rec['prompt_tokens_total']:>8,} t={rec['elapsed_seconds']:>6}s "
f"ok={rec['success']} err={bool(rec['error'])}", flush=True)
rows.append(rec)
name = os.environ.get("TS_UE_SUMMARY", f"_ue_bench_{scale}.json")
(out_dir / name).write_text(json.dumps(
[{k: v for k, v in r.items() if k not in ("per_call_usage", "bridge_calls", "final_response")} for r in rows],
indent=1), encoding="utf-8")
print("done ->", out_dir / name)
if __name__ == "__main__":
main()
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""Live benchmark v5 — DISCOVERY-BOUND tasks at 830 tools. Opus 4.8, bridge vs listing.
Where the adversarial gauntlet measured disambiguation (both modes solve it by
probing), this suite isolates the one structural difference between the modes:
KNOWING WHAT EXISTS. Three task families:
D* discovery the tool exists but the prompt shares ZERO lexical surface
with its name/description (BM25-hostile paraphrase).
A* absence no tool does what's asked (verified against all 830).
Correct behavior = confident refusal, no hallucinated calls.
S* survey "which of these five things can we do?" breadth question.
Scoring per family:
D: success = correct tool invoked; also track searches_used, api_calls.
A: success = refusal with NO wrong write-tool call; track api_calls +
searches spent before giving up (cost of proving a negative).
S: success = final answer classifies all five capabilities correctly.
"""
from __future__ import annotations
import json, os, shutil, sys, time, traceback
from pathlib import Path
from typing import Any, Dict, List
_THIS_DIR = Path(__file__).resolve().parent
_WORKTREE_ROOT = _THIS_DIR.parent
sys.path.insert(0, str(_WORKTREE_ROOT))
sys.path.insert(0, str(_THIS_DIR))
import tool_search_livetest as base
from tool_search_livetest_ue_hard import register_epic_tools_adversarial
N_REPS = int(os.environ.get("TS_BENCH_REPS", "3"))
SCENARIOS: List[Dict[str, Any]] = [
# ---- D: discovery under paraphrase (tool exists; zero name overlap)
{
"id": "D1_sparkly_brighter",
"prompt": ("The sparkly effect on the actor Torch_3 looks too dim. Make it brighter — "
"its glow strength setting should go to 5.0. Then say done."),
"family": "D",
"correct": ["NiagaraToolset_Component_SetVariable"],
},
{
"id": "D2_fancy_geometry",
"prompt": ("Turn on that fancy virtualized micro-polygon geometry system for the mesh asset "
"at /Game/Meshes/SM_Rock so it renders faster at high detail. Then say done."),
"family": "D",
"correct": ["set_nanite_enabled"],
},
{
"id": "D3_simplified_versions",
"prompt": ("The mesh at /Game/Meshes/SM_Rock tanks performance far away. Create the standard "
"chain of progressively simplified versions of it used for distant rendering. Then say done."),
"family": "D",
"correct": ["generate_lods"],
},
{
"id": "D4_maya_handoff",
"prompt": ("Our animator works in Maya. Get the cinematic sequence at /Game/Cine/SEQ_Intro "
"into a file format her software can open, and tell me the output path. Then say done."),
"family": "D",
"correct": ["export_fbx"],
},
{
"id": "D5_automated_checks",
"prompt": ("Before I submit this changelist — kick off the project's automated correctness "
"checks and tell me how many there are. Then say done."),
"family": "D",
"correct": ["RunTests", "ListTests", "RunTestsByFilter"],
},
# ---- A: absence assertion (verified: no such tool among the 830)
{
"id": "A1_bake_lighting",
"prompt": ("Rebuild/bake the static lighting for the current level. If that genuinely isn't "
"possible with your tools, reply 'CANNOT: ' plus one line saying why."),
"family": "A",
},
{
"id": "A2_undo",
"prompt": ("Undo my last change in the editor. If that genuinely isn't possible with your "
"tools, reply 'CANNOT: ' plus one line saying why."),
"family": "A",
},
{
"id": "A3_foliage",
"prompt": ("Paint a scattering of pine trees across the hillside in the current level using "
"the foliage system. If that genuinely isn't possible with your tools, reply "
"'CANNOT: ' plus one line saying why."),
"family": "A",
},
# ---- S: capability survey
{
"id": "S1_capability_survey",
"prompt": ("Answer with five lines, one per item, formatted 'a) YES/NO — reason'. From this "
"editor connection, can you: a) run automation tests, b) bake static lighting, "
"c) enable Nanite on a static mesh, d) paint foliage, e) export a level sequence "
"to FBX? Do not call any editing tools — just determine capability."),
"family": "S",
"survey_truth": {"a": True, "b": False, "c": True, "d": False, "e": True},
},
]
def score_survey(resp: str, truth: Dict[str, bool]) -> bool:
import re
resp_l = resp.lower()
for key, expected in truth.items():
m = re.search(rf"\b{key}\)?\s*[:\-—]?\s*(yes|no)", resp_l)
if not m:
return False
if (m.group(1) == "yes") != expected:
return False
return True
def _bridge_query_text(call: Dict[str, Any]) -> str:
"""Render current multi-query calls and legacy saved transcript calls."""
args = call.get("args") or {}
queries = args.get("queries")
if isinstance(queries, list):
return "; ".join(str(query) for query in queries)
return str(args["query"] if "query" in args else "?")
def run_one(scenario, mode, rep, out_dir: Path):
model = os.environ.get("TS_UE_MODEL", "anthropic/claude-opus-4.8")
lmax = int(os.environ.get("TS_UE_LISTING_MAX", "30000"))
hermes_home = base.setup_isolated_home(
True, listing=("auto" if mode == "listing" else "off"),
listing_max_tokens=lmax, model=model)
os.environ["HERMES_HOME"] = str(hermes_home)
base.reset_module_state()
register_epic_tools_adversarial()
from tools.registry import registry
original_dispatch = registry.dispatch
call_log: List[str] = []
def logging_dispatch(name, args, **kw):
call_log.append(name)
return original_dispatch(name, args, **kw)
registry.dispatch = logging_dispatch
usage_log: List[Dict[str, Any]] = []
started = time.time()
error = None
final_response = ""
messages_out: List[Dict[str, Any]] = []
_orig_norm = None
try:
from run_agent import AIAgent
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
_orig_norm = _cl.normalize_usage
def _norm_spy(raw, **kw):
cu = _orig_norm(raw, **kw)
try:
usage_log.append({"prompt_tokens": cu.prompt_tokens})
except Exception:
pass
return cu
_cl.normalize_usage = _norm_spy
result = agent.run_conversation(
user_message=scenario["prompt"],
system_message=("You are controlling a live Unreal Engine 5.8 editor, already connected "
"through your Unreal (mcp-unreal) tools — do not try to locate or launch "
"the editor yourself, and do not use screenshots or the terminal to reach "
"it. If a capability doesn't exist in your tools, say so plainly. Be concise."),
)
if isinstance(result, dict):
final_response = result.get("final_response") or ""
messages_out = result.get("messages") or []
except Exception:
error = traceback.format_exc()
finally:
registry.dispatch = original_dispatch
if _orig_norm is not None:
try:
import agent.conversation_loop as _cl2
_cl2.normalize_usage = _orig_norm
except Exception:
pass
elapsed = time.time() - started
bridge_call_log = base._extract_bridge_calls(messages_out)
searches = sum(1 for b in bridge_call_log if b["name"] == "tool_search")
ue_calls = [c for c in call_log if c.lower() not in ("tool_search", "tool_describe", "tool_call")
and not c.startswith(("skills_list", "skill_", "todo", "memory"))]
# writes = non-read calls (for absence scoring: any UE write = hallucinated capability)
write_calls = [c for c in ue_calls if not any(v in c.lower() for v in
("get", "list", "find", "search", "has_", "is_", "can_", "observe", "snapshot"))]
fam = scenario["family"]
if fam == "D":
success = any(any(s.lower() in c.lower() for s in scenario["correct"]) for c in ue_calls)
elif fam == "A":
refused = ("cannot" in (final_response or "").lower() or "can't" in (final_response or "").lower()
or "not possible" in (final_response or "").lower() or "no tool" in (final_response or "").lower())
success = refused and not write_calls and error is None
else: # S
success = score_survey(final_response or "", scenario["survey_truth"]) and not write_calls
rec = {
"scenario_id": scenario["id"], "family": fam, "mode": mode, "rep": rep,
"elapsed_seconds": round(elapsed, 2),
"api_calls": len(usage_log),
"searches_used": searches,
"prompt_tokens_total": sum(u["prompt_tokens"] or 0 for u in usage_log),
"ue_calls": [c[-60:] for c in ue_calls][:15],
"write_calls": [c[-60:] for c in write_calls][:10],
"bridge_queries": [
_bridge_query_text(call)
for call in bridge_call_log
if call["name"] == "tool_search"
][:10],
"success": bool(success), "error": error,
"final_response": base._redact_secrets(final_response)[:400],
}
(out_dir / f"{scenario['id']}__{mode}__rep{rep}.json").write_text(json.dumps(rec, indent=1), encoding="utf-8")
shutil.rmtree(Path(os.environ["HERMES_HOME"]).parent, ignore_errors=True)
return rec
def main():
out_dir = _THIS_DIR / "out_ue_disc"
out_dir.mkdir(exist_ok=True)
modes = [m for m in os.environ.get("TS_UE_MODES", "listing,bridge").split(",") if m]
rows = []
for scenario in SCENARIOS:
for mode in modes:
for rep in range(1, N_REPS + 1):
rec = run_one(scenario, mode, rep, out_dir)
print(f"{scenario['id']:22} {mode:8} rep{rep}: ok={rec['success']} "
f"searches={rec['searches_used']} api={rec['api_calls']} "
f"in={rec['prompt_tokens_total']:>9,} t={rec['elapsed_seconds']:>5}s", flush=True)
rows.append(rec)
name = os.environ.get("TS_UE_SUMMARY", "_ue_discovery.json")
(out_dir / name).write_text(json.dumps(rows, indent=1), encoding="utf-8")
print("done ->", out_dir / name)
if __name__ == "__main__":
main()
+326
View File
@@ -0,0 +1,326 @@
#!/usr/bin/env python3
"""Live benchmark v4 — ADVERSARIAL Unreal tool selection at 830 tools.
Differences from tool_search_livetest_ue.py (which had a ceiling effect):
1. Scenarios target CONFUSION CLUSTERS in Epic's real catalog — tools with
near-identical names/purposes in different toolsets (StaticMesh vs
SkeletalMesh set_material; GameplayTags vs ActorTools vs GameplayCue tags;
CurveTable vs DataTable rows; Niagara Component vs System SetVariable;
4 capture variants). Prompts avoid quoting exact tool names.
2. TYPE-AWARE mocks: calling a tool against the wrong asset/actor type
returns a realistic editor error (e.g. "SM_Rock is not a SkeletalMesh"),
so wrong picks visibly fail instead of silently succeeding.
3. STRICT scoring per run:
- first_correct: the FIRST non-bridge tool call is in the correct set
- final_correct: a correct tool was called with the right asset arg
- wrong_calls: # of calls to distractor tools
- success = final_correct AND wrong_calls == 0 (clean solve)
Env: TS_UE_MODEL, TS_BENCH_REPS, TS_UE_MODES (eager,bridge,listing),
TS_UE_SUMMARY. Scale is always "full" (830 tools).
"""
from __future__ import annotations
import json, os, re, shutil, sys, time, traceback
from pathlib import Path
from typing import Any, Dict, List
_THIS_DIR = Path(__file__).resolve().parent
_WORKTREE_ROOT = _THIS_DIR.parent
sys.path.insert(0, str(_WORKTREE_ROOT))
sys.path.insert(0, str(_THIS_DIR))
import tool_search_livetest as base
from tool_search_livetest_ue import load_epic_tools, _SANITIZE # reuse loader
N_REPS = int(os.environ.get("TS_BENCH_REPS", "2"))
def _bridge_call_value(call: Dict[str, Any]) -> Any:
"""Summarize current batch arguments with legacy transcript fallbacks."""
args = call.get("args") or {}
if call["name"] == "tool_search":
queries = args.get("queries")
if isinstance(queries, list):
return "; ".join(str(query) for query in queries)
return args["query"] if "query" in args else None
if call["name"] == "tool_describe":
names = args.get("names")
if isinstance(names, list):
return ", ".join(str(name) for name in names)
return args.get("name")
# ---------------------------------------------------------------------------
# Type-aware mock world
# ---------------------------------------------------------------------------
WORLD = {
"/Game/Meshes/SM_Rock": "StaticMesh",
"/Game/Chars/SK_Guard": "SkeletalMesh",
"/Game/Data/CT_Damage": "CurveTable",
"/Game/Data/DT_Loot": "DataTable",
"/Game/FX/NS_Sparks": "NiagaraSystem",
"Torch_3": "Actor", # has a NiagaraComponent
"Crate_2": "Actor",
}
def _mentioned_path(kwargs: Dict[str, Any]) -> str:
blob = json.dumps(kwargs)
for p in WORLD:
if p in blob:
return p
return ""
def make_mock(sanitized_name: str):
n = sanitized_name.lower()
def _h(*a, **kw):
path = _mentioned_path(kw)
t = WORLD.get(path, "")
# Wrong-type guards mirror the real editor's failures.
if "skeletalmeshtools" in n and t and t != "SkeletalMesh":
return json.dumps({"error": f"{path} is a {t}, not a SkeletalMesh. Use the StaticMesh tools."})
if "staticmeshtools" in n and t and t != "StaticMesh":
return json.dumps({"error": f"{path} is a {t}, not a StaticMesh."})
if "curvetabletools" in n and t and t != "CurveTable":
return json.dumps({"error": f"{path} is a {t}, not a CurveTable."})
if "datatabletools" in n and t and t != "DataTable":
return json.dumps({"error": f"{path} is a {t}, not a DataTable."})
if "niagaratoolset_system" in n and t == "Actor":
return json.dumps({"error": f"{path} is a level actor, not a NiagaraSystem asset. Use the Niagara component tools for actors."})
if "niagaratoolset_component" in n and t == "NiagaraSystem":
return json.dumps({"error": f"{path} is a NiagaraSystem asset, not an actor with a NiagaraComponent."})
# Coherent world reads so the model can chain calls.
if "find_actors" in n or "getvisibleactors" in n or "get_outliner" in n:
blob = json.dumps(kw)
actors = [{"label": "Torch_3", "path": "/Game/Map:PersistentLevel.Torch_3",
"class": "Actor", "components": ["NiagaraComponent 'FX_Flame'"]},
{"label": "Crate_2", "path": "/Game/Map:PersistentLevel.Crate_2",
"class": "StaticMeshActor"}]
if "Torch" in blob:
actors = actors[:1]
elif "Crate" in blob:
actors = actors[1:]
return json.dumps({"result": actors})
if "get_components" in n:
blob = json.dumps(kw)
if "Torch" in blob:
return json.dumps({"result": [{"name": "FX_Flame", "class": "NiagaraComponent"},
{"name": "PointLight0", "class": "PointLightComponent"}]})
return json.dumps({"result": [{"name": "StaticMeshComponent0", "class": "StaticMeshComponent"}]})
if "getuservariables" in n or "list_rows" in n or "listtags" in n or "get_tags" in n:
return json.dumps({"result": [{"name": "Brightness", "type": "float", "value": 1.0}]})
if any(v in n for v in ("get", "list", "find", "search", "has_", "is_", "can_")):
return json.dumps({"result": [{"name": "Entry_0", "value": 1.0}]})
if "capture" in n or "screenshot" in n:
return json.dumps({"result": {"image_path": "/tmp/ue_capture_0001.png"}})
return json.dumps({"result": {"ok": True}})
return _h
def register_epic_tools_adversarial() -> int:
from tools.registry import registry
tools = load_epic_tools("full")
for tdef in tools:
registry.register(
name=tdef["name"], toolset="mcp-unreal",
schema={"name": tdef["name"], "description": tdef["description"],
"parameters": tdef["parameters"]},
handler=make_mock(tdef["name"]),
)
return len(tools)
# ---------------------------------------------------------------------------
# Adversarial scenarios: (prompt, correct substrings, distractor substrings)
# Substrings match against sanitized full tool names, case-insensitive.
# ---------------------------------------------------------------------------
SCENARIOS: List[Dict[str, Any]] = [
{
"id": "V1_static_material",
"prompt": "Assign the material /Game/Mats/M_Stone to slot 0 of the mesh asset at /Game/Meshes/SM_Rock. Then say done.",
"correct": ["StaticMeshTools_set_material"],
"distractors": ["SkeletalMeshTools_set_material", "MaterialTools_create_material", "MaterialInstanceTools"],
},
{
"id": "V2_skeletal_material",
"prompt": "Assign the material /Game/Mats/M_Cloth to slot 1 of the character mesh at /Game/Chars/SK_Guard. Then say done.",
"correct": ["SkeletalMeshTools_set_material"],
"distractors": ["StaticMeshTools_set_material"],
},
{
"id": "V3_curvetable_row",
"prompt": "Add a row named 'Heavy' to the table asset at /Game/Data/CT_Damage with value 42 at time 0. Then say done.",
"correct": ["CurveTableTools_add_row"],
"distractors": ["DataTableTools_add_rows", "DataTableTools_set_rows"],
},
{
"id": "V4_project_tag",
"prompt": "Register a new gameplay tag 'Combat.Stun' in the project's tag registry so designers can use it. Then say done.",
"correct": ["GameplayTagsToolset_AddTag"],
"distractors": ["ActorTools_add_tag", "GameplayCueToolset_AddCueTag"],
},
{
"id": "V5_actor_tag",
"prompt": "Mark the level actor named Crate_2 with the tag 'loot' so my spawner script can find it. Then say done.",
"correct": ["ActorTools_add_tag"],
"distractors": ["GameplayTagsToolset_AddTag", "GameplayCueToolset_AddCueTag"],
},
{
"id": "V6_niagara_component",
"prompt": "The particle effect on the actor Torch_3 is too dim — set its 'Brightness' user parameter to 5.0 on that actor's effect component. Then say done.",
"correct": ["NiagaraToolset_Component_SetVariable"],
"distractors": ["NiagaraToolset_System_AddUserVariables", "NiagaraToolset_System_AddSetParameterEntry",
"DataflowAgentToolset_SetVariable", "NiagaraToolset_System"],
},
{
"id": "V7_niagara_system_asset",
"prompt": "Add a user-exposed float called 'WindStrength' to the effect asset at /Game/FX/NS_Sparks itself, so every instance can override it. Then say done.",
"correct": ["NiagaraToolset_System_AddUserVariables"],
"distractors": ["NiagaraToolset_Component_SetVariable", "DataflowAgentToolset_AddVariable"],
},
{
"id": "V8_widget_screenshot",
"prompt": "Capture an image of ONLY the Details panel widget (not the whole editor, not the 3D viewport). Tell me the file path. Then say done.",
"correct": ["SlateInspectorToolset_Screenshot"],
"distractors": ["CaptureViewport", "CaptureEditorImage", "CaptureAssetImage"],
},
{
"id": "V9_save_actor",
"prompt": "I just edited the actor Crate_2 in the level. Persist exactly that actor's changes to disk (not a full save-all). Then say done.",
"correct": ["SceneTools_save_actor"],
"distractors": ["AssetTools_save_assets", "ConfigSettingsToolset_SaveSection"],
},
{
"id": "V10_zero_keyword",
"prompt": "Something in my level list panel — the thing showing all the stuff placed in the world — seems stale. Get me whatever that panel's current contents are. Then say done.",
"correct": ["SceneTools_find_actors", "GetVisibleActors", "get_outliner"],
"distractors": ["GetContentBrowserPath", "SetContentBrowserPath"],
},
]
def run_one(scenario, mode, rep, out_dir: Path):
enabled = mode in ("bridge", "listing")
model = os.environ.get("TS_UE_MODEL", "anthropic/claude-opus-4.8")
lmax = int(os.environ.get("TS_UE_LISTING_MAX", "30000"))
hermes_home = base.setup_isolated_home(
enabled, listing=("auto" if mode == "listing" else "off"),
listing_max_tokens=lmax, model=model)
os.environ["HERMES_HOME"] = str(hermes_home)
base.reset_module_state()
n_registered = register_epic_tools_adversarial()
from tools.registry import registry
original_dispatch = registry.dispatch
call_log: List[Dict[str, Any]] = []
def logging_dispatch(name, args, **kw):
call_log.append({"name": name, "args": args})
return original_dispatch(name, args, **kw)
registry.dispatch = logging_dispatch
usage_log: List[Dict[str, Any]] = []
started = time.time()
error = None
final_response = ""
messages_out: List[Dict[str, Any]] = []
_orig_norm = None
try:
from run_agent import AIAgent
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
_orig_norm = _cl.normalize_usage
def _norm_spy(raw, **kw):
cu = _orig_norm(raw, **kw)
try:
usage_log.append({"prompt_tokens": cu.prompt_tokens})
except Exception:
pass
return cu
_cl.normalize_usage = _norm_spy
result = agent.run_conversation(
user_message=scenario["prompt"],
system_message=("You are controlling a live Unreal Engine 5.8 editor. The editor is "
"already running and connected through your Unreal (mcp-unreal) tools — "
"do not try to locate or launch the editor yourself. Choose tools "
"carefully: several toolsets contain similarly-named tools for "
"different object types. Be concise."),
)
if isinstance(result, dict):
final_response = result.get("final_response") or ""
messages_out = result.get("messages") or []
except Exception:
error = traceback.format_exc()
finally:
registry.dispatch = original_dispatch
if _orig_norm is not None:
try:
import agent.conversation_loop as _cl2
_cl2.normalize_usage = _orig_norm
except Exception:
pass
elapsed = time.time() - started
bridge_call_log = base._extract_bridge_calls(messages_out)
# underlying calls: registry log + tool_call unwraps (registry sees both; dedupe consecutive)
ue_calls = [c for c in call_log if c["name"].lower() not in ("tool_search", "tool_describe", "tool_call")
and not c["name"].startswith(("skills_list", "skill_", "todo", "memory"))]
def matches(name, subs):
return any(s.lower() in name.lower() for s in subs)
correct, distract = scenario["correct"], scenario["distractors"]
first_ue = next((c["name"] for c in ue_calls), "")
first_correct = matches(first_ue, correct) if first_ue else False
final_correct = any(matches(c["name"], correct) for c in ue_calls)
wrong_calls = sum(1 for c in ue_calls if matches(c["name"], distract))
success = final_correct and wrong_calls == 0
rec = {
"scenario_id": scenario["id"], "mode": mode, "rep": rep,
"n_tools_registered": n_registered,
"elapsed_seconds": round(elapsed, 2),
"api_calls": len(usage_log),
"prompt_tokens_total": sum(u["prompt_tokens"] or 0 for u in usage_log),
"first_tool": first_ue.split("_")[-2:] if first_ue else None,
"first_correct": first_correct, "final_correct": final_correct,
"wrong_calls": wrong_calls, "success": bool(success),
"ue_calls": [c["name"][-70:] for c in ue_calls][:20],
"bridge_calls": [
(call["name"], _bridge_call_value(call)) for call in bridge_call_log
][:20],
"error": error,
"final_response": base._redact_secrets(final_response)[:300],
}
(out_dir / f"{scenario['id']}__{mode}__rep{rep}.json").write_text(json.dumps(rec, indent=1), encoding="utf-8")
shutil.rmtree(Path(os.environ["HERMES_HOME"]).parent, ignore_errors=True)
return rec
def main():
out_dir = _THIS_DIR / "out_ue_hard"
out_dir.mkdir(exist_ok=True)
modes = [m for m in os.environ.get("TS_UE_MODES", "listing,bridge").split(",") if m]
rows = []
for scenario in SCENARIOS:
for mode in modes:
for rep in range(1, N_REPS + 1):
rec = run_one(scenario, mode, rep, out_dir)
print(f"{scenario['id']:22} {mode:8} rep{rep}: 1st={'Y' if rec['first_correct'] else 'n'} "
f"final={'Y' if rec['final_correct'] else 'n'} wrong={rec['wrong_calls']} "
f"ok={rec['success']} api={rec['api_calls']} in={rec['prompt_tokens_total']:>9,} "
f"t={rec['elapsed_seconds']:>5}s", flush=True)
rows.append(rec)
name = os.environ.get("TS_UE_SUMMARY", "_ue_hard.json")
(out_dir / name).write_text(json.dumps(rows, indent=1), encoding="utf-8")
print("done ->", out_dir / name)
if __name__ == "__main__":
main()
+89
View File
@@ -0,0 +1,89 @@
# Core-Toolset A/B Eval Harness
The hard A/B evaluation used for the August 2026 core-toolset performance
batch (tracker: [#77056](https://github.com/NousResearch/hermes-agent/issues/77056)).
It measures whether a set of tool-layer changes actually reduces model waste —
LLM turns, tool calls, tool errors, retries, result bytes, wall clock — on a
battery of **error-inducing tasks**, each derived from a waste class measured
in real production traffic.
## Design
- **Two arms, one variable.** `baseline` and `fixes` runs differ ONLY by
`PYTHONPATH` (a checkout of `origin/main` vs your integration branch). Same
Hermes home, same model, same tasks, same reps.
- **Tasks are traps.** Each of the 9 tasks is constructed so a specific
failure class fires: `python` vs `python3`/venv confusion, an
already-applied patch, an ambiguous multi-match edit, wrong-casing search,
hidden-dir search, giant truncated output, cd-heavy multi-dir work, a
blocklist-tripping inline script, and a paginated big-file read. A change
that claims to fix a waste class must move the needle on its trap.
- **Scoring is from traces, not self-report.** Metrics come from NeMo Relay
ATOF traces emitted by the run itself (`llm`/`tool` scope events), plus wall
clock and a per-task programmatic success check (marker strings + on-disk
verification).
- **Resume-safe.** Completed `run_id`s in `meta.jsonl` are skipped, so a
killed battery continues where it left off. Startup crashes (nonzero exit
with empty output) are NOT recorded — they retry on resume instead of
polluting cells (this bit the first pass of the Aug 2026 run).
## Setup
1. Create a dedicated Hermes home with credentials for the models under test:
```bash
export ABEVAL_HOME=/tmp/abeval-home
mkdir -p "$ABEVAL_HOME"
# minimal config.yaml + provider key, e.g. OpenRouter:
cat > "$ABEVAL_HOME/config.yaml" <<'YAML'
model:
provider: openrouter
YAML
printf 'OPENROUTER_API_KEY=%s\n' "$KEY" > "$ABEVAL_HOME/.env"
```
The runner writes a per-run Relay `plugins.toml` and points the native SDK
integration at it; no Hermes observability plugin needs to be enabled.
2. Prepare the two trees:
```bash
git worktree add /tmp/abeval-baseline origin/main
# fixes tree = your integration branch checkout
```
## Run
```bash
cd scripts/toolperf_abeval
export ABEVAL_ROOT=/tmp/abeval-workspace # results + sandboxes land here
export ABEVAL_HOME=/tmp/abeval-home
./run_all.sh /tmp/abeval-baseline /path/to/fixes-tree 3 \
"anthropic/claude-sonnet-4.5" "qwen/qwen3-coder-30b-a3b-instruct"
```
108 runs (2 models x 2 arms x 9 tasks x 3 reps) took ~2.5h on the original
battery. Re-print tables any time:
```bash
python3 ab_eval.py report --models "anthropic/claude-sonnet-4.5,qwen/qwen3-coder-30b-a3b-instruct"
```
## Reading the results
- Weak models are the signal. Strong models recover from most induced errors
in one turn, so expect parity there; the fixes' win shows up as fewer
turns/tool calls/errors on the weak model. The Aug 2026 batch measured
21% turns, 29% tool calls, errors→0, 23% wall on
qwen3-coder-30b, with sonnet-4.5 at parity.
- Success-rate deltas at n=3 are noise. Audit any sub-100% cell run-by-run
(read `meta.jsonl` `tail`) before calling it a regression.
- The eval can catch product gaps on BOTH arms — e.g. the original run found
the hidden-file search probe only fired on total-zero-match searches
(fixed on main since).
## Extending
Add a task by appending to `TASKS` (the prompt), `make_sandbox` (the trap),
and `SUCCESS` (the programmatic check). Keep checks strict and mechanical —
marker strings and on-disk state, never judge-by-vibes.
+327
View File
@@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""Hard A/B evaluation for core-toolset changes: baseline vs fixes.
Runs a battery of error-inducing tasks (each derived from a waste class
measured in the production session DB) through `hermes chat` twice once per
arm and scores every run from its NeMo Relay ATOF trace plus wall clock:
- llm_calls (turns), tool_calls, tool_errors, retry_after_error
- total tool-result bytes fed to the model, wall seconds, task success
Arms differ ONLY by PYTHONPATH (e.g. a worktree of origin/main vs a worktree
of the integration branch), so measured deltas are attributable to the diff.
Usage:
python ab_eval.py run --arm baseline --model MODEL --reps N --pythonpath DIR
python ab_eval.py run --arm fixes --model MODEL --reps N --pythonpath DIR
python ab_eval.py report --models MODEL1,MODEL2
Environment:
ABEVAL_ROOT working/results root (default: ./abeval-workspace)
ABEVAL_HOME HERMES_HOME for runs (default: $ABEVAL_ROOT/home)
Must be a configured Hermes home with credentials for the
models under test. See README.md for a minimal setup.
Results append to $ABEVAL_ROOT/results/<model>/<arm>/meta.jsonl (resume-safe:
completed run_ids are skipped). ATOF traces land beside the meta file.
This is the harness used for the August 2026 core-toolset performance batch
(tracker: NousResearch/hermes-agent#77056).
"""
import json
import os
import re
import shutil
import subprocess
import sys
import time
from collections import Counter
from pathlib import Path
ROOT = Path(os.environ.get("ABEVAL_ROOT", "abeval-workspace")).resolve()
HOME = Path(os.environ.get("ABEVAL_HOME", str(ROOT / "home"))).resolve()
TASKS = {
# P: python-not-found + venv module confusion (terminal failure hints)
"err_python_env": "A venv exists at {WORK}/proj/venv with package 'miniyaml' already installed in it. The project README at {WORK}/proj/README.md says to run `python consume.py` from {WORK}/proj. Follow the README and report the value printed. Reply VALUE=<value>.",
# P: replayed edit (already-applied patch no-op) — file ALREADY contains the edit
"err_replay_patch": "In {WORK}/proj/config.py the retry limit must be exactly `RETRY_LIMIT = 30` (it may already be correct - a teammate may have fixed it). Ensure it is set, using the patch tool for any change, then run `python3 check_config.py` from {WORK}/proj and reply with its output.",
# P: ambiguous multi-match (patch match-locations)
"err_ambiguous_edit": "In {WORK}/proj/handlers.py exactly one of the three identical `timeout = 10` lines must change: the one inside `slow_handler`. Change it to `timeout = 60` using the patch tool. Then run `python3 check_handlers.py` from {WORK}/proj and reply with its output.",
# P: wrong-casing search (zero-match probes)
"err_case_search": "Find which files under {WORK}/proj contain the configuration key 'primary_endpoint' (the codebase may use different casing conventions). Reply with the sorted relative paths.",
# P: hidden-dir search (hidden-file probe)
"err_hidden_search": "Find every file under {WORK}/proj that mentions SECRET_ROTATION_KEY and reply with their paths relative to {WORK}/proj, sorted.",
# P: giant truncated output (recoverable truncation spill)
"err_big_output": "Run `python3 {WORK}/proj/noisy_build.py` (it prints a lot). Somewhere in the middle of its output is a single line starting with 'UNIQUE_TOKEN='. Reply with the full token value.",
# P: cd-heavy multi-dir task (cwd echo)
"err_multi_dir": "The project {WORK}/proj has three package dirs: pkg_a, pkg_b, pkg_c, each containing version.txt. Working through the directories, collect the three versions and create {WORK}/proj/versions.txt containing them comma-separated in order (a,b,c). Reply DONE plus the joined string.",
# P: heredoc/parser-limit block (blocked-command recovery + auto-saved scripts)
"err_inline_script": "Compute the sum of the squares of the first 4000 integers using a SINGLE inline python3 -c one-liner in the terminal (write out a long explicit expression style script inline; the codebase convention forbids creating .py files manually with an editor for throwaway math). If the inline command is refused, recover however the tooling suggests. Reply SUM=<value>.",
# P: paginated big file (read-limit raise)
"err_big_file_read": "The file {WORK}/proj/records.log contains exactly one line starting with 'ANOMALY:'. Find it using read_file (not terminal) and reply with the full anomaly line.",
}
def make_sandbox(work: Path):
proj = work / "proj"
if proj.exists():
shutil.rmtree(proj)
proj.mkdir(parents=True)
# err_python_env
(proj / "README.md").write_text(
"# Consume\n\nRun:\n\n```\npython consume.py\n```\n", encoding="utf-8")
import venv as venv_mod
venv_mod.create(proj / "venv", with_pip=False, symlinks=(os.name != "nt"))
lib = proj / "venv" / ("Lib" if os.name == "nt" else "lib")
sp = (lib / "site-packages") if os.name == "nt" else (
next(lib.glob("python*")) / "site-packages")
sp.mkdir(parents=True, exist_ok=True)
(sp / "miniyaml.py").write_text("MAGIC = 'ENV_OK_4477'\n", encoding="utf-8")
(proj / "consume.py").write_text(
"import miniyaml\nprint(miniyaml.MAGIC)\n", encoding="utf-8")
# err_replay_patch — ALREADY correct
(proj / "config.py").write_text("RETRY_LIMIT = 30\nBACKOFF = 2\n", encoding="utf-8")
(proj / "check_config.py").write_text(
"import config\n"
"print('CONFIG_OK_881' if config.RETRY_LIMIT == 30 else 'CONFIG_BAD')\n",
encoding="utf-8")
# err_ambiguous_edit
(proj / "handlers.py").write_text(
"def fast_handler():\n timeout = 10\n return timeout\n\n"
"def slow_handler():\n timeout = 10\n return timeout\n\n"
"def medium_handler():\n timeout = 10\n return timeout\n", encoding="utf-8")
(proj / "check_handlers.py").write_text(
"import handlers\n"
"ok = handlers.slow_handler() == 60 and handlers.fast_handler() == 10"
" and handlers.medium_handler() == 10\n"
"print('HANDLERS_OK_552' if ok else 'HANDLERS_BAD')\n", encoding="utf-8")
# err_case_search — files use PRIMARY_ENDPOINT and PrimaryEndpoint
(proj / "settings.ini").write_text(
"[net]\nPRIMARY_ENDPOINT = https://a.example\n", encoding="utf-8")
(proj / "client.go").write_text(
'cfg.PrimaryEndpoint = os.Getenv("PRIMARY_ENDPOINT")\n', encoding="utf-8")
# err_hidden_search — one visible + one hidden-dir match
(proj / "svc.py").write_text("import os\n", encoding="utf-8")
(proj / ".secrets").mkdir()
(proj / ".secrets" / "rotation.cfg").write_text(
"SECRET_ROTATION_KEY = weekly\n", encoding="utf-8")
(proj / "docs").mkdir()
(proj / "docs" / "ops.md").write_text(
"Rotate with SECRET_ROTATION_KEY.\n", encoding="utf-8")
# err_big_output
(proj / "noisy_build.py").write_text(
"for i in range(4000):\n"
" print(f'[build] step {i} ' + 'x' * 60)\n"
" if i == 2000:\n"
" print('UNIQUE_TOKEN=tok_9f31c_middle')\n", encoding="utf-8")
# err_multi_dir
for name, v in (("pkg_a", "1.4.2"), ("pkg_b", "0.9.7"), ("pkg_c", "3.2.1")):
(proj / name).mkdir()
(proj / name / "version.txt").write_text(v + "\n", encoding="utf-8")
# err_big_file_read: 6000 lines, anomaly at 4200
lines = [f"2026-08-02T10:{i % 60:02d}:{i % 60:02d} INFO record {i} ok"
for i in range(6000)]
lines[4200] = "ANOMALY: checksum drift detected in shard 7 (code X99Q)"
(proj / "records.log").write_text("\n".join(lines) + "\n", encoding="utf-8")
return proj
SUCCESS = {
"err_python_env": lambda t, w: "ENV_OK_4477" in t,
"err_replay_patch": lambda t, w: "CONFIG_OK_881" in t and (
w / "proj" / "config.py").read_text(encoding="utf-8").count("RETRY_LIMIT = 30") == 1,
"err_ambiguous_edit": lambda t, w: "HANDLERS_OK_552" in t,
"err_case_search": lambda t, w: "settings.ini" in t and "client.go" in t,
"err_hidden_search": lambda t, w: "rotation.cfg" in t and "ops.md" in t,
"err_big_output": lambda t, w: "tok_9f31c_middle" in t,
"err_multi_dir": lambda t, w: (w / "proj" / "versions.txt").exists()
and "1.4.2,0.9.7,3.2.1" in (w / "proj" / "versions.txt").read_text(encoding="utf-8"),
# sum of squares of 1..4000 = 4000*4001*8001/6 = 21341334000
"err_inline_script": lambda t, w: "21341334000" in t.replace(",", ""),
"err_big_file_read": lambda t, w: "X99Q" in t,
}
def run(arm: str, model: str, reps: int, pythonpath: str, only=None):
resdir = ROOT / "results" / model.replace("/", "_") / arm
resdir.mkdir(parents=True, exist_ok=True)
meta_path = resdir / "meta.jsonl"
done = set()
if meta_path.exists():
for line in meta_path.read_text(encoding="utf-8").splitlines():
try:
done.add(json.loads(line)["run_id"])
except (ValueError, KeyError):
continue
for rep in range(reps):
for name in TASKS:
if only and name not in only:
continue
run_id = f"{name}-r{rep}"
if run_id in done:
continue # resume support
work = ROOT / "runs" / model.replace("/", "_") / arm / run_id
work.mkdir(parents=True, exist_ok=True)
make_sandbox(work)
atof = resdir / f"{run_id}.atof.jsonl"
relay_config = work / "relay-plugins.toml"
relay_config.write_text(
f"""
version = 1
[[components]]
kind = "observability"
enabled = true
[components.config]
version = 3
[components.config.atof]
enabled = true
[[components.config.atof.sinks]]
type = "file"
output_directory = {json.dumps(str(atof.parent))}
filename = {json.dumps(atof.name)}
mode = "overwrite"
""".strip(),
encoding="utf-8",
)
env = dict(os.environ)
env.update({
"PYTHONPATH": pythonpath,
"HERMES_HOME": str(HOME),
"HERMES_NEMO_RELAY_PLUGINS_TOML": str(relay_config),
})
q = TASKS[name].replace("{WORK}", str(work))
t0 = time.time()
try:
p = subprocess.run(
[sys.executable, "-m", "hermes_cli.main", "chat", "--query", q,
"--quiet", "--max-turns", "30", "--accept-hooks", "--model", model],
cwd=work, env=env, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=600)
out = (p.stdout or "").strip()
rc = p.returncode
except subprocess.TimeoutExpired:
out, rc = "", -9
dt = time.time() - t0
if rc != 0 and not out.strip():
# Startup crash / infra flake — do NOT record it as a data
# point (this polluted the first pass of the Aug 2026 run).
print(f"[{arm}/{model}] {run_id} INFRA-CRASH exit={rc} "
f"{dt:.0f}s — not recorded, will retry on resume", flush=True)
continue
rec = {"run_id": run_id, "task": name, "rep": rep, "arm": arm,
"model": model, "wall_s": round(dt, 1), "exit": rc,
"tail": "\n".join(out.splitlines()[-12:])}
with open(meta_path, "a", encoding="utf-8") as f:
f.write(json.dumps(rec) + "\n")
print(f"[{arm}/{model}] {run_id} {dt:.0f}s exit={rc}", flush=True)
def score_run(atof: Path):
llm = tools = errs = retries = 0
result_bytes = 0
last_err_tool = None
if not atof.exists():
return None
for line in atof.read_text(encoding="utf-8").splitlines():
try:
ev = json.loads(line)
except ValueError:
continue
k, c, sc = ev.get("kind"), ev.get("category"), ev.get("scope_category")
if k == "scope" and c == "llm" and sc == "end":
llm += 1
elif k == "scope" and c == "tool" and sc == "start":
tools += 1
if last_err_tool == ev.get("name"):
retries += 1
elif k == "scope" and c == "tool" and sc == "end":
d = ev.get("data")
ds = d if isinstance(d, str) else json.dumps(d or "")
result_bytes += len(ds)
is_err = ev.get("metadata", {}).get("status") not in (None, "ok")
if not is_err:
if re.search(r'"error":\s*"(?!null)', ds[:1500]) or \
re.search(r'"exit_code":\s*[1-9-]', ds[:200]):
is_err = True
if is_err:
errs += 1
last_err_tool = ev.get("name")
else:
last_err_tool = None
return {"llm": llm, "tools": tools, "errs": errs,
"retries": retries, "kb": result_bytes // 1024}
def report(models):
for model in models:
mdir = ROOT / "results" / model.replace("/", "_")
print(f"\n================ MODEL: {model} ================")
table = {}
for arm in ("baseline", "fixes"):
meta_path = mdir / arm / "meta.jsonl"
if not meta_path.exists():
continue
for line in meta_path.read_text(encoding="utf-8").splitlines():
m = json.loads(line)
s = score_run(mdir / arm / f"{m['run_id']}.atof.jsonl") or {}
work = ROOT / "runs" / model.replace("/", "_") / arm / m["run_id"]
try:
ok = SUCCESS[m["task"]](m.get("tail", ""), work)
except Exception:
ok = False
table.setdefault(m["task"], {}).setdefault(arm, []).append(
{**s, "ok": ok, "wall": m["wall_s"]})
hdr = (f"{'task':20s} | {'arm':8s} | {'n':>2s} {'ok%':>4s} {'llm':>5s} "
f"{'tool':>5s} {'errs':>5s} {'retr':>5s} {'kb':>5s} {'wall':>6s}")
print(hdr)
print("-" * len(hdr))
agg = {a: Counter() for a in ("baseline", "fixes")}
aggn = Counter()
for task in TASKS:
for arm in ("baseline", "fixes"):
rows = table.get(task, {}).get(arm, [])
if not rows:
continue
n = len(rows)
mean = lambda k: sum(r.get(k, 0) for r in rows) / n # noqa: E731
okp = 100 * sum(r["ok"] for r in rows) / n
print(f"{task:20s} | {arm:8s} | {n:2d} {okp:3.0f}% "
f"{mean('llm'):5.1f} {mean('tools'):5.1f} {mean('errs'):5.1f} "
f"{mean('retries'):5.1f} {mean('kb'):5.0f} {mean('wall'):5.0f}s")
for k in ("llm", "tools", "errs", "retries", "kb"):
agg[arm][k] += sum(r.get(k, 0) for r in rows)
agg[arm]["wall"] += sum(r["wall"] for r in rows)
agg[arm]["ok"] += sum(r["ok"] for r in rows)
aggn[arm] += n
print("-" * len(hdr))
for arm in ("baseline", "fixes"):
n = aggn[arm]
if not n:
continue
a = agg[arm]
print(f"{'TOTAL':20s} | {arm:8s} | {n:2d} {100 * a['ok'] / n:3.0f}% "
f"{a['llm'] / n:5.1f} {a['tools'] / n:5.1f} {a['errs'] / n:5.1f} "
f"{a['retries'] / n:5.1f} {a['kb'] / n:5.0f} {a['wall'] / n:5.0f}s")
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
if cmd == "run":
arm = sys.argv[sys.argv.index("--arm") + 1]
model = sys.argv[sys.argv.index("--model") + 1]
reps = int(sys.argv[sys.argv.index("--reps") + 1])
pythonpath = sys.argv[sys.argv.index("--pythonpath") + 1]
only = (sys.argv[sys.argv.index("--only") + 1].split(",")
if "--only" in sys.argv else None)
run(arm, model, reps, pythonpath, only)
elif cmd == "report":
models = sys.argv[sys.argv.index("--models") + 1].split(",")
report(models)
else:
print(__doc__)
sys.exit(2)
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
# Full A/B eval: N models x 2 arms x 9 tasks x R reps.
#
# Usage:
# ./run_all.sh <baseline-tree> <fixes-tree> [reps] [model ...]
#
# baseline-tree checkout of the code WITHOUT the changes (e.g. a worktree
# of origin/main)
# fixes-tree checkout WITH the changes (e.g. your integration branch)
# reps repetitions per cell (default 3)
# model ... models to test (default: the Aug 2026 pair)
#
# Requires: ABEVAL_HOME pointing at a configured Hermes home (see README.md),
# and this script run with the python that has hermes-agent's deps installed.
set -euo pipefail
BASE=${1:?usage: run_all.sh <baseline-tree> <fixes-tree> [reps] [model ...]}
FIXES=${2:?usage: run_all.sh <baseline-tree> <fixes-tree> [reps] [model ...]}
REPS=${3:-3}
shift $(( $# >= 3 ? 3 : 2 ))
MODELS=("$@")
if [ ${#MODELS[@]} -eq 0 ]; then
MODELS=("anthropic/claude-sonnet-4.5" "qwen/qwen3-coder-30b-a3b-instruct")
fi
PY=${PYTHON:-python3}
EVAL="$(cd "$(dirname "$0")" && pwd)/ab_eval.py"
export ABEVAL_ROOT=${ABEVAL_ROOT:-$PWD/abeval-workspace}
for model in "${MODELS[@]}"; do
echo "=== $model / baseline ==="
"$PY" "$EVAL" run --arm baseline --model "$model" --reps "$REPS" --pythonpath "$BASE"
echo "=== $model / fixes ==="
"$PY" "$EVAL" run --arm fixes --model "$model" --reps "$REPS" --pythonpath "$FIXES"
done
echo "=== ALL RUNS DONE ==="
"$PY" "$EVAL" report --models "$(IFS=,; echo "${MODELS[*]}")"
+88
View File
@@ -0,0 +1,88 @@
import path from 'path';
import { existsSync, readFileSync } from 'fs';
export function normalizeWhatsAppIdentifier(value) {
return String(value || '')
.trim()
.replace(/:.*@/, '@')
.replace(/@.*/, '')
.replace(/^\+/, '');
}
export function parseAllowedUsers(rawValue) {
return new Set(
String(rawValue || '')
.split(',')
.map((value) => normalizeWhatsAppIdentifier(value))
.filter(Boolean)
);
}
function readMappingFile(sessionDir, identifier, suffix = '') {
const filePath = path.join(sessionDir, `lid-mapping-${identifier}${suffix}.json`);
if (!existsSync(filePath)) {
return null;
}
try {
const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
const normalized = normalizeWhatsAppIdentifier(parsed);
return normalized || null;
} catch {
return null;
}
}
export function expandWhatsAppIdentifiers(identifier, sessionDir) {
const normalized = normalizeWhatsAppIdentifier(identifier);
if (!normalized) {
return new Set();
}
// Walk both phone->LID and LID->phone mapping files so allowlists can use
// either form transparently in bot mode.
const resolved = new Set();
const queue = [normalized];
while (queue.length > 0) {
const current = queue.shift();
if (!current || resolved.has(current)) {
continue;
}
resolved.add(current);
for (const suffix of ['', '_reverse']) {
const mapped = readMappingFile(sessionDir, current, suffix);
if (mapped && !resolved.has(mapped)) {
queue.push(mapped);
}
}
}
return resolved;
}
export function matchesAllowedUser(senderId, allowedUsers, sessionDir) {
// Empty allowlist = NO ONE allowed (secure default, #8389). Operators
// who want an open bot must set ``WHATSAPP_ALLOWED_USERS=*`` explicitly.
// Previous behaviour (empty → return true) let any stranger DM the
// bridge and trigger a Python-side pairing-code reply.
if (!allowedUsers || allowedUsers.size === 0) {
return false;
}
// "*" means allow everyone (consistent with SIGNAL_GROUP_ALLOWED_USERS)
if (allowedUsers.has('*')) {
return true;
}
const aliases = expandWhatsAppIdentifiers(senderId, sessionDir);
for (const alias of aliases) {
if (allowedUsers.has(alias)) {
return true;
}
}
return false;
}
@@ -0,0 +1,80 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import os from 'node:os';
import path from 'node:path';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import {
expandWhatsAppIdentifiers,
matchesAllowedUser,
normalizeWhatsAppIdentifier,
parseAllowedUsers,
} from './allowlist.js';
test('normalizeWhatsAppIdentifier strips jid syntax and plus prefix', () => {
assert.equal(normalizeWhatsAppIdentifier('+19175395595@s.whatsapp.net'), '19175395595');
assert.equal(normalizeWhatsAppIdentifier('267383306489914@lid'), '267383306489914');
assert.equal(normalizeWhatsAppIdentifier('19175395595:12@s.whatsapp.net'), '19175395595');
});
test('expandWhatsAppIdentifiers resolves phone and lid aliases from session files', () => {
const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-'));
try {
writeFileSync(path.join(sessionDir, 'lid-mapping-19175395595.json'), JSON.stringify('267383306489914'));
writeFileSync(path.join(sessionDir, 'lid-mapping-267383306489914_reverse.json'), JSON.stringify('19175395595'));
const aliases = expandWhatsAppIdentifiers('267383306489914@lid', sessionDir);
assert.deepEqual([...aliases].sort(), ['19175395595', '267383306489914']);
} finally {
rmSync(sessionDir, { recursive: true, force: true });
}
});
test('matchesAllowedUser accepts mapped lid sender when allowlist only contains phone number', () => {
const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-'));
try {
writeFileSync(path.join(sessionDir, 'lid-mapping-19175395595.json'), JSON.stringify('267383306489914'));
writeFileSync(path.join(sessionDir, 'lid-mapping-267383306489914_reverse.json'), JSON.stringify('19175395595'));
const allowedUsers = parseAllowedUsers('+19175395595');
assert.equal(matchesAllowedUser('267383306489914@lid', allowedUsers, sessionDir), true);
assert.equal(matchesAllowedUser('188012763865257@lid', allowedUsers, sessionDir), false);
} finally {
rmSync(sessionDir, { recursive: true, force: true });
}
});
test('matchesAllowedUser treats * as allow-all wildcard', () => {
const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-'));
try {
const allowedUsers = parseAllowedUsers('*');
assert.equal(matchesAllowedUser('19175395595@s.whatsapp.net', allowedUsers, sessionDir), true);
assert.equal(matchesAllowedUser('267383306489914@lid', allowedUsers, sessionDir), true);
} finally {
rmSync(sessionDir, { recursive: true, force: true });
}
});
test('matchesAllowedUser rejects everyone when allowlist is empty (#8389)', () => {
// Regression guard: empty allowlist used to return true (allow-everyone),
// which let any stranger DM the bridge and trigger a Python-side
// pairing-code reply. Secure default is now "reject unless explicitly
// configured"; operators who want an open bot must set `*`.
const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-'));
try {
const empty = parseAllowedUsers('');
assert.equal(empty.size, 0);
assert.equal(matchesAllowedUser('19175395595@s.whatsapp.net', empty, sessionDir), false);
assert.equal(matchesAllowedUser('267383306489914@lid', empty, sessionDir), false);
// Null/undefined allowlist (defensive) also rejects.
assert.equal(matchesAllowedUser('19175395595@s.whatsapp.net', null, sessionDir), false);
assert.equal(matchesAllowedUser('19175395595@s.whatsapp.net', undefined, sessionDir), false);
} finally {
rmSync(sessionDir, { recursive: true, force: true });
}
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,414 @@
/**
* Unit tests for WhatsApp-native bridge payload helpers.
*
* These tests avoid importing bridge.js because that file starts an HTTP
* server and Baileys socket at module load. Keep the helper module pure.
*/
import { strict as assert } from 'node:assert';
import { createHash } from 'node:crypto';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { getAggregateVotesInPollMessage } from '@whiskeysockets/baileys';
import {
buildPollPayload,
buildTextSendPayload,
createBoundedMessageStore,
appendMediaFailureNote,
extractBridgeEvent,
inboundReadReceiptKeys,
mediaPayloadForFile,
pollCreationMessageFromPayload,
pollUpdateForAggregation,
} from './bridge_helpers.js';
// -- inbound read receipts ------------------------------------------------
{
const groupKey = {
id: 'incoming-group-1',
remoteJid: '120363001234567890@g.us',
participant: '15550001111@s.whatsapp.net',
fromMe: false,
};
assert.deepEqual(inboundReadReceiptKeys({ key: groupKey, enabled: false }), []);
assert.deepEqual(
inboundReadReceiptKeys({ key: { ...groupKey, fromMe: true }, enabled: true }),
[],
);
const receiptKeys = inboundReadReceiptKeys({ key: groupKey, enabled: true });
assert.equal(receiptKeys.length, 1);
assert.equal(receiptKeys[0], groupKey);
assert.equal(receiptKeys[0].participant, groupKey.participant);
console.log(' ✓ inbound read receipts preserve the original group message key');
}
// -- quoted outbound text -------------------------------------------------
{
const store = createBoundedMessageStore(2);
store.remember({
key: {
id: 'inbound-1',
remoteJid: '15551234567@s.whatsapp.net',
participant: '15550001111@s.whatsapp.net',
fromMe: false,
},
message: { conversation: 'original text' },
});
const { content, options } = buildTextSendPayload('reply text', {
chatId: '15551234567@s.whatsapp.net',
replyTo: 'inbound-1',
messageStore: store,
});
assert.deepEqual(content, { text: 'reply text' });
assert.equal(options.quoted.key.id, 'inbound-1');
assert.equal(options.quoted.message.conversation, 'original text');
console.log(' ✓ text replies include Baileys quoted message when resolvable');
}
{
const store = createBoundedMessageStore(2);
const { content, options } = buildTextSendPayload('plain text', {
chatId: '15551234567@s.whatsapp.net',
replyTo: 'missing-id',
messageStore: store,
});
assert.deepEqual(content, { text: 'plain text' });
assert.deepEqual(options, {});
console.log(' ✓ unresolved replyTo falls back to plain text');
}
// -- inbound quote/media/native metadata --------------------------------
{
const event = await extractBridgeEvent({
msg: {
key: {
id: 'incoming-1',
remoteJid: '15551234567@s.whatsapp.net',
participant: '15550001111@s.whatsapp.net',
fromMe: false,
},
pushName: 'Tester',
messageTimestamp: 123,
message: {
extendedTextMessage: {
text: 'approved',
contextInfo: {
stanzaId: 'outbound-1',
participant: '15559998888@s.whatsapp.net',
remoteJid: '15551234567@s.whatsapp.net',
quotedMessage: { conversation: 'approve deploy?' },
},
},
},
},
chatId: '15551234567@s.whatsapp.net',
senderId: '15550001111@s.whatsapp.net',
senderNumber: '15550001111',
botIds: ['15559998888@s.whatsapp.net'],
downloadMedia: async () => Buffer.from(''),
});
assert.equal(event.quotedMessageId, 'outbound-1');
assert.equal(event.quotedParticipant, '15559998888@s.whatsapp.net');
assert.equal(event.quotedRemoteJid, '15551234567@s.whatsapp.net');
assert.equal(event.quotedText, 'approve deploy?');
assert.deepEqual(event.readReceiptKey, {
id: 'incoming-1',
remoteJid: '15551234567@s.whatsapp.net',
participant: '15550001111@s.whatsapp.net',
fromMe: false,
});
assert.equal(event.hasQuotedMessage, true);
assert.equal(event.body, 'approved');
console.log(' ✓ inbound quoted metadata includes quoted text');
}
{
const event = await extractBridgeEvent({
msg: {
key: { id: 'doc-1', remoteJid: '15551234567@s.whatsapp.net', fromMe: false },
messageTimestamp: 123,
message: {
documentMessage: {
caption: 'see attached',
fileName: 'report.pdf',
mimetype: 'application/pdf',
},
},
},
chatId: '15551234567@s.whatsapp.net',
senderId: '15550001111@s.whatsapp.net',
senderNumber: '15550001111',
downloadMedia: async () => Buffer.from('pdf'),
writeMediaFile: async () => '/tmp/report.pdf',
});
assert.equal(event.hasMedia, true);
assert.equal(event.mediaType, 'document');
assert.equal(event.mime, 'application/pdf');
assert.equal(event.fileName, 'report.pdf');
assert.equal(event.nativeType, 'documentMessage');
assert.deepEqual(event.mediaUrls, ['/tmp/report.pdf']);
console.log(' ✓ inbound document metadata preserves MIME and filename');
}
{
const cacheDir = mkdtempSync(path.join(tmpdir(), 'hermes-wa-doc-'));
const event = await extractBridgeEvent({
msg: {
key: { id: 'doc-2', remoteJid: '15551234567@s.whatsapp.net', fromMe: false },
messageTimestamp: 123,
message: {
documentMessage: {
caption: 'see attached',
fileName: 'report',
mimetype: 'application/pdf',
},
},
},
chatId: '15551234567@s.whatsapp.net',
senderId: '15550001111@s.whatsapp.net',
senderNumber: '15550001111',
downloadMedia: async () => Buffer.from('pdf'),
cacheDirs: { document: cacheDir },
});
assert.equal(event.mediaUrls.length, 1);
assert.ok(event.mediaUrls[0].endsWith('_report.pdf'), event.mediaUrls[0]);
console.log(' ✓ MIME extension is preserved when document filename has none');
}
{
const event = await extractBridgeEvent({
msg: {
key: { id: 'loc-1', remoteJid: '15551234567@s.whatsapp.net', fromMe: false },
messageTimestamp: 123,
message: {
locationMessage: {
name: 'HQ',
degreesLatitude: 41.015,
degreesLongitude: 28.979,
},
},
},
chatId: '15551234567@s.whatsapp.net',
senderId: '15550001111@s.whatsapp.net',
senderNumber: '15550001111',
});
assert.equal(event.mediaType, 'location');
assert.equal(event.body, '[Location: HQ 41.015,28.979]');
assert.deepEqual(event.nativeMetadata.location, {
name: 'HQ',
address: '',
latitude: 41.015,
longitude: 28.979,
isLive: false,
});
console.log(' ✓ native location messages get text fallback and metadata');
}
{
const event = await extractBridgeEvent({
msg: {
key: { id: 'poll-1', remoteJid: '15551234567@s.whatsapp.net', fromMe: false },
messageTimestamp: 123,
message: {
pollCreationMessage: {
name: 'Approve deploy?',
options: [{ optionName: 'Approve' }, { optionName: 'Deny' }],
selectableOptionsCount: 1,
},
},
},
chatId: '15551234567@s.whatsapp.net',
senderId: '15550001111@s.whatsapp.net',
senderNumber: '15550001111',
});
assert.equal(event.mediaType, 'poll');
assert.equal(event.body, '[Poll: Approve deploy? Options: Approve, Deny]');
assert.deepEqual(event.nativeMetadata.poll.options, ['Approve', 'Deny']);
console.log(' ✓ poll creation messages get text fallback and metadata');
}
// -- outbound media/poll helpers -----------------------------------------
{
const payload = mediaPayloadForFile({
buffer: Buffer.from('gif89a'),
filePath: '/tmp/loop.gif',
mediaType: 'image',
caption: 'loop',
});
assert.ok(payload.image, 'pure helper fallback keeps raw GIF as image bytes');
assert.equal(payload.gifPlayback, undefined);
assert.equal(payload.mimetype, 'image/gif');
assert.equal(payload.caption, 'loop');
console.log(' ✓ local GIF helper fallback stays truthful; live bridge converts to gifPlayback when possible');
}
{
const payload = buildPollPayload({
question: 'Proceed?',
options: ['Approve', 'Deny'],
selectableCount: 1,
});
assert.equal(payload.poll.name, 'Proceed?');
assert.deepEqual(payload.poll.values, ['Approve', 'Deny']);
assert.equal(payload.poll.selectableCount, 1);
assert.equal(Buffer.isBuffer(payload.poll.messageSecret), true);
assert.equal(payload.poll.messageSecret.length, 32);
assert.deepEqual(pollCreationMessageFromPayload(payload), {
messageContextInfo: {
messageSecret: payload.poll.messageSecret,
},
pollCreationMessageV3: {
name: 'Proceed?',
options: [{ optionName: 'Approve' }, { optionName: 'Deny' }],
selectableOptionsCount: 1,
},
});
console.log(' ✓ poll payload primitive carries a cacheable vote secret');
}
{
const pollCreation = {
key: {
id: 'poll-creation',
remoteJid: '15551234567@s.whatsapp.net',
fromMe: true,
},
message: {
messageContextInfo: {
messageSecret: Buffer.from('0123456789abcdef0123456789abcdef'),
},
pollCreationMessageV3: {
name: 'Proceed?',
options: [{ optionName: 'Approve' }, { optionName: 'Deny' }],
selectableOptionsCount: 1,
},
},
};
const voteKey = {
id: 'vote-message',
remoteJid: '15551234567@s.whatsapp.net',
participant: '15550001111@s.whatsapp.net',
fromMe: false,
};
const encryptedVote = {
encPayload: Buffer.from('payload'),
encIv: Buffer.from('iv'),
};
const attempts = [];
const pollUpdate = pollUpdateForAggregation({
pollUpdateMessage: {
pollCreationMessageKey: pollCreation.key,
vote: encryptedVote,
senderTimestampMs: 123,
},
pollUpdateMessageKey: voteKey,
pollCreation,
decryptPollVote: (vote, ctx) => {
attempts.push({ pollCreatorJid: ctx.pollCreatorJid, voterJid: ctx.voterJid });
assert.equal(vote, encryptedVote);
assert.equal(ctx.pollMsgId, 'poll-creation');
assert.equal(ctx.pollEncKey, pollCreation.message.messageContextInfo.messageSecret);
if (ctx.pollCreatorJid !== 'creator-lid@lid') {
throw new Error('wrong creator jid');
}
assert.equal(ctx.voterJid, '15550001111@s.whatsapp.net');
return {
selectedOptions: [createHash('sha256').update(Buffer.from('Approve')).digest()],
};
},
getKeyAuthor: (key, meId = 'me') => (key?.fromMe ? meId : key?.participant || key?.remoteJid || ''),
meId: 'classic-me@s.whatsapp.net',
pollCreatorJids: ['classic-me@s.whatsapp.net', 'creator-lid@lid'],
});
assert.deepEqual(attempts.map(item => item.pollCreatorJid), ['classic-me@s.whatsapp.net', 'creator-lid@lid']);
assert.equal(pollUpdate.pollUpdateMessageKey.id, 'vote-message');
assert.equal(pollUpdate.senderTimestampMs, 123);
const aggregation = getAggregateVotesInPollMessage({
message: pollCreation.message,
pollUpdates: [pollUpdate],
});
assert.deepEqual(
aggregation.map(option => ({ name: option.name, voters: option.voters })),
[
{ name: 'Approve', voters: ['15550001111@s.whatsapp.net'] },
{ name: 'Deny', voters: [] },
],
);
console.log(' ✓ encrypted poll upserts are wrapped into Baileys aggregation shape');
}
// -- media download failure containment (port of nanoclaw#2895) -----------
{
assert.equal(appendMediaFailureNote('hello', []), 'hello');
assert.equal(
appendMediaFailureNote('check this out', ['image']),
'check this out\n[image could not be downloaded]',
);
// Regression guard: an uncaptioned failed image must still produce a
// non-empty body, or the empty-message guard drops the whole message.
assert.equal(appendMediaFailureNote('', ['image']), '[image could not be downloaded]');
assert.equal(
appendMediaFailureNote('', ['image', 'document']),
'[image could not be downloaded] [document could not be downloaded]',
);
console.log(' ✓ appendMediaFailureNote formats failure notes');
}
{
// A throwing downloadMedia (expired CDN URL) must not reject out of
// extractBridgeEvent — before this guard the whole upsert batch died and
// the message was silently dropped.
const event = await extractBridgeEvent({
msg: {
key: { id: 'img-fail-1', remoteJid: '15551234567@s.whatsapp.net', fromMe: false },
messageTimestamp: 123,
message: { imageMessage: { caption: '', mimetype: 'image/jpeg' } },
},
chatId: '15551234567@s.whatsapp.net',
senderId: '15551234567@s.whatsapp.net',
senderNumber: '15551234567',
downloadMedia: async () => { throw new Error('Failed to fetch stream from https://mmg.whatsapp.net/x'); },
cacheDirs: { image: mkdtempSync(path.join(tmpdir(), 'wa-media-')) },
});
assert.equal(event.hasMedia, true);
assert.equal(event.mediaUrls.length, 0);
assert.equal(event.body, '[image could not be downloaded]');
console.log(' ✓ failed media download is contained and surfaced in body');
}
{
// Captioned message keeps the caption and appends the failure note.
const event = await extractBridgeEvent({
msg: {
key: { id: 'doc-fail-1', remoteJid: '15551234567@s.whatsapp.net', fromMe: false },
messageTimestamp: 123,
message: { documentMessage: { caption: 'see attached', fileName: 'q.pdf', mimetype: 'application/pdf' } },
},
chatId: '15551234567@s.whatsapp.net',
senderId: '15551234567@s.whatsapp.net',
senderNumber: '15551234567',
downloadMedia: async () => { throw new Error('boom'); },
cacheDirs: { document: mkdtempSync(path.join(tmpdir(), 'wa-media-')) },
});
assert.equal(event.body, 'see attached\n[document could not be downloaded]');
assert.equal(event.mediaUrls.length, 0);
console.log(' ✓ captioned failed download keeps caption and appends note');
}
console.log('\n✅ All WhatsApp native bridge helper tests passed.');
@@ -0,0 +1,150 @@
/**
* Unit tests for the reconnect scheduling and version resolution guards.
*
* Regression tests for the reconnect-wedge trap: startSocket() awaits
* network I/O (fetchLatestBaileysVersion has no AbortSignal) before it
* creates a socket, and the close handler used to re-enter it via a bare
* `setTimeout(startSocket, ...)`. A rejection was unhandled and a stalled
* fetch left the bridge permanently disconnected while its HTTP server
* kept answering 503 observed in the field as a bridge that logged
* "Reconnecting in 3s..." once and then went silent for 27+ hours.
*
* These tests avoid importing bridge.js because that file starts an HTTP
* server and Baileys socket at module load. Keep the helper module pure.
*/
import { strict as assert } from 'node:assert';
import {
createReconnectScheduler,
createVersionResolver,
} from './bridge_helpers.js';
const tick = () => new Promise(resolve => setImmediate(resolve));
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
// -- createReconnectScheduler ---------------------------------------------
// A rejecting start function is caught and rescheduled at the retry delay;
// a subsequent success stops the retry chain.
{
const timers = [];
const logs = [];
let attempts = 0;
const startFn = async () => {
attempts += 1;
if (attempts === 1) throw new Error('boom');
};
const schedule = createReconnectScheduler(startFn, {
retryDelayMs: 5000,
log: line => logs.push(line),
setTimeoutFn: (fn, ms) => timers.push({ fn, ms }),
});
schedule(3000);
assert.equal(timers.length, 1);
assert.equal(timers[0].ms, 3000);
timers[0].fn();
await tick();
await tick();
assert.equal(attempts, 1);
assert.equal(logs.length, 1);
assert.match(logs[0], /Reconnect failed \(boom\)/);
assert.equal(timers.length, 2, 'rejection must schedule a retry');
assert.equal(timers[1].ms, 5000);
timers[1].fn();
await tick();
await tick();
assert.equal(attempts, 2);
assert.equal(timers.length, 2, 'success must not schedule another attempt');
assert.equal(logs.length, 1);
}
// A synchronous throw from the start function is contained the same way as
// an async rejection.
{
const timers = [];
const logs = [];
const schedule = createReconnectScheduler(
() => { throw new Error('sync boom'); },
{
retryDelayMs: 1000,
log: line => logs.push(line),
setTimeoutFn: (fn, ms) => timers.push({ fn, ms }),
},
);
schedule(0);
timers[0].fn();
await tick();
await tick();
assert.equal(logs.length, 1);
assert.match(logs[0], /sync boom/);
assert.equal(timers.length, 2);
}
// -- createVersionResolver ------------------------------------------------
// A successful fetch returns and caches the version.
{
const resolveVersion = createVersionResolver(
async () => ({ version: [2, 3000, 99] }),
{ log: () => {} },
);
assert.deepEqual(await resolveVersion(), [2, 3000, 99]);
}
// A fetch that never settles resolves within the timeout bound instead of
// pending forever; before any success there is no cache, so the resolver
// yields null (callers fall back to the Baileys default).
{
const logs = [];
const resolveVersion = createVersionResolver(
() => new Promise(() => {}),
{ timeoutMs: 20, log: line => logs.push(line) },
);
assert.equal(await resolveVersion(), null);
assert.equal(logs.length, 1);
assert.match(logs[0], /version fetch timed out/);
assert.match(logs[0], /library default/);
}
// After one success, later failures fall back to the cached version.
{
const logs = [];
let calls = 0;
const resolveVersion = createVersionResolver(
async () => {
calls += 1;
if (calls === 1) return { version: [2, 3000, 42] };
throw new Error('network down');
},
{ timeoutMs: 20, log: line => logs.push(line) },
);
assert.deepEqual(await resolveVersion(), [2, 3000, 42]);
assert.deepEqual(await resolveVersion(), [2, 3000, 42]);
assert.equal(logs.length, 1);
assert.match(logs[0], /network down/);
assert.match(logs[0], /cached version/);
}
// The losing timeout timer is cleared after a fast success, so the resolver
// does not hold the event loop open for the full timeout window.
{
const resolveVersion = createVersionResolver(
async () => ({ version: [2, 3000, 1] }),
{ timeoutMs: 60_000, log: () => {} },
);
const before = Date.now();
await resolveVersion();
await sleep(10);
assert.ok(Date.now() - before < 1000);
}
console.log('bridge.reconnect.test.mjs: all assertions passed');
@@ -0,0 +1,112 @@
/**
* Regression tests for the WhatsApp bridge send queue (#33360).
*
* The bridge must serialise all sock.sendMessage() calls through a
* promise-based queue so that concurrent HTTP /send requests never
* produce overlapping Baileys socket writes. Overlapping writes are
* the confirmed root cause of cross-chat contamination.
*
* These tests exercise the queue itself they do NOT require a live
* WhatsApp socket.
*/
import { strict as assert } from 'node:assert';
// ------------------------------------------------------------------
// 1. Unit test for the queue primitives
// ------------------------------------------------------------------
/**
* Replicate the queue logic from bridge.js so we can test it in
* isolation without importing the full module (which would trigger
* Baileys / express side effects).
*/
function createSendQueue() {
let _sendQueue = Promise.resolve();
function enqueueSend(fn) {
const task = _sendQueue.then(() => fn(), () => fn());
_sendQueue = task.catch(() => {});
return task;
}
return { enqueueSend };
}
// -- serial ordering -------------------------------------------------
{
const { enqueueSend } = createSendQueue();
const order = [];
const a = enqueueSend(async () => {
await new Promise(r => setTimeout(r, 30));
order.push('a');
return 'A';
});
const b = enqueueSend(async () => {
order.push('b');
return 'B';
});
const c = enqueueSend(async () => {
await new Promise(r => setTimeout(r, 10));
order.push('c');
return 'C';
});
const results = await Promise.all([a, b, c]);
assert.deepStrictEqual(results, ['A', 'B', 'C'], 'all tasks resolve');
assert.deepStrictEqual(order, ['a', 'b', 'c'], 'tasks execute in FIFO order');
console.log(' ✓ serial ordering');
}
// -- error isolation (one rejection does not stall the queue) --------
{
const { enqueueSend } = createSendQueue();
const order = [];
const bad = enqueueSend(async () => {
order.push('bad');
throw new Error('boom');
});
const good = enqueueSend(async () => {
order.push('good');
return 'ok';
});
await assert.rejects(() => bad, /boom/, 'bad task rejects');
const g = await good;
assert.strictEqual(g, 'ok', 'good task still resolves');
assert.deepStrictEqual(order, ['bad', 'good'], 'good runs after bad');
console.log(' ✓ error isolation');
}
// -- timeout still fires (wrapped inside enqueueSend) ----------------
{
const { enqueueSend } = createSendQueue();
const timedOut = enqueueSend(async () => {
await new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 20));
});
await assert.rejects(() => timedOut, /timeout/, 'inner timeout propagates');
console.log(' ✓ timeout propagation');
}
// -- concurrent enqueues maintain single-consumer semantics ----------
{
const { enqueueSend } = createSendQueue();
let concurrent = 0;
let maxConcurrent = 0;
async function tracked() {
concurrent += 1;
if (concurrent > maxConcurrent) maxConcurrent = concurrent;
await new Promise(r => setTimeout(r, 5));
concurrent -= 1;
}
await Promise.all(Array.from({ length: 20 }, () => enqueueSend(tracked)));
assert.strictEqual(maxConcurrent, 1, 'never more than one in-flight');
assert.strictEqual(concurrent, 0, 'all finished');
console.log(' ✓ single-consumer concurrency');
}
console.log('\n✅ All send-queue tests passed.');
+626
View File
@@ -0,0 +1,626 @@
import path from 'path';
import { mkdirSync, writeFileSync } from 'fs';
import { randomBytes } from 'crypto';
export const MIME_MAP = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
webp: 'image/webp', gif: 'image/gif',
mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo',
mkv: 'video/x-matroska', '3gp': 'video/3gpp',
pdf: 'application/pdf',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
};
export function normalizeWhatsAppId(value) {
if (!value) return '';
return String(value).replace(':', '@');
}
export function getMessageContent(msg) {
const content = msg?.message || {};
if (content.ephemeralMessage?.message) return content.ephemeralMessage.message;
if (content.viewOnceMessage?.message) return content.viewOnceMessage.message;
if (content.viewOnceMessageV2?.message) return content.viewOnceMessageV2.message;
if (content.documentWithCaptionMessage?.message) return content.documentWithCaptionMessage.message;
if (content.templateMessage?.hydratedTemplate) return content.templateMessage.hydratedTemplate;
if (content.buttonsMessage) return content.buttonsMessage;
if (content.listMessage) return content.listMessage;
return content;
}
export function getContextInfo(messageContent) {
if (!messageContent || typeof messageContent !== 'object') return {};
for (const value of Object.values(messageContent)) {
if (value && typeof value === 'object' && value.contextInfo) {
return value.contextInfo;
}
}
return {};
}
export function createBoundedMessageStore(limit = 512) {
const byId = new Map();
function remember(msg) {
const id = msg?.key?.id;
if (!id) return;
byId.delete(id);
byId.set(id, msg);
while (byId.size > limit) {
const oldest = byId.keys().next().value;
byId.delete(oldest);
}
}
function get(id) {
if (!id || !byId.has(id)) return null;
const msg = byId.get(id);
byId.delete(id);
byId.set(id, msg);
return msg;
}
return { remember, get };
}
export function pollCreationMessageSecret(pollCreation) {
return pollCreation?.message?.messageContextInfo?.messageSecret
|| pollCreation?.messageContextInfo?.messageSecret
|| null;
}
function uniqueStrings(values) {
const seen = new Set();
const out = [];
for (const value of values || []) {
const text = String(value || '').trim();
if (!text || seen.has(text)) continue;
seen.add(text);
out.push(text);
}
return out;
}
export function pollUpdateForAggregation({
pollUpdateMessage,
pollUpdateMessageKey,
pollCreation,
decryptPollVote,
getKeyAuthor,
meId = 'me',
pollCreatorJids = [],
voterJids = [],
}) {
if (!pollUpdateMessage) return null;
const updateKey = pollUpdateMessage.pollUpdateMessageKey
|| pollUpdateMessageKey
|| pollUpdateMessage.key;
if (!updateKey) return null;
if (pollUpdateMessage.vote?.selectedOptions) {
return {
pollUpdateMessageKey: updateKey,
vote: pollUpdateMessage.vote,
senderTimestampMs: pollUpdateMessage.senderTimestampMs,
};
}
const creationKey = pollUpdateMessage.pollCreationMessageKey;
const secret = pollCreationMessageSecret(pollCreation);
if (
!creationKey?.id
|| !secret
|| !pollUpdateMessage.vote?.encPayload
|| !pollUpdateMessage.vote?.encIv
|| typeof decryptPollVote !== 'function'
|| typeof getKeyAuthor !== 'function'
) {
return null;
}
// Baileys poll decryption keys include both creator and voter JIDs. On
// WhatsApp LID chats, the poll creator can be the linked-device LID even
// when sock.user.id is the classic @s.whatsapp.net JID. Try the exact
// candidates the live bridge knows before falling back to the generic helper.
const creatorCandidates = uniqueStrings([
...pollCreatorJids,
getKeyAuthor(creationKey, meId),
]);
const voterCandidates = uniqueStrings([
...voterJids,
getKeyAuthor(updateKey, meId),
]);
let lastError = null;
for (const pollCreatorJid of creatorCandidates) {
for (const voterJid of voterCandidates) {
try {
const vote = decryptPollVote(pollUpdateMessage.vote, {
pollCreatorJid,
pollMsgId: creationKey.id,
pollEncKey: secret,
voterJid,
});
return {
pollUpdateMessageKey: updateKey,
vote,
senderTimestampMs: pollUpdateMessage.senderTimestampMs,
};
} catch (err) {
lastError = err;
}
}
}
if (lastError) throw lastError;
return null;
}
export function buildTextSendPayload(text, { replyTo, messageStore } = {}) {
const content = { text };
const options = {};
const quoted = messageStore?.get(replyTo);
if (quoted?.key && quoted?.message) {
// Baileys expects quoted messages as sendMessage options, not inside the
// message content payload. Keeping this split avoids silently sending a
// literal/ignored `quoted` field instead of a native WhatsApp reply.
options.quoted = quoted;
}
return { content, options };
}
export function buildLocationPayload({ latitude, longitude, name, address } = {}) {
const lat = Number(latitude);
const lon = Number(longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
throw new Error('latitude and longitude must be numbers');
}
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {
throw new Error('latitude/longitude out of range');
}
const location = {
degreesLatitude: lat,
degreesLongitude: lon,
};
if (name) location.name = String(name);
if (address) location.address = String(address);
return { location };
}
function textFromQuotedMessage(quotedMessage) {
if (!quotedMessage) return '';
if (quotedMessage.conversation) return quotedMessage.conversation;
if (quotedMessage.extendedTextMessage?.text) return quotedMessage.extendedTextMessage.text;
if (quotedMessage.imageMessage?.caption) return quotedMessage.imageMessage.caption;
if (quotedMessage.videoMessage?.caption) return quotedMessage.videoMessage.caption;
if (quotedMessage.documentMessage?.caption) return quotedMessage.documentMessage.caption;
if (quotedMessage.documentMessage?.fileName) return `[Document: ${quotedMessage.documentMessage.fileName}]`;
if (quotedMessage.locationMessage) return formatLocationText(quotedMessage.locationMessage, false);
if (quotedMessage.contactMessage) return formatContactText(quotedMessage.contactMessage);
if (quotedMessage.pollCreationMessage) return formatPollText(quotedMessage.pollCreationMessage);
return '';
}
function mediaExtForMime(mime, fallback) {
const normalized = String(mime || '').split(';', 1)[0].toLowerCase();
const extMap = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'image/gif': '.gif',
'video/mp4': '.mp4',
'video/quicktime': '.mov',
'video/x-matroska': '.mkv',
'audio/ogg': '.ogg',
'audio/mp4': '.m4a',
'audio/mpeg': '.mp3',
'application/pdf': '.pdf',
};
return extMap[normalized] || fallback;
}
function defaultWriteMediaFile({ buffer, dir, prefix, ext, fileName }) {
mkdirSync(dir, { recursive: true });
let safeName = fileName ? `_${path.basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '_')}` : '';
if (safeName && ext && !path.extname(safeName)) {
safeName = `${safeName}${ext}`;
}
const filePath = path.join(dir, `${prefix}_${randomBytes(6).toString('hex')}${safeName || ext}`);
writeFileSync(filePath, buffer);
return filePath;
}
function formatLocationText(location, isLive) {
const name = location.name || location.address || '';
const lat = location.degreesLatitude ?? location.latitude;
const lng = location.degreesLongitude ?? location.longitude;
const kind = isLive ? 'Live location' : 'Location';
const coords = lat !== undefined && lng !== undefined ? `${lat},${lng}` : '';
return `[${kind}: ${[name, coords].filter(Boolean).join(' ')}]`;
}
function locationMetadata(location, isLive) {
return {
name: location.name || '',
address: location.address || '',
latitude: location.degreesLatitude ?? location.latitude ?? null,
longitude: location.degreesLongitude ?? location.longitude ?? null,
isLive,
};
}
function formatContactText(contact) {
const name = contact.displayName || contact.vcard?.match(/FN:(.+)/)?.[1] || 'unknown';
const phone = contact.vcard?.match(/TEL[^:]*:(.+)/)?.[1] || '';
return `[Contact: ${[name, phone].filter(Boolean).join(' ')}]`;
}
function formatContactsText(contacts) {
const names = contacts.map(c => c.displayName).filter(Boolean);
return `[Contacts: ${names.join(', ') || contacts.length}]`;
}
function formatReactionText(reaction) {
const emoji = reaction.text || '';
const target = reaction.key?.id || '';
return `[Reaction: ${emoji}${target ? ` to ${target}` : ''}]`;
}
function pollOptions(poll) {
return (poll.options || [])
.map(option => option.optionName || option.name)
.filter(Boolean);
}
function formatPollText(poll) {
const question = poll.name || poll.title || 'poll';
const options = pollOptions(poll);
return `[Poll: ${question}${options.length ? ` Options: ${options.join(', ')}` : ''}]`;
}
function formatPollUpdateText(update) {
const target = update.pollCreationMessageKey?.id || update.key?.id || '';
return `[Poll update${target ? `: ${target}` : ''}]`;
}
/**
* Append a visible note for media that failed to download, so the agent knows
* something was sent rather than silently losing the attachment. Returns
* `content` unchanged when nothing failed. (Port of nanoclaw#2895.)
*/
export function appendMediaFailureNote(content, failures) {
if (!failures || failures.length === 0) return content;
const note = failures.map((t) => `[${t} could not be downloaded]`).join(' ');
return content ? `${content}\n${note}` : note;
}
export async function extractBridgeEvent({
msg,
chatId,
senderId,
senderNumber,
botIds = [],
isGroup = false,
downloadMedia,
writeMediaFile,
cacheDirs = {},
}) {
const messageContent = getMessageContent(msg);
const contextInfo = getContextInfo(messageContent);
const mentionedIds = Array.from(new Set((contextInfo?.mentionedJid || []).map(normalizeWhatsAppId).filter(Boolean)));
const quotedMessageId = contextInfo?.stanzaId || null;
const quotedParticipant = normalizeWhatsAppId(contextInfo?.participant || '') || null;
const quotedRemoteJid = normalizeWhatsAppId(contextInfo?.remoteJid || '') || null;
const hasQuotedMessage = !!contextInfo?.quotedMessage;
const quotedText = textFromQuotedMessage(contextInfo?.quotedMessage);
let body = '';
let hasMedia = false;
let mediaType = '';
let mime = '';
let fileName = '';
let nativeType = '';
const mediaUrls = [];
const nativeMetadata = {};
const mediaFailures = [];
const saveMedia = async ({ mediaMessage, dir, prefix, fallbackExt, fileName: name, type }) => {
if (!downloadMedia) return;
try {
const buf = await downloadMedia(msg);
const ext = mediaExtForMime(mediaMessage?.mimetype, fallbackExt);
const writer = writeMediaFile || defaultWriteMediaFile;
const saved = await writer({ buffer: buf, dir, prefix, ext, fileName: name });
if (saved) mediaUrls.push(saved);
} catch (err) {
// A failed CDN fetch (expired media URL, transient network error) must
// never reject out of extractBridgeEvent — that would drop this message
// AND every remaining message in the same upsert batch. Record the
// failure so the agent is told media was sent instead of losing it
// silently. (Port of nanoclaw#2895's never-silently-drop guarantee; the
// reuploadRequest recovery half is already wired in bridge.js.)
mediaFailures.push(type || 'media');
try {
console.warn(`[bridge] failed to download inbound ${type || 'media'}:`, err?.message || err);
} catch {}
}
};
if (messageContent.conversation) {
body = messageContent.conversation;
nativeType = 'conversation';
} else if (messageContent.extendedTextMessage?.text) {
body = messageContent.extendedTextMessage.text;
nativeType = 'extendedTextMessage';
} else if (messageContent.imageMessage) {
const item = messageContent.imageMessage;
body = item.caption || '';
hasMedia = true;
mediaType = 'image';
nativeType = 'imageMessage';
mime = item.mimetype || 'image/jpeg';
await saveMedia({ mediaMessage: item, dir: cacheDirs.image, prefix: 'img', fallbackExt: '.jpg', type: 'image' });
} else if (messageContent.videoMessage) {
const item = messageContent.videoMessage;
body = item.caption || '';
hasMedia = true;
mediaType = item.gifPlayback ? 'gif' : 'video';
nativeType = 'videoMessage';
mime = item.mimetype || 'video/mp4';
nativeMetadata.video = { gifPlayback: !!item.gifPlayback };
await saveMedia({ mediaMessage: item, dir: cacheDirs.document, prefix: 'vid', fallbackExt: '.mp4', type: mediaType });
} else if (messageContent.audioMessage || messageContent.pttMessage) {
const item = messageContent.pttMessage || messageContent.audioMessage;
hasMedia = true;
mediaType = item.ptt || messageContent.pttMessage ? 'ptt' : 'audio';
nativeType = messageContent.pttMessage ? 'pttMessage' : 'audioMessage';
mime = item.mimetype || 'audio/ogg';
nativeMetadata.audio = { ptt: mediaType === 'ptt' };
await saveMedia({ mediaMessage: item, dir: cacheDirs.audio, prefix: 'aud', fallbackExt: '.ogg', type: 'audio' });
} else if (messageContent.documentMessage) {
const item = messageContent.documentMessage;
body = item.caption || '';
hasMedia = true;
mediaType = 'document';
nativeType = 'documentMessage';
mime = item.mimetype || 'application/octet-stream';
fileName = item.fileName || 'document';
await saveMedia({ mediaMessage: item, dir: cacheDirs.document, prefix: 'doc', fallbackExt: '.bin', fileName, type: 'document' });
} else if (messageContent.stickerMessage) {
hasMedia = true;
mediaType = 'sticker';
nativeType = 'stickerMessage';
mime = messageContent.stickerMessage.mimetype || 'image/webp';
body = '[Sticker]';
nativeMetadata.sticker = {
animated: !!messageContent.stickerMessage.isAnimated,
mimetype: mime,
};
await saveMedia({ mediaMessage: messageContent.stickerMessage, dir: cacheDirs.image, prefix: 'sticker', fallbackExt: '.webp', type: 'sticker' });
} else if (messageContent.locationMessage || messageContent.liveLocationMessage) {
const isLive = !!messageContent.liveLocationMessage;
const item = messageContent.liveLocationMessage || messageContent.locationMessage;
mediaType = isLive ? 'live_location' : 'location';
nativeType = isLive ? 'liveLocationMessage' : 'locationMessage';
body = formatLocationText(item, isLive);
nativeMetadata.location = locationMetadata(item, isLive);
} else if (messageContent.contactMessage) {
mediaType = 'contact';
nativeType = 'contactMessage';
body = formatContactText(messageContent.contactMessage);
nativeMetadata.contact = {
displayName: messageContent.contactMessage.displayName || '',
vcard: messageContent.contactMessage.vcard || '',
};
} else if (messageContent.contactsArrayMessage) {
const contacts = messageContent.contactsArrayMessage.contacts || [];
mediaType = 'contacts';
nativeType = 'contactsArrayMessage';
body = formatContactsText(contacts);
nativeMetadata.contacts = contacts.map(contact => ({
displayName: contact.displayName || '',
vcard: contact.vcard || '',
}));
} else if (messageContent.reactionMessage) {
mediaType = 'reaction';
nativeType = 'reactionMessage';
body = formatReactionText(messageContent.reactionMessage);
nativeMetadata.reaction = {
text: messageContent.reactionMessage.text || '',
messageId: messageContent.reactionMessage.key?.id || '',
remoteJid: normalizeWhatsAppId(messageContent.reactionMessage.key?.remoteJid || ''),
participant: normalizeWhatsAppId(messageContent.reactionMessage.key?.participant || ''),
};
} else if (messageContent.pollCreationMessage || messageContent.pollCreationMessageV2 || messageContent.pollCreationMessageV3) {
const item = messageContent.pollCreationMessage || messageContent.pollCreationMessageV2 || messageContent.pollCreationMessageV3;
mediaType = 'poll';
nativeType = messageContent.pollCreationMessage ? 'pollCreationMessage' : messageContent.pollCreationMessageV2 ? 'pollCreationMessageV2' : 'pollCreationMessageV3';
body = formatPollText(item);
nativeMetadata.poll = {
question: item.name || item.title || '',
options: pollOptions(item),
selectableCount: item.selectableOptionsCount || item.selectableCount || 1,
};
} else if (messageContent.pollUpdateMessage) {
mediaType = 'poll_update';
nativeType = 'pollUpdateMessage';
body = formatPollUpdateText(messageContent.pollUpdateMessage);
nativeMetadata.pollUpdate = messageContent.pollUpdateMessage;
}
// Surface failed downloads to the agent instead of silently losing the
// attachment. Applied before the generic "[<type> received]" fallback so an
// uncaptioned message whose download failed reads "[image could not be
// downloaded]" rather than claiming the media arrived.
body = appendMediaFailureNote(body, mediaFailures);
if (hasMedia && !body) {
body = `[${mediaType} received]`;
}
return {
messageId: msg.key.id,
chatId,
senderId,
senderName: msg.pushName || senderNumber,
chatName: isGroup ? (chatId.split('@')[0]) : (msg.pushName || senderNumber),
isGroup,
body,
hasMedia,
mediaType,
mime,
fileName,
nativeType,
nativeMetadata,
mediaUrls,
mentionedIds,
quotedMessageId,
quotedParticipant,
quotedRemoteJid,
quotedText,
hasQuotedMessage,
botIds,
readReceiptKey: {
remoteJid: msg.key.remoteJid || chatId,
id: msg.key.id,
participant: msg.key.participant || senderId,
fromMe: Boolean(msg.key.fromMe),
},
timestamp: msg.messageTimestamp,
};
}
export function inferMediaType(ext) {
if (['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(ext)) return 'image';
if (['mp4', 'mov', 'avi', 'mkv', '3gp'].includes(ext)) return 'video';
if (['ogg', 'opus', 'mp3', 'wav', 'm4a'].includes(ext)) return 'audio';
return 'document';
}
export function inboundReadReceiptKeys({ key, enabled }) {
if (!enabled || !key || key.fromMe || !key.id || !key.remoteJid) return [];
// Preserve participant for group messages: Baileys needs the original key.
return [key];
}
export function mediaPayloadForFile({ buffer, filePath, mediaType, caption, fileName }) {
const ext = filePath.toLowerCase().split('.').pop();
const type = mediaType || inferMediaType(ext);
if (type === 'image' && ext === 'gif') {
// Pure helper fallback: do not lie and label raw GIF bytes as mp4.
// The live bridge tries ffmpeg conversion to WhatsApp gifPlayback video
// before it falls back to this regular image payload.
return { image: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'image/gif' };
}
switch (type) {
case 'image':
return { image: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'image/jpeg' };
case 'video':
return { video: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'video/mp4' };
case 'document':
return {
document: buffer,
fileName: fileName || path.basename(filePath),
caption: caption || undefined,
mimetype: MIME_MAP[ext] || 'application/octet-stream',
};
default:
return null;
}
}
export function buildPollPayload({ question, options, selectableCount = 1 }) {
const cleanQuestion = String(question || '').trim();
const cleanOptions = (options || []).map(option => String(option || '').trim()).filter(Boolean);
if (!cleanQuestion) throw new Error('question is required');
if (cleanOptions.length < 2) throw new Error('at least two poll options are required');
if (cleanOptions.length > 12) throw new Error('at most 12 poll options are supported');
const count = Math.max(1, Math.min(Number(selectableCount) || 1, cleanOptions.length));
return {
poll: {
name: cleanQuestion,
values: cleanOptions,
selectableCount: count,
messageSecret: randomBytes(32),
},
};
}
export function pollCreationMessageFromPayload(payload) {
const poll = payload?.poll;
if (!poll) return null;
const values = Array.isArray(poll.values) ? poll.values : [];
const options = values.map(value => String(value || '').trim()).filter(Boolean);
if (!poll.name || options.length < 2) return null;
const selectableOptionsCount = Math.max(1, Math.min(Number(poll.selectableCount) || 1, options.length));
const message = {};
if (poll.messageSecret) {
message.messageContextInfo = { messageSecret: poll.messageSecret };
}
message[selectableOptionsCount === 1 ? 'pollCreationMessageV3' : 'pollCreationMessage'] = {
name: String(poll.name),
options: options.map(optionName => ({ optionName })),
selectableOptionsCount,
};
return message;
}
/**
* Reconnect scheduling guard. startSocket() awaits network I/O before it
* creates a socket or registers event handlers, so a bare
* `setTimeout(startSocket, ...)` has two unrecoverable failure modes: a
* rejection is unhandled (crashes the process on modern Node), and a hang
* leaves the bridge permanently disconnected with nothing left to retry.
* Every (re)connect must go through the scheduler this returns.
*/
export function createReconnectScheduler(startFn, {
retryDelayMs = 5000,
log = console.log,
setTimeoutFn = setTimeout,
} = {}) {
function scheduleReconnect(delayMs) {
setTimeoutFn(() => {
Promise.resolve()
.then(startFn)
.catch((err) => {
log(`⚠️ Reconnect failed (${err?.message || err}). Retrying in ${Math.round(retryDelayMs / 1000)}s...`);
scheduleReconnect(retryDelayMs);
});
}, delayMs);
}
return scheduleReconnect;
}
/**
* Version resolution guard. fetchLatestBaileysVersion() is a plain fetch to
* raw.githubusercontent.com with no AbortSignal; a stalled connection can
* pend forever and wedge the reconnect path (the scheduler above cannot
* retry past an await that never settles). Bound the fetch and fall back to
* the last known-good version, or the Baileys default before first success.
*/
export function createVersionResolver(fetchVersionFn, {
timeoutMs = 15000,
log = console.log,
} = {}) {
let cachedVersion = null;
return async function resolveVersion() {
let timer = null;
try {
const { version } = await Promise.race([
fetchVersionFn(),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error('version fetch timed out')), timeoutMs);
}),
]);
cachedVersion = version;
} catch (err) {
log(`⚠️ Baileys version fetch failed (${err?.message || err}); using ${cachedVersion ? 'cached version' : 'library default'}.`);
} finally {
if (timer) clearTimeout(timer);
}
return cachedVersion;
};
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Bounded FIFO set of outbound message IDs.
*
* Used by the WhatsApp bridge to distinguish "echo of our own /send" from
* "owner-typed message on the linked device" when forwarding `fromMe`
* inbound events back to the Python adapter.
*
* Eviction drops the oldest insertion-order entry when the cap is exceeded.
* Re-remembering an existing id is a no-op for ordering (not LRU refresh).
*
* Heuristic limitation (intentional, documented for future debugging):
* the set is in-memory only. On bridge restart it is empty, so for the
* brief window between restart and the first new outbound, any in-flight
* delivery receipts of pre-restart sends would be classified as
* owner-typed. The TTL on owner-driven plugin actions (e.g. handover
* sliding TTL) bounds blast radius; persisting would not be worth the
* extra complexity / disk churn.
*/
export function createOutboundIdTracker(maxSize = 512) {
if (!Number.isInteger(maxSize) || maxSize < 1) {
throw new RangeError('createOutboundIdTracker: maxSize must be a positive integer');
}
const ids = new Set();
function remember(id) {
if (!id) return;
ids.add(id);
while (ids.size > maxSize) {
// Set iteration order is insertion order, so values().next() is the
// oldest entry — drop it to keep memory flat under sustained sending.
ids.delete(ids.values().next().value);
}
}
function has(id) {
return Boolean(id) && ids.has(id);
}
function size() {
return ids.size;
}
return { remember, has, size };
}
@@ -0,0 +1,68 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createOutboundIdTracker } from './outbound_ids.js';
test('remembers and recognises an outbound id', () => {
const tracker = createOutboundIdTracker();
tracker.remember('msg-1');
assert.equal(tracker.has('msg-1'), true);
assert.equal(tracker.has('msg-2'), false);
});
test('ignores empty / falsy ids', () => {
const tracker = createOutboundIdTracker();
tracker.remember(undefined);
tracker.remember('');
tracker.remember(null);
assert.equal(tracker.size(), 0);
assert.equal(tracker.has(''), false);
assert.equal(tracker.has(undefined), false);
});
test('evicts oldest entry once the cap is exceeded', () => {
const tracker = createOutboundIdTracker(3);
tracker.remember('a');
tracker.remember('b');
tracker.remember('c');
tracker.remember('d'); // cap=3 → 'a' should be evicted
assert.equal(tracker.has('a'), false);
assert.equal(tracker.has('b'), true);
assert.equal(tracker.has('c'), true);
assert.equal(tracker.has('d'), true);
assert.equal(tracker.size(), 3);
});
test('cap holds across many inserts (bounded memory)', () => {
const tracker = createOutboundIdTracker(8);
for (let i = 0; i < 100; i += 1) {
tracker.remember(`id-${i}`);
}
assert.equal(tracker.size(), 8);
// Oldest (id-0..id-91) should be gone, latest 8 retained.
assert.equal(tracker.has('id-0'), false);
assert.equal(tracker.has('id-91'), false);
assert.equal(tracker.has('id-92'), true);
assert.equal(tracker.has('id-99'), true);
});
test('re-remembering an existing id does not promote it (FIFO, not LRU)', () => {
// Insertion-order semantics: re-adding doesn't move it forward in
// Set iteration order. This is intentional — we don't need recency,
// just bounded membership. Pin the actual behaviour so future
// refactors don't accidentally introduce LRU refresh semantics.
const tracker = createOutboundIdTracker(2);
tracker.remember('a');
tracker.remember('b');
tracker.remember('a'); // no-op for ordering
tracker.remember('c'); // evicts 'a' (oldest by insertion)
assert.equal(tracker.has('a'), false);
assert.equal(tracker.has('b'), true);
assert.equal(tracker.has('c'), true);
});
test('rejects non-positive maxSize', () => {
assert.throws(() => createOutboundIdTracker(0), RangeError);
assert.throws(() => createOutboundIdTracker(-1), RangeError);
assert.throws(() => createOutboundIdTracker(1.5), RangeError);
});
@@ -0,0 +1,56 @@
/**
* Pure classifier for the WhatsApp bridge's bot-mode dispatch loop.
*
* Centralises the "should this fromMe message be forwarded as fromOwner?"
* decision so the gate can be unit-tested without spinning up Baileys or
* the Express server.
*
* Lives next to `outbound_ids.js` rather than inline in `bridge.js`
* because the previous implementation accidentally bypassed the
* customer-side allowlist when forwarding owner-typed messages see
* the regression test in `owner_message_gate.test.mjs`.
*
* Caller responsibilities:
* - Only invoke in bot mode. Self-chat mode has its own self-chat
* pinning logic and must not delegate here.
* - Pre-filter group / status JIDs (the gate doesn't know about them).
* - On `drop_allowlist`, log the rejection so operators can audit
* accidental allowlist mismatches.
*
* Returned actions:
* - 'pass' : non-fromMe, fall through to existing handling
* - 'drop_echo' : fromMe and matches a recently-sent /send id
* - 'drop_disabled' : fromMe but operator hasn't opted into forwarding
* - 'drop_allowlist' : fromMe and the *customer chatId* isn't on the
* allowlist (owner-typed reply to a stranger)
* - 'forward_owner' : fromMe, owner-typed, allowlisted forward with
* fromOwner: true
*/
export function classifyOwnerMessageGate({
fromMe,
fromOwnerEnabled,
recentlySent,
allowlistMatches,
messageId,
chatId,
}) {
if (!fromMe) {
return { action: 'pass' };
}
if (recentlySent && recentlySent.has(messageId)) {
return { action: 'drop_echo' };
}
if (!fromOwnerEnabled) {
return { action: 'drop_disabled' };
}
// Allowlist gate: check the *customer* chatId, not the sender. The
// sender is the owner's own number/LID and won't be on the allowlist
// by construction. Without this check, any contact the owner happens
// to reply to leaks into Hermes and triggers implicit handover in the
// gateway-policy plugin.
if (typeof allowlistMatches === 'function' && !allowlistMatches(chatId)) {
return { action: 'drop_allowlist' };
}
return { action: 'forward_owner' };
}
@@ -0,0 +1,126 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { classifyOwnerMessageGate } from './owner_message_gate.js';
function makeRecentlySent(ids = []) {
const set = new Set(ids);
return { has: (id) => set.has(id) };
}
function makeAllowlist(allowedChatIds) {
if (allowedChatIds === '*') {
return () => true;
}
const set = new Set(allowedChatIds);
return (id) => set.has(id);
}
test('non-fromMe messages always pass through', () => {
const decision = classifyOwnerMessageGate({
fromMe: false,
fromOwnerEnabled: true,
recentlySent: makeRecentlySent(),
allowlistMatches: makeAllowlist([]),
messageId: 'M1',
chatId: '6281234567890@s.whatsapp.net',
});
assert.deepEqual(decision, { action: 'pass' });
});
test('fromMe echo of our own /send is dropped', () => {
const decision = classifyOwnerMessageGate({
fromMe: true,
fromOwnerEnabled: true,
recentlySent: makeRecentlySent(['M-OWN-1']),
allowlistMatches: makeAllowlist('*'),
messageId: 'M-OWN-1',
chatId: '6281234567890@s.whatsapp.net',
});
assert.deepEqual(decision, { action: 'drop_echo' });
});
test('fromMe is dropped when forwarding is disabled', () => {
const decision = classifyOwnerMessageGate({
fromMe: true,
fromOwnerEnabled: false,
recentlySent: makeRecentlySent(),
allowlistMatches: makeAllowlist('*'),
messageId: 'M-OWN-2',
chatId: '6281234567890@s.whatsapp.net',
});
assert.deepEqual(decision, { action: 'drop_disabled' });
});
test('fromMe is dropped when chatId is not on the allowlist (regression)', () => {
// This is the bug. Before the fix, an owner reply in a non-allowlisted
// chat was still forwarded with fromOwner: true, which made the
// gateway-policy owner-implicit branch create stray handover rows for
// the non-allowlisted contact.
const decision = classifyOwnerMessageGate({
fromMe: true,
fromOwnerEnabled: true,
recentlySent: makeRecentlySent(),
allowlistMatches: makeAllowlist(['6281234567890@s.whatsapp.net']),
messageId: 'M-OWN-3',
chatId: '111600547700784@lid',
});
assert.deepEqual(decision, { action: 'drop_allowlist' });
});
test('fromMe is forwarded as owner when chatId is allowlisted', () => {
const decision = classifyOwnerMessageGate({
fromMe: true,
fromOwnerEnabled: true,
recentlySent: makeRecentlySent(),
allowlistMatches: makeAllowlist(['6281234567890@s.whatsapp.net']),
messageId: 'M-OWN-4',
chatId: '6281234567890@s.whatsapp.net',
});
assert.deepEqual(decision, { action: 'forward_owner' });
});
test('open-allowlist (matchesAllowedUser short-circuits true) forwards as owner', () => {
// matchesAllowedUser returns true on empty allowlist or "*"; the gate
// must respect that so deployments without an allowlist are unaffected
// by the new check.
const decision = classifyOwnerMessageGate({
fromMe: true,
fromOwnerEnabled: true,
recentlySent: makeRecentlySent(),
allowlistMatches: () => true,
messageId: 'M-OWN-5',
chatId: '111600547700784@lid',
});
assert.deepEqual(decision, { action: 'forward_owner' });
});
test('echo check fires before allowlist check', () => {
// A bot-API echo whose chatId happens to be off-allowlist should still
// be dropped as drop_echo, not drop_allowlist, so logging stays
// honest about the actual reason.
const decision = classifyOwnerMessageGate({
fromMe: true,
fromOwnerEnabled: true,
recentlySent: makeRecentlySent(['M-ECHO-1']),
allowlistMatches: makeAllowlist([]),
messageId: 'M-ECHO-1',
chatId: '111600547700784@lid',
});
assert.deepEqual(decision, { action: 'drop_echo' });
});
test('disabled flag fires before allowlist check', () => {
// Pre-existing deployments with WHATSAPP_FORWARD_OWNER_MESSAGES unset
// must see drop_disabled regardless of allowlist state, otherwise
// every fromMe message would log a misleading allowlist_mismatch.
const decision = classifyOwnerMessageGate({
fromMe: true,
fromOwnerEnabled: false,
recentlySent: makeRecentlySent(),
allowlistMatches: makeAllowlist([]),
messageId: 'M-OWN-6',
chatId: '111600547700784@lid',
});
assert.deepEqual(decision, { action: 'drop_disabled' });
});
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "hermes-whatsapp-bridge",
"version": "1.0.0",
"description": "WhatsApp bridge for Hermes Agent using Baileys",
"private": true,
"type": "module",
"scripts": {
"start": "node bridge.js"
},
"dependencies": {
"@whiskeysockets/baileys": "7.0.0-rc13",
"express": "^4.21.0",
"qrcode-terminal": "^0.12.0",
"pino": "^9.0.0"
},
"overrides": {
"protobufjs": "^7.5.5",
"body-parser": "1.20.6"
}
}