feat(desktop): openExternalFileForIpc opens files via OS handler
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Advisory lint: profile-scope hazard patterns on the lines a change adds.
|
||||
|
||||
One Hermes process may serve many profiles (multiplex gateway, Desktop/dashboard ``serve``), and
|
||||
``os.environ`` / module globals hold only the LAUNCH profile's values. Every pattern in
|
||||
``scripts/ci/profile_scope_patterns.json`` is a call-site shape that turned out to be
|
||||
profile-sensitive at least once — a child env built from ``os.environ``, a raw ``os.getenv`` of a
|
||||
platform credential, an RPC decorator that binds the home but not the secret scope, a bare PID
|
||||
liveness check. The invariant itself is in the root ``AGENTS.md`` (§ Code Shape Rules).
|
||||
|
||||
Advisory by construction: it prints ``file:line <id>/<class> why`` for every hit and ALWAYS
|
||||
exits 0, because most patterns have legitimate sites (a standalone ``hermes -p x`` process where
|
||||
environ IS the profile). The reviewer reads each finding against its ``scope_hint``.
|
||||
|
||||
Usage:
|
||||
python scripts/check_profile_scope_patterns.py [--base origin/main] [--head HEAD]
|
||||
python scripts/check_profile_scope_patterns.py --files tools/bot_relay.py ... # whole files
|
||||
python scripts/check_profile_scope_patterns.py --base origin/main --json out.json
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PATTERNS = ROOT / "scripts" / "ci" / "profile_scope_patterns.json"
|
||||
SUFFIXES = (".py", ".ts", ".tsx")
|
||||
SKIP_PREFIXES = ("tests/", "website/", "skills/", "optional-skills/", "evals/", "scripts/", ".worktrees/")
|
||||
_HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Finding:
|
||||
path: str
|
||||
line: int
|
||||
pattern_id: str
|
||||
pattern_class: str
|
||||
why: str
|
||||
scope_hint: str
|
||||
text: str
|
||||
|
||||
|
||||
def load_patterns(path: Path = PATTERNS) -> list[dict]:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
out = []
|
||||
for p in data["patterns"]:
|
||||
out.append({**p, "_rx": re.compile(p["pattern_regex"], re.M)})
|
||||
return out
|
||||
|
||||
|
||||
def _lint_path(rel: str) -> bool:
|
||||
return rel.endswith(SUFFIXES) and not rel.startswith(SKIP_PREFIXES) and "/tests/" not in rel and "node_modules" not in rel
|
||||
|
||||
|
||||
def scan_text(rel: str, text: str, patterns: list[dict], lines: set[int] | None = None) -> list[Finding]:
|
||||
"""Findings for *text*; ``lines`` restricts to those 1-based line numbers (None = whole file).
|
||||
Multi-line patterns are anchored on the line where the match starts."""
|
||||
findings: list[Finding] = []
|
||||
src_lines = text.split("\n")
|
||||
for p in patterns:
|
||||
for m in p["_rx"].finditer(text):
|
||||
line_no = text.count("\n", 0, m.start()) + 1
|
||||
if lines is not None and line_no not in lines:
|
||||
continue
|
||||
findings.append(Finding(rel, line_no, p["id"], p["class"], p["why"], p["scope_hint"],
|
||||
src_lines[line_no - 1].strip()[:160]))
|
||||
return findings
|
||||
|
||||
|
||||
def _git(*args: str) -> str:
|
||||
proc = subprocess.run(["git", *args], cwd=ROOT, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def added_lines_vs_base(base: str, head: str | None) -> dict[str, set[int]]:
|
||||
"""``{path: {added line numbers in head}}`` for lint-able files changed between *base* and *head*
|
||||
(working tree when *head* is None)."""
|
||||
rev = [base, head] if head else [base]
|
||||
diff = _git("diff", "--no-color", "-U0", "--diff-filter=AM", *rev, "--")
|
||||
out: dict[str, set[int]] = {}
|
||||
current: str | None = None
|
||||
for raw in diff.split("\n"):
|
||||
if raw.startswith("+++ "):
|
||||
name = raw[4:]
|
||||
current = name[2:] if name.startswith("b/") else None
|
||||
if current is not None and not _lint_path(current):
|
||||
current = None
|
||||
continue
|
||||
if current is None:
|
||||
continue
|
||||
m = _HUNK_RE.match(raw)
|
||||
if m:
|
||||
start, count = int(m.group(1)), int(m.group(2) or "1")
|
||||
out.setdefault(current, set()).update(range(start, start + count))
|
||||
return out
|
||||
|
||||
|
||||
def _read(rel: str, head: str | None) -> str | None:
|
||||
if head:
|
||||
cmd = ["git", "show", f"{head}:{rel}"]
|
||||
r = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
return r.stdout if r.returncode == 0 else None
|
||||
path = ROOT / rel
|
||||
return path.read_text(encoding="utf-8", errors="replace") if path.is_file() else None
|
||||
|
||||
|
||||
def run(base: str | None, head: str | None, files: list[str], patterns: list[dict]) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
if files:
|
||||
for f in files:
|
||||
path = Path(f)
|
||||
text = path.read_text(encoding="utf-8", errors="replace") if path.is_file() else None
|
||||
if text is None:
|
||||
continue
|
||||
resolved = path.resolve()
|
||||
rel = str(resolved.relative_to(ROOT)) if resolved.is_relative_to(ROOT) else str(path)
|
||||
findings.extend(scan_text(rel, text, patterns))
|
||||
return findings
|
||||
for rel, lines in sorted(added_lines_vs_base(base or "origin/main", head).items()):
|
||||
text = _read(rel, head)
|
||||
if text is not None:
|
||||
findings.extend(scan_text(rel, text, patterns, lines))
|
||||
return findings
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--base", default="origin/main", help="base ref for the added-lines diff")
|
||||
ap.add_argument("--head", default=None, help="head ref (default: working tree)")
|
||||
ap.add_argument("--files", nargs="*", default=[], help="scan these whole files instead of a diff")
|
||||
ap.add_argument("--json", default=None, help="also write findings as JSON to this path")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
patterns = load_patterns()
|
||||
findings = run(args.base, args.head, args.files, patterns)
|
||||
if args.json:
|
||||
Path(args.json).write_text(json.dumps([asdict(f) for f in findings], indent=2) + "\n", encoding="utf-8")
|
||||
if not findings:
|
||||
print("profile-scope patterns: 0 findings")
|
||||
return 0
|
||||
print(f"profile-scope patterns: {len(findings)} finding(s) — ADVISORY, read each against its scope hint "
|
||||
f"(root AGENTS.md § Code Shape Rules; scripts/ci/profile_scope_patterns.json)")
|
||||
for f in findings:
|
||||
print(f"{f.path}:{f.line} {f.pattern_id}/{f.pattern_class} {f.why}")
|
||||
print(f" | {f.text}")
|
||||
print(f" hint: {f.scope_hint}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail when a test file fakes macOS without carrying ``@pytest.mark.macos_only``.
|
||||
|
||||
The OS lanes are marker-driven: ``.github/workflows/tests-os.yml`` selects the
|
||||
files the macOS job imports via ``scripts/ci/list_os_marked_tests.py macos_only``
|
||||
and then runs ``-m macos_only``. A file whose tests only pass because they make
|
||||
the interpreter believe it is on macOS (``is_macos`` patched to ``True``,
|
||||
``sys.platform`` set to ``"darwin"``) but that carries no marker is invisible to
|
||||
that lane: it is green on Linux over a faked branch and never imported on the
|
||||
host it exists for (#111866). Root ``AGENTS.md`` § "Don't fake the host OS" is
|
||||
the rule; this check makes a violation a red job instead of a review catch.
|
||||
|
||||
Flags, per ``tests/**/test_*.py`` without a whole-word ``macos_only``:
|
||||
|
||||
monkeypatch.setattr(mod, "is_macos", lambda: True) / patch(..., return_value=True)
|
||||
monkeypatch.setattr(sys, "platform", "darwin") / patch("sys.platform", "darwin")
|
||||
platform.system patched to return "Darwin"
|
||||
|
||||
Opt out of one line with ``# os-marker: ok — <why>`` on that line (a pure
|
||||
function taking the platform as data is host-independent and stays unmarked).
|
||||
``_BASELINE`` lists the files that already faked macOS when this check landed;
|
||||
they are a burn-down list, not a policy — split the macOS arm out, mark it,
|
||||
and drop the entry (a stale entry fails the check).
|
||||
|
||||
Run: python scripts/ci/check_os_marker_fakes.py [tests_root]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
MARKER = "macos_only"
|
||||
OPT_OUT = "os-marker: ok"
|
||||
|
||||
_TRUE = r"(?:lambda[^:]*:\s*True|return_value\s*=\s*True|,\s*True\b)"
|
||||
_FAKES = (
|
||||
re.compile(rf"\bis_macos\b.*{_TRUE}"),
|
||||
re.compile(r"\bis_macos\.return_value\s*=\s*True\b"),
|
||||
# setattr(sys, "platform", "darwin") / patch("sys.platform", "darwin") / x.platform = "darwin";
|
||||
# a host-honest READ (`if sys.platform == "darwin":`) is not a fake and does not match.
|
||||
re.compile(r"""\bplatform["']?\s*,\s*["']darwin["']"""),
|
||||
re.compile(r"""\.platform\s*=\s*["']darwin["']"""),
|
||||
re.compile(r"""\bplatform\.system\b.*(?:lambda[^:]*:|return_value\s*=)\s*["']Darwin["']"""),
|
||||
)
|
||||
|
||||
# Files that faked macOS before this check existed (#111866). Burn down, never extend.
|
||||
_BASELINE = frozenset(
|
||||
{
|
||||
"tests/hermes_cli/test_doctor.py",
|
||||
"tests/hermes_cli/test_gateway.py",
|
||||
"tests/hermes_cli/test_gateway_proc_fallback.py",
|
||||
"tests/hermes_cli/test_linux_sandbox_fixup.py",
|
||||
"tests/hermes_cli/test_macos_fda_guidance.py",
|
||||
"tests/hermes_cli/test_orphan_desktop_serve_reap.py",
|
||||
"tests/hermes_cli/test_update_launchd_restart_verification.py",
|
||||
"tests/hermes_cli/test_update_launchd_unloaded_gateway.py",
|
||||
"tests/hermes_cli/test_urllib_security.py",
|
||||
"tests/hermes_state/test_state_synchronous_pragma.py",
|
||||
"tests/test_hermes_constants.py",
|
||||
"tests/tools/test_macos_protected_search.py",
|
||||
"tests/tools/test_skills_tool.py",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _code_lines(text: str) -> list[tuple[int, str, str]]:
|
||||
"""Yield ``(lineno, code, raw)`` with the ``#`` comment stripped from *code*.
|
||||
|
||||
A ``#`` inside a string literal is rare in these patterns and only ever
|
||||
hides a hit (never invents one), so a plain split is the honest trade
|
||||
against a full tokenizer.
|
||||
"""
|
||||
out = []
|
||||
for i, raw in enumerate(text.splitlines(), 1):
|
||||
out.append((i, raw.split("#", 1)[0], raw))
|
||||
return out
|
||||
|
||||
|
||||
def find_unmarked_fakes(root: Path, repo_root: Path) -> dict[str, list[tuple[int, str]]]:
|
||||
"""Map repo-relative test path -> ``[(lineno, line)]`` of un-opted-out macOS fakes."""
|
||||
marker_pat = re.compile(rf"\b{MARKER}\b")
|
||||
hits: dict[str, list[tuple[int, str]]] = {}
|
||||
for dirpath, _dirnames, filenames in os.walk(root):
|
||||
for fname in filenames:
|
||||
if not (fname.startswith("test_") and fname.endswith(".py")):
|
||||
continue
|
||||
path = Path(dirpath) / fname
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
if marker_pat.search(text):
|
||||
continue
|
||||
lines = [
|
||||
(n, raw.strip())
|
||||
for n, code, raw in _code_lines(text)
|
||||
if OPT_OUT not in raw and any(p.search(code) for p in _FAKES)
|
||||
]
|
||||
if lines:
|
||||
resolved = path.resolve()
|
||||
base = repo_root if resolved.is_relative_to(repo_root) else root.resolve()
|
||||
hits[resolved.relative_to(base).as_posix()] = lines
|
||||
return hits
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
root = Path(argv[1]) if len(argv) > 1 else repo_root / "tests"
|
||||
if not root.is_dir():
|
||||
print(f"error: no such directory: {root}", file=sys.stderr)
|
||||
return 2
|
||||
hits = find_unmarked_fakes(root, repo_root)
|
||||
new = {rel: lines for rel, lines in hits.items() if rel not in _BASELINE}
|
||||
stale = sorted(_BASELINE - set(hits))
|
||||
for rel, lines in sorted(new.items()):
|
||||
for n, line in lines:
|
||||
print(f"{rel}:{n}: fakes macOS without @pytest.mark.{MARKER}: {line}")
|
||||
if new:
|
||||
print(
|
||||
f"\n{len(new)} test file(s) make the interpreter believe it is on macOS but carry no "
|
||||
f"`{MARKER}` marker, so the macOS lane never imports them (AGENTS.md § Don't fake the "
|
||||
f"host OS). Split the macOS arm into its own `@pytest.mark.{MARKER}` test that runs the "
|
||||
f"real branch, or mark `# {OPT_OUT} — <why>` on a host-independent line.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for rel in stale:
|
||||
print(f"{rel}: listed in _BASELINE but no longer fakes macOS — remove the entry")
|
||||
return 1 if new or stale else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -91,6 +91,17 @@ _PY_RELEVANT_SITE = (
|
||||
"website/docs/",
|
||||
"website/scripts/",
|
||||
)
|
||||
# Cross-language contract files: data committed under a frontend tree that a
|
||||
# pytest pins against the Python side (emitter inventory, command registry).
|
||||
# Editing only the JSON in an apps/-only PR would otherwise skip the one test
|
||||
# that can catch the drift, so these force the Python lane too.
|
||||
_PY_RELEVANT_CONTRACT_FILES = {
|
||||
# tests/tui_gateway/contracts/test_generated.py (rendered from tui_gateway/contracts)
|
||||
"apps/shared/src/gateway-contract.generated.ts",
|
||||
"apps/shared/src/gateway-contract.openrpc.json",
|
||||
# tests/hermes_cli/test_desktop_slash_registry.py
|
||||
"apps/desktop/src/lib/desktop-slash-registry.json",
|
||||
}
|
||||
|
||||
# CI-sensitive files: eslint config, workflow files, composite actions.
|
||||
# Changes here can influence what code the autofix job executes and pushes to
|
||||
@@ -121,7 +132,7 @@ _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_TEST_PREFIX = "tests/scripts/desktop_update/"
|
||||
_DESKTOP_UPDATER_FILES = {
|
||||
"apps/desktop/electron/updater-process.ts",
|
||||
"apps/desktop/electron/managed-ssh-update.ts",
|
||||
@@ -147,7 +158,7 @@ def _is_nix(p: str) -> bool:
|
||||
|
||||
|
||||
def _py_irrelevant(p: str) -> bool:
|
||||
if p.startswith(_PY_RELEVANT_SITE):
|
||||
if p.startswith(_PY_RELEVANT_SITE) or p in _PY_RELEVANT_CONTRACT_FILES:
|
||||
return False
|
||||
return (
|
||||
_is_docs(p)
|
||||
|
||||
@@ -26,6 +26,7 @@ Prints one path per line (POSIX separators, repo-relative), sorted.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -43,14 +44,20 @@ def find_marked_files(marker: str, root: Path) -> list[Path]:
|
||||
"""
|
||||
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
|
||||
# os.walk, not Path.rglob: rglob raises FileNotFoundError when a directory
|
||||
# (a sibling job's __pycache__) vanishes mid-scan; os.walk skips it.
|
||||
for dirpath, _dirnames, filenames in os.walk(root):
|
||||
for fname in filenames:
|
||||
if not (fname.startswith("test_") and fname.endswith(".py")):
|
||||
continue
|
||||
path = Path(dirpath) / fname
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
if pattern.search(text):
|
||||
hits.append(path)
|
||||
return sorted(hits)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"meta": {
|
||||
"source": "hermes-agent-dev skill, cross-cutting-profile-scope-patterns.json (validated pattern set)",
|
||||
"selection": "patterns with a scope_hint that hit <= 50 sites on main; the >50 ones are review greps, not lint",
|
||||
"class_legend": {
|
||||
"C1": "secret/home read outside the turn scope",
|
||||
"C2": "child-process env built from os.environ",
|
||||
"C3": "side-worker / secondary entrypoint binds home only",
|
||||
"C4": "per-profile key introduced, consumer reads raw name/id",
|
||||
"C5": "adapter setting precedence & raw os.getenv fallback",
|
||||
"C6": "launch-profile / reserved-name asymmetry",
|
||||
"C7": "process identity by bare PID or argv substring",
|
||||
"C8": "config key registry vs runtime reader",
|
||||
"C9": "systemd/launchd unit variants & migration transactionality",
|
||||
"C10": "profile lifecycle ops under a live multiplexer",
|
||||
"C11": "bare thread lifecycle / supervision",
|
||||
"C12": "Desktop topology / surface parity (CLI vs REST vs RPC)",
|
||||
"C13": "kanban notifier routing / silent fail-closed"
|
||||
},
|
||||
"dropped": [
|
||||
"P01: 296 hits on main",
|
||||
"P02: 378 hits on main",
|
||||
"P03: 222 hits on main",
|
||||
"P04: 613 hits on main",
|
||||
"P07: 115 hits on main",
|
||||
"P09: 78 hits on main",
|
||||
"P12: 73 hits on main",
|
||||
"P14: 102 hits on main",
|
||||
"P15: 212 hits on main",
|
||||
"P16: 100 hits on main",
|
||||
"P20: 81 hits on main",
|
||||
"P24: 62 hits on main",
|
||||
"P26: 252 hits on main"
|
||||
],
|
||||
"usage": "scripts/check_profile_scope_patterns.py --base origin/main [--head HEAD] | --files <paths>"
|
||||
},
|
||||
"patterns": [
|
||||
{
|
||||
"id": "P05",
|
||||
"class": "C2",
|
||||
"pattern_regex": "subprocess\\.(Popen|run|check_output)\\([^)]*env\\s*=\\s*(os\\.environ|dict\\(os\\.environ|\\{\\*\\*os\\.environ)|env\\s*=\\s*os\\.environ\\.copy\\(\\)|spawn\\([^)]*env:\\s*process\\.env|env\\s*=\\s*dict\\(os\\.environ\\)|\\{\\*\\*os\\.environ\\}",
|
||||
"scope_hint": "Also grep the named builders: _build_child_env, _bridge_env, _brv_child_env, build_subprocess_env( without scrub/scope. Assert HERMES_HOME and profile-varying vars from INSIDE a real child.",
|
||||
"why": "Children inherit the launch process's HERMES_HOME and secrets: MCP stdio servers got the default vault, WhatsApp bridge.js ran the default's dm_policy, brv curated into the default's cloud account, execute_code skill scripts read the default's OAuth tokens. Four spawn sites fixed one at a time."
|
||||
},
|
||||
{
|
||||
"id": "P06",
|
||||
"class": "C5",
|
||||
"pattern_regex": "os\\.getenv\\(\\s*[\"'](DISCORD|TELEGRAM|SLACK|MATRIX|WHATSAPP|FEISHU|SIGNAL|TEAMS|LINE|WECOM|YUANBAO|HINDSIGHT|BRV|A2A|WEIXIN)_|os\\.environ\\.get\\(\\s*[\"'](DISCORD|TELEGRAM|SLACK|MATRIX|WHATSAPP|FEISHU|SIGNAL|TEAMS|LINE|WECOM|YUANBAO|HINDSIGHT|BRV|A2A|WEIXIN)_",
|
||||
"scope_hint": "plugins/platforms/*, plugins/memory/*, gateway/run_config_loaders.py, gateway/platforms/*. Replace with gateway.platforms._shared.extra_or_secret(extra, key, ENV, default) or get_scoped_secret.",
|
||||
"why": "Raw env is the launch profile's. A secondary that omits a key must get the adapter default, not the launch profile's value (Matrix notices/session_scope, Discord everyone-mentions, Slack ignored channels all inherited). MindDragon probe still lists TELEGRAM_WEBHOOK_HOST and run_config_loaders.py:64,93 as open."
|
||||
},
|
||||
{
|
||||
"id": "P08",
|
||||
"class": "C5",
|
||||
"pattern_regex": "configured\\s*=\\s*extra\\.get\\(|if\\s+configured\\s+is\\s+not\\s+None:\\s*return|extra\\.get\\([\"'][a-z_]+[\"']\\)\\s*(if|or)\\s*.*os\\.getenv",
|
||||
"scope_hint": "Any 'YAML first, env fallback' reader in an adapter. Order must be explicit scoped env -> own YAML -> default; add the single-profile control test (env=false beats materialized YAML true).",
|
||||
"why": "Materialized defaults (telegram.reactions: false) are always present in YAML, so a YAML-first reader makes the documented env switch a permanent no-op."
|
||||
},
|
||||
{
|
||||
"id": "P10",
|
||||
"class": "C6",
|
||||
"pattern_regex": "if\\s+not\\s+(get_hermes_home_override|current_secret_scope|_served_profile_homes)\\b|profile\\s*(==|!=)\\s*[\"']main[\"']|agent:main\\b",
|
||||
"scope_hint": "Launch-profile branches that treat 'no override' as 'no scope needed'; reserved-name checks.",
|
||||
"why": "The launch profile has its own contract (env-only TERMINAL_ENV=ssh, root files writable, a profile literally named 'main'); tests only asserted secondary isolation and the launch turn collapsed to file-only policy / the default namespace."
|
||||
},
|
||||
{
|
||||
"id": "P11",
|
||||
"class": "C4",
|
||||
"pattern_regex": "^(_active_sessions|_DB_CACHE|_servers|_backends|_session_owner_homes|_trust[a-z_]*|_parallel[a-z_]*|_cooldown[a-z_]*)\\s*[:=]\\s*(\\{\\}|dict\\(|\\{\\s*$)|\\.setdefault\\((task_id|session_id|server_name|name)\\b",
|
||||
"scope_hint": "Module-level dicts keyed by session_id / task_id / server_name / display alone. Key must include hermes_home_key() or (scope, name) when a profile override is active; release must use the same key.",
|
||||
"why": "Two profiles legitimately share session names, DISPLAY numbers and MCP server names; the first profile's entry wins and B's release stops A's driver. #108935 keyed 36 caches and still missed browser_exec/computer_use."
|
||||
},
|
||||
{
|
||||
"id": "P13",
|
||||
"class": "C4",
|
||||
"pattern_regex": "def _connection_identity\\(|def _same_server_route\\(|config_fingerprint\\(",
|
||||
"scope_hint": "MCP connection sharing across profiles: identity must include every credential source, including ones stored outside the config dict (OAuth token files under <profile>/mcp-tokens, client_cert/client_key).",
|
||||
"why": "Identical-looking configs authenticated as different accounts were adopted across profiles; whoami flipped between two Google accounts inside one WhatsApp session."
|
||||
},
|
||||
{
|
||||
"id": "P17",
|
||||
"class": "C8",
|
||||
"pattern_regex": "DEFAULT_CONFIG\\[[\"']\\w+[\"']\\]\\[[\"']\\w+[\"']\\]|\\.get\\([\"'](auto_migrate|auto_multiplex_migration|notify_in_gateway|dispatch_in_gateway)[\"']",
|
||||
"scope_hint": "For every new DEFAULT_CONFIG key, one test: the effective config exposes exactly the key the reader consumes (grep the reader's .get() spelling). Also: a runtime that requires a key (webhook route 'profile') needs the CLI that writes the file to expose it.",
|
||||
"why": "DEFAULT_CONFIG declared gateway.auto_migrate while the guard read gateway.auto_multiplex_migration, and 'hermes config set' pointed operators at the dead spelling; hermes webhook subscribe never wrote the 'profile' key the runtime required (100% 404 on /p/<profile>/)."
|
||||
},
|
||||
{
|
||||
"id": "P18",
|
||||
"class": "C9",
|
||||
"pattern_regex": "systemd_install\\(|_installed_service\\(|_service_op\\(|launchd_install\\(|User=",
|
||||
"scope_hint": "Pass run_as_user read from the unit being replaced; represent every installed unit (user AND system) not a scalar; unresolved User= stays None and blocks the unattended path; wrap install/start after destructive steps in rollback via the manifest; treat flag-on + manifest + no live default as 'interrupted', not 'already multiplexing'.",
|
||||
"why": "Migration passed preflight, uninstalled the secondaries, then raised 'Refusing to install ... as root' with nothing catching it: host left with no gateway and the flag on. Unattended hermes update folded per-UNIX-user system units into one process."
|
||||
},
|
||||
{
|
||||
"id": "P19",
|
||||
"class": "C10",
|
||||
"pattern_regex": "copytree\\([^)]*symlinks\\s*=\\s*True|shutil\\.copytree\\(.*profiles|old_dir\\.rename\\(|_check_gateway_running\\(\\s*old_dir",
|
||||
"scope_hint": "Profile create/clone/rename/delete: materialize symlinked .env/config.yaml/auth.json before editing; build in profiles/.<name>.staging-<pid> and publish with one rename; under a live multiplexer unroute before mutating (a served secondary has no gateway.pid of its own).",
|
||||
"why": "--clone-all stripped the SOURCE's Telegram token through a preserved symlink; the hot-serve rescan adopted a half-copied clone with the source's bots; rename left a ghost the multiplexer re-scaffolded and served."
|
||||
},
|
||||
{
|
||||
"id": "P21",
|
||||
"class": "C3",
|
||||
"pattern_regex": "def _spawn_side_agent\\(|def _profile_build_scope\\(|prompt\\.(background|btw)|preview\\.restart|_build_branch_agent\\(",
|
||||
"scope_hint": "Every secondary entrypoint must enter _session_profile_runtime_scope (home -> secrets -> terminal, same composition as a prompt turn) and hold its own registry reference on the DB.",
|
||||
"why": "Side workers bound HERMES_HOME only: they picked the launch terminal backend (local instead of the secondary's docker) and shared the parent's state.db handle so parent close() closed it under a running background turn."
|
||||
},
|
||||
{
|
||||
"id": "P22",
|
||||
"class": "C3",
|
||||
"pattern_regex": "scan_skill_commands\\(|get_skill_bundles\\(|resolve_bundle_command_key\\(|_is_profile_skill_command\\(|def _dispatch_(skill|bundle)\\(",
|
||||
"scope_hint": "command.dispatch's whole stage loop (quick -> plugin -> bundle -> skill) and slash.exec bundle routing must run under the session's profile home; use the home-keyed get_skill_commands().",
|
||||
"why": "The router bound the profile and said 'skill exists', the dispatcher scanned the launch home and answered 4018 'not a skill command' for every secondary-only skill."
|
||||
},
|
||||
{
|
||||
"id": "P23",
|
||||
"class": "C1",
|
||||
"pattern_regex": "@_profile_scoped_rpc|@_profile_scoped\\b|def _profile_scoped_rpc\\(|_profile_home\\(\\s*profile",
|
||||
"scope_hint": "A decorator that only sets the HERMES_HOME override is insufficient for anything that expands ${VAR} refs, builds MCP clients, or spawns terminals; bind secret scope (after hydrating external secret sources) and terminal scope too.",
|
||||
"why": "Desktop 'Test connection' and the REST MCP list/test/auth sites resolved ${GITHUB_PERSONAL_ACCESS_TOKEN} from the launch process, sending the default's bearer to a secondary's server (HTTP 400 loop, tools missing)."
|
||||
},
|
||||
{
|
||||
"id": "P25",
|
||||
"class": "C12",
|
||||
"pattern_regex": "fetch\\(\\s*[`'\"]/api/(gateway|status|mcp|cron|sessions)[^`'\"]*[`'\"]\\s*[,)]|apiFetch\\([^)]*\\)(?!.*profile)|profilePickConnectionId\\(|resolveNewChatOwnerRoute\\(",
|
||||
"scope_hint": "apps/desktop/src and web/src: every lifecycle/status/settings request against a pooled local backend must carry ?profile= (or the profile param) and every new-session tile must record an owner route.",
|
||||
"why": "A pooled local backend routed lifecycle to the ambient profile (served profiles showed stopped); tab-strip + on a named local profile minted a session with no owner metadata so session.control.read failed closed."
|
||||
},
|
||||
{
|
||||
"id": "P27",
|
||||
"class": "C11",
|
||||
"pattern_regex": "def start\\(self\\).*\\n(?:.*\\n){0,15}?.*(recover_interrupted|record_ticker_heartbeat)|record_ticker_heartbeat\\(",
|
||||
"scope_hint": "cron/scheduler_provider.py and the Desktop desktop-cron-ticker: all pre-loop work inside the BaseException guard; publish the profile list only after the gate filtered it; housekeeping respawns a dead ticker.",
|
||||
"why": "A corrupt executions.db killed the ticker thread before the guarded loop; gateway stayed up, heartbeat frozen, no jobs, no error marker."
|
||||
},
|
||||
{
|
||||
"id": "P28",
|
||||
"class": "C1",
|
||||
"pattern_regex": "filter_media_delivery_paths\\(|_extract_response_content\\(|_docker_sandbox_dir_candidates\\(|_parse_docker_volume_mounts\\(",
|
||||
"scope_hint": "Delivery-side call sites run AFTER the turn's _profile_scope_for_source exited; wrap them in _media_delivery_scope (home + terminal policy).",
|
||||
"why": "A secondary's MEDIA:/output/x.png was validated against the default's Docker mounts and dropped (or the default's same-named decoy delivered)."
|
||||
},
|
||||
{
|
||||
"id": "P29",
|
||||
"class": "C1",
|
||||
"pattern_regex": "no_cache_check_fn\\(|_run_check_fn_uncached\\(|unresolved_scope\\s*=",
|
||||
"scope_hint": "tools/registry.py: classify UnscopedSecretError from current_secret_scope() at the catch site, never from a branch hint; boot-time probes with no scope are DEBUG, not WARNING+traceback.",
|
||||
"why": "The uncached check_fn branch passed unresolved_scope=False at gateway boot under multiplex, logging a traceback that tripped a deployment's post-update health gate and rolled it back."
|
||||
},
|
||||
{
|
||||
"id": "P30",
|
||||
"class": "C1",
|
||||
"pattern_regex": "_hydrate_profile_secret_sources\\(|_applied_homes|secrets\\.command",
|
||||
"scope_hint": "Mark a home hydrated only when every source succeeded; each attempt replaces the prior snapshot; revoke stale snapshots.",
|
||||
"why": "One failing secrets.command helper pinned an empty snapshot for the gateway lifetime, so the secondary ran credential-less until restart."
|
||||
},
|
||||
{
|
||||
"id": "P31",
|
||||
"class": "C6",
|
||||
"pattern_regex": "f\"agent:\\{|[\"']agent:[\"']\\s*\\+|session_key\\s*=\\s*f\"[a-z]+:",
|
||||
"scope_hint": "Every adapter-built session key carries the agent:<profile>: namespace (profile 'main' is 'agent:main~'); yuanbao still builds keys with no profile component per the MindDragon probe.",
|
||||
"why": "Rows for a served profile land in the root store; browser/computer_use caches never saw the namespace because turns pass the bare session id."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -629,7 +629,7 @@ tcc_pick_update_invoke() { # sets UPDATE_INVOKE; safety net past a failed heal
|
||||
# ── 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.
|
||||
# tests/scripts/desktop_update/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"
|
||||
|
||||
@@ -39,6 +39,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -187,14 +188,24 @@ def reseed_if_terminal(auth_path: str, seed_raw: str) -> str:
|
||||
# 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)
|
||||
# 0600 from creation: the seed holds a refresh token and must never sit at umask, even briefly.
|
||||
# (stdlib only by design — see module docstring — so this mirrors utils.atomic_json_write by hand.)
|
||||
# Randomly named: boot-hook PIDs inside a container repeat, so a PID-named temp left by a
|
||||
# SIGKILL'd run would collide with O_EXCL forever and main() would swallow the FileExistsError.
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
dir=os.path.dirname(auth_path) or ".", prefix=os.path.basename(auth_path) + ".rebootstrap.", suffix=".tmp")
|
||||
try:
|
||||
os.chmod(auth_path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(store, fh)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp_path, auth_path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
return "reseeded" if terminal else "reseeded_newer"
|
||||
|
||||
|
||||
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate apps/desktop/src/lib/desktop-slash-registry.json from COMMAND_REGISTRY.
|
||||
|
||||
Run after changing any ``desktop=`` value or alias in ``hermes_cli/commands.py``;
|
||||
``tests/hermes_cli/test_desktop_slash_registry.py`` fails until the committed
|
||||
copy matches. ``--check`` writes nothing and exits 1 when the committed JSON
|
||||
differs from what the registry renders (the same verdict the pytest gives,
|
||||
usable from any shell or CI step without pytest).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OUT = ROOT / "apps" / "desktop" / "src" / "lib" / "desktop-slash-registry.json"
|
||||
|
||||
|
||||
def render() -> str:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from hermes_cli.commands import desktop_surface_registry
|
||||
|
||||
return json.dumps(desktop_surface_registry(), indent=2, sort_keys=True) + "\n"
|
||||
|
||||
|
||||
def check(out: Path = OUT) -> int:
|
||||
"""0 when ``out`` matches the registry byte-for-byte, else 1 with a hint on stderr."""
|
||||
committed = out.read_text(encoding="utf-8") if out.exists() else ""
|
||||
if committed == render():
|
||||
return 0
|
||||
rel = out.relative_to(ROOT) if out.is_relative_to(ROOT) else out
|
||||
print(f"{rel} is stale — run scripts/dump_desktop_slash_registry.py", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if argv == ["--check"]:
|
||||
return check()
|
||||
if argv:
|
||||
print(f"usage: {Path(__file__).name} [--check]", file=sys.stderr)
|
||||
return 2
|
||||
OUT.write_text(render(), encoding="utf-8")
|
||||
print(f"wrote {OUT.relative_to(ROOT)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Render ``tui_gateway/contracts`` into TypeScript and OpenRPC.
|
||||
|
||||
Python-only (the Python CI lane has no Node): Pydantic's ``model_json_schema()`` output is walked
|
||||
by a deliberately small JSON-Schema-subset renderer — object/properties/required, primitives,
|
||||
enum, const, anyOf-with-null, array/items, ``$ref``, oneOf + discriminator, additionalProperties.
|
||||
Anything else raises at generation time so an unsupported model is fixed at the model, never
|
||||
worked around in the output. Prettier runs on the TS when a node_modules binary is present
|
||||
(output is already in the repo's prettier style; the Python CI lane regenerates and diffs it).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from pydantic.json_schema import GenerateJsonSchema
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from tui_gateway import contracts # noqa: E402,F401 (imports every topic module → fills the tables)
|
||||
from tui_gateway.contracts.registry import EVENTS, METHODS, SERVER_REQUESTS # noqa: E402
|
||||
|
||||
TS_OUT = ROOT / "apps" / "shared" / "src" / "gateway-contract.generated.ts"
|
||||
OPENRPC_OUT = ROOT / "apps" / "shared" / "src" / "gateway-contract.openrpc.json"
|
||||
|
||||
HEADER = (
|
||||
"// GENERATED by scripts/gen_gateway_contracts.py from tui_gateway/contracts — DO NOT EDIT.\n"
|
||||
"// Regenerate: .venv/bin/python scripts/gen_gateway_contracts.py\n"
|
||||
"// tests/tui_gateway/contracts/test_generated.py fails when this file is stale.\n"
|
||||
)
|
||||
|
||||
|
||||
class _Schema(GenerateJsonSchema):
|
||||
"""Stable ``$defs`` naming: the model's class name (no module qualifiers)."""
|
||||
|
||||
def normalize_name(self, name: str) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9_]", "_", name)
|
||||
|
||||
|
||||
def _schema_for(models: list[type]) -> tuple[dict[str, dict], list[dict]]:
|
||||
"""One shared ``$defs`` for every model, plus each model's own schema (a ``$ref`` in practice)."""
|
||||
from pydantic.json_schema import models_json_schema
|
||||
|
||||
defs, top = models_json_schema(
|
||||
[(m, "serialization") for m in models], schema_generator=_Schema, ref_template="#/$defs/{model}"
|
||||
)
|
||||
return top.get("$defs", {}), [defs[(m, "serialization")] for m in models]
|
||||
|
||||
|
||||
# ── TypeScript rendering ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Renderer:
|
||||
def __init__(self, defs: dict[str, dict]):
|
||||
self.defs = defs
|
||||
self.emitted: OrderedDict[str, str] = OrderedDict()
|
||||
|
||||
def ref_name(self, ref: str) -> str:
|
||||
assert ref.startswith("#/$defs/"), ref
|
||||
return ref[len("#/$defs/"):]
|
||||
|
||||
def type_of(self, schema: dict, *, inline_depth: int = 0) -> str:
|
||||
if "$ref" in schema:
|
||||
name = self.ref_name(schema["$ref"])
|
||||
self.ensure(name)
|
||||
return name
|
||||
if "const" in schema:
|
||||
return _lit(schema["const"])
|
||||
if "enum" in schema:
|
||||
return " | ".join(_lit(v) for v in schema["enum"])
|
||||
if "anyOf" in schema or "oneOf" in schema:
|
||||
variants = schema.get("anyOf") or schema.get("oneOf") or []
|
||||
rendered = list(dict.fromkeys(self.type_of(v, inline_depth=inline_depth) for v in variants))
|
||||
return " | ".join(rendered)
|
||||
t = schema.get("type")
|
||||
if isinstance(t, list):
|
||||
return " | ".join(self.type_of({**schema, "type": x}, inline_depth=inline_depth) for x in t)
|
||||
if t == "string":
|
||||
return "string"
|
||||
if t in ("integer", "number"):
|
||||
return "number"
|
||||
if t == "boolean":
|
||||
return "boolean"
|
||||
if t == "null":
|
||||
return "null"
|
||||
if t == "array":
|
||||
items = schema.get("items")
|
||||
if items is None:
|
||||
return "unknown[]"
|
||||
if "prefixItems" in schema:
|
||||
return "[" + ", ".join(self.type_of(x) for x in schema["prefixItems"]) + "]"
|
||||
inner = self.type_of(items, inline_depth=inline_depth)
|
||||
return f"({inner})[]" if " | " in inner else f"{inner}[]"
|
||||
if t == "object" or "properties" in schema or "additionalProperties" in schema:
|
||||
return self.object_literal(schema, inline_depth)
|
||||
if not schema or set(schema) <= {"title", "description", "default"}:
|
||||
return "unknown"
|
||||
raise ValueError(f"unsupported JSON-Schema construct: {json.dumps(schema)[:200]}")
|
||||
|
||||
def object_literal(self, schema: dict, depth: int) -> str:
|
||||
props = schema.get("properties")
|
||||
extra = schema.get("additionalProperties")
|
||||
if not props:
|
||||
if extra is False:
|
||||
return "Record<string, never>"
|
||||
if extra in (None, True):
|
||||
return "Record<string, unknown>"
|
||||
return f"Record<string, {self.type_of(extra, inline_depth=depth + 1)}>"
|
||||
required = set(schema.get("required", ()))
|
||||
lines = ["{"]
|
||||
for key, sub in props.items():
|
||||
opt = "" if key in required else "?"
|
||||
lines.append(f" {_prop(key)}{opt}: {self.type_of(sub, inline_depth=depth + 1)}")
|
||||
if extra not in (None, False):
|
||||
lines.append(f" [key: string]: {'unknown' if extra is True else self.type_of(extra)}")
|
||||
lines.append("}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def ensure(self, name: str) -> None:
|
||||
if name in self.emitted:
|
||||
return
|
||||
self.emitted[name] = "" # cycle guard
|
||||
schema = self.defs[name]
|
||||
doc = _doc(schema.get("description"))
|
||||
if "enum" in schema:
|
||||
body = f"export type {name} = {self.type_of({'enum': schema['enum']})}\n"
|
||||
elif schema.get("properties"):
|
||||
body = f"export interface {name} {self.object_literal(schema, 0)}\n"
|
||||
else:
|
||||
body = f"export type {name} = {self.type_of(schema)}\n"
|
||||
self.emitted[name] = doc + body
|
||||
|
||||
|
||||
_IDENT = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*$")
|
||||
|
||||
|
||||
def _prop(key: str) -> str:
|
||||
return key if _IDENT.match(key) else _lit(key)
|
||||
|
||||
|
||||
def _const_items(names: list[str]) -> str:
|
||||
return ",\n".join(f" {_lit(n)}" for n in names) + "\n"
|
||||
|
||||
|
||||
def _lit(value) -> str:
|
||||
"""A TS literal in the repo's prettier style (single quotes) so the committed file needs no
|
||||
Node-side formatting pass — the Python CI lane regenerates and diffs it."""
|
||||
if isinstance(value, str):
|
||||
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
|
||||
return json.dumps(value)
|
||||
|
||||
|
||||
def _doc(text: str | None, indent: str = "") -> str:
|
||||
if not text:
|
||||
return ""
|
||||
clean = " ".join(text.split())
|
||||
return f"{indent}/** {clean} */\n"
|
||||
|
||||
|
||||
def _pascal(name: str) -> str:
|
||||
return "".join(p[:1].upper() + p[1:] for p in re.split(r"[._]", name))
|
||||
|
||||
|
||||
def render_ts() -> str:
|
||||
models: list[type] = []
|
||||
for m in METHODS.values():
|
||||
models += [m.params, m.result]
|
||||
for r in SERVER_REQUESTS.values():
|
||||
models += [r.params, r.result]
|
||||
for e in EVENTS.values():
|
||||
if e.payload is not None:
|
||||
models.append(e.payload)
|
||||
# de-dup preserving order
|
||||
seen: dict[type, None] = OrderedDict()
|
||||
for m in models:
|
||||
seen.setdefault(m)
|
||||
models = list(seen)
|
||||
defs, tops = _schema_for(models)
|
||||
r = Renderer(defs)
|
||||
name_of = {m: r.ref_name(t["$ref"]) for m, t in zip(models, tops)}
|
||||
for m in models:
|
||||
r.ensure(name_of[m])
|
||||
|
||||
out = [HEADER, "/* eslint-disable */\n", "// ── Types ──\n"]
|
||||
out.extend(r.emitted.values())
|
||||
|
||||
out.append("\n// ── Client→server methods ──\n")
|
||||
out.append("export interface RpcMethods {\n")
|
||||
for m in sorted(METHODS.values(), key=lambda x: x.name):
|
||||
out.append(_doc(m.doc, " "))
|
||||
out.append(f" {_prop(m.name)}: {{ params: {name_of[m.params]}; result: {name_of[m.result]} }}\n")
|
||||
out.append("}\n")
|
||||
out.append("export type RpcMethod = keyof RpcMethods\n")
|
||||
out.append("export const RPC_METHODS = [\n" + _const_items(sorted(METHODS)) + "] as const satisfies readonly RpcMethod[]\n")
|
||||
|
||||
out.append("\n// ── Server→client requests ──\n")
|
||||
out.append("export interface ServerRequestMap {\n")
|
||||
for s in sorted(SERVER_REQUESTS.values(), key=lambda x: x.name):
|
||||
out.append(_doc(s.doc, " "))
|
||||
out.append(f" {_prop(s.name)}: {{ params: {name_of[s.params]}; result: {name_of[s.result]} }}\n")
|
||||
out.append("}\n")
|
||||
out.append("export type ServerRequestMethod = keyof ServerRequestMap\n")
|
||||
out.append("export const SERVER_REQUEST_METHODS = [\n" + _const_items(sorted(SERVER_REQUESTS))
|
||||
+ "] as const satisfies readonly ServerRequestMethod[]\n")
|
||||
|
||||
out.append("\n// ── Notifications (`event` frames) ──\n")
|
||||
out.append("export interface BackendGatewayEventMap {\n")
|
||||
for e in sorted(EVENTS.values(), key=lambda x: x.name):
|
||||
out.append(_doc(e.doc, " "))
|
||||
payload = name_of[e.payload] if e.payload is not None else "Record<string, never>"
|
||||
out.append(f" {_prop(e.name)}: {payload}\n")
|
||||
out.append("}\n")
|
||||
out.append("export type BackendGatewayEventName = keyof BackendGatewayEventMap\n")
|
||||
out.append("export const GATEWAY_EVENT_TYPES = [\n" + _const_items(sorted(EVENTS))
|
||||
+ "] as const satisfies readonly BackendGatewayEventName[]\n")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _tidy(text: str) -> str:
|
||||
"""No trailing whitespace, single trailing newline (matches `git diff --check` + prettier)."""
|
||||
return "\n".join(line.rstrip() for line in text.splitlines()).rstrip("\n") + "\n"
|
||||
|
||||
|
||||
# ── OpenRPC rendering ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _openrpc_schema(model: type) -> dict:
|
||||
schema = TypeAdapter(model).json_schema(schema_generator=_Schema, ref_template="#/components/schemas/{model}")
|
||||
schema.pop("$defs", None)
|
||||
return schema
|
||||
|
||||
|
||||
def render_openrpc() -> str:
|
||||
components: dict[str, dict] = {}
|
||||
all_models: list[type] = []
|
||||
for m in METHODS.values():
|
||||
all_models += [m.params, m.result]
|
||||
for r in SERVER_REQUESTS.values():
|
||||
all_models += [r.params, r.result]
|
||||
for e in EVENTS.values():
|
||||
if e.payload is not None:
|
||||
all_models.append(e.payload)
|
||||
from pydantic.json_schema import models_json_schema
|
||||
|
||||
seen: dict[type, None] = OrderedDict()
|
||||
for m in all_models:
|
||||
seen.setdefault(m)
|
||||
_, top = models_json_schema(
|
||||
[(m, "serialization") for m in seen], schema_generator=_Schema,
|
||||
ref_template="#/components/schemas/{model}",
|
||||
)
|
||||
components = top.get("$defs", {})
|
||||
|
||||
def ref(model: type) -> dict:
|
||||
return {"$ref": f"#/components/schemas/{model.__name__}"}
|
||||
|
||||
doc = {
|
||||
"openrpc": "1.3.2",
|
||||
"info": {"title": "Hermes TUI/Desktop gateway", "version": "1",
|
||||
"description": "Generated from tui_gateway/contracts by scripts/gen_gateway_contracts.py."},
|
||||
"methods": [
|
||||
{"name": m.name, "summary": " ".join(m.doc.split()),
|
||||
"params": [{"name": "params", "schema": ref(m.params)}],
|
||||
"result": {"name": "result", "schema": ref(m.result)}}
|
||||
for m in sorted(METHODS.values(), key=lambda x: x.name)
|
||||
],
|
||||
"components": {"schemas": components},
|
||||
"x-server-requests": [
|
||||
{"name": s.name, "summary": " ".join(s.doc.split()),
|
||||
"params": [{"name": "params", "schema": ref(s.params)}],
|
||||
"result": {"name": "result", "schema": ref(s.result)}}
|
||||
for s in sorted(SERVER_REQUESTS.values(), key=lambda x: x.name)
|
||||
],
|
||||
"x-notifications": [
|
||||
{"name": e.name, "summary": " ".join(e.doc.split()),
|
||||
"params": [{"name": "payload", "schema": ref(e.payload) if e.payload is not None
|
||||
else {"type": "object", "additionalProperties": False}}]}
|
||||
for e in sorted(EVENTS.values(), key=lambda x: x.name)
|
||||
],
|
||||
}
|
||||
return json.dumps(doc, indent=2, sort_keys=False) + "\n"
|
||||
|
||||
|
||||
def render_all() -> dict[Path, str]:
|
||||
return {TS_OUT: _tidy(render_ts()), OPENRPC_OUT: render_openrpc()}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = argv if argv is not None else sys.argv[1:]
|
||||
check = "--check" in args
|
||||
stale = []
|
||||
for path, text in render_all().items():
|
||||
current = path.read_text(encoding="utf-8") if path.exists() else None
|
||||
if current == text:
|
||||
continue
|
||||
if check:
|
||||
stale.append(path)
|
||||
else:
|
||||
path.write_text(text, encoding="utf-8")
|
||||
print(f"wrote {path.relative_to(ROOT)}")
|
||||
if stale:
|
||||
for p in stale:
|
||||
print(f"stale: {p.relative_to(ROOT)} — run scripts/gen_gateway_contracts.py", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -17,7 +17,7 @@
|
||||
# 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/agent/ tests/acp_adapter/ # 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"
|
||||
|
||||
@@ -303,6 +303,56 @@ def _kill_tree(proc: "subprocess.Popen", pgid: int | None = None) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _effective_file_timeout(
|
||||
file: Path,
|
||||
repo_root: Path,
|
||||
file_timeout: float,
|
||||
durations: dict[str, float] | None,
|
||||
) -> float:
|
||||
"""Scale the per-file timeout for files whose last observed runtime
|
||||
approaches the flat cap.
|
||||
|
||||
The flat ``file_timeout`` (default 300s) is sized for the typical file,
|
||||
but a handful of large-collection files (e.g. ``tests/test_hermes_state.py``,
|
||||
239 tests × subprocess-per-test overhead) legitimately run 200s+ on a
|
||||
quiet runner. Under CI load that dilates past the cap, the file is
|
||||
SIGKILL'd mid-run, and the automatic retry then passes — a manufactured
|
||||
FLAKY report for a file that was never broken (seen 2026-08-18 on main:
|
||||
first attempt killed at 300s, retry passed in 205s).
|
||||
|
||||
Rule: a file gets ``max(flat_cap, 3 × last_observed_duration)``. Files
|
||||
without a cache entry keep the flat cap. This only ever *raises* the
|
||||
bound — a genuinely hung file is still killed, just with headroom
|
||||
proportional to its known-good runtime.
|
||||
"""
|
||||
if not durations:
|
||||
return file_timeout
|
||||
cached = durations.get(_format_file(file, repo_root))
|
||||
if not cached:
|
||||
return file_timeout
|
||||
return max(file_timeout, float(cached) * 3.0)
|
||||
|
||||
|
||||
def _clean_pass_durations(
|
||||
file_times: List[Tuple[Path, float]],
|
||||
failures: List[Tuple[Path, str, Dict[str, int]]],
|
||||
flaky: List[Tuple[Path, str]],
|
||||
) -> List[Tuple[Path, float]]:
|
||||
"""Keep only durations from files that passed on their first attempt.
|
||||
|
||||
``file_times`` records every file's total subprocess wall, including a
|
||||
timed-out attempt (~the cap) and retry-summed walls for FLAKY files.
|
||||
Feeding those into the cache would let the timeout scaler compound: a
|
||||
file that hung once is cached at ~300s, gets a 900s bound next run,
|
||||
hangs again and is cached at ~900s, and so on until the job timeout
|
||||
is the only bound left. A duration is a measurement of a healthy run
|
||||
or it is not a measurement; failed and retried files keep their last
|
||||
known-good entry instead.
|
||||
"""
|
||||
excluded = {f for f, _o, _s in failures} | {f for f, _o in flaky}
|
||||
return [(f, t) for f, t in file_times if f not in excluded]
|
||||
|
||||
|
||||
def _run_one_file(
|
||||
file: Path,
|
||||
pytest_args: List[str],
|
||||
@@ -508,8 +558,8 @@ def _parse_pytest_summary(output: str) -> dict[str, int]:
|
||||
|
||||
def _format_file(file: Path, repo_root: Path) -> str:
|
||||
"""Render a test-file path for display: strip the repo-root prefix
|
||||
when possible so output reads ``tests/acp/test_auth.py`` instead of
|
||||
``/home/runner/work/hermes-agent/hermes-agent/tests/acp/test_auth.py``.
|
||||
when possible so output reads ``tests/acp_adapter/test_auth.py`` instead of
|
||||
``/home/runner/work/hermes-agent/hermes-agent/tests/acp_adapter/test_auth.py``.
|
||||
|
||||
Falls back to the absolute path for anything outside the repo root.
|
||||
"""
|
||||
@@ -1123,12 +1173,19 @@ def main() -> int:
|
||||
_print_inline_failure(fpath, output, repo_root, pytest_passthrough)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=args.jobs) as pool:
|
||||
# Duration cache for the timeout scaler: known-slow files get
|
||||
# proportional headroom instead of a false timeout-kill under
|
||||
# CI load (see _effective_file_timeout).
|
||||
timeout_durations = _load_durations(repo_root)
|
||||
futures: List[Future] = []
|
||||
for file in files:
|
||||
t0 = time.monotonic()
|
||||
fut = pool.submit(
|
||||
_run_one_file, file, pytest_passthrough, repo_root,
|
||||
args.file_timeout, args.file_retries,
|
||||
_effective_file_timeout(
|
||||
file, repo_root, args.file_timeout, timeout_durations
|
||||
),
|
||||
args.file_retries,
|
||||
)
|
||||
fut.add_done_callback(lambda f, file=file, t0=t0: _on_done(file, t0, f))
|
||||
futures.append(fut)
|
||||
@@ -1188,13 +1245,15 @@ def main() -> int:
|
||||
print(f" {_format_file(f, repo_root)}")
|
||||
print(output.rstrip())
|
||||
|
||||
# Save durations for future --slice runs. Each slice writes its own
|
||||
# partial test_durations.json; a CI merge step joins them later.
|
||||
# Locally, _save_durations merges with any existing cache so entries
|
||||
# from previous runs aren't lost.
|
||||
if file_times:
|
||||
_save_durations(file_times, repo_root)
|
||||
print(f" Durations cached to {_DURATIONS_FILE} ({len(file_times)} files)")
|
||||
# Save durations for future runs (LPT slicing and the per-file timeout
|
||||
# scaler, see _effective_file_timeout). _save_durations merges with any
|
||||
# existing cache so entries from previous runs aren't lost.
|
||||
clean_times = _clean_pass_durations(
|
||||
file_times, failures, _FLAKY_RESULTS,
|
||||
)
|
||||
if clean_times:
|
||||
_save_durations(clean_times, repo_root)
|
||||
print(f" Durations cached to {_DURATIONS_FILE} ({len(clean_times)} files)")
|
||||
|
||||
# Per-file time distribution (throwaway diagnostic — shows how
|
||||
# subprocess time is distributed so we can see if startup dominates).
|
||||
|
||||
@@ -42,6 +42,7 @@ except ImportError: # pragma: no cover - dependency guidance only
|
||||
NAME_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
|
||||
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
TIERS = ("official", "community")
|
||||
CATEGORIES = ("desktop", "memory", "platform", "web", "tools", "voice", "automation", "models", "general")
|
||||
PLATFORMS = ("linux", "macos", "windows")
|
||||
CAPABILITY_KEYS = (
|
||||
"provides_tools",
|
||||
@@ -59,6 +60,7 @@ KNOWN_KEYS = {
|
||||
"description",
|
||||
"maintainer",
|
||||
"tier",
|
||||
"category",
|
||||
"requires_hermes",
|
||||
"docs_url",
|
||||
"platforms",
|
||||
@@ -125,6 +127,10 @@ def validate_entry(data: object) -> tuple[list[str], list[str]]:
|
||||
if tier not in TIERS:
|
||||
errors.append(f"tier {tier!r} must be one of {list(TIERS)}")
|
||||
|
||||
category = data.get("category", "desktop")
|
||||
if category not in CATEGORIES:
|
||||
errors.append(f"category {category!r} must be one of {list(CATEGORIES)}")
|
||||
|
||||
if "requires_hermes" in data:
|
||||
_check_requires_hermes(data["requires_hermes"], errors)
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
inboundReadReceiptKeys,
|
||||
inferMediaType,
|
||||
mediaPayloadForFile,
|
||||
normalizeWhatsAppId,
|
||||
pollCreationMessageFromPayload,
|
||||
pollUpdateForAggregation,
|
||||
} from './bridge_helpers.js';
|
||||
@@ -205,11 +206,6 @@ function trackSentMessageId(sent) {
|
||||
rememberSentId(sent?.key?.id);
|
||||
}
|
||||
|
||||
function normalizeWhatsAppId(value) {
|
||||
if (!value) return '';
|
||||
return String(value).replace(':', '@');
|
||||
}
|
||||
|
||||
function redactWhatsAppId(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return '';
|
||||
|
||||
@@ -15,7 +15,11 @@ export const MIME_MAP = {
|
||||
|
||||
export function normalizeWhatsAppId(value) {
|
||||
if (!value) return '';
|
||||
return String(value).replace(':', '@');
|
||||
// Baileys reports the bot's own ids device-qualified (`<user>:<device>@lid`), while
|
||||
// inbound mentionedJid / contextInfo.participant are not. Drop the suffix so both
|
||||
// forms compare equal; the old `':' -> '@'` swap produced `<user>@<device>@lid`,
|
||||
// which never matched and silently broke @mention / reply-to-bot gating in groups.
|
||||
return String(value).replace(/:\d+(?=@)/, '').replace(/:\d+$/, '');
|
||||
}
|
||||
|
||||
function unwrapMessageEnvelopes(content) {
|
||||
|
||||
Reference in New Issue
Block a user