feat(desktop): openExternalFileForIpc opens files via OS handler

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-09-17 22:28:07 +08:00
3398 changed files with 379040 additions and 22836 deletions
+156
View File
@@ -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())
+135
View File
@@ -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))
+13 -2
View File
@@ -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)
+15 -8
View File
@@ -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:
+165
View File
@@ -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."
}
]
}
+1 -1
View File
@@ -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"
+18 -7
View File
@@ -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"
+50
View File
@@ -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:]))
+317
View File
@@ -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())
+1 -1
View File
@@ -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"
+69 -10
View File
@@ -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).
+6
View File
@@ -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)
+1 -5
View File
@@ -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 '';
+5 -1
View File
@@ -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) {