mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
tests: pin ink engine in _make_tui_argv npm-bootstrap tests (post-merge semantic fix)
Main's rewritten test_tui_npm_install.py tests call _make_tui_argv expecting the Ink/npm flow unconditionally; with the dual-engine dispatch merged in, _resolve_tui_engine() auto-selects opentui whenever ui-opentui/dist is built in the repo, routing the call away from the path under test (first subprocess became 'node --version' instead of 'npm run build'). Pin the engine to ink via an autouse fixture, mirroring the existing pinning precedent in test_tui_resume_flow.py.
This commit is contained in:
parent
ab37440ce6
commit
e1067dbbe5
756 changed files with 79874 additions and 19585 deletions
|
|
@ -272,8 +272,9 @@ def main():
|
|||
# (well above current catalog size) lets the full catalog land in the
|
||||
# index instead of being truncated at an arbitrary build-time limit.
|
||||
SOURCE_LIMITS = {
|
||||
# ClawHub had 49,698+ skills as of May 2026; 200k leaves headroom.
|
||||
"clawhub": 200_000,
|
||||
# 0 = unbounded catalog walk (max_items=0 in ClawHubSource). A positive
|
||||
# limit bounds the walk and also enables the interactive 12s budget.
|
||||
"clawhub": 0,
|
||||
"lobehub": 100_000,
|
||||
"browse-sh": 5_000,
|
||||
"claude-marketplace": 5_000,
|
||||
|
|
@ -297,6 +298,21 @@ def main():
|
|||
# Batch resolve GitHub paths for skills.sh entries
|
||||
all_skills = batch_resolve_paths(all_skills, auth)
|
||||
|
||||
# Collect which sources hit a GitHub API rate limit during the crawl.
|
||||
# github / claude-marketplace / well-known all read api.github.com, so a
|
||||
# rate-limited token zeroes all three at once — surfaced below so the
|
||||
# failure message names the real cause instead of "source returned 0".
|
||||
rate_limited_sources = {
|
||||
name for name, source in sources.items()
|
||||
if getattr(source, "is_rate_limited", False)
|
||||
}
|
||||
if rate_limited_sources:
|
||||
print(
|
||||
" WARNING: GitHub API rate limit hit for: "
|
||||
+ ", ".join(sorted(rate_limited_sources)),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Deduplicate by identifier
|
||||
seen: dict[str, dict] = {}
|
||||
for skill in all_skills:
|
||||
|
|
@ -311,25 +327,9 @@ def main():
|
|||
"browse-sh": 5, "claude-marketplace": 6, "lobehub": 7}
|
||||
deduped.sort(key=lambda s: (source_order.get(s["source"], 99), s["name"]))
|
||||
|
||||
# Build index
|
||||
index = {
|
||||
"version": INDEX_VERSION,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"skill_count": len(deduped),
|
||||
"skills": deduped,
|
||||
}
|
||||
|
||||
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
|
||||
with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(index, f, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
elapsed = time.time() - overall_start
|
||||
file_size = os.path.getsize(OUTPUT_PATH)
|
||||
print(f"\nDone! {len(deduped)} skills indexed in {elapsed:.0f}s")
|
||||
print(f"Output: {OUTPUT_PATH} ({file_size / 1024:.0f} KB)")
|
||||
|
||||
from collections import Counter
|
||||
by_source = Counter(s["source"] for s in deduped)
|
||||
print(f"\nCrawled {len(deduped)} skills in {time.time() - overall_start:.0f}s")
|
||||
for src, count in sorted(by_source.items(), key=lambda x: -x[1]):
|
||||
resolved = sum(1 for s in deduped
|
||||
if s["source"] == src and s.get("resolved_github_id"))
|
||||
|
|
@ -380,14 +380,46 @@ def main():
|
|||
)
|
||||
for line in health_errors:
|
||||
print(line, file=sys.stderr)
|
||||
if rate_limited_sources:
|
||||
print(
|
||||
"\nGitHub API rate limit was hit during this crawl for: "
|
||||
+ ", ".join(sorted(rate_limited_sources))
|
||||
+ ". This is the usual cause of an all-GitHub-tap collapse "
|
||||
"(github / claude-marketplace / well-known dropping to zero "
|
||||
"together). Re-run with a higher-quota GITHUB_TOKEN.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"\nIf the drop is expected (e.g. a hub is genuinely shutting "
|
||||
"down), lower the floor in scripts/build_skills_index.py "
|
||||
"EXPECTED_FLOORS in the same PR.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
# IMPORTANT: do NOT write OUTPUT_PATH on failure. The index file is
|
||||
# gitignored, so a fresh deploy checkout has no copy on disk — leaving
|
||||
# it absent lets website/scripts/extract-skills.py fall back to the
|
||||
# legacy snapshot cache (or skip the unified index) instead of reading
|
||||
# a degenerate file. Writing-then-exiting-2 was the bug that shipped an
|
||||
# index with every GitHub-API source dropped to zero: deploy-site.yml
|
||||
# swallows the exit code with `|| echo non-fatal`, and the partial file
|
||||
# was already on disk for extract-skills to pick up.
|
||||
sys.exit(2)
|
||||
|
||||
# Healthy — only now write the index out for the docs build to consume.
|
||||
index = {
|
||||
"version": INDEX_VERSION,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"skill_count": len(deduped),
|
||||
"skills": deduped,
|
||||
}
|
||||
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
|
||||
with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(index, f, separators=(",", ":"), ensure_ascii=False)
|
||||
file_size = os.path.getsize(OUTPUT_PATH)
|
||||
print(f"\nDone! {len(deduped)} skills indexed in "
|
||||
f"{time.time() - overall_start:.0f}s")
|
||||
print(f"Output: {OUTPUT_PATH} ({file_size / 1024:.0f} KB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
177
scripts/check_subprocess_stdin.py
Normal file
177
scripts/check_subprocess_stdin.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Check that subprocess calls in TUI-context code specify stdin=.
|
||||
|
||||
When Hermes runs in TUI mode, the gateway child process communicates with
|
||||
the Node.js parent over a JSON-RPC protocol on stdin. Subprocess calls that
|
||||
inherit this fd can cause the gateway to exit with stdin EOF during tool
|
||||
execution (issue #14036, PR #39257).
|
||||
|
||||
This script checks that all subprocess.run() and subprocess.Popen() calls
|
||||
in TUI-context files (agent/, tools/, plugins/, tui_gateway/) explicitly
|
||||
set stdin= to prevent fd inheritance.
|
||||
|
||||
Exit codes:
|
||||
0 — all calls are safe
|
||||
1 — violations found
|
||||
2 — script error
|
||||
|
||||
Usage:
|
||||
python scripts/check_subprocess_stdin.py [--fix]
|
||||
|
||||
With --fix, prints the commands to add stdin=subprocess.DEVNULL to each
|
||||
violation (does not modify files).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Directories that run inside the TUI gateway child process.
|
||||
TUI_CONTEXT_DIRS = [
|
||||
"agent/",
|
||||
"tools/",
|
||||
"plugins/",
|
||||
"tui_gateway/",
|
||||
]
|
||||
|
||||
# Files with intentional stdin= override (e.g. input= creates a pipe).
|
||||
# Format: "filepath:line" or just "filepath" to skip the whole file.
|
||||
KNOWN_SAFE = {
|
||||
"agent/shell_hooks.py", # uses input=stdin_json, creates a pipe
|
||||
"plugins/security-guidance/patterns.py", # subprocess mentions are in reminder strings, not calls
|
||||
}
|
||||
|
||||
# Inline marker that exempts a single subprocess call from this check.
|
||||
# Put it in a comment on (or within) the call when the process MUST inherit
|
||||
# stdin — e.g. an interactive login the user explicitly invokes. Travels with
|
||||
# the line, so it survives edits that shift line numbers (unlike a pinned
|
||||
# file:line entry).
|
||||
EXEMPT_MARKER = "noqa: subprocess-stdin"
|
||||
|
||||
# Directories to skip entirely.
|
||||
SKIP_DIRS = {
|
||||
"tests/",
|
||||
"scripts/",
|
||||
"skills/",
|
||||
"optional-skills/",
|
||||
"hermes_cli/",
|
||||
"gateway/",
|
||||
"cron/",
|
||||
}
|
||||
|
||||
|
||||
def find_subprocess_calls(content: str, filepath: str) -> list[dict]:
|
||||
"""Find all subprocess.run/Popen calls missing stdin= in content."""
|
||||
violations = []
|
||||
lines = content.split("\n")
|
||||
|
||||
# Match only actual function calls — not comments, docstrings, or prose.
|
||||
# The pattern requires an opening paren followed by an arg character
|
||||
# (quote, bracket, letter, or closing paren for empty calls).
|
||||
# This excludes ``subprocess.Popen(...)`` in docstrings and
|
||||
# subprocess.run(...) in comments.
|
||||
pattern = re.compile(r'subprocess\.(run|Popen)\s*\(["\'a-zA-Z_\[\(]')
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
# Skip comments.
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("#"):
|
||||
continue
|
||||
|
||||
# Skip lines where the match is inside backticks (docstring references).
|
||||
if "``subprocess" in line:
|
||||
continue
|
||||
|
||||
if not pattern.search(line):
|
||||
continue
|
||||
|
||||
# Collect the full call (may span multiple lines).
|
||||
call_start = i
|
||||
paren_depth = 0
|
||||
found_open = False
|
||||
call_lines = []
|
||||
for j in range(i, min(i + 30, len(lines))):
|
||||
call_lines.append(lines[j])
|
||||
for ch in lines[j]:
|
||||
if ch == "(":
|
||||
paren_depth += 1
|
||||
found_open = True
|
||||
elif ch == ")":
|
||||
paren_depth -= 1
|
||||
if found_open and paren_depth == 0:
|
||||
call_text = "\n".join(call_lines)
|
||||
|
||||
# Already has stdin= → safe.
|
||||
if "stdin=" in call_text:
|
||||
break
|
||||
|
||||
# Has input= → creates a pipe, safe.
|
||||
if "input=" in call_text:
|
||||
break
|
||||
|
||||
# Inline exemption marker on the call itself or within
|
||||
# the few comment lines immediately above it → the call
|
||||
# intentionally inherits stdin.
|
||||
window_start = max(0, i - 4)
|
||||
preceding = "\n".join(lines[window_start:i])
|
||||
if EXEMPT_MARKER in call_text or EXEMPT_MARKER in preceding:
|
||||
break
|
||||
|
||||
violations.append({
|
||||
"file": filepath,
|
||||
"line": i + 1,
|
||||
"snippet": line.strip()[:120],
|
||||
})
|
||||
break
|
||||
else:
|
||||
continue
|
||||
break
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def main() -> int:
|
||||
fix_mode = "--fix" in sys.argv
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
os.chdir(repo_root)
|
||||
|
||||
all_violations = []
|
||||
|
||||
for tui_dir in TUI_CONTEXT_DIRS:
|
||||
dirpath = repo_root / tui_dir
|
||||
if not dirpath.exists():
|
||||
continue
|
||||
|
||||
for py_file in dirpath.rglob("*.py"):
|
||||
rel = str(py_file.relative_to(repo_root))
|
||||
|
||||
# Skip known-safe files.
|
||||
if rel in KNOWN_SAFE:
|
||||
continue
|
||||
|
||||
# Skip test files inside tools/ etc.
|
||||
parts = py_file.parts
|
||||
if any(skip.rstrip("/") in parts for skip in SKIP_DIRS):
|
||||
continue
|
||||
|
||||
content = py_file.read_text()
|
||||
violations = find_subprocess_calls(content, rel)
|
||||
all_violations.extend(violations)
|
||||
|
||||
if all_violations:
|
||||
print(f"❌ {len(all_violations)} subprocess calls missing stdin=:")
|
||||
for v in all_violations:
|
||||
print(f" {v['file']}:{v['line']}: {v['snippet']}")
|
||||
if fix_mode:
|
||||
print("\nAdd stdin=subprocess.DEVNULL to each call above.")
|
||||
return 1
|
||||
else:
|
||||
print("✅ All TUI-context subprocess calls have explicit stdin=")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -892,6 +892,42 @@ function Test-Node {
|
|||
return $true
|
||||
}
|
||||
|
||||
function Update-ProcessPathForPackages {
|
||||
# Make freshly-installed shims (rg.exe, ffmpeg.exe) visible to Get-Command in
|
||||
# THIS process without spawning a new shell, by folding the persisted
|
||||
# User+Machine hives plus winget's alias-shim directory into $env:Path.
|
||||
# Called after every package-manager attempt (winget/choco/scoop): previously
|
||||
# PATH was only refreshed inside the winget branch, so a successful
|
||||
# choco/scoop fallback -- or any install on a box without winget -- could be
|
||||
# misreported as "not installed".
|
||||
#
|
||||
# MERGE rather than overwrite: start from the existing process PATH so any
|
||||
# process-only entries added earlier in this installer run survive, then
|
||||
# APPEND hive/winget-Links entries not already present (case-insensitive,
|
||||
# order-preserving dedupe). A wholesale replace would silently drop those
|
||||
# process-only entries.
|
||||
$candidates = @()
|
||||
$candidates += $env:Path
|
||||
$candidates += [Environment]::GetEnvironmentVariable("Path", "User")
|
||||
$candidates += [Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||
$wingetLinks = Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Links"
|
||||
if (Test-Path $wingetLinks) {
|
||||
$candidates += $wingetLinks
|
||||
}
|
||||
$seen = New-Object System.Collections.Generic.HashSet[string] ([StringComparer]::OrdinalIgnoreCase)
|
||||
$ordered = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($chunk in $candidates) {
|
||||
if ([string]::IsNullOrEmpty($chunk)) { continue }
|
||||
foreach ($entry in $chunk.Split(';')) {
|
||||
$trimmed = $entry.Trim()
|
||||
if ($trimmed -and $seen.Add($trimmed)) {
|
||||
$ordered.Add($trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
$env:Path = [string]::Join(';', $ordered)
|
||||
}
|
||||
|
||||
function Install-SystemPackages {
|
||||
$script:HasRipgrep = $false
|
||||
$script:HasFfmpeg = $false
|
||||
|
|
@ -961,25 +997,33 @@ function Install-SystemPackages {
|
|||
try {
|
||||
$output = winget install --exact --id $pkg --source winget --silent `
|
||||
--accept-package-agreements --accept-source-agreements 2>&1
|
||||
$code = $LASTEXITCODE
|
||||
$output | Out-File -FilePath $log -Encoding utf8
|
||||
"winget exit: $LASTEXITCODE" | Out-File -FilePath $log -Encoding utf8 -Append
|
||||
"winget exit: $code" | Out-File -FilePath $log -Encoding utf8 -Append
|
||||
# 0x8A15002B (-1978335189) = APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE.
|
||||
# winget treats `install` on a package it already has registered as
|
||||
# an *upgrade*, finds no newer version, and bails with this code --
|
||||
# even when the binary is gone from disk/PATH (stale registration,
|
||||
# files removed outside winget, or a missing alias shim). We KNOW the
|
||||
# command was missing (that's why we're here), so a plain install
|
||||
# dead-ends forever. Force a reinstall to repair the registration so
|
||||
# the shim reappears.
|
||||
if ($code -eq -1978335189) {
|
||||
"-> already-installed/no-upgrade; retrying with --force" | Out-File -FilePath $log -Encoding utf8 -Append
|
||||
$output = winget install --exact --id $pkg --source winget --silent --force `
|
||||
--accept-package-agreements --accept-source-agreements 2>&1
|
||||
$output | Out-File -FilePath $log -Encoding utf8 -Append
|
||||
"winget exit (force): $LASTEXITCODE" | Out-File -FilePath $log -Encoding utf8 -Append
|
||||
}
|
||||
} catch {
|
||||
$_ | Out-File -FilePath $log -Encoding utf8 -Append
|
||||
"winget exit: <exception>" | Out-File -FilePath $log -Encoding utf8 -Append
|
||||
}
|
||||
}
|
||||
# Refresh PATH from both env-var hives AND winget's alias shim directory.
|
||||
# winget exposes packages via "command line aliases" in %LOCALAPPDATA%\
|
||||
# Microsoft\WinGet\Links, which is added to PATH by the AppExecutionAlias
|
||||
# machinery only in *newly-spawned* shells -- not the current process.
|
||||
# Without this addition, Get-Command rg below would falsely return null
|
||||
# immediately after a successful install.
|
||||
$wingetLinks = Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Links"
|
||||
$envPath = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||
if (Test-Path $wingetLinks) {
|
||||
$envPath = "$envPath;$wingetLinks"
|
||||
}
|
||||
$env:Path = $envPath
|
||||
# Refresh PATH so packages winget exposed via "command line aliases" in
|
||||
# %LOCALAPPDATA%\Microsoft\WinGet\Links (added to PATH only in
|
||||
# newly-spawned shells, not this process) are visible to Get-Command below.
|
||||
Update-ProcessPathForPackages
|
||||
if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) {
|
||||
Write-Success "ripgrep installed"
|
||||
$script:HasRipgrep = $true
|
||||
|
|
@ -1005,6 +1049,7 @@ function Install-SystemPackages {
|
|||
foreach ($pkg in $chocoPkgs) {
|
||||
try { choco install $pkg -y 2>&1 | Out-Null } catch { }
|
||||
}
|
||||
Update-ProcessPathForPackages
|
||||
if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) {
|
||||
Write-Success "ripgrep installed via chocolatey"
|
||||
$script:HasRipgrep = $true
|
||||
|
|
@ -1023,6 +1068,7 @@ function Install-SystemPackages {
|
|||
foreach ($pkg in $scoopPkgs) {
|
||||
try { scoop install $pkg 2>&1 | Out-Null } catch { }
|
||||
}
|
||||
Update-ProcessPathForPackages
|
||||
if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) {
|
||||
Write-Success "ripgrep installed via scoop"
|
||||
$script:HasRipgrep = $true
|
||||
|
|
@ -1060,9 +1106,10 @@ function Install-Repository {
|
|||
# directory OR a symlink OR a submodule-style gitfile -- and also when
|
||||
# it's a broken stub left over from a failed previous install (e.g.
|
||||
# a partial Remove-Item that couldn't delete a locked index.lock).
|
||||
# Validate the repo properly by asking git itself. Two checks
|
||||
# belt-and-braces: rev-parse AND git status. If either fails the
|
||||
# repo is broken and we fall through to a fresh clone.
|
||||
# Validate the repo properly by asking git itself. Three checks
|
||||
# belt-and-braces: rev-parse (work tree), git status, and a resolvable
|
||||
# HEAD (an initial commit). If any fails the repo is broken and we
|
||||
# fall through to a fresh clone.
|
||||
$repoValid = $false
|
||||
if (Test-Path "$InstallDir\.git") {
|
||||
Push-Location $InstallDir
|
||||
|
|
@ -1077,7 +1124,17 @@ function Install-Repository {
|
|||
$null = & git -c windows.appendAtomically=false status --short 2>&1
|
||||
$statusOk = ($LASTEXITCODE -eq 0)
|
||||
|
||||
if ($revParseOk -and $statusOk) {
|
||||
# An interrupted previous clone leaves a repo with NO initial
|
||||
# commit. rev-parse/status still succeed there, but the update
|
||||
# path's `git stash` (and later `git checkout`) abort with
|
||||
# "You do not have the initial commit yet" and fail the install
|
||||
# (#40998). Require a resolvable HEAD so such partial checkouts
|
||||
# are treated as broken and re-cloned fresh below.
|
||||
$global:LASTEXITCODE = 0
|
||||
$null = & git -c windows.appendAtomically=false rev-parse --verify HEAD 2>&1
|
||||
$hasCommit = ($LASTEXITCODE -eq 0)
|
||||
|
||||
if ($revParseOk -and $statusOk -and $hasCommit) {
|
||||
$repoValid = $true
|
||||
}
|
||||
} catch {}
|
||||
|
|
@ -1119,7 +1176,7 @@ function Install-Repository {
|
|||
git -c windows.appendAtomically=false stash push --include-untracked -m "$stashName"
|
||||
if ($LASTEXITCODE -eq 0) { $autostashRef = "stash@{0}" }
|
||||
}
|
||||
git -c windows.appendAtomically=false fetch origin
|
||||
git -c windows.appendAtomically=false fetch origin $Branch
|
||||
if ($LASTEXITCODE -ne 0) { throw "git fetch failed (exit $LASTEXITCODE)" }
|
||||
# Precedence: Commit > Tag > Branch. Commit and Tag check
|
||||
# out as detached HEAD intentionally -- they're meant to be
|
||||
|
|
@ -1198,16 +1255,19 @@ function Install-Repository {
|
|||
}
|
||||
$didUpdate = $true
|
||||
} else {
|
||||
# Directory exists but isn't a usable git repo. Wipe it and
|
||||
# fall through to a fresh clone. A leftover ``.git`` stub from
|
||||
# a partial uninstall used to lock the installer into the
|
||||
# "update" branch forever, emitting three ``fatal: not a git
|
||||
# repository`` errors and failing with "not in a git directory".
|
||||
Write-Warn "Existing directory at $InstallDir is not a valid git repo -- replacing it."
|
||||
# Directory exists but isn't a usable git repo -- e.g. an
|
||||
# interrupted clone with no initial commit (#40998), or a leftover
|
||||
# ``.git`` stub from a partial uninstall that used to lock the
|
||||
# installer into the "update" branch forever. Move it aside rather
|
||||
# than deleting it -- never destroy a directory the user might still
|
||||
# want -- and fall through to a fresh clone.
|
||||
$backupDir = "$InstallDir.broken-" + (Get-Date -Format "yyyyMMdd-HHmmss")
|
||||
Write-Warn "Existing directory at $InstallDir is not a valid git repo."
|
||||
Write-Warn "Moving it aside to $backupDir before re-cloning."
|
||||
try {
|
||||
Remove-Item -Recurse -Force $InstallDir -ErrorAction Stop
|
||||
Move-Item -LiteralPath $InstallDir -Destination $backupDir -ErrorAction Stop
|
||||
} catch {
|
||||
Write-Err "Could not remove $InstallDir : $_"
|
||||
Write-Err "Could not move $InstallDir aside : $_"
|
||||
Write-Info "Close any programs that might be using files in $InstallDir (editors,"
|
||||
Write-Info "terminals, running hermes processes) and try again."
|
||||
throw
|
||||
|
|
@ -1711,7 +1771,7 @@ function Write-BootstrapMarker {
|
|||
function Copy-ConfigTemplates {
|
||||
Write-Info "Setting up configuration files..."
|
||||
|
||||
# Create ~/.hermes directory structure
|
||||
# Create the HERMES_HOME directory structure ($HermesHome, default %LOCALAPPDATA%\hermes)
|
||||
New-Item -ItemType Directory -Force -Path "$HermesHome\cron" | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path "$HermesHome\sessions" | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path "$HermesHome\logs" | Out-Null
|
||||
|
|
@ -1729,13 +1789,13 @@ function Copy-ConfigTemplates {
|
|||
$examplePath = "$InstallDir\.env.example"
|
||||
if (Test-Path $examplePath) {
|
||||
Copy-Item $examplePath $envPath
|
||||
Write-Success "Created ~/.hermes/.env from template"
|
||||
Write-Success "Created $envPath from template"
|
||||
} else {
|
||||
New-Item -ItemType File -Force -Path $envPath | Out-Null
|
||||
Write-Success "Created ~/.hermes/.env"
|
||||
Write-Success "Created $envPath"
|
||||
}
|
||||
} else {
|
||||
Write-Info "~/.hermes/.env already exists, keeping it"
|
||||
Write-Info "$envPath already exists, keeping it"
|
||||
}
|
||||
|
||||
# Create config.yaml
|
||||
|
|
@ -1744,10 +1804,10 @@ function Copy-ConfigTemplates {
|
|||
$examplePath = "$InstallDir\cli-config.yaml.example"
|
||||
if (Test-Path $examplePath) {
|
||||
Copy-Item $examplePath $configPath
|
||||
Write-Success "Created ~/.hermes/config.yaml from template"
|
||||
Write-Success "Created $configPath from template"
|
||||
}
|
||||
} else {
|
||||
Write-Info "~/.hermes/config.yaml already exists, keeping it"
|
||||
Write-Info "$configPath already exists, keeping it"
|
||||
}
|
||||
|
||||
# Create SOUL.md if it doesn't exist (global persona file).
|
||||
|
|
@ -1780,25 +1840,25 @@ Delete the contents (or this file) to use the default personality.
|
|||
"@
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($soulPath, $soulContent, $utf8NoBom)
|
||||
Write-Success "Created ~/.hermes/SOUL.md (edit to customize personality)"
|
||||
Write-Success "Created $soulPath (edit to customize personality)"
|
||||
}
|
||||
|
||||
Write-Success "Configuration directory ready: ~/.hermes/"
|
||||
Write-Success "Configuration directory ready: $HermesHome"
|
||||
|
||||
# Seed bundled skills into ~/.hermes/skills/ (manifest-based, one-time per skill)
|
||||
Write-Info "Syncing bundled skills to ~/.hermes/skills/ ..."
|
||||
# Seed bundled skills into $HermesHome\skills (manifest-based, one-time per skill)
|
||||
Write-Info "Syncing bundled skills to $HermesHome\skills ..."
|
||||
$pythonExe = "$InstallDir\venv\Scripts\python.exe"
|
||||
if (Test-Path $pythonExe) {
|
||||
try {
|
||||
& $pythonExe "$InstallDir\tools\skills_sync.py" 2>$null
|
||||
Write-Success "Skills synced to ~/.hermes/skills/"
|
||||
Write-Success "Skills synced to $HermesHome\skills"
|
||||
} catch {
|
||||
# Fallback: simple directory copy
|
||||
$bundledSkills = "$InstallDir\skills"
|
||||
$userSkills = "$HermesHome\skills"
|
||||
if ((Test-Path $bundledSkills) -and -not (Get-ChildItem $userSkills -Exclude '.bundled_manifest' -ErrorAction SilentlyContinue)) {
|
||||
Copy-Item -Path "$bundledSkills\*" -Destination $userSkills -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Write-Success "Skills copied to ~/.hermes/skills/"
|
||||
Write-Success "Skills copied to $HermesHome\skills"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2234,6 +2294,24 @@ function Install-Desktop {
|
|||
$code = $LASTEXITCODE
|
||||
}
|
||||
}
|
||||
# Still failing and the user hasn't pinned their own mirror: GitHub's
|
||||
# Electron release host is likely blocked/throttled (the repeating
|
||||
# "retrying" log). Retry once via npmmirror.com — the de-facto Electron
|
||||
# community mirror (Alibaba). @electron/get SHASUM-checks the download,
|
||||
# but the SHASUMS come from the same mirror, so that guards against a
|
||||
# corrupt/partial download, NOT a compromised mirror: an explicit trust
|
||||
# trade-off we only make AFTER the canonical GitHub download has failed,
|
||||
# and we never override a user-pinned ELECTRON_MIRROR.
|
||||
if ($code -ne 0 -and -not $env:ELECTRON_MIRROR) {
|
||||
$prevMirror = $env:ELECTRON_MIRROR
|
||||
$env:ELECTRON_MIRROR = "https://npmmirror.com/mirrors/electron/"
|
||||
Write-Warn "Desktop build still failing - the Electron download from GitHub looks blocked."
|
||||
Write-Warn "Retrying once via a public Electron mirror ($($env:ELECTRON_MIRROR)):"
|
||||
Write-Info " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)"
|
||||
& $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog
|
||||
$code = $LASTEXITCODE
|
||||
$env:ELECTRON_MIRROR = $prevMirror
|
||||
}
|
||||
$ErrorActionPreference = $prevEAP
|
||||
if ($code -ne 0) {
|
||||
$errText = Get-Content $buildLog -Raw -ErrorAction SilentlyContinue
|
||||
|
|
|
|||
|
|
@ -1092,6 +1092,18 @@ show_manual_install_hint() {
|
|||
clone_repo() {
|
||||
log_info "Installing to $INSTALL_DIR..."
|
||||
|
||||
# An interrupted previous clone leaves a .git with no initial commit, where
|
||||
# the update path's `git stash` / `git checkout` abort with "You do not
|
||||
# have the initial commit yet" and fail the install (#40998). Move such a
|
||||
# partial checkout aside -- never delete it, in case it holds something the
|
||||
# user wants -- so the fresh-clone path below can proceed.
|
||||
if [ -d "$INSTALL_DIR/.git" ] && ! git -C "$INSTALL_DIR" rev-parse --verify HEAD >/dev/null 2>&1; then
|
||||
backup_dir="${INSTALL_DIR}.broken-$(date -u +%Y%m%d-%H%M%S)"
|
||||
log_warn "Existing checkout at $INSTALL_DIR has no commits (interrupted clone)."
|
||||
log_warn "Moving it aside to $backup_dir before re-cloning."
|
||||
mv "$INSTALL_DIR" "$backup_dir"
|
||||
fi
|
||||
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
if [ -d "$INSTALL_DIR/.git" ]; then
|
||||
log_info "Existing installation found, updating..."
|
||||
|
|
@ -1106,7 +1118,12 @@ clone_repo() {
|
|||
autostash_ref="stash@{0}"
|
||||
fi
|
||||
|
||||
git fetch origin
|
||||
# Fetch only the target branch. A bare `git fetch origin` pulls
|
||||
# every ref, and this repo carries thousands of auto-generated
|
||||
# branches — on a non-single-branch checkout that turns each update
|
||||
# into a multi-minute download that can stall the installer.
|
||||
git remote set-branches origin "$BRANCH" 2>/dev/null || true
|
||||
git fetch origin "$BRANCH"
|
||||
git checkout "$BRANCH"
|
||||
git pull --ff-only origin "$BRANCH"
|
||||
|
||||
|
|
@ -2334,6 +2351,93 @@ postinstall_mode() {
|
|||
fi
|
||||
}
|
||||
|
||||
# Clear the cached Electron download + any half-written unpacked output so the
|
||||
# next `npm run pack` re-downloads and re-stages from scratch. A corrupt zip in
|
||||
# the per-user Electron download cache - most often a partial/resumed download
|
||||
# that leaves concatenated junk - makes electron-builder's `unpack-electron`
|
||||
# extract a tree MISSING the electron binary, so the `electron`->`Hermes` rename
|
||||
# dies with ENOENT and every re-run repeats the broken extraction forever. This
|
||||
# is the bash sibling of install.ps1's Clear-ElectronBuildCache and the Python
|
||||
# _purge_electron_build_cache() used by `hermes desktop`; install.sh was the only
|
||||
# build path lacking it. Echoes the removed paths (one per line); best-effort.
|
||||
clear_electron_build_cache() {
|
||||
local desktop_dir="$1"
|
||||
local removed=""
|
||||
|
||||
# Per-user Electron download cache dirs, honoring the overrides @electron/get
|
||||
# respects, then the platform defaults (macOS: ~/Library/Caches/electron,
|
||||
# Linux: $XDG_CACHE_HOME/electron or ~/.cache/electron).
|
||||
local cache_dirs=()
|
||||
[ -n "${electron_config_cache:-}" ] && cache_dirs+=("$electron_config_cache")
|
||||
[ -n "${ELECTRON_CACHE:-}" ] && cache_dirs+=("$ELECTRON_CACHE")
|
||||
if [ "$OS" = "macos" ]; then
|
||||
cache_dirs+=("$HOME/Library/Caches/electron")
|
||||
else
|
||||
[ -n "${XDG_CACHE_HOME:-}" ] && cache_dirs+=("$XDG_CACHE_HOME/electron")
|
||||
cache_dirs+=("$HOME/.cache/electron")
|
||||
fi
|
||||
|
||||
local dir zip
|
||||
for dir in "${cache_dirs[@]}"; do
|
||||
[ -d "$dir" ] || continue
|
||||
# Recurse: the bad copy may be the top-level zip OR a copy inside an
|
||||
# @electron/get hash subdir.
|
||||
while IFS= read -r zip; do
|
||||
[ -n "$zip" ] || continue
|
||||
if rm -f "$zip" 2>/dev/null; then
|
||||
removed="$removed$zip
|
||||
"
|
||||
fi
|
||||
done <<EOF
|
||||
$(find "$dir" -type f -name 'electron-*.zip' 2>/dev/null)
|
||||
EOF
|
||||
done
|
||||
|
||||
# A half-written unpacked dir from an interrupted prior pack poisons the
|
||||
# rename even after the zip is fixed (mac-arm64-unpacked / linux-unpacked).
|
||||
local release_dir="$desktop_dir/release"
|
||||
if [ -d "$release_dir" ]; then
|
||||
local unpacked
|
||||
while IFS= read -r unpacked; do
|
||||
[ -n "$unpacked" ] || continue
|
||||
if rm -rf "$unpacked" 2>/dev/null; then
|
||||
removed="$removed$unpacked
|
||||
"
|
||||
fi
|
||||
done <<EOF
|
||||
$(find "$release_dir" -maxdepth 1 -type d -name '*-unpacked' 2>/dev/null)
|
||||
EOF
|
||||
fi
|
||||
|
||||
printf '%s' "$removed"
|
||||
}
|
||||
|
||||
# Run the desktop pack in $1 (the apps/desktop dir). `npm run pack` = tsc +
|
||||
# vite build + electron-builder --dir, producing an unpacked app for the
|
||||
# current OS. Signing auto-discovery is disabled so electron-builder falls back
|
||||
# to an ad-hoc signature instead of grabbing an unrelated Developer ID from the
|
||||
# keychain (a real signed/notarized .dmg needs Apple credentials — a separate
|
||||
# release concern). Optional $2 = an ELECTRON_MIRROR base URL for this attempt,
|
||||
# used as a fallback when the default GitHub release download is blocked.
|
||||
_desktop_pack() {
|
||||
local desktop_dir="$1"
|
||||
local mirror="${2:-}"
|
||||
if [ -n "$mirror" ]; then
|
||||
( cd "$desktop_dir" && ELECTRON_MIRROR="$mirror" CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack )
|
||||
else
|
||||
( cd "$desktop_dir" && CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack )
|
||||
fi
|
||||
}
|
||||
|
||||
# Public Electron mirror used as a last-resort fallback when GitHub's release
|
||||
# host is blocked/throttled (the repeating "retrying" symptom). npmmirror.com is
|
||||
# the de-facto Electron community mirror (Alibaba). @electron/get SHASUM-checks
|
||||
# the download, but the SHASUMS come from the same mirror — that guards against a
|
||||
# corrupt/partial download, NOT a compromised mirror. Reaching for it is an
|
||||
# explicit trust trade-off we only make AFTER the canonical GitHub download has
|
||||
# failed, and we never override a user-pinned ELECTRON_MIRROR.
|
||||
DESKTOP_ELECTRON_FALLBACK_MIRROR="https://npmmirror.com/mirrors/electron/"
|
||||
|
||||
# Build apps/desktop into a launchable native app. Mirrors install.ps1's
|
||||
# Install-Desktop: a root-level npm install so the apps/* workspace resolves
|
||||
# the desktop's own deps (Electron ~150MB), then `npm run pack`
|
||||
|
|
@ -2391,18 +2495,53 @@ install_desktop() {
|
|||
}
|
||||
log_success "Desktop workspace dependencies installed"
|
||||
|
||||
# 2. Build. `npm run pack` = tsc + vite build + electron-builder --dir,
|
||||
# producing an unpacked app for the current OS. We disable signing
|
||||
# auto-discovery so electron-builder falls back to an ad-hoc signature
|
||||
# instead of grabbing an unrelated Developer ID from the keychain; a
|
||||
# real signed/notarized .dmg needs Apple credentials and is a separate
|
||||
# release concern.
|
||||
# 2. Build, with up to three escalating attempts so a transient/blocked
|
||||
# Electron download self-heals instead of failing the whole install:
|
||||
# a) plain `npm run pack` (downloads Electron from GitHub),
|
||||
# b) on failure, purge a corrupt cached zip + stale unpacked dir and
|
||||
# retry (matches install.ps1 / `hermes desktop`),
|
||||
# c) on still-failing, fall back to a public Electron mirror — this is
|
||||
# the GitHub-blocked/throttled case (the repeating "retrying" log).
|
||||
log_info "Building desktop app (this takes 1-3 minutes)..."
|
||||
( cd "$desktop_dir" && CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack ) || {
|
||||
local pack_ok=false
|
||||
if _desktop_pack "$desktop_dir"; then
|
||||
pack_ok=true
|
||||
else
|
||||
# (b) Corrupt cached Electron zip is the most common self-healable cause.
|
||||
local purged
|
||||
purged="$(clear_electron_build_cache "$desktop_dir")"
|
||||
if [ -n "$purged" ]; then
|
||||
log_warn "Desktop build failed; cleared cached Electron download and retrying once..."
|
||||
if _desktop_pack "$desktop_dir"; then
|
||||
pack_ok=true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# (c) Still failing and the user hasn't pinned their own mirror: the GitHub
|
||||
# release host is likely blocked/throttled. Retry once via a public
|
||||
# Electron mirror (@electron/get still SHASUM-verifies the download).
|
||||
if [ "$pack_ok" = false ] && [ -z "${ELECTRON_MIRROR:-}" ]; then
|
||||
log_warn "Desktop build still failing — the Electron download from GitHub looks blocked."
|
||||
log_warn "Retrying once via a public Electron mirror ($DESKTOP_ELECTRON_FALLBACK_MIRROR)..."
|
||||
log_warn " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)"
|
||||
if _desktop_pack "$desktop_dir" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; then
|
||||
pack_ok=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$pack_ok" = false ]; then
|
||||
log_error "Desktop app build failed"
|
||||
log_info "Run manually: cd $desktop_dir && npm run pack"
|
||||
# If the log shows repeated "retrying" lines fetching the Electron zip,
|
||||
# the binary download is blocked/throttled (firewall, proxy, region) and
|
||||
# the mirror fallback above also couldn't reach a host. Try a mirror you
|
||||
# trust and rebuild (@electron/get honors ELECTRON_MIRROR):
|
||||
log_info "If the log shows Electron download retries, rebuild via a reachable mirror:"
|
||||
log_info " ELECTRON_MIRROR=<mirror-base-url> \\"
|
||||
log_info " bash -c 'cd \"$desktop_dir\" && CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack'"
|
||||
log_info "Otherwise build manually: cd $desktop_dir && npm run pack"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
local app=""
|
||||
if [ "$OS" = "linux" ]; then
|
||||
|
|
|
|||
|
|
@ -45,26 +45,45 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
|||
|
||||
# Auto-extracted from noreply emails + manual overrides
|
||||
AUTHOR_MAP = {
|
||||
"peterhao@Peters-MacBook-Air.local": "pinguarmy",
|
||||
"barronlroth@gmail.com": "barronlroth",
|
||||
"ondrej.drapalik@gmail.com": "OndrejDrapalik",
|
||||
"tomasz.panek@gmail.com": "tomekpanek",
|
||||
"philipadsouza@gmail.com": "PhilipAD",
|
||||
"zhuhaoyu0909@icloud.com": "underthestars-zhy",
|
||||
"raysun12142006@gmail.com": "yanxue06",
|
||||
"alberto.regalado@ymail.com": "ARegalado1",
|
||||
"alchemistchaos@protonmail.com": "AlchemistChaos", # co-author only
|
||||
"gilad@smiti.ai": "giladbau",
|
||||
"yusufalweshdemir@gmail.com": "Dusk1e",
|
||||
"804436395@qq.com": "LaPhilosophie",
|
||||
"maxmitcham@mac.home": "maxtrigify",
|
||||
"ccook@nvms.com": "ccook1963",
|
||||
"kristian@agrointel.no": "kristianvast",
|
||||
"thomas.paquette@gmail.com": "RyTsYdUp",
|
||||
"techxacm@gmail.com": "ProgramCaiCai",
|
||||
"266365592+bmoore210@users.noreply.github.com": "bmoore210",
|
||||
"123150002+deaneeth@users.noreply.github.com": "deaneeth",
|
||||
"157839748+psionic73@users.noreply.github.com": "psionic73",
|
||||
"manishbyatroy@gmail.com": "manishbyatroy",
|
||||
"chilltulpa@gmail.com": "TheGardenGallery",
|
||||
"al@randomsnowflake.me": "randomsnowflake",
|
||||
"zakame@zakame.net": "zakame",
|
||||
"152110621+jiangkoumo@users.noreply.github.com": "jiangkoumo",
|
||||
"834740219@qq.com": "ViewWay",
|
||||
"matt@vestigial.dev": "m4dni5",
|
||||
"harjoth.khara@gmail.com": "harjothkhara",
|
||||
"129007007+HeLLGURD@users.noreply.github.com": "HeLLGURD",
|
||||
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
|
||||
"dirtyren@users.noreply.github.com": "dirtyren",
|
||||
"mvanhorn@MacBook-Pro.local": "mvanhorn",
|
||||
"470766206@qq.com": "youjunxiaji",
|
||||
"mharris@parallel.ai": "NormallyGaussian",
|
||||
"roger@roger.local": "mollusk",
|
||||
"ted.malone@outlook.com": "temalo",
|
||||
"adityamalik2833@gmail.com": "alarcritty",
|
||||
"islam666@users.noreply.github.com": "islam666",
|
||||
"mnajafian@nvidia.com": "mnajafian-nv",
|
||||
"25539605+lsaether@users.noreply.github.com": "lsaether",
|
||||
"30080538+JimStenstrom@users.noreply.github.com": "JimStenstrom",
|
||||
"rod.boev@gmail.com": "rodboev",
|
||||
|
|
@ -95,6 +114,7 @@ AUTHOR_MAP = {
|
|||
"metalclaudbot@gmail.com": "HashClawAI",
|
||||
"tonybear55665566@gmail.com": "TonyPepeBear",
|
||||
"kaspersniels@gmail.com": "nielskaspers",
|
||||
"daxxpasquini@gmail.com": "bpasquini",
|
||||
"kurobaryo@gmail.com": "kurobaryo",
|
||||
"scubamount@users.noreply.github.com": "scubamount",
|
||||
"251514042+youngstar-eth@users.noreply.github.com": "youngstar-eth",
|
||||
|
|
@ -119,6 +139,7 @@ AUTHOR_MAP = {
|
|||
"wangpuv@hotmail.com": "wangpuv",
|
||||
"202622897+ticketclosed-wontfix@users.noreply.github.com": "ticketclosed-wontfix",
|
||||
"wuxuebin1993@gmail.com": "victorGPT",
|
||||
"xiaoxingitee@gmail.com": "xiaoxinova",
|
||||
"wei.chen.coder@gmail.com": "wenchengxucool",
|
||||
"frowte3k@gmail.com": "Frowtek",
|
||||
"211828103+julio-cloudvisor@users.noreply.github.com": "julio-cloudvisor",
|
||||
|
|
@ -927,6 +948,8 @@ AUTHOR_MAP = {
|
|||
"michel.belleau@malaiwah.com": "malaiwah",
|
||||
"gnanasekaran.sekareee@gmail.com": "gnanam1990",
|
||||
"jz.pentest@gmail.com": "0xyg3n",
|
||||
"56406949+RaumfahrerSpiffy@users.noreply.github.com": "Spaceman-Spiffy", # PR #35586 (renamed account)
|
||||
"ian@culling.ca": "ianculling", # PR #36087
|
||||
"7093928+0xyg3n@users.noreply.github.com": "0xyg3n",
|
||||
"nftpoetrist@gmail.com": "nftpoetrist", # PR #18982
|
||||
"millerc79@users.noreply.github.com": "millerc79", # PR #19033
|
||||
|
|
@ -966,6 +989,7 @@ AUTHOR_MAP = {
|
|||
"draixagent@gmail.com": "draix",
|
||||
"martin.alca@gmail.com": "draix",
|
||||
"junminliu@gmail.com": "JimLiu",
|
||||
"juraj@bednar.io": "jooray",
|
||||
"jarvischer@gmail.com": "maxchernin",
|
||||
"levantam.98.2324@gmail.com": "LVT382009",
|
||||
"zhurongcheng@rcrai.com": "heykb",
|
||||
|
|
@ -1026,6 +1050,7 @@ AUTHOR_MAP = {
|
|||
"zhang9w0v5@qq.com": "zhang9w0v5",
|
||||
"fuleinist@outlook.com": "fuleinist",
|
||||
"43494187+Llugaes@users.noreply.github.com": "Llugaes",
|
||||
"xiangji.chen@centurygame.com": "Llugaes",
|
||||
"fengtianyu88@users.noreply.github.com": "fengtianyu88",
|
||||
"l.moncany@gmail.com": "lmoncany",
|
||||
"fatinghenji@users.noreply.github.com": "fatinghenji",
|
||||
|
|
@ -1086,6 +1111,7 @@ AUTHOR_MAP = {
|
|||
"holynn@placeholder.local": "holynn-q",
|
||||
"agent@hermes.local": "jacdevos",
|
||||
"sunsky.lau@gmail.com": "liuhao1024",
|
||||
"rob@rbrtbn.com": "rbrtbn",
|
||||
"haaasined@gmail.com": "VinciZhu",
|
||||
"fabianoeq@gmail.com": "rodrigoeqnit",
|
||||
"178342791+sgtworkman@users.noreply.github.com": "sgtworkman",
|
||||
|
|
@ -1235,6 +1261,7 @@ AUTHOR_MAP = {
|
|||
"charliekerfoot@gmail.com": "CharlieKerfoot", # PR #18951
|
||||
# Debug share upload-time redaction (May 2026)
|
||||
"dhuysamen@gmail.com": "GodsBoy", # PR #19318
|
||||
"github@nadyahermes.anonaddy.com": "ruangraung", # PR #42308
|
||||
"mrcoferland@gmail.com": "mrcoferland", # PR #19023
|
||||
"chenlinfeng@ruije.com.cn": "noOne-list", # PR #19050
|
||||
"briansu@Mac-mini.attlocal.net": "likejudy", # PR #19052
|
||||
|
|
@ -1257,6 +1284,7 @@ AUTHOR_MAP = {
|
|||
"leon@sgp43.com": "LeonSGP43", # PR #18739 salvage of #14570
|
||||
"miniding@miniding.home": "Foolafroos", # PR #20329 French locale
|
||||
"montbra@gmail.com": "Montbra", # PR #20897 salvage of #16189 (TUI voice PTT)
|
||||
"275835513+paulb26@users.noreply.github.com": "paulb26", # PR #24135 salvage (pty-bridge killpg)
|
||||
"promptsiren@gmail.com": "firefly", # PR #18123 salvage of #16660 (ContextVars)
|
||||
"wtyopenclaw@gmail.com": "WuTianyi123", # PR #20275 salvage of #13723 (feishu markdown)
|
||||
"zhicheng.han@mathematik.uni-goettingen.de": "hanzckernel", # PR #20311 (api-server approval events)
|
||||
|
|
@ -1462,6 +1490,7 @@ AUTHOR_MAP = {
|
|||
# v0.15.0 additions
|
||||
"glen@workmanfirearms.com": "sgtworkman",
|
||||
"jorge.fuenmayort@gmail.com": "jfuenmayor",
|
||||
"josh.dow@prepad.io": "joshuadow", # PR #43004 salvage (desktop WS session rebind)
|
||||
"mordred@inaugust.com": "emonty",
|
||||
"rodrigoeq@hotmail.com": "rodrigoeqnit",
|
||||
"soliva.johnpaul@icloud.com": "jonpol01",
|
||||
|
|
@ -1485,6 +1514,10 @@ AUTHOR_MAP = {
|
|||
"leonard@sellem.me": "leonardsellem", # PR #37405 (desktop WS origin guard on remote/Tailscale binds)
|
||||
"42903577+ohMyJason@users.noreply.github.com": "ohMyJason", # PR #29810 (discover_models in custom_providers section 4)
|
||||
"singhsanidhya741@gmail.com": "sanidhyasin", # PR #40403 salvage (model.default_headers for custom OpenAI-compatible providers, #40033)
|
||||
"josephjohnson.joel@gmail.com": "JoelJJohnson", # PR #39913 salvage (Windows ConPTY dashboard chat bridge)
|
||||
"andreas@schwarz-ketsch.de": "Nea74", # PR #40022 co-author credit (same Windows ConPTY bridge design)
|
||||
"chanhokyim@gmail.com": "joel611", # PR #33958 salvage (DISCORD_ALLOWED_ROLES role_authorized gateway flag)
|
||||
"desg38@gmail.com": "dschnurbusch", # PR #42373 salvage (archive compressed conversation lineages)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -246,6 +246,98 @@ def _kill_tree(proc: "subprocess.Popen", pgid: int | None = None) -> None:
|
|||
pass
|
||||
|
||||
|
||||
def _spawn_pytest_once(
|
||||
cmd: List[str],
|
||||
repo_root: Path,
|
||||
file_timeout: float,
|
||||
*,
|
||||
timeout_note: str = "per-file timeout",
|
||||
) -> Tuple[int, str]:
|
||||
"""Run one ``pytest`` subprocess to completion and return ``(rc, output)``.
|
||||
|
||||
Spawns the child in its own process group / session so a hung file and
|
||||
its grandchildren (uvicorn servers, async runtimes, etc.) can be SIGKILL'd
|
||||
as a tree on timeout rather than orphaning onto PID 1. Shared by the
|
||||
primary per-file run and the exit-4 retry loop so the lifecycle/cleanup
|
||||
logic lives in exactly one place.
|
||||
"""
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=repo_root,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
# POSIX: place the child at the head of its own process group so
|
||||
# _kill_tree can SIGKILL the group atomically.
|
||||
# Windows: this maps to CREATE_NEW_PROCESS_GROUP in CPython 3.12+;
|
||||
# _kill_tree handles the Windows path via taskkill /F /T.
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
# Capture the pgid NOW, before the leader can exit and be reaped. Once
|
||||
# the leader is reaped, os.getpgid(proc.pid) raises ProcessLookupError
|
||||
# even though grandchildren in that group are still alive — defeating
|
||||
# the whole cleanup. None on Windows where the pgid concept doesn't apply.
|
||||
pgid: int | None = None
|
||||
if sys.platform != "win32":
|
||||
try:
|
||||
pgid = os.getpgid(proc.pid)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pgid = None
|
||||
|
||||
try:
|
||||
output, _ = proc.communicate(timeout=file_timeout)
|
||||
rc = proc.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
_kill_tree(proc, pgid=pgid)
|
||||
try:
|
||||
output, _ = proc.communicate(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
output = "(file timeout exceeded; output unavailable)"
|
||||
rc = 124 # de facto convention for "killed by timeout".
|
||||
output = (
|
||||
f"({timeout_note}: {file_timeout:.0f}s exceeded; "
|
||||
f"process tree SIGKILL'd)\n{output}"
|
||||
)
|
||||
except BaseException:
|
||||
# KeyboardInterrupt / runner crash — make sure no zombie
|
||||
# grandchildren outlive us.
|
||||
_kill_tree(proc, pgid=pgid)
|
||||
raise
|
||||
else:
|
||||
# Happy path: pytest exited on its own. Kill the group anyway in
|
||||
# case it left grandchildren behind; already-dead is a no-op.
|
||||
_kill_tree(proc, pgid=pgid)
|
||||
|
||||
return rc, output
|
||||
|
||||
|
||||
# How many times to re-run a file that exits 4 ("file or directory not found")
|
||||
# while the file demonstrably exists on disk. On loaded shared CI runners the
|
||||
# planner can enumerate a file (tests counted via --collect-only) but the
|
||||
# per-file subprocess fail to stat it moments later — and a SINGLE immediate
|
||||
# retry can land in the same brief high-load window and fail again. We retry a
|
||||
# few times with a short backoff so transient I/O pressure has time to settle.
|
||||
_EXIT4_RETRY_ATTEMPTS = 3
|
||||
_EXIT4_RETRY_BACKOFF_SECONDS = 0.5
|
||||
|
||||
|
||||
def _file_present(file: Path, *, attempts: int = 3, delay: float = 0.2) -> bool:
|
||||
"""Return True if ``file`` exists, re-checking a few times.
|
||||
|
||||
``Path.exists()`` itself issues a ``stat`` that can transiently fail under
|
||||
the same load that makes pytest report "file or directory not found", so a
|
||||
single negative check is not authoritative. Only conclude the file is
|
||||
genuinely missing if it's absent across several spaced checks.
|
||||
"""
|
||||
for i in range(attempts):
|
||||
if file.exists():
|
||||
return True
|
||||
if i < attempts - 1:
|
||||
time.sleep(delay)
|
||||
return False
|
||||
|
||||
|
||||
def _run_one_file(
|
||||
file: Path,
|
||||
pytest_args: List[str],
|
||||
|
|
@ -280,60 +372,60 @@ def _run_one_file(
|
|||
"""
|
||||
cmd = [sys.executable, "-m", "pytest", str(file), *pytest_args]
|
||||
subproc_start = time.monotonic()
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=repo_root,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
# POSIX: place the child at the head of its own process group so
|
||||
# _kill_tree can SIGKILL the group atomically.
|
||||
# Windows: this maps to CREATE_NEW_PROCESS_GROUP in CPython 3.12+;
|
||||
# _kill_tree handles the Windows path via taskkill /F /T.
|
||||
start_new_session=True,
|
||||
)
|
||||
rc, output = _spawn_pytest_once(cmd, repo_root, file_timeout)
|
||||
|
||||
# Capture the pgid NOW, before the leader can exit and be reaped.
|
||||
# Once the leader is reaped, os.getpgid(proc.pid) raises
|
||||
# ProcessLookupError even though grandchildren in that group are
|
||||
# still alive — defeating the whole cleanup. None on Windows where
|
||||
# the pgid concept doesn't apply (taskkill walks ppid chain instead).
|
||||
pgid: int | None = None
|
||||
if sys.platform != "win32":
|
||||
try:
|
||||
pgid = os.getpgid(proc.pid)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
# Astonishingly fast child? Already dead. _kill_tree's
|
||||
# fallback will handle this case as a no-op.
|
||||
pgid = None
|
||||
|
||||
try:
|
||||
output, _ = proc.communicate(timeout=file_timeout)
|
||||
rc = proc.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
_kill_tree(proc, pgid=pgid)
|
||||
# Drain whatever the child wrote before we killed it so we have
|
||||
# something to surface in the failure dump.
|
||||
try:
|
||||
output, _ = proc.communicate(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
output = "(file timeout exceeded; output unavailable)"
|
||||
rc = 124 # de facto convention for "killed by timeout".
|
||||
output = (
|
||||
f"(per-file timeout: {file_timeout:.0f}s exceeded; "
|
||||
f"process tree SIGKILL'd)\n{output}"
|
||||
# pytest exit 4 = "file or directory not found" at exec time. On loaded
|
||||
# shared CI runners we have seen the planner enumerate a file (its tests
|
||||
# counted via --collect-only) but the per-file subprocess fail to stat it
|
||||
# moments later — a transient the deterministic LPT slicer otherwise
|
||||
# reproduces on every rerun (same file set → same shard). Re-run the file a
|
||||
# few times with a short backoff so the I/O pressure has time to settle,
|
||||
# but ONLY while the file demonstrably exists on disk. A single immediate
|
||||
# retry (the old behaviour) could land in the same brief high-load window
|
||||
# and fail again; a single Path.exists() check could itself be a flaky stat
|
||||
# under that load, so we re-check existence across spaced attempts.
|
||||
# We do NOT widen the exit-5 rule: exit 4 on a file that genuinely does not
|
||||
# exist must still fail.
|
||||
attempt = 0
|
||||
while rc == 4 and attempt < _EXIT4_RETRY_ATTEMPTS and _file_present(file):
|
||||
attempt += 1
|
||||
time.sleep(_EXIT4_RETRY_BACKOFF_SECONDS * attempt)
|
||||
rc, output = _spawn_pytest_once(
|
||||
cmd, repo_root, file_timeout,
|
||||
timeout_note=f"per-file timeout on exit-4 retry {attempt}",
|
||||
)
|
||||
except BaseException:
|
||||
# KeyboardInterrupt / runner crash — make sure no zombie
|
||||
# grandchildren outlive us.
|
||||
_kill_tree(proc, pgid=pgid)
|
||||
raise
|
||||
else:
|
||||
# Happy path: pytest exited on its own. The child process already
|
||||
# cleaned up its grandchildren if it's well-behaved, but
|
||||
# well-behaved is not universal — kill the group anyway. Already-
|
||||
# dead processes are a no-op.
|
||||
_kill_tree(proc, pgid=pgid)
|
||||
|
||||
if rc == 4:
|
||||
# Exit-4 survived the retries (or the file was judged absent).
|
||||
# Capture filesystem forensics so a CI-only "file not found" can
|
||||
# be diagnosed from the log instead of guessed at: does the file
|
||||
# exist NOW, what does the parent dir hold, and is the git tree
|
||||
# clean? (June 2026: a PR-added test file repeatedly hit exit 4
|
||||
# on one CI shard while passing locally — these lines exist so
|
||||
# the next occurrence is attributable.)
|
||||
forensics = [f"--- exit-4 forensics for {file} ---"]
|
||||
try:
|
||||
forensics.append(f"exists={file.exists()} retries_used={attempt}")
|
||||
parent = file.parent
|
||||
if parent.exists():
|
||||
names = sorted(p.name for p in parent.iterdir())
|
||||
sibling_hint = [n for n in names if file.stem[:12] in n]
|
||||
forensics.append(
|
||||
f"parent={parent} entries={len(names)} "
|
||||
f"similar={sibling_hint[:5]}"
|
||||
)
|
||||
else:
|
||||
forensics.append(f"parent={parent} MISSING")
|
||||
git_st = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
cwd=repo_root, capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
dirty = git_st.stdout.strip().splitlines()
|
||||
forensics.append(f"git_dirty_entries={len(dirty)}")
|
||||
forensics.extend(f" {line}" for line in dirty[:10])
|
||||
except Exception as exc: # noqa: BLE001 — forensics must never mask rc=4
|
||||
forensics.append(f"(forensics error: {exc})")
|
||||
output = output + "\n" + "\n".join(forensics)
|
||||
|
||||
if rc == 5:
|
||||
# No tests collected — every test in the file was filtered out.
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ import { Boom } from '@hapi/boom';
|
|||
import pino from 'pino';
|
||||
import path from 'path';
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from 'fs';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import { execSync } from 'child_process';
|
||||
import { tmpdir } from 'os';
|
||||
import qrcode from 'qrcode-terminal';
|
||||
|
|
@ -45,9 +46,28 @@ const WHATSAPP_DEBUG =
|
|||
|
||||
const PORT = parseInt(getArg('port', '3000'), 10);
|
||||
const SESSION_DIR = getArg('session', path.join(process.env.HOME || '~', '.hermes', 'whatsapp', 'session'));
|
||||
const IMAGE_CACHE_DIR = path.join(process.env.HOME || '~', '.hermes', 'image_cache');
|
||||
const DOCUMENT_CACHE_DIR = path.join(process.env.HOME || '~', '.hermes', 'document_cache');
|
||||
const AUDIO_CACHE_DIR = path.join(process.env.HOME || '~', '.hermes', 'audio_cache');
|
||||
// Cache directories: the Python gateway passes the profile-aware paths via
|
||||
// env (HERMES_HOME-aware, new cache/ layout). Fall back to the legacy
|
||||
// hardcoded locations for bridges launched outside the gateway.
|
||||
const IMAGE_CACHE_DIR = process.env.HERMES_IMAGE_CACHE_DIR
|
||||
|| path.join(process.env.HOME || '~', '.hermes', 'image_cache');
|
||||
const DOCUMENT_CACHE_DIR = process.env.HERMES_DOCUMENT_CACHE_DIR
|
||||
|| path.join(process.env.HOME || '~', '.hermes', 'document_cache');
|
||||
const AUDIO_CACHE_DIR = process.env.HERMES_AUDIO_CACHE_DIR
|
||||
|| path.join(process.env.HOME || '~', '.hermes', 'audio_cache');
|
||||
|
||||
// Self-hash of this script file. Reported in /health so the Python gateway
|
||||
// can detect a running bridge that predates the current bridge.js and
|
||||
// restart it instead of silently reusing stale code (stale-bridge trap:
|
||||
// `hermes update` updates bridge.js on disk but a long-lived bridge process
|
||||
// keeps serving the old behavior forever).
|
||||
let SCRIPT_HASH = '';
|
||||
try {
|
||||
SCRIPT_HASH = createHash('sha256')
|
||||
.update(readFileSync(fileURLToPath(import.meta.url)))
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
} catch {}
|
||||
const PAIR_ONLY = args.includes('--pair-only');
|
||||
const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat'); // "bot" or "self-chat"
|
||||
const ALLOWED_USERS = parseAllowedUsers(process.env.WHATSAPP_ALLOWED_USERS || '');
|
||||
|
|
@ -700,6 +720,7 @@ app.get('/health', (req, res) => {
|
|||
status: connectionState,
|
||||
queueLength: messageQueue.length,
|
||||
uptime: process.uptime(),
|
||||
scriptHash: SCRIPT_HASH,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue