Compare commits

..
Author SHA1 Message Date
ethernet a0f6c1fab0 ci(windows): disable e2e job (not used yet) 2026-07-20 14:45:53 -04:00
ethernet ef7749d4c2 ci(windows): add desktop installer e2e with AutoHotkey
Adds a Windows E2E workflow that downloads the built installer, runs it via AutoHotkey automation (install-hermes-desktop.ahk), and launches the installed app. Includes button reference screenshots for the AHK image matching.
2026-07-20 14:45:48 -04:00
871 changed files with 7182 additions and 123719 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ description: >-
inputs:
github-token:
description: Token for the GitHub API (gh CLI). Pass steps.app-token.outputs.token from the calling workflow.
description: Token for the GitHub API (gh CLI). Pass secrets.AUTOFIX_BOT_PAT from the calling workflow.
required: false
default: ${{ github.token }}
-59
View File
@@ -1,59 +0,0 @@
name: Get GitHub App Token
description: >-
Mint a short-lived (1-hour) installation access token from the repo's
GitHub App, replacing the long-lived AUTOFIX_BOT_PAT. App tokens get
5,000 req/hr per installation (vs 1,000 for the default GITHUB_TOKEN)
and are scoped to the App's installation permissions, not a user account.
Falls back to the built-in GITHUB_TOKEN when APP_CLIENT_ID is not set —
this happens on fork PRs where repo secrets are unavailable. The fallback
ensures classification, timings, and review comments still work on
forks (with the lower GITHUB_TOKEN rate limit).
Composite actions cannot access the secrets context directly, so the
calling workflow must pass secrets.APP_CLIENT_ID and secrets.APP_PRIVATE_KEY
as inputs. When both are empty (fork PRs), the fallback fires.
inputs:
client-id:
description: GitHub App Client ID. Pass secrets.APP_CLIENT_ID from the calling workflow.
required: false
default: ''
private-key:
description: GitHub App private key PEM. Pass secrets.APP_PRIVATE_KEY from the calling workflow.
required: false
default: ''
outputs:
token:
description: A GitHub App installation access token (1-hour TTL), or GITHUB_TOKEN on forks.
value: ${{ steps.app-token.outputs.token || steps.fallback.outputs.token }}
runs:
using: composite
steps:
- name: Check if App credentials exist
id: check
shell: bash
env:
CLIENT_ID: ${{ inputs.client-id }}
run: |
if [ -n "$CLIENT_ID" ]; then
echo "has_app=true" >> "$GITHUB_OUTPUT"
else
echo "has_app=false" >> "$GITHUB_OUTPUT"
fi
- name: Create GitHub App token
id: app-token
if: steps.check.outputs.has_app == 'true'
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ inputs.client-id }}
private-key: ${{ inputs.private-key }}
- name: Fall back to GITHUB_TOKEN
id: fallback
if: steps.check.outputs.has_app != 'true'
shell: bash
run: echo "token=${{ github.token }}" >> "$GITHUB_OUTPUT"
@@ -1,56 +0,0 @@
name: Hermes Plugin Validate
description: >-
Validate a Hermes Agent plugin (plugin.yaml manifest schema AND
declared-vs-actually-registered capabilities) using
`hermes plugins validate`. Drop this into your plugin repo's CI:
- uses: actions/checkout@<sha>
- uses: NousResearch/hermes-agent/.github/actions/plugin-validate@main
with:
path: .
The caller's job owns checkout; this action installs Python + hermes-agent
(git install — a supported CI-context install route) and runs the
validator against your plugin directory.
inputs:
path:
description: Path to the plugin directory (containing plugin.yaml).
default: "."
hermes-ref:
description: hermes-agent git ref (branch/tag/sha) to install and validate with.
default: "main"
runs:
using: composite
steps:
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Install hermes-agent
shell: bash
env:
_HERMES_REF: ${{ inputs.hermes-ref }}
run: |
set -euo pipefail
# CI-context install from git; the ref lets plugin authors validate
# against a pinned hermes release instead of main.
pip install "git+https://github.com/NousResearch/hermes-agent@${_HERMES_REF}"
- name: Validate plugin
shell: bash
env:
_PLUGIN_PATH: ${{ inputs.path }}
run: |
set -uo pipefail
# `hermes plugins validate` checks the plugin.yaml manifest schema
# and loads the plugin in a scratch subprocess to verify that the
# capabilities it DECLARES match what it actually registers.
if hermes plugins validate "$_PLUGIN_PATH"; then
echo "✅ PASS: plugin at '$_PLUGIN_PATH' validated cleanly"
else
echo "❌ FAIL: plugin at '$_PLUGIN_PATH' failed validation (see output above)"
exit 1
fi
+12 -129
View File
@@ -17,7 +17,7 @@ on:
permissions:
contents: read
pull-requests: write # needed by lint (PR comment) + supply-chain review_status
pull-requests: write # needed by lint (PR comment) + supply-chain (PR comment)
actions: read # needed by osv-scanner (SARIF upload)
security-events: write # needed by osv-scanner (SARIF upload)
packages: write # needed by docker build
@@ -49,19 +49,13 @@ jobs:
event_name: ${{ github.event_name }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Detect affected areas
id: classify
uses: ./.github/actions/detect-changes
with:
# The get-app-token composite action falls back to GITHUB_TOKEN
# on fork PRs where APP_ID is unavailable.
github-token: ${{ steps.app-token.outputs.token }}
# Forks get no repo secrets (AUTOFIX_BOT_PAT is empty); fall back to
# the built-in read-only token so classification still works there.
github-token: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
# ─────────────────────────────────────────────────────────────────────
# Lane-gated sub-workflows. Each runs in parallel after detect finishes.
@@ -79,10 +73,11 @@ jobs:
lint:
name: Python lints
needs: detect
if: needs.detect.outputs.python == 'true'
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.ci_review == 'true'
uses: ./.github/workflows/lint.yml
with:
event_name: ${{ needs.detect.outputs.event_name }}
ci_review: ${{ needs.detect.outputs.ci_review == 'true' }}
secrets: inherit
js-tests:
@@ -149,22 +144,13 @@ jobs:
supply-chain:
name: Supply-chain scan
needs: detect
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true')
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true' || needs.detect.outputs.mcp_catalog == 'true')
uses: ./.github/workflows/supply-chain-audit.yml
with:
event_name: ${{ needs.detect.outputs.event_name }}
scan: ${{ needs.detect.outputs.scan == 'true' }}
deps: ${{ needs.detect.outputs.deps == 'true' }}
review-labels:
name: Review label gate
needs: [detect, supply-chain]
if: always() && needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.ci_review == 'true' || needs.detect.outputs.mcp_catalog == 'true' || needs.supply-chain.outputs.critical_findings == 'true')
uses: ./.github/workflows/review-labels.yml
with:
ci_review: ${{ needs.detect.outputs.ci_review == 'true' }}
mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }}
supply_chain: ${{ needs.supply-chain.outputs.critical_findings == 'true' }}
secrets: inherit
osv-scanner:
@@ -172,95 +158,16 @@ jobs:
uses: ./.github/workflows/osv-scanner.yml
secrets: inherit
# ─────────────────────────────────────────────────────────────────────
# Live-updating PR review comment.
#
# A single ``comment-live`` job polls the GitHub Actions API every 15s
# for job statuses in this run, re-assembles the review comment from
# whatever results are available, and upserts it via the
# ``<!-- hermes-ci-review-bot -->`` marker.
#
# The poller exits when all non-infra jobs are completed (or on
# timeout). ci-timings' review_status is picked up automatically when
# its artifact becomes available — the poller downloads and merges it.
# ─────────────────────────────────────────────────────────────────────
comment-live:
name: CI review comment (live)
needs: [detect, review-labels, lockfile-diff, supply-chain, osv-scanner, uv-lockfile, history-check, contributor-check]
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork != true
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run live comment poller
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_RUN_ID: ${{ github.run_id }}
PR_NUMBER: ${{ github.event.pull_request.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
# Commit info for the review comment header.
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
COMMIT_MESSAGE: ${{ github.event.pull_request.head.commit.message }}
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/pull/${{ github.event.pull_request.number }}/commits/${{ github.event.pull_request.head.sha }}
# Structured review statuses from workflow_call jobs.
# Each job outputs a JSON array of {source, results: [...]} objects
# that the assembler renders directly — no hardcoded job-name
# matching. We merge all available outputs into one array.
REVIEW_STATUSES: ${{ toJSON(needs.*.outputs.review_status) }}
run: |
set -uo pipefail
# REVIEW_STATUSES is a JSON array of strings (some may be empty
# when a job was skipped). Parse each string and merge into one
# flat array for the assembler.
python3 - <<'PYEOF'
import json, os, sys
raw = os.environ.get("REVIEW_STATUSES", "")
merged = []
if raw:
try:
arr = json.loads(raw)
except (json.JSONDecodeError, TypeError):
arr = []
for item in arr:
if not item:
continue
try:
statuses = json.loads(item)
except (json.JSONDecodeError, TypeError):
continue
if isinstance(statuses, list):
merged.extend(statuses)
# Write merged array to a temp file the poller reads.
with open("/tmp/review_statuses.json", "w") as f:
json.dump(merged, f)
print(f"Merged {len(merged)} review status entries")
PYEOF
python3 scripts/ci/live_comment.py \
--interval 15 \
--timeout 2100 \
--review-statuses-file /tmp/review_statuses.json
# ─────────────────────────────────────────────────────────────────────
# Gate: runs after everything. ``if: always()`` ensures it reports a
# status even when some deps were skipped. Only actual ``failure``
# results cause it to fail; ``skipped`` is treated as success.
#
# Branch protection should require ONLY this check.
#
# Outputs ``needs-json`` — a compact ``{job_name: result}`` dict — so
# the live comment poller can list failed jobs in the PR comment.
# ─────────────────────────────────────────────────────────────────────
all-checks-pass:
name: All required checks pass
needs:
- detect
- tests
- lint
- js-tests
@@ -272,30 +179,20 @@ jobs:
- lockfile-diff
- docker-lint
- supply-chain
- review-labels
- osv-scanner
# comment-live is a polling job — it doesn't block the gate.
# we don't require docker to pass rn because it's so slow lol
# - docker
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
needs-json: ${{ steps.evaluate.outputs.needs-json }}
steps:
- name: Evaluate job results
id: evaluate
env:
NEEDS: ${{ toJSON(needs) }}
run: |
echo "$NEEDS" | python3 -c "
import json, sys
needs = json.load(sys.stdin)
# Emit compact {job_name: result} for the comment assembler.
compact = {name: info['result'] for name, info in needs.items()}
print(f'needs-json={json.dumps(compact)}')
with open('$GITHUB_OUTPUT', 'a') as f:
f.write(f'needs-json={json.dumps(compact)}\n')
failed = [name for name, info in needs.items() if info['result'] == 'failure']
for name, info in sorted(needs.items()):
result = info['result']
@@ -312,9 +209,6 @@ jobs:
# cache them on main (as a baseline), and on PRs generate an HTML diff
# report with a gantt chart + per-step breakdown. The report is uploaded
# as an artifact and a markdown summary is written to $GITHUB_STEP_SUMMARY.
#
# The live comment poller picks up ci-timings' completion automatically —
# it reads review-status.json from the artifact when the job finishes.
# ─────────────────────────────────────────────────────────────────────
ci-timings:
name: CI timing report
@@ -326,13 +220,6 @@ jobs:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Restore baseline cache (PR only)
if: github.event_name == 'pull_request'
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -349,28 +236,24 @@ jobs:
# Forks get no repo secrets (AUTOFIX_BOT_PAT is empty); fall back to
# the built-in read-only token so the timings API read still works
# there instead of hard-failing this advisory job on every fork PR.
# The get-app-token composite action falls back to GITHUB_TOKEN
# on fork PRs where APP_ID is unavailable.
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
run: |
python3 scripts/ci/timings_report.py \
--baseline ci-timings-baseline.json \
--output ci-timings-report.html \
--json-out ci-timings.json \
--summary-out ci-timings-summary.md \
--review-status-out review-status.json
--summary-out ci-timings-summary.md
- name: Upload HTML report + review status
- name: Upload HTML report
# Advisory report — artifact-service blips must not fail the job.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
id: ci-timings-artifact
with:
name: ci-timings-report
path: |
ci-timings-report.html
review-status.json
path: ci-timings-report.html
retention-days: 14
archive: false
- name: Output summary
env:
-18
View File
@@ -2,10 +2,6 @@ name: Contributor Attribution Check
on:
workflow_call:
outputs:
review_status:
description: "JSON array of review status objects"
value: ${{ jobs.check-attribution.outputs.review_status }}
permissions:
contents: read
@@ -14,15 +10,12 @@ jobs:
check-attribution:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
review_status: ${{ steps.check-emails.outputs.review_status }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # Full history needed for git log
- name: Check for unmapped contributor emails
id: check-emails
run: |
# Get the merge base between this PR and main
MERGE_BASE=$(git merge-base origin/main HEAD)
@@ -32,7 +25,6 @@ jobs:
if [ -z "$NEW_EMAILS" ]; then
echo "No new commits to check."
echo "review_status=[]" >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -75,16 +67,6 @@ jobs:
echo ""
echo "To find the GitHub username for an email:"
echo " gh api 'search/users?q=EMAIL+in:email' --jq '.items[0].login'"
# Emit review_status for unmapped emails
DETAIL=$(echo -e "$MISSING" | sed '/^$/d; s/^ //')
HOW_TO_FIX=$'Add mappings to scripts/release.py AUTHOR_MAP:\n```\n"<email>": "<github-username>",\n```\nTo find the GitHub username for an email:\n```\ngh api \'search/users?q=EMAIL+in:email\' --jq \'.items[0].login\'\n```\n'
REVIEW_STATUS=$(jq -nc \
--arg detail "$DETAIL" \
--arg how_to_fix "$HOW_TO_FIX" \
'[{"source":"contributor attribution","results":[{"kind":"action_required","title":"Unmapped contributor email(s)","summary":"New contributor email(s) are not in AUTHOR_MAP.","detail":$detail,"how_to_fix":$how_to_fix}]}]')
echo "review_status=$REVIEW_STATUS" >> "$GITHUB_OUTPUT"
exit 1
else
echo "✅ All contributor emails are mapped."
+2 -12
View File
@@ -56,13 +56,6 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
@@ -80,8 +73,8 @@ jobs:
- name: Prepare skills index (unified multi-source catalog)
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
SKILLS_INDEX_RUN_ID: ${{ github.event.inputs.skills_index_run_id || '' }}
REBUILD_SKILLS_INDEX: ${{ github.event.inputs.rebuild_skills_index || 'false' }}
run: |
@@ -157,9 +150,6 @@ jobs:
- name: Extract skill metadata for dashboard
run: python3 website/scripts/extract-skills.py
- name: Extract plugin catalog for the Plugins page
run: python3 website/scripts/extract-plugins.py
- name: Regenerate per-skill docs pages + catalogs
run: python3 website/scripts/generate-skill-docs.py
+396
View File
@@ -0,0 +1,396 @@
name: E2E Windows Desktop
on:
push:
branches: [ethie/e2e]
workflow_dispatch:
concurrency:
group: e2e-windows-${{ github.ref }}
cancel-in-progress: true
jobs:
# this is separated so we don't have node.js and stuff polluting the system
build-installer:
if: false # NOTE: build-installer is disabled for now, since we don't ship updates to the installer. when we do, re-enable it.
name: Build Hermes-Setup.exe
runs-on: windows-latest
timeout-minutes: 30
steps:
- name: checkout cache inputs
uses: actions/checkout@v4
with:
sparse-checkout: |
package-lock.json
apps/bootstrap-installer
sparse-checkout-cone-mode: true
# The cache key is the exact installer build fingerprint. A hit means
# this package-lock + bootstrap-installer source combo was already built,
# so we can skip the entire Node/Rust/toolchain dance and just upload it.
- name: Restore installer build cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
id: installer-cache
with:
path: Hermes-Setup.exe
key: hermes-installer-built-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'apps/bootstrap-installer/**', '!apps/bootstrap-installer/src-tauri/target/**') }}
- name: Setup Node.js
if: steps.installer-cache.outputs.cache-hit != 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- name: Setup Rust
if: steps.installer-cache.outputs.cache-hit != 'true'
uses: dtolnay/rust-toolchain@1.96.0 # stable
- name: checkout full tree on cache miss
if: steps.installer-cache.outputs.cache-hit != 'true'
uses: actions/checkout@v4
- name: Install npm dependencies
if: steps.installer-cache.outputs.cache-hit != 'true'
run: npm ci
- name: Build dev installer for this branch
if: steps.installer-cache.outputs.cache-hit != 'true'
run: npm run tauri:build
working-directory: apps/bootstrap-installer
timeout-minutes: 10
# Only runs on cache miss. Pick the exe the Tauri build produced and
# normalize its name so downstream jobs always know what to download.
- name: Normalize installer artifact name
if: steps.installer-cache.outputs.cache-hit != 'true'
shell: pwsh
run: |
$candidates = @(
'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes.exe',
'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes_0.0.1_x64.exe',
'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes_0.0.1_x64-setup.exe',
'apps/bootstrap-installer/src-tauri/target/release/Hermes.exe'
)
$installer = $null
foreach ($c in $candidates) {
if (Test-Path $c) {
$installer = Resolve-Path $c
break
}
}
if (-not $installer) {
$installer = Get-ChildItem -Path 'apps/bootstrap-installer/src-tauri/target/release' `
-Recurse -Filter '*.exe' | Where-Object { $_.Name -notlike '*setup*' -or $true } | Select-Object -First 1 -ExpandProperty FullName
}
if (-not $installer) {
throw 'Could not find built Hermes-Setup.exe'
}
Copy-Item -Path $installer -Destination 'Hermes-Setup.exe' -Force
Write-Host "Normalized installer: Hermes-Setup.exe (from $installer)"
e2e:
name: Install this commit as latest main
if: false # NOTE: this job isn't used yet.
# needs: build-installer
runs-on: windows-latest
timeout-minutes: 60
env:
# Isolated install directory so the real install flow doesn't touch the
# runner's user profile. Kept under the workspace for easy cleanup.
HERMES_HOME: ${{ github.workspace }}\.e2e-hermes-home
INSTALL_DIR: ${{ github.workspace }}\.e2e-hermes-home\hermes-agent
steps:
- uses: actions/checkout@v4
with:
path: source
- name: Restore installer from build cache
if: false # build-installer is since we don't update the installer right now. instead, we download the latest installer from the website
id: installer-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: Hermes-Setup.exe
key: hermes-installer-built-cache-${{ runner.os }}-${{ hashFiles('source/package-lock.json', 'source/apps/bootstrap-installer/**', '!source/apps/bootstrap-installer/src-tauri/target/**') }}
- name: Download latest production installer
id: download-installer
shell: pwsh
run: |
Invoke-WebRequest https://hermes-assets.nousresearch.com/Hermes-Setup.exe -OutFile Hermes-Setup.exe
- name: Restore cached test tools
id: test-tools-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: test-bins
key: test-bins-${{ runner.os }}-v1
- name: Install AutoHotkey v2 and ffmpeg
if: steps.test-tools-cache.outputs.cache-hit != 'true'
shell: pwsh
run: |
# Install fresh when the cache missed.
New-Item -ItemType Directory -Path test-bins\autohotkey, test-bins\ffmpeg -Force | Out-Null
# AutoHotkey: copy its whole v2 directory so helper exes/dlls come along.
winget install -e --id AutoHotkey.AutoHotkey --silent --accept-source-agreements --accept-package-agreements --disable-interactivity
$ahkDir = "$env:ProgramW6432\AutoHotkey\v2"
if (-not (Test-Path $ahkDir)) {
throw "AutoHotkey install directory not found: $ahkDir"
}
Copy-Item -Path "$ahkDir\*" -Destination test-bins\autohotkey -Recurse -Force
# ffmpeg : just install into dir
winget install -e --id Gyan.FFmpeg --silent --accept-source-agreements --accept-package-agreements --disable-interactivity --location ffmpeg_dir
Copy-Item -Path "ffmpeg_dir\*\*" -Destination test-bins\ffmpeg -Recurse -Force
- name: Add test-bins to PATH
shell: pwsh
run: |
ls "$PWD\test-bins\ffmpeg"
Add-Content -Path $env:GITHUB_PATH -Value "$PWD\test-bins\autohotkey"
Add-Content -Path $env:GITHUB_PATH -Value "$PWD\test-bins\ffmpeg\bin"
# ── Prepare an isolated HERMES_HOME and copy checked-out repo ──────
# actions/checkout already has the right commit; just mirror it into the
# isolated home so the installer doesn't need to reach GitHub.
- name: Move checked-out workspace into isolated HERMES_HOME
shell: pwsh
run: |
New-Item -ItemType Directory -Path $env:INSTALL_DIR -Force
Get-ChildItem -Path ${{ github.workspace }}\source -Force | Move-Item -Destination $env:INSTALL_DIR -Force
Write-Host "Isolated install dir ready: $env:INSTALL_DIR"
# ── Run the headed installer + AHK helper ───────────────────────
- name: Launch Hermes-Setup.exe and install it
shell: pwsh
timeout-minutes: 10
env:
HERMES_SETUP_DEV_REPO_ROOT: ${{ env.INSTALL_DIR }}
run: |
$installer = "Hermes-Setup.exe"
# ── Start screen recording (live stdin pipe) ──────────────────
# ffmpeg must be started, fed, and stopped from the SAME step: the
# graceful-stop signal is the character 'q' written to ffmpeg's live
# stdin. A separate teardown step can't do this because the process
# that owns the writable stdin pipe dies when this step ends.
#
# Start-Process / -RedirectStandardInput <file> does NOT work: it
# hands ffmpeg a file handle opened once at EOF, so appending 'q' to
# the file on disk never reaches the running process. We need a real
# writable pipe, which only System.Diagnostics.Process exposes.
#
# -pix_fmt yuv420p keeps
# the output broadly playable.
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "ffmpeg"
$psi.Arguments = "-y -f gdigrab -framerate 15 -i desktop " +
"-hide_banner -loglevel error " +
"-c:v libx264 -preset ultrafast -pix_fmt yuv420p recording.mkv"
$psi.RedirectStandardInput = $true
$psi.UseShellExecute = $false
$ffmpeg = [System.Diagnostics.Process]::Start($psi)
$ffmpeg.Id | Out-File ffmpeg.pid
# Note: stderr is intentionally left attached to the console so it is
# captured in the step log. Do NOT redirect a stream we don't drain --
# ffmpeg's progress output would fill the pipe buffer and block.
Write-Host "ffmpeg recording started (pid $($ffmpeg.Id ))"
$e2eDir = "$env:RUNNER_TEMP\e2e-windows"
Copy-Item -Path "$env:INSTALL_DIR\e2e\windows" -Destination $e2eDir -Force -Recurse
$installerSuccess = $false
try {
# Launch the real installer
$proc = Start-Process -FilePath $installer -PassThru -NoNewWindow
$proc.Id | Out-File installer.pid
$ahkProc = Start-Process -FilePath ".\test-bins\autohotkey\AutoHotkey64.exe" `
-ArgumentList "$e2eDir\install-hermes-desktop.ahk", "$PWD\ahk.log" -PassThru -NoNewWindow
# Wait for AHK helper to finish, and tail logs.
$logReader = $null
$logStream = $null
$logPath = Join-Path $env:HERMES_HOME "logs\bootstrap-installer.log"
# can take a long time for installer!
$deadline = (Get-Date).AddSeconds(60 * 8)
try {
while ((Get-Date) -lt $deadline -and -not $ahkProc.HasExited) {
if (-not $logReader) {
if (Test-Path $logPath) {
Write-Host "Found bootstrap-installer.log; tailing..."
# FileShare.ReadWrite is required: the installer almost
# certainly still has the file open for writing, and a
# plain Get-Content/File.Open would throw or lock it out.
$logStream = [System.IO.File]::Open(
$logPath, 'Open', 'Read', 'ReadWrite')
$logReader = New-Object System.IO.StreamReader($logStream)
}
} else {
$line = $logReader.ReadLine()
while ($null -ne $line) {
Write-Host "[bootstrap] $line"
$line = $logReader.ReadLine()
}
}
Start-Sleep -Milliseconds 500
}
# Drain anything written in the final tick before exit/timeout.
if ($logReader) {
$line = $logReader.ReadLine()
while ($null -ne $line) {
Write-Host "[bootstrap] $line"
$line = $logReader.ReadLine()
}
}
}
finally {
if ($logReader) { $logReader.Dispose() }
if ($logStream) { $logStream.Dispose() }
}
if (-not $ahkProc.HasExited) {
Write-Host "AHK helper is still running; stopping it"
Stop-Process -Id $ahkProc.Id -Force -ErrorAction SilentlyContinue
} else {
Write-Host "autohotkey helper exited"
}
}
finally {
# Gracefully stop ffmpeg by writing 'q' to its LIVE stdin pipe, so
# the container header/index are finalized and the mkv is playable.
# This runs in the same step that owns the pipe, even on failure.
if ($ffmpeg -and -not $ffmpeg.HasExited) {
Write-Host "Stopping ffmpeg gracefully (q on stdin)"
try {
$ffmpeg.StandardInput.Write("q")
$ffmpeg.StandardInput.Close()
} catch {
Write-Host "Failed to write q to ffmpeg stdin: $_"
}
if (-not $ffmpeg.WaitForExit(15000)) {
Write-Host "ffmpeg did not exit after 15s; killing"
$ffmpeg.Kill()
}
}
Write-Host "ffmpeg stopped"
# Installer should have exited
if ($proc.HasExited) {
# TODO check exit code once we add exit code in installer
$installerSuccess = $true
} else {
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
throw "Installer is still running. Install did not succeed."
}
if (-not $installerSuccess) {
throw "Installer did not exit after autohotkey script finished. Check installer logs!"
}
}
# ── Run Playwright against the installed binary ─────────────────
# (placeholder: will be enabled once installer completes successfully.)
- name: Launch installed app and run e2e
if: false
working-directory: source/apps/desktop
run: npm exec playwright test e2e/ --reporter=list
env:
# Point the e2e spec at the desktop binary that the installer built.
HERMES_E2E_INSTALL_ROOT: ${{ env.HERMES_HOME }}\hermes-agent
# ── Teardown & artifacts ────────────────────────────────────────
# ffmpeg is normally started AND gracefully stopped inside the launch
# step (so 'q' reaches its live stdin pipe). This step is only a
# safety net: if the launch step timed out or crashed before its
# finally block ran, force-kill any orphaned ffmpeg so the runner can
# release recording.mkv for upload. The mkv container survives a hard
# kill (only the trailing seek index is lost), so the artifact is still
# usable for coordinate discovery even on this fallback path.
- name: Stop orphaned screen recording (safety net)
if: always()
shell: pwsh
run: |
$ffmpegpid = Get-Content ffmpeg.pid -ErrorAction SilentlyContinue
if ($ffmpegpid) {
$proc = Get-Process -Id $ffmpegpid -ErrorAction SilentlyContinue
if ($proc) {
Write-Host "Orphaned ffmpeg (pid $ffmpegpid) still running; force-stopping"
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 2
} else {
Write-Host "ffmpeg already exited cleanly; nothing to do"
}
}
- name: Burn debug overlay into recording
if: always()
shell: pwsh
run: |
$logPath = Join-Path $PWD 'ahk.log'
$x = $y = $w = $h = $null
if (Test-Path $logPath) {
$line = Get-Content $logPath -Raw | Select-String -Pattern 'Window found at x=(\d+) y=(\d+) w=(\d+) h=(\d+)' -AllMatches | Select-Object -Last 1
if ($line) {
$x = [int]$line.Matches[0].Groups[1].Value
$y = [int]$line.Matches[0].Groups[2].Value
$w = [int]$line.Matches[0].Groups[3].Value
$h = [int]$line.Matches[0].Groups[4].Value
Write-Host "Parsed window rect: x=$x y=$y w=$w h=$h"
} else {
Write-Host "no window rect found in ahk.log; only timestamp will be burned"
}
} else {
Write-Host "ahk.log not found; only timestamp will be burned"
}
# Build the timestamp overlay
$vf = "drawtext=text='%{pts\:hms}':fontfile='C\:\\Windows\\Fonts\\arial.ttf':fontsize=20:fontcolor=white:box=1:boxcolor=black@0.5:x=8:y=8"
if ($x -ne $null -and $y -ne $null -and $w -ne $null -and $h -ne $null) {
# Window border + 16px grid only over the window + axis labels
$grid = "drawbox=x=$x`:y=$y`:w=$w`:hf=$h`:color=red@0.9:t=2,split=2[box][win];[win]crop=$w`:$h`:$x`:$y`:$y"
}
Write-Host "Overlay filter: $vf"
ffmpeg -y -i recording.mkv -vf "$vf" -c:v libx264 -preset veryfast -pix_fmt yuv420p recording-overlay.mkv
if ($LASTEXITCODE -ne 0) {
throw "ffmpeg overlay failed"
}
Move-Item -Path recording-overlay.mkv -Destination recording.mkv -Force
Write-Host "Overlayed recording saved as recording.mkv"
- name: Upload screen recording
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
id: upload-recording
if: always()
with:
name: screen-recording-${{ github.sha }}
path: recording.mkv
retention-days: 1
archive: false
overwrite: true
- name: Bootstrap Installer log
if: always()
shell: pwsh
run: |
Get-Content "$env:HERMES_HOME\logs\bootstrap-installer.log"
- name: Autohotkey log
if: always()
shell: pwsh
run: |
Get-Content ahk.log
+1 -11
View File
@@ -15,10 +15,6 @@ name: History Check
on:
workflow_call:
outputs:
review_status:
description: "JSON array of review_status objects for the synthesizer."
value: ${{ jobs.check-common-ancestor.outputs.review_status }}
permissions:
contents: read
@@ -27,23 +23,18 @@ jobs:
check-common-ancestor:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
review_status: ${{ steps.merge-base-check.outputs.review_status }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # full history both sides for merge-base
- id: merge-base-check
name: Reject PRs with no common ancestor on main
- name: Reject PRs with no common ancestor on main
run: |
# `git merge-base` exits non-zero AND prints nothing when the two
# commits share no ancestor. We check both conditions explicitly
# so the failure message is clear regardless of which signal fires
# first.
if ! BASE=$(git merge-base origin/main HEAD 2>/dev/null) || [ -z "$BASE" ]; then
STATUS='[{"source":"unrelated histories","results":[{"kind":"action_required","title":"Unrelated histories","summary":"This PR has no common ancestor with main.","detail":"","how_to_fix":"Rebase your changes onto current main:\n```\ngit fetch origin main\ngit checkout -b fix-branch origin/main\n# re-apply your changes (cherry-pick, copy files, etc.)\ngit push -f origin fix-branch\n```\n"}]}]'
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
echo ""
echo "::error::This PR has no common ancestor with main."
echo ""
@@ -65,4 +56,3 @@ jobs:
exit 1
fi
echo "::notice::Common ancestor with main: $BASE"
echo "review_status=[]" >> "$GITHUB_OUTPUT"
+3 -10
View File
@@ -7,7 +7,7 @@ name: auto-fix lint issues & formatting
# auto-corrected on merge so PRs aren't blocked by them. The PR-time eslint
# check in typecheck.yml fails only when un-fixable errors remain.
#
# NOTE: App token pushes DO trigger further workflow runs (unlike
# NOTE: AUTOFIX_BOT_PAT pushes DO trigger further workflow runs (unlike
# secrets.GITHUB_TOKEN). The concurrency group (ts-autofix-${{ github.ref }})
# with cancel-in-progress: true prevents an infinite loop — a re-triggered
# run cancels the in-flight one, and since the second run finds no new fixes
@@ -128,13 +128,6 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Download patch
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
@@ -177,7 +170,7 @@ jobs:
- name: Create/update PR and enable auto-merge
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
BOT_BRANCH: bot/js-autofix
run: |
set -euo pipefail
@@ -200,7 +193,7 @@ jobs:
- name: Wait for merge, auto-close on failure or stale
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
START_SHA: ${{ github.sha }}
run: |
set -euo pipefail
-81
View File
@@ -1,81 +0,0 @@
name: Label rerun
# When the ``ci-reviewed`` label is added to a PR, rerun all failed jobs in
# the latest CI run. This re-evaluates ``review-labels`` (which now sees the
# label) and GitHub automatically reruns dependent jobs (``comment-live``,
# ``all-checks-pass``) — so the review comment gets updated too.
#
# If the CI run is still in progress when the label is added, we wait for it
# to finish before rerunning (``gh run rerun`` only works on completed runs).
# The wait can be long (20+ min for a full CI run), but it's better than
# silently failing and leaving the reviewer stuck.
on:
pull_request:
types: [labeled]
permissions:
actions: write
pull-requests: read
concurrency:
group: label-rerun-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
rerun-review-labels:
name: Rerun review-labels job
if: github.event.label.name == 'ci-reviewed'
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: Wait for CI run to finish, then rerun failed jobs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -uo pipefail
# Find the latest CI run for this PR's head SHA.
RUN_ID=$(gh run list \
--repo "$REPO" \
--commit "$HEAD_SHA" \
--workflow ci.yml \
--limit 1 \
--json databaseId,status \
--jq '.[0] | "\(.databaseId) \(.status)"' 2>/dev/null || true)
if [ -z "$RUN_ID" ]; then
echo "No CI run found for this PR — nothing to rerun."
exit 0
fi
# Split "RUN_ID STATUS" into two vars.
RUN_ID="${RUN_ID%% *}"
STATUS="${RUN_ID##* }"
echo "Latest CI run: $RUN_ID (status: $STATUS)"
# If the run is still in progress, wait for it to finish.
# gh run rerun only works on completed runs — if we try while it's
# running, GitHub rejects with "cannot be rerun; This workflow is
# already running".
if [ "$STATUS" != "completed" ]; then
echo "Run is $STATUS — waiting for completion (this may take a while)..."
# gh run watch --exit-status exits non-zero if the run fails,
# which is expected (the label gate fails). Don't let that kill
# the workflow — we WANT to rerun failed jobs.
timeout 2100 gh run watch "$RUN_ID" --repo "$REPO" --interval 15 || true
# Verify it's actually completed now.
STATUS=$(gh run view "$RUN_ID" --repo "$REPO" --json status --jq '.status' 2>/dev/null || echo "unknown")
if [ "$STATUS" != "completed" ]; then
echo "Run is still $STATUS after wait — giving up."
exit 0
fi
fi
echo "Run completed. Rerunning all failed jobs..."
gh run rerun "$RUN_ID" --repo "$REPO" --failed || true
echo "Done. GitHub will rerun review-labels and all dependent jobs."
+120 -6
View File
@@ -2,14 +2,11 @@ name: Lint (ruff + ty)
# Two things here:
# 1. Advisory diff — ruff + ty diagnostics as a diff vs the target branch.
# Writes a Markdown summary to the run page. Exit zero always.
# Posts a Markdown summary and a PR comment. Exit zero always.
# 2. Blocking ``ruff check .`` — enforces the explicit rules in
# ``[tool.ruff.lint.select]`` (currently PLW1514). Failure blocks merge.
# Separate job so the advisory diff still runs even when enforcement
# fails.
#
# CI-sensitive file review was previously here as a ``ci-review`` job but
# has moved to ``review-labels.yml`` so it can be rerun independently.
# Separate job so the advisory diff still runs and posts even when
# enforcement fails.
on:
workflow_call:
@@ -18,9 +15,14 @@ on:
description: The event name from the calling orchestrator (pull_request or push).
type: string
required: true
ci_review:
description: Whether CI-sensitive files (eslint config, workflows, actions) changed and require a review label.
type: boolean
default: false
permissions:
contents: read
pull-requests: write # needed to post/update PR comments
concurrency:
group: lint-${{ github.ref }}
@@ -160,3 +162,115 @@ jobs:
- name: Run footgun checker
run: python scripts/check-windows-footguns.py --all
ci-review:
# Require explicit maintainer review when CI-sensitive files change:
# eslint config, workflow YAMLs, or composite actions. These files
# influence what code the js-autofix job executes and pushes to
# main, so a malicious PR could inject arbitrary code via a custom eslint
# rule's `fix` function. The label gate ensures a human reviews before
# merge. Mirrors the mcp-catalog-reviewed pattern in supply-chain-audit.yml.
name: CI-sensitive file review
if: inputs.event_name == 'pull_request' && inputs.ci_review
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Require ci-reviewed label
id: label-check
env:
# Read-only label lookup. Use the built-in GITHUB_TOKEN (present and
# read-only on forks) so the gate works on fork PRs; fall back to it
# when AUTOFIX_BOT_PAT is empty. `|| true` degrades an API blip to
# "label absent" rather than hard-failing the step.
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true)
if echo "$LABELS" | grep -Fxq 'ci-reviewed'; then
echo "reviewed=true" >> "$GITHUB_OUTPUT"
echo "ci-reviewed label present."
exit 0
fi
echo "reviewed=false" >> "$GITHUB_OUTPUT"
# On failure: find the bot's previous comment and edit it, or create
# a new one if none exists. Using an HTML comment marker so we can
# locate it reliably across runs without parsing the body text.
# Skipped on fork PRs — GITHUB_TOKEN is read-only there, so the API
# call would fail. The label gate still holds via the step below.
- name: Post or update review warning
if: steps.label-check.outputs.reviewed != 'true' && github.event.pull_request.head.repo.fork != true
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
MARKER="<!-- ci-review-bot -->"
BODY="${MARKER}
## ⚠️ CI-sensitive file review required
This PR changes CI-sensitive files (eslint config, workflow YAMLs,
or composite actions). These files influence what code the
js-autofix job executes and pushes to main.
A maintainer should verify:
- no new eslint rules with custom \`fix\` functions that write outside linted paths,
- no workflow changes that widen permissions or remove guards,
- no composite action changes that alter what gets executed.
After review, add the \`ci-reviewed\` label and re-run this check."
# Find an existing comment with our marker.
COMMENT_ID=$(gh api \
"repos/${{ github.repository }}/issues/${PR}/comments" \
--paginate --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \
| head -1 || true)
if [ -n "$COMMENT_ID" ]; then
gh api --method PATCH \
"repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \
-f body="$BODY"
else
gh pr comment "$PR" --body "$BODY"
fi
# Fail the job when the label is missing — always runs (including
# fork PRs) so the security gate holds even when the comment step
# was skipped above.
- name: Fail on missing label
if: steps.label-check.outputs.reviewed != 'true'
run: |
echo "::error::CI-sensitive changes require the ci-reviewed label."
exit 1
# On success: if a previous warning comment exists, edit it to show
# the review passed so the PR doesn't have a stale ⚠️ sitting around.
# Skipped on fork PRs — no comment was ever posted to update.
- name: Update previous warning to passed
if: steps.label-check.outputs.reviewed == 'true' && github.event.pull_request.head.repo.fork != true
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
MARKER="<!-- ci-review-bot -->"
# Find an existing comment with our marker.
COMMENT_ID=$(gh api \
"repos/${{ github.repository }}/issues/${PR}/comments" \
--paginate --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \
| head -1 || true)
if [ -n "$COMMENT_ID" ]; then
BODY="${MARKER}
## ✅ CI-sensitive file review passed
The \`ci-reviewed\` label is present on this PR."
gh api --method PATCH \
"repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \
-f body="$BODY"
fi
+42 -39
View File
@@ -7,25 +7,22 @@ name: Lockfile diff
# the ``packages`` map at the merge base and at HEAD and set-diffs the
# {install path: version} maps instead.
#
# The semantic diff is exposed as a workflow_call output ``review_status``
# (a JSON array in the unified status format) and an artifact
# (``lockfile-diff`` containing the markdown fragment) for the step
# summary.
# The comment is upserted: the script embeds a hidden HTML marker and the
# workflow PATCHes the existing comment when one is found, so a PR gets
# exactly one lockfile-diff comment that tracks the latest push instead
# of a stack of stale ones. When a later push reverts all lockfile
# changes, the comment is updated to say so (deleting it would be more
# surprising than telling the reviewer it's resolved).
#
# Never blocking — this is review signal, not enforcement.
# Never blocking — this is review signal, not enforcement. Exit is 0 even
# when commenting fails (fork PRs get a read-only GITHUB_TOKEN).
on:
workflow_call:
outputs:
changed:
description: Whether package-lock.json changed relative to the target branch.
value: ${{ jobs.diff.outputs.changed }}
review_status:
description: JSON array of review status objects for the unified PR comment.
value: ${{ jobs.diff.outputs.review_status }}
permissions:
contents: read
pull-requests: write # post/update the diff comment
concurrency:
group: lockfile-diff-${{ github.event.pull_request.number || github.ref }}
@@ -36,9 +33,6 @@ jobs:
name: package-lock.json semantic diff
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
changed: ${{ steps.diff.outputs.changed }}
review_status: ${{ steps.emit-status.outputs.review_status }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -60,36 +54,45 @@ jobs:
--output /tmp/lockfile-diff.md
if [ -s /tmp/lockfile-diff.md ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
{
echo "## package-lock.json semantic diff"
echo ""
cat /tmp/lockfile-diff.md
} >> "$GITHUB_STEP_SUMMARY"
cat /tmp/lockfile-diff.md >> "$GITHUB_STEP_SUMMARY"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
: > /tmp/lockfile-diff.md
fi
- name: Emit review_status
id: emit-status
- name: Post or update PR comment
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
CHANGED: ${{ steps.diff.outputs.changed }}
run: |
set -euo pipefail
CHANGED="${{ steps.diff.outputs.changed }}"
STATUS="[]"
MARKER='<!-- hermes-lockfile-diff -->'
if [ "$CHANGED" = "true" ]; then
CONTENT=$(cat /tmp/lockfile-diff.md | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
STATUS="[{\"source\":\"lockfile-diff\",\"results\":[{\"kind\":\"action_required\",\"title\":\"package-lock.json\",\"summary\":\"Locked npm dependency versions changed.\",\"detail\":${CONTENT},\"how_to_fix\":\"Add the \`ci-reviewed\` label after verifying the version changes are expected.\"}]}"
else
STATUS="[]"
# Find our previous comment (paginated — busy PRs exceed one page).
EXISTING=$(gh api --paginate "repos/${REPO}/issues/${PR}/comments" \
--jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" \
| head -1 || true)
if [ "$CHANGED" != "true" ]; then
if [ -n "$EXISTING" ]; then
# A previous push changed the lockfile but the latest one
# doesn't — update the comment rather than leave stale info.
printf '%s\n✅ package-lock.json changes from an earlier push have been reverted — locked versions now match the target branch.\n' "$MARKER" > /tmp/lockfile-diff.md
else
echo "No lockfile changes and no existing comment — nothing to do."
exit 0
fi
fi
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
- name: Upload diff artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: lockfile-diff
path: /tmp/lockfile-diff.md
retention-days: 1
overwrite: true
if [ -n "$EXISTING" ]; then
echo "Updating existing comment ${EXISTING}"
gh api --method PATCH "repos/${REPO}/issues/comments/${EXISTING}" \
-F body=@/tmp/lockfile-diff.md > /dev/null \
|| echo "::warning::Could not update PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)"
else
echo "Creating new comment"
gh api "repos/${REPO}/issues/${PR}/comments" \
-F body=@/tmp/lockfile-diff.md > /dev/null \
|| echo "::warning::Could not post PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)"
fi
+57 -80
View File
@@ -14,14 +14,14 @@ name: OSV-Scanner
# code patterns in PR diffs) by covering the orthogonal "currently-pinned
# dep became known-vulnerable" case.
#
# Uses Google's officially-recommended reusable workflow, pinned by SHA.
# Steps below are inlined from Google's officially-recommended reusable
# workflow (google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml),
# rather than called via `uses:` so we can set a `timeout-minutes` in the
# degenerate case where this job hangs.
# Findings land in the repo's Security tab (Code Scanning > OSV-Scanner).
# fail-on-vuln is disabled so the job does not block merges on pre-existing
# vulnerabilities in pinned deps that we may need to patch deliberately.
#
# The reusable workflow can't emit custom outputs, so a wrapper job
# downloads the SARIF result and summarizes the vulnerability count into
# a review_status for the unified PR comment.
on:
workflow_call:
@@ -40,85 +40,62 @@ permissions:
jobs:
scan:
name: Scan lockfiles
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
with:
# Scan explicit lockfiles rather than recursing, so we only look at
# the three sources of truth and skip vendored / test / worktree dirs.
scan-args: |-
--lockfile=uv.lock
--lockfile=package-lock.json
--lockfile=website/package-lock.json
fail-on-vuln: false
emit-status:
name: Emit review status
runs-on: ubuntu-latest
needs: scan
if: always()
outputs:
review_status: ${{ steps.emit.outputs.review_status }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Download SARIF result
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
name: osv-results
path: /tmp/osv-results
persist-credentials: false
- name: 'Run scanner'
uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
with:
# Scan explicit lockfiles rather than recursing, so we only look at
# the three sources of truth and skip vendored / test / worktree dirs.
scan-args: |-
--output=results.json
--format=json
--lockfile=uv.lock
--lockfile=package-lock.json
--lockfile=website/package-lock.json
continue-on-error: true
- name: Emit review_status
id: emit
- name: 'Run osv-scanner-reporter'
uses: google/osv-scanner-action/osv-reporter-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
with:
scan-args: |-
--output=results.sarif
--new=results.json
--gh-annotations=false
--fail-on-vuln=false
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: 'Upload artifact'
id: 'upload_artifact'
if: ${{ !cancelled() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: OSV Scanner SARIF file
path: results.sarif
retention-days: 5
# Upload the results to GitHub's code scanning dashboard.
- name: 'Upload to code-scanning'
if: ${{ !cancelled() }}
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
with:
sarif_file: results.sarif
- name: 'Print Code Scanning URL'
if: ${{ !cancelled() }}
run: |
set -euo pipefail
STATUS="[]"
echo "View the OSV-Scanner results in the 'Security' tab, using the following link:"
echo "${{ github.server_url }}/${{ github.repository }}/security/code-scanning?query=is%3Aopen+branch%3A${GITHUB_REF_NAME}+tool%3Aosv-scanner"
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
if [ -f /tmp/osv-results/osv-results.sarif ]; then
# Count vulnerabilities from the SARIF file
VULN_COUNT=$(python3 -c "
import json, sys
try:
with open('/tmp/osv-results/osv-results.sarif') as f:
data = json.load(f)
count = 0
vulns = []
for run in data.get('runs', []):
for result in run.get('results', []):
count += 1
rule_id = result.get('ruleId', 'unknown')
message = result.get('message', {}).get('text', '')
loc = result.get('locations', [{}])[0].get('physicalLocation', {}).get('artifactLocation', {}).get('uri', '')
vulns.append(f'- {rule_id} in {loc}: {message}')
print(count)
if vulns:
print('\n'.join(vulns[:20]), file=sys.stderr)
except Exception:
print(0)
")
VULN_DETAIL=""
if [ "$VULN_COUNT" -gt 0 ] 2>/dev/null; then
VULN_PLURAL=$([ "$VULN_COUNT" -eq 1 ] && echo "y" || echo "ies")
VULN_DETAIL=$(python3 -c "
import json, sys
try:
with open('/tmp/osv-results/osv-results.sarif') as f:
data = json.load(f)
vulns = []
for run in data.get('runs', []):
for result in run.get('results', []):
rule_id = result.get('ruleId', 'unknown')
loc = result.get('locations', [{}])[0].get('physicalLocation', {}).get('artifactLocation', {}).get('uri', '')
vulns.append(f'- {rule_id} in {loc}')
print(json.dumps('\n'.join(vulns[:20])))
except Exception:
print(json.dumps(''))
")
STATUS="[{\"source\":\"osv scan\",\"results\":[{\"kind\":\"warning\",\"title\":\"OSV vulnerability scan\",\"summary\":\"${VULN_COUNT} known vulnerabilit${VULN_PLURAL} found in pinned dependencies.\",\"detail\":${VULN_DETAIL},\"how_to_fix\":\"Review the findings in the [Security tab](../../security/code-scanning). Update the affected dependencies if a patched version is available.\"}]}]"
else
STATUS="[]"
fi
fi
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
- name: 'Error troubleshooter'
if: ${{ always() && steps.upload_artifact.outcome == 'failure' }}
run: |
echo "::error::Artifact upload failed. This is most likely caused by a error during scanning earlier in the workflow."
exit 1
-140
View File
@@ -1,140 +0,0 @@
name: Plugin Catalog CI
# Admission gate for plugin-catalog entries. Fires ONLY on PRs touching
# plugin-catalog/** so it can never go red on unrelated PRs.
#
# Two gates:
# structural — cheap schema check, no hermes install needed
# pinned-source-validate — supply-chain gate: the pinned sha MUST be
# reachable in the entry's repo, and the plugin
# at that exact commit must pass
# `hermes plugins validate`.
on:
pull_request:
paths:
- "plugin-catalog/**"
permissions:
contents: read
jobs:
structural:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Install PyYAML
uses: ./.github/actions/retry
with:
command: pip install pyyaml==6.0.2
- name: Validate catalog files (structural)
run: |
set -euo pipefail
# Validating the whole directory is simpler than diffing and keeps
# the invariant that EVERYTHING in plugin-catalog/ stays valid.
python3 scripts/validate_plugin_catalog.py plugin-catalog/
pinned-source-validate:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # need the merge-base to diff changed catalog files
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Find changed catalog entries
id: changed
run: |
set -euo pipefail
MERGE_BASE=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
# Added + modified entry files only; deletions and removed.yaml
# have nothing to clone.
CHANGED=$(git diff --name-only --diff-filter=AM "$MERGE_BASE"...HEAD \
-- 'plugin-catalog/*.yaml' 'plugin-catalog/*.yml' \
| grep -v '/removed\.yaml$' || true)
echo "Changed catalog entries:"
echo "${CHANGED:-<none>}"
{
echo 'files<<__EOF__'
echo "$CHANGED"
echo '__EOF__'
} >> "$GITHUB_OUTPUT"
- name: Install hermes-agent from the PR's own checkout
if: steps.changed.outputs.files != ''
uses: ./.github/actions/retry
with:
command: pip install -e .
- name: Clone each entry at its pinned sha and validate
if: steps.changed.outputs.files != ''
env:
CHANGED_FILES: ${{ steps.changed.outputs.files }}
run: |
set -euo pipefail
FAILED=0
while IFS= read -r entry; do
[ -z "$entry" ] && continue
echo "::group::validate $entry"
# Parse repo / sha / subdir from the entry yaml.
eval "$(python3 - "$entry" <<'PYEOF'
import shlex
import sys
import yaml
with open(sys.argv[1], encoding="utf-8") as fh:
data = yaml.safe_load(fh) or {}
print(f"REPO={shlex.quote(str(data.get('repo', '')))}")
print(f"SHA={shlex.quote(str(data.get('sha', '')))}")
print(f"SUBDIR={shlex.quote(str(data.get('subdir', '') or ''))}")
PYEOF
)"
echo "repo=$REPO sha=$SHA subdir=$SUBDIR"
CLONE_DIR=$(mktemp -d)
# Full clone (no --depth 1): the pinned sha may not be the branch tip.
if ! git clone "$REPO" "$CLONE_DIR"; then
echo "::error file=$entry::clone failed for $REPO"
FAILED=1; echo "::endgroup::"; continue
fi
# SUPPLY-CHAIN GATE: the pinned sha must be reachable in the repo.
if ! git -C "$CLONE_DIR" checkout --detach "$SHA"; then
echo "::error file=$entry::pinned sha $SHA is not reachable in $REPO"
FAILED=1; echo "::endgroup::"; continue
fi
PLUGIN_DIR="$CLONE_DIR${SUBDIR:+/$SUBDIR}"
if [ ! -f "$PLUGIN_DIR/plugin.yaml" ]; then
echo "::error file=$entry::no plugin.yaml at subdir '$SUBDIR' of $REPO@$SHA"
FAILED=1; echo "::endgroup::"; continue
fi
# Manifest schema + declared-vs-registered capability check.
if hermes plugins validate "$PLUGIN_DIR"; then
echo "✅ PASS: $entry"
else
echo "::error file=$entry::hermes plugins validate failed"
FAILED=1
fi
echo "::endgroup::"
done <<< "$CHANGED_FILES"
if [ "$FAILED" -ne 0 ]; then
echo "❌ FAIL: one or more catalog entries failed pinned-source validation"
exit 1
fi
echo "✅ PASS: all changed catalog entries validated at their pinned shas"
-98
View File
@@ -1,98 +0,0 @@
name: Review labels
# Require explicit maintainer review when CI-sensitive files or the MCP
# catalog change. Previously this was split across two jobs in two
# workflows: ``ci-review`` in lint.yml (gated on ``ci_review``) and
# ``mcp-catalog-review`` in supply-chain-audit.yml (gated on
# ``mcp_catalog``). Both checked for their own label.
#
# Now consolidated: a single ``ci-reviewed`` label covers both. The
# comment sections tell the reviewer exactly what to verify per area,
# so one label is enough — the human reads the comment, not the label
# name.
#
# Outputs:
# ci_reviewed — "true" / "false" / "" (empty when neither lane ran)
# review_status — JSON array of status objects consumed by the review
# comment assembler. See scripts/ci/emit_review_status.py.
on:
workflow_call:
inputs:
ci_review:
description: Whether CI-sensitive files (eslint config, workflows, actions) changed.
type: boolean
default: false
mcp_catalog:
description: Whether the MCP catalog / installer changed.
type: boolean
default: false
supply_chain:
description: Whether the critical supply-chain scan found a risk requiring review.
type: boolean
default: false
outputs:
ci_reviewed:
description: Whether the ci-reviewed label is present. Empty when neither input was true.
value: ${{ jobs.check.outputs.ci_reviewed }}
review_status:
description: JSON array of status objects for the review comment assembler.
value: ${{ jobs.check.outputs.review_status }}
permissions:
contents: read
pull-requests: read # read PR labels
jobs:
check:
name: Review label gate
if: inputs.ci_review || inputs.mcp_catalog || inputs.supply_chain
runs-on: ubuntu-latest
timeout-minutes: 2
outputs:
ci_reviewed: ${{ steps.label-check.outputs.ci_reviewed }}
review_status: ${{ steps.build-status.outputs.review_status }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check ci-reviewed label
id: label-check
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
LABELS=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name' || true)
if echo "$LABELS" | grep -Fxq 'ci-reviewed'; then
echo "ci-reviewed label present."
echo "ci_reviewed=true" >> "$GITHUB_OUTPUT"
else
echo "ci-reviewed label missing."
echo "ci_reviewed=false" >> "$GITHUB_OUTPUT"
fi
- name: Build review_status JSON
id: build-status
env:
CI_REVIEW: ${{ inputs.ci_review }}
MCP_CATALOG: ${{ inputs.mcp_catalog }}
SUPPLY_CHAIN: ${{ inputs.supply_chain }}
LABEL_PRESENT: ${{ steps.label-check.outputs.ci_reviewed }}
run: |
set -euo pipefail
args=()
if [ "$CI_REVIEW" = "true" ]; then args+=(--ci-review); fi
if [ "$MCP_CATALOG" = "true" ]; then args+=(--mcp-catalog); fi
if [ "$SUPPLY_CHAIN" = "true" ]; then args+=(--supply-chain); fi
if [ "$LABEL_PRESENT" = "true" ]; then args+=(--label-present); fi
python3 scripts/ci/emit_review_status.py "${args[@]}" --output "$GITHUB_OUTPUT"
- name: Fail on missing label
if: steps.label-check.outputs.ci_reviewed != 'true'
run: |
echo "::error::CI-sensitive changes require the ci-reviewed label. Add the label and re-run this check."
exit 1
+1 -9
View File
@@ -108,18 +108,10 @@ jobs:
echo "Summary: ${{ steps.probe.outputs.summary }}"
fi
- name: Get GitHub App token
if: steps.probe.outputs.status != 'ok'
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Open issue on degraded / failed probe
if: steps.probe.outputs.status != 'ok'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
STATUS: ${{ steps.probe.outputs.status }}
DETAIL: ${{ steps.probe.outputs.detail }}
run: |
+2 -15
View File
@@ -24,13 +24,6 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
@@ -42,7 +35,7 @@ jobs:
- name: Build skills index
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
run: python scripts/build_skills_index.py
- name: Upload index artifact
@@ -61,13 +54,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Trigger Deploy Site workflow
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
run: gh workflow run deploy-site.yml --repo ${{ github.repository }} -f skills_index_run_id=${{ github.run_id }}
+76 -101
View File
@@ -10,18 +10,9 @@ name: Supply Chain Audit
# advisory-only workflow instead.
#
# Path-gating is handled centrally by the ``ci.yml`` orchestrator's
# ``detect`` job. The orchestrator passes ``scan`` / ``deps`` booleans as
# inputs; this workflow's jobs gate on those inputs instead of re-computing
# the diff. MCP catalog review was previously here but has moved to
# ``review-labels.yml`` so it can be rerun independently.
#
# Outputs:
# review_status — JSON array of status objects consumed by the review
# comment assembler (scripts/ci/assemble_review_comment.py).
# critical_findings — "true" when the narrow critical-pattern scan found
# something. The review-label gate consumes this and
# owns the action-required result, so adding
# ``ci-reviewed`` can heal the run on rerun.
# ``detect`` job. The orchestrator passes ``scan`` / ``deps`` /
# ``mcp_catalog`` booleans as inputs; this workflow's jobs gate on those
# inputs instead of re-computing the diff.
on:
workflow_call:
@@ -38,13 +29,10 @@ on:
description: Whether pyproject.toml changed.
type: boolean
required: true
outputs:
review_status:
description: JSON array of review status objects for the review comment assembler.
value: ${{ jobs.aggregate.outputs.review_status }}
critical_findings:
description: Whether the critical-pattern scan found a risk requiring maintainer review.
value: ${{ jobs.aggregate.outputs.critical_findings }}
mcp_catalog:
description: Whether the MCP catalog / installer changed.
type: boolean
required: true
permissions:
pull-requests: write
@@ -56,26 +44,16 @@ jobs:
if: inputs.scan
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
review_status: ${{ steps.emit-status.outputs.review_status }}
critical_findings: ${{ steps.scan.outputs.found }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Scan diff for critical patterns
id: scan
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
run: |
set -euo pipefail
@@ -83,7 +61,7 @@ jobs:
HEAD="${{ github.event.pull_request.head.sha }}"
# Added lines only, excluding lockfiles.
# Three-point diff (base...head) diffs from the merge base to HEAD,
# Three-dot diff (base...head) diffs from the merge base to HEAD,
# so only changes introduced by this PR are included — not changes
# that landed on main after the PR branched off.
DIFF=$(git diff "$BASE"..."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true)
@@ -161,32 +139,33 @@ jobs:
echo "found=false" >> "$GITHUB_OUTPUT"
fi
- name: Emit review_status
id: emit-status
if: always()
- name: Post critical finding comment
if: steps.scan.outputs.found == 'true'
env:
FOUND: ${{ steps.scan.outputs.found }}
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
run: |
python3 - <<'PYEOF'
import json, os
BODY="## 🚨 CRITICAL Supply Chain Risk Detected
# The review-label gate renders and blocks critical findings. Keep
# this scan a fact-finder so adding ci-reviewed can rerun the gate
# without requiring the scanner itself to fail again.
status = []
This PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging.
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
f.write(f"review_status={json.dumps(status)}\n")
PYEOF
$(cat /tmp/findings.md)
---
*Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting.*"
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)"
- name: Fail on critical findings
if: steps.scan.outputs.found == 'true'
run: |
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
exit 1
dep-bounds:
name: Check PyPI dependency upper bounds
if: inputs.deps
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
review_status: ${{ steps.emit-status.outputs.review_status }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -209,7 +188,7 @@ jobs:
exit 0
fi
# Match PyPI dep specs that have >= and no < ceiling.
# Match PyPI dep specs that have >= but no < ceiling.
# Pattern: "package>=version" without a following ",<" bound.
# Excludes git+ URLs (which use commit SHAs) and comments.
UNBOUNDED=$(echo "$ADDED" | grep -oE '"[a-zA-Z0-9_-]+(\[[^\]]*\])?>=[ 0-9.]+"' | grep -v ',<' || true)
@@ -221,36 +200,26 @@ jobs:
echo "found=false" >> "$GITHUB_OUTPUT"
fi
- name: Emit review_status
id: emit-status
if: always()
- name: Post unbounded dep warning
if: steps.bounds.outputs.found == 'true'
env:
FOUND: ${{ steps.bounds.outputs.found }}
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
run: |
python3 - <<'PYEOF'
import json, os
BODY="## ⚠️ Unbounded PyPI Dependency Detected
found = os.environ.get("FOUND", "") == "true"
This PR adds PyPI dependencies without a \`<next_major\` upper bound. Per our [supply chain policy](../blob/main/CONTRIBUTING.md#dependency-pinning-policy-supply-chain-hardening), all PyPI deps must be pinned as \`>=floor,<next_major\`.
if found:
with open("/tmp/unbounded.txt", encoding="utf-8") as f:
detail = f.read()
status = [{
"source": "supply chain",
"results": [{
"kind": "action_required",
"title": "Unbounded PyPI dependencies",
"summary": "This PR adds PyPI dependencies without upper bounds.",
"detail": detail,
"how_to_fix": 'Add a `<next_major` upper bound, e.g. `"package>=1.2.0,<2"`. See CONTRIBUTING.md dependency pinning policy.'
}]
}]
else:
status = []
**Unbounded specs found:**
\`\`\`
$(cat /tmp/unbounded.txt)
\`\`\`
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
f.write(f"review_status={json.dumps(status)}\n")
PYEOF
**Fix:** Add an upper bound, e.g. \`"package>=1.2.0,<2"\`
---
*See PR #2810 and CONTRIBUTING.md for the full policy rationale.*"
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
- name: Fail on unbounded deps
if: steps.bounds.outputs.found == 'true'
@@ -258,39 +227,45 @@ jobs:
echo "::error::PyPI dependencies without upper bounds detected. Add <next_major ceiling per CONTRIBUTING.md policy."
exit 1
aggregate:
name: Aggregate review statuses
needs: [scan, dep-bounds]
if: always()
mcp-catalog-review:
name: MCP catalog security review
if: inputs.mcp_catalog
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
review_status: ${{ steps.merge.outputs.review_status }}
critical_findings: ${{ steps.merge.outputs.critical_findings }}
steps:
- name: Merge review statuses
id: merge
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Require explicit MCP catalog review label
env:
SCAN_STATUS: ${{ needs.scan.outputs.review_status }}
DEP_STATUS: ${{ needs.dep-bounds.outputs.review_status }}
CRITICAL_FINDINGS: ${{ needs.scan.outputs.critical_findings }}
# Read-only label lookup. Use the built-in GITHUB_TOKEN (present and
# read-only on forks) so the gate works on fork PRs; fall back to it
# when AUTOFIX_BOT_PAT is empty. `|| true` degrades an API blip to
# "label absent" rather than hard-failing the step.
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
run: |
python3 - <<'PYEOF'
import json, os
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true)
if echo "$LABELS" | grep -Fxq 'mcp-catalog-reviewed'; then
echo "MCP catalog review label present."
exit 0
fi
merged = []
for key in ("SCAN_STATUS", "DEP_STATUS"):
raw = os.environ.get(key, "")
if not raw:
continue
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
continue
if isinstance(data, list):
merged.extend(data)
BODY="## ⚠️ MCP catalog security review required
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
f.write(f"review_status={json.dumps(merged)}\n")
f.write("critical_findings=" + os.environ.get("CRITICAL_FINDINGS", "false") + "\n")
PYEOF
This PR changes the bundled MCP catalog or MCP catalog installer code. MCP entries can define local commands that users later install into \`mcp_servers\`, so this needs explicit maintainer review before merge.
A maintainer should verify:
- any new/changed \`optional-mcps/**/manifest.yaml\` command and args are expected,
- stdio transports do not use shell+egress/exfiltration payloads,
- git install refs are pinned and bootstrap commands are minimal,
- requested env vars/secrets match the upstream MCP's documented needs.
After review, add the \`mcp-catalog-reviewed\` label and re-run this check."
gh pr comment "$PR" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
echo "::error::MCP catalog changes require the mcp-catalog-reviewed label."
exit 1
+2 -9
View File
@@ -143,16 +143,9 @@ jobs:
name: python-package-distributions
path: dist/
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Wait for GitHub Release to exist
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
# release.py creates the GitHub Release after pushing the tag,
# but this workflow starts from the tag push — wait for it.
run: |
@@ -178,7 +171,7 @@ jobs:
- name: Attach signed artifacts to GitHub Release
if: env.skip_sign != 'true'
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
# release.py already created the GitHub Release — just upload
# the Sigstore signatures alongside the existing assets.
run: >-
-11
View File
@@ -45,10 +45,6 @@ name: uv.lock check
on:
workflow_call:
outputs:
review_status:
description: "JSON review status for the review-status aggregator"
value: ${{ jobs.check.outputs.review_status }}
permissions:
contents: read
@@ -62,8 +58,6 @@ jobs:
name: uv lock --check
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
review_status: ${{ steps.verify.outputs.review_status }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -79,7 +73,6 @@ jobs:
# of this file) — failures often mean "your branch is behind main,
# rebase and regenerate uv.lock."
- name: Verify uv.lock is up-to-date
id: verify
run: |
# uv lock --check re-resolves against PyPI (network). Retry so a
# registry blip doesn't read as "lockfile stale". A genuinely stale
@@ -124,9 +117,5 @@ jobs:
on `main` post-merge.
EOF
echo "::error title=uv.lock out of sync::Run \`uv lock\` locally and commit the result. If on a PR, sync with main first."
review_status='[{"source":"uv.lock check","results":[{"kind":"action_required","title":"uv.lock out of sync","summary":"uv.lock is out of sync with pyproject.toml.","how_to_fix":"Run `uv lock` locally and commit the result. If on a PR, sync with main first:\n```\ngit fetch origin main\ngit rebase origin/main\nuv lock\ngit add uv.lock\ngit commit -m \"chore: refresh uv.lock\"\n```\n"}]}]'
echo "review_status=${review_status}" >> "$GITHUB_OUTPUT"
exit 1
fi
review_status='[]'
echo "review_status=${review_status}" >> "$GITHUB_OUTPUT"
+1 -11
View File
@@ -44,10 +44,7 @@ run_datagen_sonnet.sh
source-data/*
run_datagen_megascience_glm4-6.sh
data/*
# No trailing slash: also matches node_modules SYMLINKS (worktrees often
# symlink node_modules to the main checkout; the dir-only pattern let one
# slip into a commit and break `npm ci` on CI with ENOTDIR).
node_modules
node_modules/
browser-use/
agent-browser/
# Private keys
@@ -75,8 +72,6 @@ environments/benchmarks/evals/
# Web UI build output
hermes_cli/web_dist/
# Cross-process web UI build lock (flock target, always empty)
.web_ui_build.lock
apps/desktop/build/
apps/desktop/dist/
@@ -113,10 +108,6 @@ website/static/api/skills-index.json
# every build).
website/static/api/skills.json
website/static/api/skills-meta.json
# plugins.json + plugins-meta.json are build artifacts emitted by
# website/scripts/extract-plugins.py during prebuild (Plugin Catalog page).
website/static/api/plugins.json
website/static/api/plugins-meta.json
# automation-blueprints-index.json is a build artifact emitted by
# website/scripts/extract-automation-blueprints.py during prebuild.
website/static/api/automation-blueprints-index.json
@@ -173,4 +164,3 @@ apps/desktop/demo/
# PR body is the archive. See the hermes-agent-dev skill's
# pr-infographic-workflow reference (storage rule + lapse #8 / #COMMIT-1).
infographic/
native/fts5_cjk/*.so
+1 -2
View File
@@ -998,8 +998,7 @@ Two shapes:
Roles:
- `role="leaf"` (default) — focused worker. Cannot call `delegate_task`,
`clarify`, `memory`, `send_message`, `cronjob`. Retains `execute_code`
(programmatic tool calling).
`clarify`, `memory`, `send_message`, `execute_code`.
- `role="orchestrator"` — retains `delegate_task` so it can spawn its
own workers. Gated by `delegation.orchestrator_enabled` (default true)
and bounded by `delegation.max_spawn_depth` (default 2).
-1
View File
@@ -1,7 +1,6 @@
graft skills
graft optional-skills
graft optional-mcps
graft hermes_cli/web_dist
graft locales
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs
+6 -6
View File
@@ -456,7 +456,7 @@ class HermesACPAgent(acp.Agent):
"tools": "List available tools",
"context": "Show conversation context info",
"reset": "Clear conversation history",
"compress": "Compress conversation context",
"compact": "Compress conversation context",
"steer": "Inject guidance into the currently running agent turn",
"queue": "Queue a prompt to run after the current turn finishes",
"version": "Show Hermes version",
@@ -485,7 +485,7 @@ class HermesACPAgent(acp.Agent):
"description": "Clear conversation history",
},
{
"name": "compress",
"name": "compact",
"description": "Compress conversation context",
},
{
@@ -1756,7 +1756,7 @@ class HermesACPAgent(acp.Agent):
"tools": self._cmd_tools,
"context": self._cmd_context,
"reset": self._cmd_reset,
"compress": self._cmd_compress,
"compact": self._cmd_compact,
"steer": self._cmd_steer,
"queue": self._cmd_queue,
"version": self._cmd_version,
@@ -1898,7 +1898,7 @@ class HermesACPAgent(acp.Agent):
lines.append(
f"Compression: due now (threshold ~{threshold_tokens:,}"
+ (f", {threshold_pct:.0f}%" if threshold_pct else "")
+ "). Run /compress."
+ "). Run /compact."
)
else:
lines.append(
@@ -1913,7 +1913,7 @@ class HermesACPAgent(acp.Agent):
if getattr(agent, "compression_enabled", True) is False:
lines.append("Compression is disabled for this agent.")
else:
lines.append("Tip: run /compress to compress manually before the threshold.")
lines.append("Tip: run /compact to compress manually before the threshold.")
return "\n".join(lines)
@@ -1933,7 +1933,7 @@ class HermesACPAgent(acp.Agent):
return "Conversation history cleared. Agent session state reset failed; see logs."
return "Conversation history cleared."
def _cmd_compress(self, args: str, state: SessionState) -> str:
def _cmd_compact(self, args: str, state: SessionState) -> str:
if not state.history:
return "Nothing to compress — conversation is empty."
try:
+2 -2
View File
@@ -1,7 +1,7 @@
{
"id": "hermes-agent",
"name": "Hermes Agent",
"version": "0.19.0",
"version": "0.18.2",
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
"repository": "https://github.com/NousResearch/hermes-agent",
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
@@ -9,7 +9,7 @@
"license": "MIT",
"distribution": {
"uvx": {
"package": "hermes-agent[acp]==0.19.0",
"package": "hermes-agent[acp]==0.18.2",
"args": ["hermes-acp"]
}
}
+15 -388
View File
@@ -28,7 +28,7 @@ import time
import uuid
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional
from urllib.parse import parse_qs, urlparse, urlunparse
from urllib.parse import urlparse, parse_qs, urlunparse
from agent.context_compressor import ContextCompressor
from agent.iteration_budget import IterationBudget
@@ -48,7 +48,6 @@ from agent.tool_guardrails import (
ToolGuardrailDecision,
)
from hermes_cli.config import cfg_get
from hermes_cli.route_identity import normalize_route_base_url
from hermes_cli.timeouts import get_provider_request_timeout
from hermes_constants import get_hermes_home
from utils import base_url_host_matches, is_truthy_value
@@ -69,151 +68,18 @@ def _ra():
return run_agent
def _normalize_route_base_url(base_url: Any) -> str:
"""Canonicalize an endpoint URL for model-route identity comparisons."""
return normalize_route_base_url(base_url)
def _provider_default_routes(provider: str) -> set[str]:
"""Return known exact default routes for a canonical provider id."""
routes: set[str] = set()
try:
from hermes_cli.providers import HERMES_OVERLAYS, get_provider
overlay = HERMES_OVERLAYS.get(provider)
provider_def = get_provider(provider)
for value in (
getattr(overlay, "base_url_override", ""),
getattr(provider_def, "base_url", ""),
):
route = _normalize_route_base_url(value)
if route:
routes.add(route)
except Exception:
pass
try:
from providers import get_provider_profile
profile = get_provider_profile(provider)
route = _normalize_route_base_url(
getattr(profile, "base_url", "")
)
if route:
routes.add(route)
except Exception:
pass
try:
from hermes_cli.auth import PROVIDER_REGISTRY
from hermes_cli.models import normalize_provider as normalize_model_provider
from hermes_cli.providers import normalize_provider as normalize_registry_provider
for provider_id, config in PROVIDER_REGISTRY.items():
canonical_id = normalize_registry_provider(
normalize_model_provider(provider_id)
)
if canonical_id != provider:
continue
route = _normalize_route_base_url(
getattr(config, "inference_base_url", "")
)
if route:
routes.add(route)
except Exception:
pass
if provider == "gemini":
routes.update(
f"{route.rstrip('/')}/openai"
for route in list(routes)
)
return routes
def _context_route_mismatch(
configured_base_url: Any,
active_base_url: Any,
configured_provider: Any,
active_provider: Any,
*,
already_normalized: bool = False,
) -> bool:
"""Return whether a context pin's configured route differs from runtime."""
if already_normalized:
configured_route = str(configured_base_url or "")
active_route = str(active_base_url or "")
else:
configured_route = _normalize_route_base_url(configured_base_url)
active_route = _normalize_route_base_url(active_base_url)
if configured_route:
return configured_route != active_route
configured_provider = str(configured_provider or "").strip()
active_provider = str(active_provider or "").strip()
if not configured_provider:
return False
try:
from hermes_cli.models import normalize_provider as normalize_model_provider
configured_provider = normalize_model_provider(configured_provider)
active_provider = normalize_model_provider(active_provider)
except Exception:
configured_provider = configured_provider.lower()
active_provider = active_provider.lower()
try:
from hermes_cli.providers import normalize_provider as normalize_registry_provider
configured_provider = normalize_registry_provider(configured_provider)
active_provider = normalize_registry_provider(active_provider)
except Exception:
pass
if active_route:
configured_routes = _provider_default_routes(configured_provider)
return not configured_routes or active_route not in configured_routes
return bool(
configured_provider
and active_provider
and configured_provider != active_provider
)
def _normalize_custom_provider_name(value: Any) -> str:
"""Mirror runtime normalization for a requested custom-provider identity."""
return str(value or "").strip().lower().replace(" ", "-")
def _custom_provider_runtime_ids(value: Any) -> set[str]:
"""Return raw/menu identities that runtime accepts for a configured name."""
normalized = _normalize_custom_provider_name(value)
if not normalized:
return set()
return {normalized, f"custom:{normalized}"}
def _build_codex_gpt5_autoraise_notice(
autoraise: Dict[str, Any], context_length: Optional[int] = None
) -> str:
def _build_codex_gpt5_autoraise_notice(autoraise: Dict[str, Any]) -> str:
"""Build the one-time notice shown when Codex gpt-5.x raises compaction.
``autoraise`` is ``{"model": <slug>, "from": <old_ratio>, "to": <new_ratio>}``.
``context_length`` is the live-resolved window from the context compressor
(Codex's /models catalog is authoritative and can change server-side, e.g.
the gpt-5.6 family's 272K → 372K → 272K shifts in July 2026), so the banner
reports what this session actually got rather than a hardcoded cap. The
same text is printed inline for CLI users and replayed via
The same text is printed inline for CLI users and replayed via
``status_callback`` for gateway users, so it must be self-contained and
include the exact opt-back-out command.
"""
model = str(autoraise.get("model") or "gpt-5.4/5.5").strip().lower().rsplit("/", 1)[-1]
if isinstance(context_length, int) and context_length > 0:
cap = f"{round(context_length / 1000)}K"
else:
# Static fallback when the resolved window isn't available:
# gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5/5.6
# family is capped at 272K by the Codex OAuth backend.
cap = "128K" if model.startswith("gpt-5.3-codex-spark") else "272K"
# gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5/5.6 family
# is capped at 272K by the Codex OAuth backend.
cap = "128K" if model.startswith("gpt-5.3-codex-spark") else "272K"
from_pct = int(round(autoraise["from"] * 100))
to_pct = int(round(autoraise["to"] * 100))
return (
@@ -1561,14 +1427,7 @@ def init_agent(
agent._memory_nudge_interval = 10
agent._turns_since_memory = 0
agent._iters_since_skill = 0
# A flush/background agent may pass skip_memory=True to avoid spinning up an
# external memory *provider*, but if the caller also explicitly enables the
# "memory" toolset it still needs the built-in file-backed store — otherwise
# the memory tool dispatches with store=None and every call fails (#65429).
# So the built-in store is created unless memory is globally disabled, while
# the external-provider block below stays gated on skip_memory.
_memory_toolset_requested = "memory" in (agent.enabled_toolsets or [])
if not skip_memory or _memory_toolset_requested:
if not skip_memory:
try:
mem_config = _agent_cfg.get("memory", {})
agent._memory_enabled = mem_config.get("memory_enabled", False)
@@ -1788,34 +1647,6 @@ def init_agent(
compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"}
compression_target_ratio = float(_compression_cfg.get("target_ratio", 0.20))
compression_protect_last = int(_compression_cfg.get("protect_last_n", 20))
# Cap on compression retry rounds before a turn gives up with "max
# compression attempts reached" (compression.max_attempts). Hardcoding 3
# strands sessions that legitimately need more rounds — e.g. a restart
# history reload whose incompressible tool schemas keep the request
# estimate above the threshold even though the messages compress fine
# (the #62605 failure class). Default 3 preserves current behavior, so
# an unset key is behavior-neutral; validated >= 1, hard-capped at 10,
# and any non-int-like value falls back to 3. Booleans are rejected
# (bool subclasses int, so int(True) would silently become 1) and
# fractional floats are rejected rather than truncated — "4.7 attempts"
# is a config mistake, not a request for 4.
_raw_max_attempts = _compression_cfg.get("max_attempts", 3)
if isinstance(_raw_max_attempts, bool):
compression_max_attempts = 3
elif isinstance(_raw_max_attempts, int):
compression_max_attempts = _raw_max_attempts
elif isinstance(_raw_max_attempts, float):
compression_max_attempts = (
int(_raw_max_attempts) if _raw_max_attempts.is_integer() else 3
)
else:
try:
compression_max_attempts = int(str(_raw_max_attempts).strip())
except (TypeError, ValueError):
compression_max_attempts = 3
if compression_max_attempts < 1:
compression_max_attempts = 3
compression_max_attempts = min(compression_max_attempts, 10)
# protect_first_n is the number of non-system messages to protect at
# the head, in addition to the system prompt (which is always
# implicitly protected by the compressor). Floor at 0 — a value of
@@ -1828,29 +1659,6 @@ def init_agent(
compression_abort_on_summary_failure = str(
_compression_cfg.get("abort_on_summary_failure", False)
).lower() in {"true", "1", "yes"}
# Per-model threshold overrides: keys are substring-matched against the
# model name (longest match wins). Empty dict = use the global threshold
# for all models (backward compatible).
_raw_model_thresholds = _compression_cfg.get("model_thresholds", {})
if isinstance(_raw_model_thresholds, dict):
compression_model_thresholds = {
str(k): float(v) for k, v in _raw_model_thresholds.items()
if isinstance(v, (int, float)) and not isinstance(v, bool)
}
else:
compression_model_thresholds = {}
# Absolute token cap: when set, compression triggers at the lower of
# the ratio-based threshold and this absolute count. Clamped to the
# model's context length at apply-time so a cap above the window is
# a no-op (ratio-based threshold wins).
compression_threshold_tokens = _compression_cfg.get("threshold_tokens")
if compression_threshold_tokens is not None:
try:
compression_threshold_tokens = int(compression_threshold_tokens)
if compression_threshold_tokens <= 0:
compression_threshold_tokens = None
except (TypeError, ValueError):
compression_threshold_tokens = None
# In-place compaction: when True, compress_context() rewrites the message
# list + rebuilds the system prompt WITHOUT rotating the session id (no
# parent_session_id chain, no `name #N` renumber). See #38763 and
@@ -1939,9 +1747,8 @@ def init_agent(
)
_config_context_length = None
# Resolve custom_providers once before route-scoping a global context pin:
# a named custom provider may keep its base URL only in this list rather
# than repeating it under ``model``.
# Resolve custom_providers list once for reuse below (startup
# context-length override and plugin context-engine init).
try:
from hermes_cli.config import get_compatible_custom_providers
_custom_providers = get_compatible_custom_providers(_agent_cfg)
@@ -1950,163 +1757,6 @@ def init_agent(
if not isinstance(_custom_providers, list):
_custom_providers = []
# ``model.context_length`` describes the configured default model. A
# process launched directly with ``--model`` / ``-m`` has already replaced
# ``agent.model`` before this initializer loads config, so carrying the
# default model's explicit window into that different runtime is stale. The
# live switch/fallback paths already clear this override; keep direct-start
# overrides consistent with them and let provider metadata resolve the
# active model's window instead.
if _config_context_length is not None and isinstance(_model_cfg, dict):
_configured_default_model = str(_model_cfg.get("default") or "").strip()
_configured_default_runtime_model = _configured_default_model
_active_runtime_model = agent.model
if _configured_default_model:
try:
from hermes_cli.model_normalize import normalize_model_for_provider
_configured_default_runtime_model = normalize_model_for_provider(
_configured_default_model, agent.provider
)
_active_runtime_model = normalize_model_for_provider(
agent.model, agent.provider
)
except Exception:
pass
_configured_provider = str(_model_cfg.get("provider") or "").strip()
_configured_base_url = _normalize_route_base_url(
_model_cfg.get("base_url")
)
_configured_provider_norm = _normalize_custom_provider_name(
_configured_provider
)
_custom_provider_candidate = bool(_configured_provider_norm)
_runtime_first_provider_ids = {
"auto",
"moa",
"vertex",
"google-vertex",
"vertex-ai",
"gcp-vertex",
"vertexai",
}
if _configured_provider_norm in _runtime_first_provider_ids:
_custom_provider_candidate = False
elif (
_custom_provider_candidate
and _configured_provider_norm != "custom"
and not _configured_provider_norm.startswith("custom:")
):
try:
from hermes_cli.auth import resolve_provider as resolve_auth_provider
_resolved_auth_provider = resolve_auth_provider(
_configured_provider_norm
)
_custom_provider_candidate = (
str(_resolved_auth_provider or "").strip().lower()
!= _configured_provider_norm
)
except Exception:
pass
if not _configured_base_url and _custom_provider_candidate:
_configured_custom_provider = _normalize_custom_provider_name(
_configured_provider
)
_user_providers = _agent_cfg.get("providers")
_disabled_custom_provider_ids: set[str] = set()
if isinstance(_user_providers, dict):
from hermes_cli.config import is_provider_enabled
for _provider_key, _provider_entry in _user_providers.items():
if not isinstance(_provider_entry, dict):
continue
_entry_name = str(
_provider_entry.get("name") or ""
).strip()
_entry_provider_ids = _custom_provider_runtime_ids(
_provider_key
) | _custom_provider_runtime_ids(_entry_name)
if not is_provider_enabled(_provider_entry):
_disabled_custom_provider_ids.update(
provider_id
for provider_id in _entry_provider_ids
if provider_id
)
continue
if _configured_custom_provider not in _entry_provider_ids:
continue
_configured_base_url = _normalize_route_base_url(
_provider_entry.get("api")
or _provider_entry.get("url")
or _provider_entry.get("base_url")
)
if _configured_base_url:
break
if not _configured_base_url:
for _provider_entry in _custom_providers:
if not isinstance(_provider_entry, dict):
continue
_entry_name = str(
_provider_entry.get("name") or ""
).strip()
_entry_provider_key = str(
_provider_entry.get("provider_key") or ""
).strip().lower()
_entry_provider_ids = _custom_provider_runtime_ids(
_entry_name
) | _custom_provider_runtime_ids(_entry_provider_key)
if (
_entry_provider_key
and _custom_provider_runtime_ids(_entry_provider_key)
& _disabled_custom_provider_ids
):
continue
if _configured_custom_provider not in _entry_provider_ids:
continue
_configured_base_url = _normalize_route_base_url(
_provider_entry.get("base_url")
)
if _configured_base_url:
break
_active_route_url = str(agent.base_url or "")
_requested_route_url = str(base_url or "")
if "?" in _requested_route_url.split("#", 1)[0]:
try:
_requested_parts = urlparse(_requested_route_url)
_requested_without_query = urlunparse(
_requested_parts._replace(query="")
)
if _normalize_route_base_url(
_requested_without_query
) == _normalize_route_base_url(_active_route_url):
_active_route_url = _requested_route_url
except (TypeError, ValueError):
pass
_active_base_url = _normalize_route_base_url(_active_route_url)
_route_mismatch = _context_route_mismatch(
_configured_base_url,
_active_base_url,
_configured_provider,
agent.provider,
already_normalized=True,
)
_model_mismatch = bool(
_configured_default_runtime_model
and _configured_default_runtime_model != _active_runtime_model
)
if _model_mismatch or _route_mismatch:
_ra().logger.debug(
"Ignoring model.context_length=%s for startup runtime %s at %s "
"(configured default is %s at %s)",
_config_context_length,
agent.model,
_active_base_url or agent.provider,
_configured_default_model,
_configured_base_url or _model_cfg.get("provider"),
)
_config_context_length = None
# Store for reuse by _check_compression_model_feasibility (auxiliary
# compression model context-length detection needs the same list).
agent._custom_providers = _custom_providers
@@ -2129,11 +1779,11 @@ def init_agent(
# Surface a clear warning if the user set a context_length but it
# wasn't a valid positive int — the helper silently skips those.
if _config_context_length is None:
_target = _normalize_route_base_url(agent.base_url)
_target = agent.base_url.rstrip("/") if agent.base_url else ""
for _cp_entry in _custom_providers:
if not isinstance(_cp_entry, dict):
continue
_cp_url = _normalize_route_base_url(_cp_entry.get("base_url"))
_cp_url = (_cp_entry.get("base_url") or "").rstrip("/")
if _target and _cp_url == _target:
_cp_models = _cp_entry.get("models", {})
if isinstance(_cp_models, dict):
@@ -2246,16 +1896,6 @@ def init_agent(
provider=agent.provider,
custom_providers=_custom_providers,
)
# Per-model threshold overrides are part of the explicit
# context-engine contract: assign them BEFORE the initial
# update_model() call so the first resolution (which derives
# threshold_percent/threshold_tokens for the initial model) already
# sees the overrides. Assigning after update_model() left the initial
# model on the engine's global threshold until the first /model
# switch. Engines that override update_model() own their own policy
# and may ignore the attribute.
if compression_model_thresholds:
agent.context_compressor.model_thresholds = compression_model_thresholds
agent.context_compressor.update_model(
model=agent.model,
context_length=_plugin_ctx_len,
@@ -2282,8 +1922,6 @@ def init_agent(
api_mode=agent.api_mode,
abort_on_summary_failure=compression_abort_on_summary_failure,
max_tokens=agent.max_tokens,
model_thresholds=compression_model_thresholds,
threshold_tokens_cap=compression_threshold_tokens,
)
_bind_session_state = getattr(agent.context_compressor, "bind_session_state", None)
if callable(_bind_session_state):
@@ -2294,7 +1932,6 @@ def init_agent(
agent.compression_enabled = compression_enabled
agent.compression_in_place = compression_in_place
agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction
agent.max_compression_attempts = compression_max_attempts
# Reject models whose context window is below the minimum required
# for reliable tool-calling workflows (64K tokens).
@@ -2479,7 +2116,7 @@ def init_agent(
# autoraised model) updates the marker state and re-notifies once. The
# config display gate (compression.codex_gpt55_autoraise_notice) still
# suppresses the banner entirely without disabling the threshold autoraise.
_autoraise = getattr(agent, "_compression_threshold_autoraised", None) or {}
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
_show_autoraise_notice = (
bool(_autoraise)
and compression_enabled
@@ -2495,21 +2132,14 @@ def init_agent(
_active_threshold_pct = getattr(
agent.context_compressor, "threshold_percent", compression_threshold
)
_cap_note = ""
_cap = getattr(agent.context_compressor, "threshold_tokens_cap", None)
if _cap and _cap > 0:
_cap_note = f" (capped at {_cap:,} tokens)"
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(_active_threshold_pct*100)}% = {agent.context_compressor.threshold_tokens:,}{_cap_note})")
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(_active_threshold_pct*100)}% = {agent.context_compressor.threshold_tokens:,})")
else:
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (auto-compression disabled)")
# Notice with the exact opt-back-out command. Printed inline at startup
# for CLI users; gateway users get the same text replayed via
# _compression_warning on turn 1 (set below).
if _show_autoraise_notice:
print(_build_codex_gpt5_autoraise_notice(
_autoraise,
context_length=getattr(agent.context_compressor, "context_length", None),
))
print(_build_codex_gpt5_autoraise_notice(_autoraise))
# Check immediately so CLI users see the warning at startup.
# Gateway status_callback is not yet wired, so any warning is stored
@@ -2519,10 +2149,7 @@ def init_agent(
# above only reaches the CLI, so stash the same text here to be replayed
# through status_callback on the first turn (Telegram/Discord/Slack/etc.).
if _show_autoraise_notice:
agent._compression_warning = _build_codex_gpt5_autoraise_notice(
_autoraise,
context_length=getattr(agent.context_compressor, "context_length", None),
)
agent._compression_warning = _build_codex_gpt5_autoraise_notice(_autoraise)
# Mark shown so repeated inits in this profile (e.g. every gateway message)
# stay silent. Recorded once, whether the notice went to the CLI print or
+1 -29
View File
@@ -457,8 +457,7 @@ def _is_kimi_coding_endpoint(base_url: str | None) -> bool:
# Model-name prefixes that identify the Kimi / Moonshot family. Covers
# - official slugs: ``kimi-k2.5``, ``kimi_thinking``, ``moonshot-v1-8k``
# - common release lines: ``k1.5-...``, ``k2-thinking``, ``k25-...``, ``k2.5-...``,
# and the bare Coding Plan slug ``k3`` (plus ``k3.x``/``k3-...`` variants)
# - common release lines: ``k1.5-...``, ``k2-thinking``, ``k25-...``, ``k2.5-...``
# Matched case-insensitively against the post-``normalize_model_name`` form,
# so a caller's ``provider/vendor/model`` slug is handled the same as a
# bare name.
@@ -468,14 +467,8 @@ _KIMI_FAMILY_MODEL_PREFIXES = (
"k1.", "k1-",
"k2.", "k2-",
"k25", "k2.5",
"k3.", "k3-",
)
# Bare release slugs with no separator suffix (Kimi Coding Plan serves K3
# as the exact slug ``k3``). Kept exact-match so unrelated model names that
# merely start with the same characters don't get misclassified.
_KIMI_FAMILY_EXACT_SLUGS = frozenset({"k3"})
def _model_name_is_kimi_family(model: str | None) -> bool:
if not isinstance(model, str):
@@ -486,8 +479,6 @@ def _model_name_is_kimi_family(model: str | None) -> bool:
# Strip vendor prefix (e.g. ``moonshotai/kimi-k2.5`` → ``kimi-k2.5``)
if "/" in m:
m = m.rsplit("/", 1)[-1]
if m in _KIMI_FAMILY_EXACT_SLUGS:
return True
return m.startswith(_KIMI_FAMILY_MODEL_PREFIXES)
@@ -2412,24 +2403,6 @@ def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None:
]
def _ensure_leading_user_turn(result: List[Dict[str, Any]]) -> None:
"""Anthropic requires messages[0] to have role=user.
After a second context compaction on the auto path the summary can be
emitted as role=assistant with nothing in front of it (the system prompt
lives outside messages[] or is extracted into the separate ``system``
param), so messages[0] ends up assistant and the Messages API rejects
the request with HTTP 400 — often masked by a misleading
"tool_use ids were found without tool_result blocks" error (#52160).
Mirror the Bedrock Converse adapter, which unconditionally prepends a
minimal user turn when the first message is not user
(convert_messages_to_converse).
"""
if result and result[0].get("role") != "user":
result.insert(0, {"role": "user", "content": [{"type": "text", "text": " "}]})
def convert_messages_to_anthropic(
messages: List[Dict],
base_url: str | None = None,
@@ -2488,7 +2461,6 @@ def convert_messages_to_anthropic(
_strip_orphaned_tool_blocks(result)
result = _merge_consecutive_roles(result)
_ensure_leading_user_turn(result)
_manage_thinking_signatures(result, base_url, model)
_evict_old_screenshots(result)
-131
View File
@@ -1,131 +0,0 @@
"""System-battery read-out for the CLI/TUI status bar.
Reads the host battery through ``psutil`` (already a Hermes dependency) and
exposes a compact, colour-coded label. Everything degrades to "unavailable"
when there is no battery (desktops, servers, VMs) or when the read fails, so
callers can render the result unconditionally and simply show nothing.
The status bar repaints often (every keystroke and on a ~1s idle refresh), so
:func:`read_battery` memoises the last reading for a few seconds instead of
hitting ``psutil`` on every frame.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class BatteryStatus:
"""A single battery reading.
``available`` is False on machines without a battery (or when the read
failed). ``percent`` is clamped to 0-100. ``plugged`` is True when on AC
power, False on battery, and None when the platform can't tell.
"""
available: bool
percent: Optional[int] = None
plugged: Optional[bool] = None
@property
def charging(self) -> bool:
return bool(self.plugged)
UNAVAILABLE = BatteryStatus(available=False)
# Colour buckets, mirroring the status-bar context styles but inverted (a full
# battery is "good", an empty one is "critical").
CATEGORY_GOOD = "good"
CATEGORY_WARN = "warn"
CATEGORY_BAD = "bad"
CATEGORY_CRITICAL = "critical"
CATEGORY_DIM = "dim"
_CACHE_TTL_SECONDS = 8.0
_cache: Optional[tuple[float, BatteryStatus]] = None
def _read_battery_uncached() -> BatteryStatus:
try:
import psutil
except Exception:
return UNAVAILABLE
# ``sensors_battery`` is missing on some platforms/builds of psutil.
reader = getattr(psutil, "sensors_battery", None)
if reader is None:
return UNAVAILABLE
try:
batt = reader()
except Exception:
return UNAVAILABLE
if batt is None:
return UNAVAILABLE
percent: Optional[int] = None
raw_percent = getattr(batt, "percent", None)
if raw_percent is not None:
try:
percent = max(0, min(100, int(round(float(raw_percent)))))
except (TypeError, ValueError):
percent = None
plugged = getattr(batt, "power_plugged", None)
if plugged is not None:
plugged = bool(plugged)
return BatteryStatus(available=True, percent=percent, plugged=plugged)
def read_battery(use_cache: bool = True) -> BatteryStatus:
"""Return the current battery status (cached for a few seconds)."""
global _cache
if use_cache and _cache is not None:
ts, cached = _cache
if time.monotonic() - ts < _CACHE_TTL_SECONDS:
return cached
status = _read_battery_uncached()
_cache = (time.monotonic(), status)
return status
def clear_cache() -> None:
"""Drop the memoised reading (used by tests)."""
global _cache
_cache = None
def battery_category(status: BatteryStatus) -> str:
"""Bucket a reading into a colour category: good/warn/bad/critical/dim."""
if not status.available or status.percent is None:
return CATEGORY_DIM
# On AC power the level isn't a concern — always read as healthy.
if status.charging:
return CATEGORY_GOOD
pct = status.percent
if pct <= 10:
return CATEGORY_CRITICAL
if pct <= 20:
return CATEGORY_BAD
if pct <= 50:
return CATEGORY_WARN
return CATEGORY_GOOD
def battery_glyph(status: BatteryStatus) -> str:
"""Return the leading glyph: a bolt while charging, else a battery."""
return "\u26a1" if status.charging else "\U0001f50b" # ⚡ / 🔋
def format_battery(status: BatteryStatus) -> str:
"""Return a compact label like ``🔋 82%`` / ``⚡ 82%`` (empty if N/A)."""
if not status.available or status.percent is None:
return ""
return f"{battery_glyph(status)} {status.percent}%"
+1 -1
View File
@@ -1,4 +1,4 @@
"""Surface-agnostic core for the Phase 2b Remote Spending screens.
"""Surface-agnostic core for the Phase 2b terminal-billing screens.
One fetch/parse per concern, consumed identically by the CLI handler
(``cli.py::_show_billing``), the TUI JSON-RPC methods
+14 -9
View File
@@ -55,12 +55,13 @@ import json
import logging
import os
import re
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from hermes_cli._subprocess_compat import bounded_git_probe
from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
logger = logging.getLogger("hermes.coding_context")
@@ -688,14 +689,18 @@ def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]:
def _git(cwd: Path, *args: str) -> str:
"""``git -C <cwd> <args>`` → stripped stdout, or ``""`` on any failure.
Uses the shared :func:`bounded_git_probe` so the post-kill cleanup is bounded
on Windows — a plain ``subprocess.run(timeout=...)`` here deadlocked the agent
turn inside ``build_coding_workspace_block`` when a killed git left a suspended
descendant holding the pipe handles (issue #66037).
"""
return bounded_git_probe(["git", "-C", str(cwd), *args], timeout=_GIT_TIMEOUT)
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
try:
out = subprocess.run(
["git", "-C", str(cwd), *args],
capture_output=True,
text=True,
timeout=_GIT_TIMEOUT,
**_popen_kwargs,
)
except (OSError, subprocess.SubprocessError):
return ""
return out.stdout.strip() if out.returncode == 0 else ""
def _parse_status(porcelain: str) -> tuple[dict[str, str], dict[str, int]]:
File diff suppressed because it is too large Load Diff
-15
View File
@@ -260,19 +260,4 @@ class ContextEngine(ABC):
(e.g. recalculate DAG budgets, switch summary models).
"""
self.context_length = context_length
# Apply per-model threshold overrides if set (longest substring match).
# Falls back to _config_threshold_percent (the raw config value) when
# no override matches. Plugin engines that override update_model() can
# call resolve_model_threshold() for the same logic.
from agent.context_compressor import resolve_model_threshold
if not hasattr(self, "_config_threshold_percent"):
# Snapshot the pre-override percent ONCE so repeated model
# switches fall back to the engine's configured value, not the
# previous model's override.
self._config_threshold_percent = self.threshold_percent
self._base_threshold_percent = resolve_model_threshold(
model, getattr(self, "model_thresholds", {}),
self._config_threshold_percent,
)
self.threshold_percent = self._base_threshold_percent
self.threshold_tokens = int(context_length * self.threshold_percent)
+26 -355
View File
@@ -30,12 +30,9 @@ from __future__ import annotations
import copy
import inspect
import json
import logging
import math
import os
import tempfile
import time
import uuid
import threading
from datetime import datetime
@@ -175,43 +172,6 @@ def _session_was_rotated_by_compression(session_db: Any, session_id: str) -> boo
)
def _emit_compression_attempt_telemetry(
agent: Any,
*,
started_at: float,
commit_status: str,
split_status: str,
failure_class: str | None = None,
) -> None:
"""Emit one content-free JSON log line for a compression attempt."""
try:
telemetry = getattr(agent.context_compressor, "_last_compression_telemetry", None)
if not isinstance(telemetry, dict):
telemetry = {}
payload = dict(telemetry)
payload.setdefault("event", "compression_attempt")
payload.setdefault("attempt_id", getattr(agent, "_compression_attempt_id", "") or uuid.uuid4().hex)
payload.setdefault("session_id", getattr(agent, "session_id", "") or "")
payload["total_duration_ms"] = int((time.monotonic() - started_at) * 1000)
payload["commit_status"] = commit_status
payload["split_status"] = split_status
if failure_class:
payload["failure_class"] = failure_class
payload.setdefault("chunking", False)
payload.setdefault("chunk_count", 0)
payload["fallback_used"] = bool(
payload.get("fallback_used")
or getattr(agent.context_compressor, "_last_summary_fallback_used", False)
or getattr(agent.context_compressor, "_last_aux_model_failure_model", None)
)
logger.info(
"context compression attempt telemetry: %s",
json.dumps(payload, sort_keys=True, separators=(",", ":")),
)
except Exception as exc:
logger.debug("failed to emit compression attempt telemetry: %s", exc)
def _compression_lock_holder(agent: Any) -> str:
"""Build a unique holder id for the lock: pid:tid:agent-instance:uuid.
@@ -271,51 +231,6 @@ def _supported_compression_kwargs(
return {name: value for name, value in candidates.items() if name in parameters}
class _CompressionActivityHeartbeat:
"""Refresh the agent inactivity tracker while compression blocks in an aux call."""
def __init__(self, agent: Any, interval_seconds: float | None = None) -> None:
self._agent = agent
if interval_seconds is None:
interval_seconds = getattr(agent, "_compression_activity_heartbeat_interval", 60.0)
try:
interval_seconds = float(interval_seconds or 60.0)
except (TypeError, ValueError):
interval_seconds = 60.0
if not math.isfinite(interval_seconds):
interval_seconds = 60.0
self._interval_seconds = max(0.1, interval_seconds)
self._stop = threading.Event()
self._thread = threading.Thread(
target=self._run,
name="compression-activity-heartbeat",
daemon=True,
)
def start(self) -> "_CompressionActivityHeartbeat":
self._touch("context compression started")
self._thread.start()
return self
def stop(self, desc: str = "context compression completed") -> None:
self._stop.set()
if self._thread.is_alive() and threading.current_thread() is not self._thread:
self._thread.join(timeout=1.0)
self._touch(desc)
def _touch(self, desc: str) -> None:
try:
touch = getattr(self._agent, "_touch_activity", None)
if callable(touch):
touch(desc)
except Exception:
logger.debug("compression activity heartbeat touch failed", exc_info=True)
def _run(self) -> None:
while not self._stop.wait(self._interval_seconds):
self._touch("context compression in progress")
class _CompressionLockLeaseRefresher:
def __init__(
self,
@@ -521,19 +436,6 @@ def check_compression_model_feasibility(agent: Any) -> None:
old_threshold = threshold
new_threshold = aux_context
agent.context_compressor.threshold_tokens = new_threshold
# ``tail_token_budget`` is derived from the trigger threshold, not
# directly from the model window. Keep it in lockstep with this
# just-in-time correction exactly as ContextCompressor.update_model()
# does. Leaving the old budget behind can make the tail's 1.5x soft
# ceiling wider than the lowered trigger, so compression preserves
# nearly the entire request and repeatedly re-fires.
summary_target_ratio = getattr(
agent.context_compressor, "summary_target_ratio", None
)
if isinstance(summary_target_ratio, (int, float)):
agent.context_compressor.tail_token_budget = int(
new_threshold * summary_target_ratio
)
# Keep threshold_percent in sync so future main-model
# context_length changes (update_model) re-derive from a
# sensible number rather than the original too-high value.
@@ -543,29 +445,6 @@ def check_compression_model_feasibility(agent: Any) -> None:
new_threshold / main_ctx
)
safe_pct = int((aux_context / main_ctx) * 100) if main_ctx else 50
# The "lower the threshold" suggestion must survive the built-in
# trigger recomputation (#67422): _effective_threshold_percent()
# raises sub-75% values back up for main windows under 512K, and
# _compute_threshold_tokens() further applies the output-token
# reservation, the 64K floor, and the degenerate-window guard.
# Recommending a value those would override is silently ignored
# and this warning would reappear every session — so mirror the
# compressor's own math and only offer the option when the
# recomputed trigger actually fits the auxiliary model's context.
# External engines own compaction policy (#44439); the built-in
# floor doesn't apply to them, so keep the plain suggestion.
from agent.context_compressor import ContextCompressor as _CC
recomputed_threshold = None
if main_ctx and isinstance(agent.context_compressor, _CC):
recomputed_threshold = _CC._compute_threshold_tokens(
main_ctx,
_CC._effective_threshold_percent(main_ctx, safe_pct / 100),
getattr(agent.context_compressor, "max_tokens", None),
)
threshold_suggestion_viable = (
recomputed_threshold is None or recomputed_threshold <= aux_context
)
# Build human-readable "model (provider)" labels for both
# the main model and the compression model so users can
# tell at a glance which provider each side is actually
@@ -599,32 +478,15 @@ def check_compression_model_feasibility(agent: Any) -> None:
f"{old_threshold:,} tokens. "
f"Auto-lowered this session's threshold to "
f"{new_threshold:,} tokens so compression can run.\n"
f" To make this permanent, edit config.yaml — either:\n"
f" 1. Use a larger compression model:\n"
f" auxiliary:\n"
f" compression:\n"
f" model: <model-with-{old_threshold:,}+-context>\n"
f" 2. Lower the compression threshold:\n"
f" compression:\n"
f" threshold: 0.{safe_pct:02d}"
)
if threshold_suggestion_viable:
msg += (
f" To make this permanent, edit config.yaml — either:\n"
f" 1. Use a larger compression model:\n"
f" auxiliary:\n"
f" compression:\n"
f" model: <model-with-{old_threshold:,}+-context>\n"
f" 2. Lower the compression threshold:\n"
f" compression:\n"
f" threshold: 0.{safe_pct:02d}"
)
else:
msg += (
f" To make this permanent, use a larger compression "
f"model in config.yaml:\n"
f" auxiliary:\n"
f" compression:\n"
f" model: <model-with-{old_threshold:,}+-context>\n"
f" (Lowering compression.threshold cannot help here — "
f"with {_main_label}'s {main_ctx:,}-token window, "
f"Hermes's small-context floor and output reservation "
f"would recompute the trigger to "
f"{recomputed_threshold:,} tokens, still above the "
f"compression model's {aux_context:,}.)"
)
agent._compression_warning = msg
agent._emit_status(msg)
logger.warning(
@@ -738,7 +600,7 @@ def _is_real_user_message(message: Any) -> bool:
return False
from agent.context_compressor import ContextCompressor
return not ContextCompressor._is_synthetic_compression_user_turn(message)
return not ContextCompressor._is_context_summary_content(text)
def _merge_anchor_into_user_message(target: dict, anchor: dict) -> None:
@@ -815,10 +677,7 @@ def _ensure_compressed_has_user_turn(original_messages: list, compressed: list)
"""Preserve human intent, not merely a synthetic user-role placeholder."""
if any(_is_real_user_message(message) for message in compressed):
return
from agent.context_compressor import (
COMPRESSION_CONTINUATION_USER_CONTENT,
_fresh_compaction_message_copy,
)
from agent.context_compressor import _fresh_compaction_message_copy
for message in reversed(original_messages):
if _is_real_user_message(message):
@@ -829,77 +688,13 @@ def _ensure_compressed_has_user_turn(original_messages: list, compressed: list)
return
compressed.append({
"role": "user",
"content": COMPRESSION_CONTINUATION_USER_CONTENT,
"content": (
"Continue from the compressed conversation context above. "
"This marker exists because no human user turn was available."
),
})
_PENDING_CONTEXT_ENGINE_NOTIFICATION = (
"_pending_context_engine_compression_notification"
)
def _notify_context_engine_compression_complete(
agent: Any,
*,
new_session_id: str,
old_session_id: str,
) -> bool:
"""Notify the active context engine after a durable compression commit."""
callback = getattr(agent.context_compressor, "on_session_start", None)
if not callable(callback):
return False
try:
callback(
new_session_id,
boundary_reason="compression",
old_session_id=old_session_id,
platform=getattr(agent, "platform", None) or "cli",
conversation_id=getattr(agent, "_gateway_session_key", None),
)
except Exception:
# Context-engine hooks are observers. A callback failure must not undo
# history that the core or an outer host transaction already committed.
logger.debug(
"context engine on_session_start (compression) failed",
exc_info=True,
)
return False
return True
def _queue_context_engine_compression_notification(
agent: Any,
*,
new_session_id: str,
old_session_id: str,
) -> None:
"""Stage exactly one existing hook call for an outer host transaction."""
if callable(getattr(agent, _PENDING_CONTEXT_ENGINE_NOTIFICATION, None)):
raise RuntimeError("a compression notification is already pending")
def _notify() -> bool:
return _notify_context_engine_compression_complete(
agent,
new_session_id=new_session_id,
old_session_id=old_session_id,
)
setattr(agent, _PENDING_CONTEXT_ENGINE_NOTIFICATION, _notify)
def finalize_context_engine_compression_notification(
agent: Any,
*,
committed: bool,
) -> bool:
"""Emit or discard a deferred notification; repeated calls are no-ops."""
pending = getattr(agent, _PENDING_CONTEXT_ENGINE_NOTIFICATION, None)
setattr(agent, _PENDING_CONTEXT_ENGINE_NOTIFICATION, None)
if not committed or not callable(pending):
return False
return bool(pending())
def compress_context(
agent: Any,
messages: list,
@@ -909,7 +704,6 @@ def compress_context(
task_id: str = "default",
focus_topic: Optional[str] = None,
force: bool = False,
defer_context_engine_notification: bool = False,
) -> Tuple[list, str]:
"""Compress conversation context and split the session in SQLite.
@@ -927,8 +721,6 @@ def compress_context(
by the manual ``/compress`` slash command so users can retry
immediately after an auto-compress abort. Auto-compress
callers use the default ``False``.
defer_context_engine_notification: Delay the existing context-engine
hook until a manual host commits its outer history transaction.
Returns:
``(compressed_messages, new_system_prompt)`` tuple. When
@@ -937,25 +729,6 @@ def compress_context(
prompt — the session is NOT rotated. Callers should detect the
no-op via ``len(returned) == len(input)`` and stop the retry loop.
"""
if (
defer_context_engine_notification
and callable(getattr(agent, _PENDING_CONTEXT_ENGINE_NOTIFICATION, None))
):
raise RuntimeError("a compression notification is already pending")
_attempt_started_at = time.monotonic()
_attempt_id = uuid.uuid4().hex
_trigger_source = "manual" if force else "auto"
try:
agent._compression_attempt_id = _attempt_id
setattr(agent.context_compressor, "_compression_telemetry_seed", {
"attempt_id": _attempt_id,
"session_id": agent.session_id or "",
"trigger_source": _trigger_source,
})
except Exception:
pass
# Codex app-server sessions: the codex agent owns the real thread context;
# Hermes' summarizer would only rewrite a local mirror without shrinking
# the actual thread (#36801). Route compaction to the app server's own
@@ -1162,18 +935,6 @@ def compress_context(
_existing_sp = getattr(agent, "_cached_system_prompt", None)
if not _existing_sp:
_existing_sp = agent._build_system_prompt(system_message)
try:
if hasattr(agent.context_compressor, "_begin_compression_telemetry"):
agent.context_compressor._begin_compression_telemetry(current_tokens=approx_tokens)
except Exception:
pass
_emit_compression_attempt_telemetry(
agent,
started_at=_attempt_started_at,
commit_status="aborted",
split_status="aborted",
failure_class="lock_contended",
)
return messages, _existing_sp
_lock_released = False
@@ -1246,7 +1007,6 @@ def compress_context(
existing_prompt = agent._build_system_prompt(system_message)
return messages, existing_prompt
_activity_heartbeat: Optional[_CompressionActivityHeartbeat] = None
try:
if _lock_holder is not None:
_lock_refresher = _CompressionLockLeaseRefresher(
@@ -1297,27 +1057,13 @@ def compress_context(
)
messages_before_compression = copy.deepcopy(messages)
_activity_heartbeat = _CompressionActivityHeartbeat(agent).start()
compressed = compress_fn(messages, **compress_kwargs)
except BaseException as _compress_exc:
except BaseException:
# ANY exception after lock acquisition — memory hook, capability
# inspection, engine lookup, or compress() — must release the lock so
# the session isn't permanently blocked from future compression.
if _activity_heartbeat is not None:
_activity_heartbeat.stop("context compression failed")
_activity_heartbeat = None
_release_lock()
_emit_compression_attempt_telemetry(
agent,
started_at=_attempt_started_at,
commit_status="aborted",
split_status="aborted",
failure_class=f"exception:{type(_compress_exc).__name__}",
)
raise
finally:
if _activity_heartbeat is not None:
_activity_heartbeat.stop("context compression completed")
try:
# Capture boundary quality before session-rotation callbacks run. Built-in
@@ -1349,16 +1095,6 @@ def compress_context(
_existing_sp = getattr(agent, "_cached_system_prompt", None)
if not _existing_sp:
_existing_sp = agent._build_system_prompt(system_message)
_emit_compression_attempt_telemetry(
agent,
started_at=_attempt_started_at,
commit_status="aborted",
split_status="aborted",
failure_class=(
getattr(agent.context_compressor, "_last_summary_error", None)
and "summary_generation_aborted"
),
)
return messages, _existing_sp
finally:
_release_lock()
@@ -1377,13 +1113,6 @@ def compress_context(
_existing_sp = getattr(agent, "_cached_system_prompt", None)
if not _existing_sp:
_existing_sp = agent._build_system_prompt(system_message)
_emit_compression_attempt_telemetry(
agent,
started_at=_attempt_started_at,
commit_status="aborted",
split_status="aborted",
failure_class="no_progress",
)
_release_lock()
return messages, _existing_sp
@@ -1464,10 +1193,7 @@ def compress_context(
new_system_prompt = agent._build_system_prompt(system_message)
agent._cached_system_prompt = new_system_prompt
_session_commit_succeeded = False
split_status = "not_applicable"
if agent._session_db:
split_status = "pending"
try:
# Trigger memory extraction on the current session before the
# transcript is rewritten (runs in BOTH modes — the logical
@@ -1495,7 +1221,6 @@ def compress_context(
# WITHOUT destroying history, unlike a hard replace_messages).
# See #38763.
agent._session_db.archive_and_compact(agent.session_id, compressed)
split_status = "in_place_committed"
# Reset the flush identity set so the next turn's appends are
# diffed against the COMPACTED transcript: the compacted dicts
# are passed as conversation_history next turn and skipped by
@@ -1511,29 +1236,8 @@ def compress_context(
# Flush any un-persisted current-turn messages to the OLD
# session before ending it, so they survive in the preserved
# parent transcript (#47202). (In-place skips this — see above.)
#
# Pass the already-durable prefix as conversation_history so
# the flush skips it by identity (#68196). Preflight
# compression runs BEFORE the normal turn flush has stamped
# the cold-resumed history dicts with _DB_PERSISTED_MARKER, so
# without a boundary _flush_messages_to_session_db treats every
# restored row as new and re-appends the whole transcript to
# the parent. turn_context anchors _persist_user_message_idx at
# the current-turn user message before preflight runs, so
# messages[:idx] is exactly the persisted prefix; only the
# current turn's new messages get written.
current_idx = getattr(agent, "_persist_user_message_idx", None)
persisted_history = (
messages[:current_idx]
if isinstance(current_idx, int)
and 0 <= current_idx <= len(messages)
else None
)
try:
agent._flush_messages_to_session_db(
messages,
conversation_history=persisted_history,
)
agent._flush_messages_to_session_db(messages)
except Exception:
pass # best-effort — don't block compression on a flush error
# Propagate title to the new session with auto-numbering
@@ -1612,7 +1316,6 @@ def compress_context(
agent._session_db_created = True
raise
agent._session_db_created = True
split_status = "rotated_committed"
# Carry a persistent /goal onto the continuation session.
# Compression mints a fresh child id; load_goal does a flat
# per-session lookup with no parent walk, so without this an
@@ -1649,9 +1352,7 @@ def compress_context(
for message in compressed
if isinstance(message, dict)
}
_session_commit_succeeded = True
except Exception as e:
split_status = "aborted" if locals().get("old_session_id") is None and not in_place else "failed_not_indexed"
# If the rotation rolled back to the parent (orphan-avoidance
# above), agent.session_id is the still-indexed parent and
# old_session_id was cleared — so this is recovery, not an
@@ -1671,9 +1372,6 @@ def compress_context(
# id on rotation, the (unchanged) current id in-place.
_old_sid = locals().get("old_session_id")
_is_boundary = bool(_old_sid) or in_place
_context_engine_boundary_committed = _session_commit_succeeded and (
bool(_old_sid) or compacted_in_place
)
_boundary_parent = _old_sid or agent.session_id or ""
# Notify the context engine that a compaction boundary occurred. Plugin
@@ -1682,19 +1380,17 @@ def compress_context(
# re-initializing fresh. See hermes-lcm#68. Built-in ContextCompressor
# ignores kwargs. Fires in BOTH modes: rotation passes old→new ids; in-place
# passes the SAME id (the boundary is real even though the id didn't move).
if _context_engine_boundary_committed:
if defer_context_engine_notification:
_queue_context_engine_compression_notification(
agent,
new_session_id=agent.session_id or "",
old_session_id=_boundary_parent,
)
else:
_notify_context_engine_compression_complete(
agent,
new_session_id=agent.session_id or "",
try:
if _is_boundary and hasattr(agent.context_compressor, "on_session_start"):
agent.context_compressor.on_session_start(
agent.session_id or "",
boundary_reason="compression",
old_session_id=_boundary_parent,
platform=getattr(agent, "platform", None) or "cli",
conversation_id=getattr(agent, "_gateway_session_key", None),
)
except Exception as _ce_err:
logger.debug("context engine on_session_start (compression): %s", _ce_err)
# Notify memory providers of the compaction boundary so provider-cached
# per-session state (Hindsight's _document_id, accumulated turn buffers,
@@ -1794,18 +1490,6 @@ def compress_context(
agent.session_id or "none", _pre_msg_count, len(compressed),
f"{_compressed_est:,}",
)
_commit_status = "committed" if split_status in {"not_applicable", "in_place_committed", "rotated_committed"} else "aborted"
_emit_compression_attempt_telemetry(
agent,
started_at=_attempt_started_at,
commit_status=_commit_status,
split_status=split_status,
failure_class=(
"session_split_failed"
if split_status in {"failed_not_indexed", "aborted"}
else None
),
)
return compressed, new_system_prompt
finally:
# Release the lock on the OLD session_id only AFTER rotation completed
@@ -1876,20 +1560,7 @@ def _compress_context_via_codex_app_server(
except Exception:
pass
_activity_heartbeat: Optional[_CompressionActivityHeartbeat] = None
try:
_activity_heartbeat = _CompressionActivityHeartbeat(agent).start()
result = codex_session.compact_thread()
except BaseException:
if _activity_heartbeat is not None:
_activity_heartbeat.stop("context compression failed")
raise
if getattr(result, "interrupted", False) or getattr(result, "error", None):
_activity_heartbeat.stop("context compression failed")
else:
_activity_heartbeat.stop("context compression completed")
result = codex_session.compact_thread()
if getattr(result, "should_retire", False):
try:
codex_session.close()
+4 -87
View File
@@ -33,7 +33,6 @@ from agent.display import KawaiiSpinner
from agent.error_classifier import FailoverReason, classify_api_error
from agent.iteration_budget import IterationBudget
from agent.turn_context import (
_compression_warrants_another_preflight_pass,
build_turn_context,
compose_user_api_content,
reanchor_current_turn_user_idx,
@@ -685,15 +684,6 @@ def run_conversation(
truncated_tool_call_retries = 0
truncated_response_parts: List[str] = []
compression_attempts = 0
# One resolved per-turn compression attempt cap, shared by every site that
# consumes ``compression_attempts``: the pre-API pressure gate, the
# overflow/413 retry handlers, and the post-tool compaction gate.
# Config-driven via compression.max_attempts (parsed + validated in
# agent_init); default 3 preserves the prior hardcoded behavior for
# objects without the attribute (older pickles / minimal stubs).
max_compression_attempts = getattr(agent, "max_compression_attempts", 3)
_last_preflight_pressure: Optional[int] = None
_preflight_compression_blocked = _ctx.preflight_compression_blocked
_turn_exit_reason = "unknown" # Diagnostic: why the loop ended
# Last composed answer intentionally held back by a verification gate. If
# that continuation consumes the remaining budget, this is the best
@@ -706,10 +696,6 @@ def run_conversation(
# reused as the final response — not merely because any interim was
# streamed. (#65919 review: response-loss blocker)
_pending_verification_response_previewed = False
# If pre-API compression fires after MoA advisors have produced guidance,
# retain that ephemeral output and rebase it onto the compacted transcript
# on the next loop iteration. This prevents a second advisor fan-out.
pending_moa_prepared_request = None
# Per-turn tally of consecutive successful credential-pool token refreshes,
# keyed by (provider, pool-entry-id). A persistent upstream 401 lets
@@ -1093,29 +1079,6 @@ def run_conversation(
# the OpenAI SDK. Sanitizing here prevents the 3-retry cycle.
_sanitize_messages_surrogates(api_messages)
# Build a persistent-MoA request before measuring compression pressure.
# MoA reference output is injected into the aggregator prompt, but it
# is deliberately ephemeral and therefore absent from ``messages``.
# Preparing here makes the pre-API guard measure the exact prompt the
# aggregator will receive; ``create()`` consumes this private prepared
# request later without running the advisors a second time.
_moa_prepared_request = None
if agent.provider == "moa":
_moa_completions = getattr(getattr(agent.client, "chat", None), "completions", None)
if pending_moa_prepared_request is not None:
_rebase_moa_request = getattr(_moa_completions, "rebase_prepared_request", None)
if callable(_rebase_moa_request):
_moa_prepared_request = _rebase_moa_request(
pending_moa_prepared_request, api_messages
)
pending_moa_prepared_request = None
if _moa_prepared_request is None:
_prepare_moa_request = getattr(_moa_completions, "prepare", None)
if callable(_prepare_moa_request):
_moa_prepared_request = _prepare_moa_request(api_messages)
if _moa_prepared_request is not None:
api_messages = _moa_prepared_request["messages"]
# One image-stripped message estimate feeds both figures. Was: a
# str(msg) char walk (re-serialized base64 every call) + a second
# messages walk inside estimate_request_tokens_rough. Tools added
@@ -1162,37 +1125,6 @@ def run_conversation(
# LLM cooldown + anti-thrash guards (#11529). compression_attempts is a
# hard per-turn backstop shared with the overflow error handlers.
_compressor = agent.context_compressor
_preflight_threshold = int(
getattr(_compressor, "threshold_tokens", 0) or 0
)
# A previous mid-turn preflight pass deliberately continued the loop so
# API-only context and all sanitization could be rebuilt. Compare that
# fully assembled request with the fully assembled request that caused
# the pass. Raw ``messages`` are not equivalent here: they omit
# api_content/plugin injections, prefills, MoA context, and ephemeral
# system text.
_previous_preflight_pressure = _last_preflight_pressure
_last_preflight_pressure = None
if (
_previous_preflight_pressure is not None
and request_pressure_tokens >= _preflight_threshold
and not _compression_warrants_another_preflight_pass(
_previous_preflight_pressure,
request_pressure_tokens,
_preflight_threshold,
)
):
# Stop proactive retries for this turn without consuming the
# shared overflow-recovery budget. If the provider proves the
# request truly does not fit, its error handler may still compact
# with that stronger signal.
_preflight_compression_blocked = True
logger.warning(
"Pre-API compression made insufficient progress: ~%s -> "
"~%s request tokens; skipping additional preflight passes",
f"{_previous_preflight_pressure:,}",
f"{request_pressure_tokens:,}",
)
_defer_preflight = getattr(
_compressor, "should_defer_preflight_to_real_usage", lambda _t: False
)
@@ -1202,30 +1134,25 @@ def run_conversation(
if (
agent.compression_enabled
and len(messages) > 1
and compression_attempts < max_compression_attempts
and not _preflight_compression_blocked
and compression_attempts < 3
and not _defer_preflight(request_pressure_tokens)
and not _compression_cooldown
and _compressor.should_compress(request_pressure_tokens)
):
if _moa_prepared_request is not None:
pending_moa_prepared_request = _moa_prepared_request
compression_attempts += 1
logger.info(
"Pre-API compression: ~%s request tokens >= %s threshold "
"(context=%s, attempt=%s/%s)",
"(context=%s, attempt=%s/3)",
f"{request_pressure_tokens:,}",
f"{int(getattr(_compressor, 'threshold_tokens', 0) or 0):,}",
f"{int(getattr(_compressor, 'context_length', 0) or 0):,}"
if getattr(_compressor, "context_length", 0) else "unknown",
compression_attempts,
max_compression_attempts,
)
agent._emit_status(
f"📦 Pre-API compression: ~{request_pressure_tokens:,} tokens "
f"near the context/output limit. Compacting before the next model call."
)
_last_preflight_pressure = request_pressure_tokens
messages, active_system_prompt = agent._compress_context(
messages,
system_message,
@@ -1289,6 +1216,7 @@ def run_conversation(
retry_count = 0
max_retries = agent._api_max_retries
_retry = TurnRetryState()
max_compression_attempts = 3
finish_reason = "stop"
response = None # Guard against UnboundLocalError if all retries fail
@@ -1457,12 +1385,6 @@ def run_conversation(
if env_var_enabled("HERMES_DUMP_REQUESTS"):
agent._dump_api_request_debug(api_kwargs, reason="preflight")
# This object is private to the in-process MoA facade. Add it
# only after middleware, hooks, and debug dumps so none of them
# attempts to serialize it as part of the provider payload.
if _moa_prepared_request is not None and agent.provider == "moa":
api_kwargs["_moa_prepared_request"] = _moa_prepared_request
# Always prefer the streaming path — even without stream
# consumers. Streaming gives us fine-grained health
# checking (90s stale-stream detection, 60s read timeout)
@@ -5217,12 +5139,7 @@ def run_conversation(
messages, tools=agent.tools or None
)
if (
agent.compression_enabled
and compression_attempts < max_compression_attempts
and _compressor.should_compress(_real_tokens)
):
compression_attempts += 1
if agent.compression_enabled and _compressor.should_compress(_real_tokens):
agent._safe_print(" ⟳ compacting context…")
messages, active_system_prompt = agent._compress_context(
messages, system_message,
+5 -6
View File
@@ -2309,10 +2309,9 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
def _get_env_prefer_dotenv(key: str) -> str:
env_file = load_env()
raw = env_file.get(key, "").strip()
scoped_value = (_get_secret(key, "") or "").strip()
env_val = os.environ.get(key, "").strip()
# If .env contains an unresolved op:// reference, prefer the
# already-resolved value supplied by the active secret scope (or by
# os.environ in legacy single-profile mode), set by
# already-resolved value from os.environ (set by
# load_hermes_dotenv() -> apply_onepassword_secrets()). The raw
# "op://Vault/Item/field" string would otherwise win and every
# provider auth attempt would receive a URL instead of a key. This
@@ -2320,9 +2319,9 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
# references straight into .env rather than the secrets.onepassword
# config block. For every non-op:// value the original
# .env-takes-precedence behaviour is preserved unchanged.
if raw.startswith("op://") and scoped_value:
return scoped_value
return raw or scoped_value
if raw.startswith("op://") and env_val:
return env_val
return raw or _get_secret(key, "") or env_val
# Honour user suppression — `hermes auth remove <provider> <N>` for an
# env-seeded credential marks the env:<VAR> source as suppressed so it
-3
View File
@@ -46,9 +46,6 @@ def build_write_denied_paths(home: str) -> set[str]:
# Top-level Anthropic PKCE credential store remains sensitive even
# when a profile is active; default/non-profile sessions still read it.
str(hermes_root / ".anthropic_oauth.json"),
# Bitwarden Secrets Manager encrypted disk cache.
str(hermes_home / "cache" / "bws_cache.enc.json"),
str(hermes_root / "cache" / "bws_cache.enc.json"),
os.path.join(home, ".netrc"),
os.path.join(home, ".pgpass"),
os.path.join(home, ".npmrc"),
+71 -140
View File
@@ -18,15 +18,9 @@ into it via :func:`agent.lsp.manager.LSPService.touch_file`.
Implementation notes:
- All per-document state lives in one :class:`_DocState` keyed by
absolute path. Freshness is tracked with **document versions**,
not timestamps: every didChange bumps ``version``, and each stored
push/pull result is tagged with the version it describes. A
result is fresh iff its tag >= the version being waited on, so a
didChange implicitly invalidates everything older — no clearing,
no clock comparisons, no race windows. This is what prevents
"ghost diagnostics": a slow server's leftovers from the previous
edit can never masquerade as a verdict on the current content.
- Push diagnostics are stored per-URI in :attr:`_push_diagnostics` from
``textDocument/publishDiagnostics`` notifications. Pull diagnostics
go in :attr:`_pull_diagnostics`. The merged view dedupes by content.
- Whole-document sync. Even when the server advertises incremental
sync, we send a single ``contentChanges`` entry replacing the
@@ -51,7 +45,6 @@ import asyncio
import logging
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
from urllib.parse import quote, unquote
@@ -131,40 +124,6 @@ def _end_position(text: str) -> Dict[str, int]:
return {"line": last_line, "character": last_col}
@dataclass
class _DocState:
"""Everything the client tracks for one open document.
``version`` is the LSP document version we last sent (didOpen=0,
each didChange +1). It doubles as the freshness token: stored
push/pull results are tagged with the version they describe
(``push_version`` / ``pull_version``), and a result is *fresh*
iff its tag has caught up to ``version``. Bumping the version on
didChange therefore invalidates all older results implicitly —
no store-clearing, no timestamps.
``push_version``/``pull_version`` start at -1 = "no data yet".
Servers that echo a document version in publishDiagnostics get
exact tagging; those that don't are credited with the current
version at receipt time (a push observed after we sent the
change describes the changed content or newer).
"""
version: int = 0
text: str = ""
push: List[Dict[str, Any]] = field(default_factory=list)
pull: List[Dict[str, Any]] = field(default_factory=list)
push_version: int = -1
pull_version: int = -1
seed_seen: bool = False
def fresh_push(self, version: Optional[int] = None) -> bool:
return self.push_version >= (self.version if version is None else version)
def fresh_pull(self, version: Optional[int] = None) -> bool:
return self.pull_version >= (self.version if version is None else version)
class LSPClient:
"""Async LSP client tied to one server process and one workspace root.
@@ -227,10 +186,18 @@ class LSPClient:
# is silently dropped by default.
}
# Per-document state (version, text, diagnostic stores, and
# their freshness tags), keyed by absolute file path (NOT URI).
# See _DocState for the version-based freshness model.
self._docs: Dict[str, _DocState] = {}
# Tracked file state — required for didChange version bumps.
self._files: Dict[str, Dict[str, Any]] = {}
# Diagnostic stores, keyed by file path (NOT URI).
self._push_diagnostics: Dict[str, List[Dict[str, Any]]] = {}
self._pull_diagnostics: Dict[str, List[Dict[str, Any]]] = {}
# Per-path "last published" time so wait-for-fresh logic works.
self._published: Dict[str, float] = {}
# Per-path version of the latest push (matches our didChange
# version when the server respects it).
self._published_version: Dict[str, int] = {}
# First-push seen flag, for typescript-style seed-on-first-push.
self._first_push_seen: Set[str] = set()
# Capability registrations — only diagnostic ones are tracked.
self._diagnostic_registrations: Dict[str, Dict[str, Any]] = {}
@@ -680,25 +647,25 @@ class LSPClient:
if not isinstance(diagnostics, list):
diagnostics = []
version = params.get("version")
loop_time = asyncio.get_event_loop().time()
doc = self._docs.setdefault(path, _DocState(version=-1))
if self._seed_first_push and not doc.seed_seen:
# First push: seed the store WITHOUT a freshness tag. It
# arrives before the user-triggered didChange could've
# produced fresh diagnostics, so it must never satisfy a
# waiter — it's baseline data only.
doc.seed_seen = True
doc.push = diagnostics
if self._seed_first_push and path not in self._first_push_seen:
# First push: seed without firing the event so a waiter
# doesn't resolve on the very first push (which arrives
# before the user-triggered didChange could've produced
# fresh diagnostics).
self._first_push_seen.add(path)
self._push_diagnostics[path] = diagnostics
self._published[path] = loop_time
if isinstance(version, int):
self._published_version[path] = version
return
doc.seed_seen = True
doc.push = diagnostics
# Tag with the echoed document version when the server provides
# one; otherwise credit the current version — a push observed
# after we sent the change describes the changed content (or
# newer). Note doc.version is -1 for never-opened paths
# (e.g. relatedDocuments spillover), keeping them unfresh.
doc.push_version = version if isinstance(version, int) else doc.version
self._push_diagnostics[path] = diagnostics
self._published[path] = loop_time
if isinstance(version, int):
self._published_version[path] = version
self._first_push_seen.add(path)
# Bump the monotonic push counter and wake every waiter. We
# keep the Event sticky-set so any wait already in progress
# resolves; waiters re-check their predicate after waking and
@@ -727,16 +694,16 @@ class LSPClient:
raise LSPProtocolError(f"cannot read {abs_path}: {e}") from e
uri = file_uri(abs_path)
doc = self._docs.get(abs_path)
existing = self._files.get(abs_path)
if doc is not None and doc.version >= 0:
if existing is not None:
# Re-open: bump version, fire didChangeWatchedFiles + didChange.
await self._send_notification(
"workspace/didChangeWatchedFiles",
{"changes": [{"uri": uri, "type": 2}]}, # 2 = CHANGED
)
new_version = doc.version + 1
old_text = doc.text
new_version = existing["version"] + 1
old_text = existing["text"]
content_changes: List[Dict[str, Any]]
if self._sync_kind == 2:
content_changes = [
@@ -757,11 +724,7 @@ class LSPClient:
"contentChanges": content_changes,
},
)
# Bumping the version is the whole invalidation story:
# every stored result tagged with an older version is now
# stale by definition (see _DocState).
doc.version = new_version
doc.text = text
self._files[abs_path] = {"version": new_version, "text": text}
return new_version
# First open: didChangeWatchedFiles CREATED + didOpen.
@@ -769,9 +732,12 @@ class LSPClient:
"workspace/didChangeWatchedFiles",
{"changes": [{"uri": uri, "type": 1}]}, # 1 = CREATED
)
# Fresh doc state — anything stashed under this path by a
# pre-open push (relatedDocuments spillover etc.) is discarded.
self._docs[abs_path] = _DocState(version=0, text=text)
# Clear any stale push/pull entries — fresh open should start
# from scratch.
self._push_diagnostics.pop(abs_path, None)
self._pull_diagnostics.pop(abs_path, None)
self._published.pop(abs_path, None)
self._published_version.pop(abs_path, None)
await self._send_notification(
"textDocument/didOpen",
{
@@ -783,6 +749,7 @@ class LSPClient:
}
},
)
self._files[abs_path] = {"version": 0, "text": text}
return 0
async def save_file(self, path: str) -> None:
@@ -802,19 +769,12 @@ class LSPClient:
async def _pull_document_diagnostics(self, path: str) -> None:
"""Send ``textDocument/diagnostic`` for one file.
Stores results into the doc's pull store, tagged with the
document version captured at request send time. If a didChange
races past the in-flight request, the version bump makes the
stored result stale automatically — no explicit invalidation.
Silently no-ops on errors (server may not support the pull
endpoint).
Stores results into :attr:`_pull_diagnostics`. Silently
no-ops on errors (server may not support the pull endpoint).
"""
abs_path = os.path.abspath(path)
doc = self._docs.get(abs_path)
sent_version = doc.version if doc else -1
try:
params: Dict[str, Any] = {
"textDocument": {"uri": file_uri(abs_path)}
"textDocument": {"uri": file_uri(os.path.abspath(path))}
}
result = await self._send_request_with_retry(
"textDocument/diagnostic",
@@ -828,9 +788,7 @@ class LSPClient:
return
items = result.get("items")
if isinstance(items, list):
doc = self._docs.setdefault(abs_path, _DocState(version=-1))
doc.pull = items
doc.pull_version = sent_version
self._pull_diagnostics[os.path.abspath(path)] = items
related = result.get("relatedDocuments")
if isinstance(related, dict):
for uri, sub in related.items():
@@ -838,11 +796,7 @@ class LSPClient:
continue
sub_items = sub.get("items")
if isinstance(sub_items, list):
rel = self._docs.setdefault(uri_to_path(uri), _DocState(version=-1))
rel.pull = sub_items
# Same send-anchored tagging: fresh only if that
# doc hasn't changed since the request went out.
rel.pull_version = rel.version
self._pull_diagnostics[uri_to_path(uri)] = sub_items
async def wait_for_diagnostics(
self,
@@ -850,36 +804,22 @@ class LSPClient:
version: int,
*,
mode: str = "document",
timeout: Optional[float] = None,
) -> bool:
) -> None:
"""Wait for the server to publish diagnostics for ``path`` at ``version``.
``mode`` is ``"document"`` (5s budget, document pulls) or
``"full"`` (10s budget, also workspace pulls). ``timeout``
overrides the mode's default budget when provided — this is
how the user's ``lsp.wait_timeout`` config reaches the wait
loop (slow servers like tsserver on big projects need more
than the 5s default).
Returns ``True`` when *fresh* diagnostics arrived (a push at
or after our didChange, or a pull answered after it) and
``False`` on timeout. Callers must treat ``False`` as "no
data", NOT as "no errors" — the diagnostic stores may still
hold stale entries from the previous edit at that point.
Best-effort — never throws if the server doesn't support pull
diagnostics; we still get the push side.
``"full"`` (10s budget, also workspace pulls). Best-effort —
returns silently on timeout. Does NOT throw if the server
doesn't support pull diagnostics; we still get the push side.
"""
if timeout is not None and timeout > 0:
budget = timeout
else:
budget = DIAGNOSTICS_FULL_WAIT if mode == "full" else DIAGNOSTICS_DOCUMENT_WAIT
budget = DIAGNOSTICS_FULL_WAIT if mode == "full" else DIAGNOSTICS_DOCUMENT_WAIT
deadline = asyncio.get_event_loop().time() + budget
abs_path = os.path.abspath(path)
while True:
remaining = deadline - asyncio.get_event_loop().time()
if remaining <= 0:
return False
return
# Concurrent: document pull + push wait.
pull_task = asyncio.create_task(self._pull_document_diagnostics(abs_path))
@@ -898,24 +838,26 @@ class LSPClient:
pass
# If we got a fresh push for our version, we're done.
doc = self._docs.get(abs_path)
if doc and doc.fresh_push(version):
return True
current_v = self._published_version.get(abs_path)
if abs_path in self._published and (
current_v is None or current_v >= version
):
return
# Pull may have answered for the current version — that's
# also success.
if doc and doc.fresh_pull(version):
return True
# Pull may have populated _pull_diagnostics — that's also
# success.
if abs_path in self._pull_diagnostics:
return
# Loop until budget runs out.
async def _wait_for_fresh_push(self, path: str, version: int, timeout: float) -> None:
"""Wait until a fresh publishDiagnostics arrives for ``path`` at ``version``+."""
"""Wait until a publishDiagnostics arrives for ``path`` at ``version``+."""
deadline = asyncio.get_event_loop().time() + timeout
baseline = self._push_counter
while True:
doc = self._docs.get(path)
if doc and doc.fresh_push(version):
current_v = self._published_version.get(path)
if path in self._published and (current_v is None or current_v >= version):
# Debounce — wait a tick in case more diagnostics arrive
# immediately after. TS often emits in pairs. We
# snapshot the counter so we wake on a *new* push, not
@@ -946,28 +888,17 @@ class LSPClient:
except asyncio.TimeoutError:
continue
def diagnostics_for(self, path: str, *, fresh_only: bool = False) -> List[Dict[str, Any]]:
def diagnostics_for(self, path: str) -> List[Dict[str, Any]]:
"""Return current merged + deduped diagnostics for one file.
Diagnostics from push and pull stores are concatenated and
deduplicated by ``(severity, code, message, range)`` content
key. Empty list if the server hasn't published anything.
With ``fresh_only=True``, a store only contributes when its
version tag has caught up to the document's current version —
stale leftovers from the previous edit cycle are excluded.
This is what report paths should use: after an edit, "stale
errors" and "no errors" must not be conflated.
"""
doc = self._docs.get(os.path.abspath(path))
if doc is None:
return []
if fresh_only:
return _dedupe(
doc.push if doc.fresh_push() else [],
doc.pull if doc.fresh_pull() else [],
)
return _dedupe(doc.push, doc.pull)
abs_path = os.path.abspath(path)
push = self._push_diagnostics.get(abs_path) or []
pull = self._pull_diagnostics.get(abs_path) or []
return _dedupe(push, pull)
def _dedupe(*lists: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+10 -41
View File
@@ -292,10 +292,7 @@ class LSPService:
if not self.enabled_for(file_path):
return
try:
# Outer join budget must exceed the inner wait budget or a
# slow-but-alive server gets falsely marked broken.
t = max(8.0, self._wait_timeout + 3.0)
diags = self._loop.run(self._snapshot_async(file_path), timeout=t)
diags = self._loop.run(self._snapshot_async(file_path), timeout=8.0)
self._delta_baseline[os.path.abspath(file_path)] = diags or []
except Exception as e: # noqa: BLE001
logger.debug("baseline snapshot failed for %s: %s", file_path, e)
@@ -344,7 +341,7 @@ class LSPService:
try:
t = timeout if timeout is not None else self._wait_timeout + 2.0
diags = self._loop.run(self._open_and_wait_async(file_path), timeout=t)
diags = self._loop.run(self._open_and_wait_async(file_path), timeout=t) or []
except asyncio.TimeoutError as e:
eventlog.log_timeout(server_id, file_path)
logger.debug("LSP diagnostics timeout for %s: %s", file_path, e)
@@ -356,17 +353,6 @@ class LSPService:
self._mark_broken_for_file(file_path, e)
return []
if diags is None:
# The server is alive but never produced diagnostics for the
# post-edit content within the wait budget (common for
# tsserver on large projects). Report "no data" rather than
# whatever stale state is in the stores — surfacing the
# previous edit's errors as if they were current is the
# ghost-diagnostics bug. The server is NOT marked broken:
# slow is not dead, and the next edit may well succeed.
eventlog.log_timeout(server_id, file_path, kind="fresh diagnostics")
return []
abs_path = os.path.abspath(file_path)
if delta:
baseline = self._delta_baseline.get(abs_path) or []
@@ -466,43 +452,26 @@ class LSPService:
return []
try:
version = await client.open_file(file_path, language_id=language_id_for(file_path))
fresh = await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
except Exception as e: # noqa: BLE001
logger.debug("snapshot open/wait failed: %s", e)
return []
self._last_used[(client.server_id, client.workspace_root)] = time.time()
if not fresh:
# No fresh data for the pre-edit content — an empty baseline
# is safe: worst case the delta filter removes less, never
# more. Never seed the baseline from stale stores.
return []
return list(client.diagnostics_for(file_path, fresh_only=True))
return list(client.diagnostics_for(file_path))
async def _open_and_wait_async(self, file_path: str) -> Optional[List[Dict[str, Any]]]:
"""Open + wait for FRESH diagnostics.
Returns the fresh diagnostic list, or ``None`` when the server
never produced post-change data within the wait budget. The
distinction matters: ``[]`` means "server checked the new
content, it's clean", ``None`` means "no verdict" — the caller
must not substitute stale data for either.
"""
async def _open_and_wait_async(self, file_path: str) -> List[Dict[str, Any]]:
client = await self._get_or_spawn(file_path)
if client is None:
return None
return []
try:
version = await client.open_file(file_path, language_id=language_id_for(file_path))
await client.save_file(file_path)
fresh = await client.wait_for_diagnostics(
file_path, version, mode=self._wait_mode, timeout=self._wait_timeout
)
await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
except Exception as e: # noqa: BLE001
logger.debug("open/wait failed for %s: %s", file_path, e)
return None
return []
self._last_used[(client.server_id, client.workspace_root)] = time.time()
if not fresh:
return None
return list(client.diagnostics_for(file_path, fresh_only=True))
return list(client.diagnostics_for(file_path))
async def _current_diags_async(self, file_path: str) -> List[Dict[str, Any]]:
ws, gated = resolve_workspace_for_file(file_path)
@@ -513,7 +482,7 @@ class LSPService:
client = self._clients.get((srv.server_id, ws))
if client is None:
return []
return list(client.diagnostics_for(file_path, fresh_only=True))
return list(client.diagnostics_for(file_path))
async def _get_or_spawn(self, file_path: str) -> Optional[LSPClient]:
srv = find_server_for_file(file_path)
+63 -117
View File
@@ -905,114 +905,7 @@ class MoAChatCompletions:
except Exception as exc: # pragma: no cover - display must never break the turn
logger.debug("MoA reference_callback failed for %s: %s", event, exc)
def prepare(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
"""Run the advisor fan-out and return the exact aggregator request.
The normal agent loop needs to measure this augmented prompt before its
compression gate. ``create()`` also uses this method for direct callers;
when the loop supplies the returned private object back to ``create()``,
the advisor fan-out is not repeated.
"""
return self.create(messages=messages, _moa_prepare_only=True)
def rebase_prepared_request(
self, prepared: dict[str, Any], messages: list[dict[str, Any]]
) -> dict[str, Any]:
"""Apply already-generated advisor guidance to a rebuilt API transcript.
Context compression changes the persisted transcript but not the
ephemeral advisor result. Reusing the guidance avoids a second costly
fan-out while keeping the aggregator request aligned with the compacted
history.
"""
guidance = prepared.get("guidance")
agg_messages = [dict(message) for message in messages]
if guidance:
_attach_reference_guidance(agg_messages, str(guidance))
return {**prepared, "messages": agg_messages}
def _call_prepared_aggregator(
self, prepared: dict[str, Any], api_kwargs: dict[str, Any]
) -> Any:
"""Send an already prepared MoA aggregator request exactly once."""
agg_messages = prepared["messages"]
aggregator = prepared["aggregator"]
aggregator_temperature = prepared["aggregator_temperature"]
if aggregator.get("provider") == "moa":
raise RuntimeError("MoA aggregator cannot be another MoA preset")
agg_kwargs = dict(api_kwargs)
max_tokens: Any = agg_kwargs.get("max_tokens")
tools: Any = agg_kwargs.get("tools")
extra_body: Any = agg_kwargs.get("extra_body")
# Record the exact aggregator INPUT (incl. the injected reference
# context) into the pending trace so a trace captures what the
# aggregator actually saw, not a reconstruction.
if self._pending_trace is not None:
self._pending_trace["aggregator_input_messages"] = agg_messages
self._pending_trace["aggregator_label"] = _slot_label(aggregator)
# The aggregator is the acting model. Resolve its slot to the provider's
# real runtime (base_url/api_key/api_mode) and call it through the same
# request-building path any model uses — so per-model wire-format
# handling (anthropic_messages, max_completion_tokens, fixed/forbidden
# temperature) applies identically to it. MoA imposes no output cap:
# max_tokens is passed through from the caller (normally None → omitted
# → the model's real maximum). The preset's old hardcoded 4096 default
# is gone — it truncated long syntheses.
# When the agent's streaming consumer calls us with stream=True, run the
# references first (above) and then return the aggregator's RAW token
# stream so the acting model's output reaches the user live. The consumer
# reassembles chunks + tool_calls, runs stale-stream detection, and falls
# back to a non-streaming retry on error. The non-streaming path
# (stream=False) is unchanged — no stream/stream_options/timeout are
# forwarded, so its behavior is byte-for-byte identical to before.
stream = bool(api_kwargs.get("stream"))
stream_kwargs: dict[str, Any] = {}
if stream:
stream_kwargs["stream"] = True
stream_kwargs["stream_options"] = (
api_kwargs.get("stream_options") or {"include_usage": True}
)
# Forward the consumer's per-request (stream read) timeout so it
# actually governs the aggregator stream, not just call_llm's default.
if api_kwargs.get("timeout") is not None:
stream_kwargs["timeout"] = api_kwargs["timeout"]
_agg_response = call_llm(
task="moa_aggregator",
messages=agg_messages,
temperature=aggregator_temperature,
max_tokens=max_tokens,
tools=tools,
extra_body=extra_body,
# Prepared requests must retain the acting aggregator's reasoning
# policy exactly as the direct create() path does (#64187).
reasoning_config=_aggregator_reasoning_config(aggregator),
**stream_kwargs,
**_slot_runtime(aggregator),
)
# Non-streaming path (quiet mode / eval / subagents): the aggregator
# output is available inline, so capture it into the pending trace now.
# Streaming path: the aggregator's raw token stream is returned to the
# consumer live and its acting output lands as the turn's assistant
# message; the trace marks it streamed and points there.
if self._pending_trace is not None:
if stream:
self._pending_trace["aggregator_streamed"] = True
self._pending_trace["aggregator_output"] = None
else:
self._pending_trace["aggregator_streamed"] = False
try:
self._pending_trace["aggregator_output"] = _extract_text(_agg_response)
except Exception: # pragma: no cover - defensive
self._pending_trace["aggregator_output"] = None
return _agg_response
def create(self, **api_kwargs: Any) -> Any:
prepared_request = api_kwargs.pop("_moa_prepared_request", None)
if prepared_request is not None:
if not isinstance(prepared_request, dict):
raise TypeError("_moa_prepared_request must be a dict")
return self._call_prepared_aggregator(prepared_request, api_kwargs)
from hermes_cli.config import load_config
from hermes_cli.moa_config import resolve_moa_preset
@@ -1172,7 +1065,6 @@ class MoAChatCompletions:
ref_count=_ref_count,
)
guidance: str | None = None
agg_messages = [dict(m) for m in messages]
if reference_outputs:
joined = "\n\n".join(
@@ -1190,15 +1082,69 @@ class MoAChatCompletions:
)
_attach_reference_guidance(agg_messages, guidance)
prepared_request = {
"messages": agg_messages,
"guidance": guidance,
"aggregator": aggregator,
"aggregator_temperature": aggregator_temperature,
}
if api_kwargs.pop("_moa_prepare_only", False):
return prepared_request
return self._call_prepared_aggregator(prepared_request, api_kwargs)
if aggregator.get("provider") == "moa":
raise RuntimeError("MoA aggregator cannot be another MoA preset")
agg_kwargs = dict(api_kwargs)
agg_kwargs["messages"] = agg_messages
# Record the exact aggregator INPUT (incl. the injected reference
# context) into the pending trace so a trace captures what the
# aggregator actually saw, not a reconstruction.
if self._pending_trace is not None:
self._pending_trace["aggregator_input_messages"] = agg_messages
self._pending_trace["aggregator_label"] = _slot_label(aggregator)
# The aggregator is the acting model. Resolve its slot to the provider's
# real runtime (base_url/api_key/api_mode) and call it through the same
# request-building path any model uses — so per-model wire-format
# handling (anthropic_messages, max_completion_tokens, fixed/forbidden
# temperature) applies identically to it. MoA imposes no output cap:
# max_tokens is passed through from the caller (normally None → omitted
# → the model's real maximum). The preset's old hardcoded 4096 default
# is gone — it truncated long syntheses.
# When the agent's streaming consumer calls us with stream=True, run the
# references first (above) and then return the aggregator's RAW token
# stream so the acting model's output reaches the user live. The consumer
# reassembles chunks + tool_calls, runs stale-stream detection, and falls
# back to a non-streaming retry on error. The non-streaming path
# (stream=False) is unchanged — no stream/stream_options/timeout are
# forwarded, so its behavior is byte-for-byte identical to before.
stream = bool(api_kwargs.get("stream"))
stream_kwargs: dict[str, Any] = {}
if stream:
stream_kwargs["stream"] = True
stream_kwargs["stream_options"] = (
api_kwargs.get("stream_options") or {"include_usage": True}
)
# Forward the consumer's per-request (stream read) timeout so it
# actually governs the aggregator stream, not just call_llm's default.
if api_kwargs.get("timeout") is not None:
stream_kwargs["timeout"] = api_kwargs["timeout"]
_agg_response = call_llm(
task="moa_aggregator",
messages=agg_messages,
temperature=aggregator_temperature,
max_tokens=agg_kwargs.get("max_tokens"),
tools=agg_kwargs.get("tools"),
extra_body=agg_kwargs.get("extra_body"),
reasoning_config=_aggregator_reasoning_config(aggregator),
**stream_kwargs,
**_slot_runtime(aggregator),
)
# Non-streaming path (quiet mode / eval / subagents): the aggregator
# output is available inline, so capture it into the pending trace now.
# Streaming path: the aggregator's raw token stream is returned to the
# consumer live and its acting output lands as the turn's assistant
# message; the trace marks it streamed and points there.
if self._pending_trace is not None:
if stream:
self._pending_trace["aggregator_streamed"] = True
self._pending_trace["aggregator_output"] = None
else:
self._pending_trace["aggregator_streamed"] = False
try:
self._pending_trace["aggregator_output"] = _extract_text(_agg_response)
except Exception: # pragma: no cover - defensive
self._pending_trace["aggregator_output"] = None
return _agg_response
class MoAClient:
+57 -211
View File
@@ -4,8 +4,6 @@ Pure utility functions with no AIAgent dependency. Used by ContextCompressor
and run_agent.py for pre-flight context checks.
"""
import base64
import hashlib
import ipaddress
import json
import logging
@@ -321,12 +319,7 @@ DEFAULT_CONTEXT_LENGTHS = {
"grok-3": 131072, # grok-3, grok-3-mini, grok-3-fast, grok-3-mini-fast
"grok-2": 131072, # grok-2, grok-2-1212, grok-2-latest
"grok": 131072, # catch-all (grok-beta, unknown grok-*)
# Kimi — K3 ships with a 1 Mi context window (1,048,576; verified against
# models.dev and OpenRouter live metadata, matching the endpoint-scoped
# override in _endpoint_scoped_context_length). Longest-key-first substring
# matching ensures "kimi-k3" resolves to 1M while older/unknown Kimi models
# still hit the generic 256K fallback.
"kimi-k3": 1_048_576,
# Kimi
"kimi": 262144,
# Upstage Solar — api.upstage.ai/v1/models does not return context_length,
# so these fallbacks keep token budgeting / compression from probing down
@@ -550,13 +543,7 @@ def _is_known_provider_base_url(base_url: str) -> bool:
def _endpoint_scoped_context_length(model: str, base_url: str) -> Optional[int]:
"""Return metadata confirmed only for the Kimi Coding endpoint.
Kimi Coding serves K3 under the bare slug ``k3``, but users may also
configure or select the public-facing aliases ``kimi-k3`` and
``kimi-k3-cot``. Only canonical ``https://api.kimi.com/coding`` endpoints
(legacy Moonshot keys do not serve K3) get the 1 Mi context window.
"""
"""Return metadata confirmed only for one provider endpoint."""
normalized = _normalize_base_url(base_url)
try:
parsed = urlparse(normalized)
@@ -572,7 +559,7 @@ def _endpoint_scoped_context_length(model: str, base_url: str) -> Optional[int]:
and parsed.path.rstrip("/") in {"/coding", "/coding/v1"}
and not parsed.query
and not parsed.fragment
and model.strip().lower() in {"k3", "kimi-k3", "kimi-k3-cot"}
and model.strip().lower() == "k3"
):
return 1_048_576
return None
@@ -583,13 +570,8 @@ def _skip_persistent_context_cache(base_url: str, provider: str) -> bool:
LM Studio excludes caching because loaded context is transient — the user
can reload the model with a different context_length at any time.
Codex OAuth excludes caching because its context window is account- and
entitlement-specific metadata supplied by the authenticated /models
endpoint. A fallback value written after a transient probe failure must
not prevent a later live probe from observing an updated allocation.
"""
return (provider or "").strip().lower() in {"lmstudio", "openai-codex"}
"""
return provider == "lmstudio"
def _maybe_cache_local_context_length(
@@ -1925,72 +1907,32 @@ _CODEX_OAUTH_CONTEXT_FALLBACK: Dict[str, int] = {
}
_codex_oauth_context_cache: Dict[str, Tuple[Dict[str, int], float]] = {}
_codex_oauth_context_cache: Dict[str, int] = {}
_codex_oauth_context_cache_time: float = 0.0
_CODEX_OAUTH_CONTEXT_CACHE_TTL = 3600 # 1 hour
def _codex_oauth_token_fingerprint(access_token: str) -> str:
"""Return a non-secret cache key for a Codex OAuth access token."""
return hashlib.sha256(access_token.encode("utf-8")).hexdigest()[:16]
def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]:
"""Probe the ChatGPT Codex /models endpoint for per-slug context windows.
Codex OAuth imposes its own context limits that differ from the direct
OpenAI API (e.g. gpt-5.5 is 1.05M on the API, 272K on Codex). The
`context_window` field in each model entry is the authoritative source.
def _extract_chatgpt_account_id(access_token: str) -> Optional[str]:
"""Extract ``chatgpt_account_id`` from the Codex OAuth JWT.
The Codex ``/backend-api/codex/models`` endpoint returns the per-account
catalog only when the ``ChatGPT-Account-Id`` header is present; without
it, the endpoint returns ``{"models":[]}`` (HTTP 200) and the context
probe falls back to the hardcoded defaults — which can be stale or
wrong for the active account's plan. Mirrors the same extraction done
in ``auxiliary_client.py`` for the request path.
Returns ``None`` on any parse error rather than raising, so a bad
token still surfaces as a normal probe failure instead of crashing
the metadata resolver.
Returns a ``{slug: context_window}`` dict. Empty on failure.
"""
try:
parts = access_token.split(".")
if len(parts) < 2:
return None
payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
claims = json.loads(base64.urlsafe_b64decode(payload_b64))
if not isinstance(claims, dict):
return None
acct_id = claims.get("https://api.openai.com/auth", {}).get("chatgpt_account_id")
return acct_id if isinstance(acct_id, str) and acct_id else None
except Exception:
return None
def _fetch_codex_oauth_context_lengths_with_source(
access_token: str,
) -> Tuple[Dict[str, int], bool]:
"""Fetch Codex catalogue data and report whether it came from HTTP.
The in-process cache is scoped by token fingerprint because Codex model
availability and context windows can vary by account entitlement. The raw
token is never retained in the cache key. The boolean is false for a
same-token in-process hit, which must not be treated as a fresh provider
confirmation when deciding whether to update persistent state.
"""
global _codex_oauth_context_cache
global _codex_oauth_context_cache, _codex_oauth_context_cache_time
now = time.time()
cache_key = _codex_oauth_token_fingerprint(access_token)
cached = _codex_oauth_context_cache.get(cache_key)
if cached is not None:
cached_models, cached_at = cached
if now - cached_at < _CODEX_OAUTH_CONTEXT_CACHE_TTL:
return cached_models, False
headers = {"Authorization": f"Bearer {access_token}"}
acct_id = _extract_chatgpt_account_id(access_token)
if acct_id:
headers["ChatGPT-Account-Id"] = acct_id
if (
_codex_oauth_context_cache
and now - _codex_oauth_context_cache_time < _CODEX_OAUTH_CONTEXT_CACHE_TTL
):
return _codex_oauth_context_cache
try:
resp = requests.get(
"https://chatgpt.com/backend-api/codex/models?client_version=1.0.0",
headers=headers,
headers={"Authorization": f"Bearer {access_token}"},
timeout=(5, 10),
verify=_resolve_requests_verify(),
)
@@ -1999,11 +1941,11 @@ def _fetch_codex_oauth_context_lengths_with_source(
"Codex /models probe returned HTTP %s; falling back to hardcoded defaults",
resp.status_code,
)
return {}, False
return {}
data = resp.json()
except Exception as exc:
logger.debug("Codex /models probe failed: %s", exc)
return {}, False
return {}
entries = data.get("models", []) if isinstance(data, dict) else []
result: Dict[str, int] = {}
@@ -2016,50 +1958,32 @@ def _fetch_codex_oauth_context_lengths_with_source(
result[slug.strip()] = ctx
if result:
_codex_oauth_context_cache[cache_key] = (result, now)
return result, True
def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]:
"""Probe the ChatGPT Codex /models endpoint for per-slug context windows.
Codex OAuth imposes its own context limits that differ from the direct
OpenAI API (e.g. gpt-5.5 is 1.05M on the API, 272K on Codex). The
`context_window` field in each model entry is the authoritative source.
Returns a ``{slug: context_window}`` dict. Empty on failure.
"""
result, _fresh = _fetch_codex_oauth_context_lengths_with_source(access_token)
_codex_oauth_context_cache = result
_codex_oauth_context_cache_time = now
return result
def _resolve_codex_oauth_context_length_with_source(
def _resolve_codex_oauth_context_length(
model: str, access_token: str = ""
) -> Tuple[Optional[int], str]:
) -> Optional[int]:
"""Resolve a Codex OAuth model's real context window.
Prefers a live probe of chatgpt.com/backend-api/codex/models (when we
have a bearer token), then falls back to ``_CODEX_OAUTH_CONTEXT_FALLBACK``.
Returns ``(context_length, source)`` where source is ``"live"`` for a
value returned by a fresh authenticated endpoint probe, ``"memory"`` for
a same-token in-process catalogue hit, or ``"fallback"`` for the static
conservative table. Only ``"live"`` is eligible for persistent writes.
"""
model_bare = _strip_provider_prefix(model).strip()
if not model_bare:
return None, ""
return None
if access_token:
live, fresh_probe = _fetch_codex_oauth_context_lengths_with_source(access_token)
live_source = "live" if fresh_probe else "memory"
live = _fetch_codex_oauth_context_lengths(access_token)
if model_bare in live:
return live[model_bare], live_source
return live[model_bare]
# Case-insensitive match in case casing drifts
model_lower = model_bare.lower()
for slug, ctx in live.items():
if slug.lower() == model_lower:
return ctx, live_source
return ctx
# Fallback: longest-key-first substring match over hardcoded defaults.
model_lower = model_bare.lower()
@@ -2067,19 +1991,9 @@ def _resolve_codex_oauth_context_length_with_source(
_CODEX_OAUTH_CONTEXT_FALLBACK.items(), key=lambda x: len(x[0]), reverse=True
):
if slug in model_lower:
return ctx, "fallback"
return ctx
return None, ""
def _resolve_codex_oauth_context_length(
model: str, access_token: str = ""
) -> Optional[int]:
"""Resolve a Codex OAuth model's context length (compatibility wrapper)."""
context_length, _source = _resolve_codex_oauth_context_length_with_source(
model, access_token=access_token,
)
return context_length
return None
def _resolve_nous_context_length(
@@ -2169,9 +2083,9 @@ def get_model_context_length(
Resolution order:
0. Explicit config override (model.context_length or custom_providers per-model)
0c. Endpoint-scoped metadata for models validated on one multiplexed endpoint
1. Persistent cache (previously discovered via probing). Nous URLs,
LM Studio, and Codex OAuth bypass the cache here so their provider
metadata can be reconciled against the authoritative live source.
1. Persistent cache (previously discovered via probing). Nous URLs
bypass the cache here so step 5b can always reconcile against
the authoritative portal /v1/models response.
1b. AWS Bedrock static table (must precede custom-endpoint probe)
2. Active endpoint metadata (/models for explicit custom endpoints)
3. Local server query (for local endpoints)
@@ -2271,13 +2185,24 @@ def get_model_context_length(
# LM Studio is excluded — its loaded context length is transient (the
# user can reload the model with a different context_length at any time
# via /api/v1/models/load), so a stale cached value would mask reloads.
# Codex OAuth is excluded because the authenticated /models catalogue is
# account-specific and a fallback must never suppress later revalidation.
if base_url and not _skip_persistent_context_cache(base_url, provider):
cached = get_cached_context_length(model, base_url)
if cached is not None:
# Invalidate stale Codex OAuth cache entries: pre-PR #14935 builds
# resolved gpt-5.x to the direct-API value (e.g. 1.05M) via
# models.dev and persisted it. Codex OAuth caps at 272K for every
# slug, so any cached Codex entry at or above 400K is a leftover
# from the old resolution path. Drop it and fall through to the
# live /models probe in step 5 below.
if provider == "openai-codex" and cached >= 400_000:
logger.info(
"Dropping stale Codex cache entry %s@%s -> %s (pre-fix value); "
"re-resolving via live /models probe",
model, base_url, f"{cached:,}",
)
_invalidate_cached_context_length(model, base_url)
# Invalidate stale 32k cache entries for Kimi-family models.
if cached <= 32768 and _model_name_suggests_kimi(model):
elif cached <= 32768 and _model_name_suggests_kimi(model):
logger.info(
"Dropping stale Kimi cache entry %s@%s -> %s (OpenRouter underreport); "
"re-resolving via hardcoded defaults",
@@ -2516,14 +2441,9 @@ def get_model_context_length(
# Codex OAuth enforces lower context limits than the direct OpenAI
# API for the same slug (e.g. gpt-5.5 is 1.05M on the API but 272K
# on Codex). Authoritative source is Codex's own /models endpoint.
codex_ctx, codex_source = _resolve_codex_oauth_context_length_with_source(
model, access_token=api_key or "",
)
codex_ctx = _resolve_codex_oauth_context_length(model, access_token=api_key or "")
if codex_ctx:
# Only a successful authenticated catalogue response is safe to
# persist. The static fallback is deliberately runtime-only so a
# transient OAuth/network failure cannot poison future probes.
if base_url and codex_source == "live":
if base_url:
save_context_length(model, base_url, codex_ctx)
return codex_ctx
if effective_provider == "gmi" and base_url:
@@ -2666,61 +2586,16 @@ async def get_model_context_length_async(
)
def _is_cjk_token_dense_char(ch: str) -> bool:
code = ord(ch)
return (
0x1100 <= code <= 0x11FF # Hangul Jamo
or 0x2E80 <= code <= 0x9FFF # CJK radicals/ideographs
or 0xA960 <= code <= 0xA97F # Hangul Jamo Extended-A
or 0xAC00 <= code <= 0xD7AF # Hangul Syllables
or 0xF900 <= code <= 0xFAFF # CJK compatibility ideographs
or 0xFF00 <= code <= 0xFFEF # Fullwidth forms / halfwidth kana
)
# Same codepoint ranges as _is_cjk_token_dense_char, as a compiled character
# class so dense-char counting runs in C (``len(text) - len(re.sub(...))``)
# instead of a per-char Python loop. MUST stay in sync with
# _is_cjk_token_dense_char.
_CJK_DENSE_RE = re.compile(
"[\u1100-\u11ff" # Hangul Jamo
"\u2e80-\u9fff" # CJK radicals/ideographs
"\ua960-\ua97f" # Hangul Jamo Extended-A
"\uac00-\ud7af" # Hangul Syllables
"\uf900-\ufaff" # CJK compatibility ideographs
"\uff00-\uffef]" # Fullwidth forms / halfwidth kana
)
def estimate_tokens_rough(text: str) -> int:
"""Rough token estimate for pre-flight checks.
"""Rough token estimate (~4 chars/token) for pre-flight checks.
Uses ceiling division so short texts (1-3 chars) never estimate as
0 tokens, which would cause the compressor and pre-flight checks to
systematically undercount when many short tool results are present.
CJK/Hangul/Kana text is much denser than English under common LLM
tokenizers, so count those codepoints as roughly one token each instead
of applying the English-centric ~4 chars/token rule.
Perf: this runs on every message in every preflight/compaction walk,
including MB-scale tool outputs, so the common all-ASCII case must stay
O(1). ``str.isascii()`` is a flag check on CPython's compact unicode
representation (no scan), and the CJK counting itself is a single
C-level ``re.findall`` rather than a per-character Python loop.
"""
if not text:
return 0
text = str(text)
if text.isascii():
# O(1) fast path — ASCII text cannot contain token-dense CJK chars.
return (len(text) + 3) // 4
dense = len(text) - len(_CJK_DENSE_RE.sub("", text))
if not dense:
# Non-ASCII but no CJK (accents, Cyrillic, emoji, ...): keep the
# classic ~4 chars/token rule.
return (len(text) + 3) // 4
sparse = len(text) - dense
return dense + ((sparse + 3) // 4)
return (len(text) + 3) // 4
def estimate_messages_tokens_rough(messages: List[Dict[str, Any]]) -> int:
@@ -2732,12 +2607,12 @@ def estimate_messages_tokens_rough(messages: List[Dict[str, Any]]) -> int:
estimated at ~250K tokens and trigger premature context compression.
"""
_IMAGE_TOKEN_COST = 1500
text_tokens = 0
total_chars = 0
image_tokens = 0
for msg in messages:
text_tokens += _estimate_message_tokens_without_images(msg)
total_chars += _estimate_message_chars(msg)
image_tokens += _count_image_tokens(msg, _IMAGE_TOKEN_COST)
return text_tokens + image_tokens
return ((total_chars + 3) // 4) + image_tokens
def _count_image_tokens(msg: Dict[str, Any], cost_per_image: int) -> int:
@@ -2799,35 +2674,6 @@ def _estimate_message_chars(msg: Dict[str, Any]) -> int:
return len(str(shadow))
def _estimate_message_tokens_without_images(msg: Dict[str, Any]) -> int:
"""Token estimate for a message shadow with image payloads stripped."""
if not isinstance(msg, dict):
return estimate_tokens_rough(str(msg))
shadow: Dict[str, Any] = {}
for k, v in msg.items():
if k == "_anthropic_content_blocks":
continue
if k == "content":
if isinstance(v, list):
cleaned = []
for part in v:
if isinstance(part, dict):
if part.get("type") in {"image", "image_url", "input_image"}:
cleaned.append({"type": part.get("type"), "image": "[stripped]"})
else:
cleaned.append(part)
else:
cleaned.append(part)
shadow[k] = cleaned
elif isinstance(v, dict) and v.get("_multimodal"):
shadow[k] = v.get("text_summary", "")
else:
shadow[k] = v
else:
shadow[k] = v
return estimate_tokens_rough(str(shadow))
def estimate_request_tokens_rough(
messages: List[Dict[str, Any]],
*,
@@ -2844,7 +2690,7 @@ def estimate_request_tokens_rough(
"""
total = 0
if system_prompt:
total += estimate_tokens_rough(system_prompt)
total += (len(system_prompt) + 3) // 4
if messages:
total += estimate_messages_tokens_rough(messages)
if tools:
+2 -33
View File
@@ -15,9 +15,6 @@ and MoonshotAI/kimi-cli#1595:
2. When ``anyOf`` is used, ``type`` must be on the ``anyOf`` children, not
the parent. Presence of both causes "type should be defined in anyOf
items instead of the parent schema".
3. Every object schema must carry a ``required`` array, even an empty one.
Standard JSON Schema allows omitting it; Moonshot 400s with
"required must be an array".
The ``#/definitions/...`` → ``#/$defs/...`` rewrite for draft-07 refs is
handled separately in ``tools/mcp_tool._normalize_mcp_input_schema`` so it
@@ -133,32 +130,9 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
else:
repaired.pop("enum")
# Rule 4: object schemas must carry a `required` array, even when empty.
if repaired.get("type") == "object":
repaired = _ensure_required_array(repaired)
return repaired
def _ensure_required_array(node: Dict[str, Any]) -> Dict[str, Any]:
"""Guarantee an object schema carries a ``required`` array (Moonshot rule).
Standard JSON Schema lets you omit ``required`` when nothing is required;
Moonshot 400s on that ("required must be an array"). Ensure the key is a
list. When ``properties`` is known, prune ``required`` entries that don't
name a real property — defensive against dangling names, which Moonshot
also rejects. Mutates and returns ``node``.
"""
props = node.get("properties")
req = node.get("required")
if isinstance(req, list):
if isinstance(props, dict):
node["required"] = [r for r in req if r in props]
else:
node["required"] = []
return node
def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
"""Infer a reasonable ``type`` if this schema node has none."""
node_type = node.get("type")
@@ -200,18 +174,17 @@ def sanitize_moonshot_tool_parameters(parameters: Any) -> Dict[str, Any]:
applied. Input is not mutated.
"""
if not isinstance(parameters, dict):
return {"type": "object", "properties": {}, "required": []}
return {"type": "object", "properties": {}}
repaired = _repair_schema(copy.deepcopy(parameters), is_schema=True)
if not isinstance(repaired, dict):
return {"type": "object", "properties": {}, "required": []}
return {"type": "object", "properties": {}}
# Top-level must be an object schema
if repaired.get("type") != "object":
repaired["type"] = "object"
if "properties" not in repaired:
repaired["properties"] = {}
_ensure_required_array(repaired)
return repaired
@@ -259,10 +232,6 @@ def is_moonshot_model(model: str | None) -> bool:
tail = bare.rsplit("/", 1)[-1]
if tail.startswith("kimi-") or tail == "kimi":
return True
# Kimi Coding Plan serves K3 under the bare slug ``k3`` (plus dated /
# suffixed variants like ``k3.1`` or ``k3-turbo``).
if tail == "k3" or tail.startswith(("k3.", "k3-")):
return True
# Vendor-prefixed forms commonly used on aggregators
if "moonshot" in bare or "/kimi" in bare or bare.startswith("kimi"):
return True
+2 -13
View File
@@ -805,19 +805,8 @@ PLATFORM_HINTS = {
),
"matrix": (
"You are in a Matrix room communicating with your user. "
"The adapter converts your Markdown to HTML for rich display — bold, "
"italic, inline code, fenced code blocks, headings, bullet and "
"numbered lists, blockquotes, and links all render.\n\n"
"Do NOT use Markdown tables: many popular Matrix clients (Element X, "
"Beeper, most mobile apps) do not render HTML tables, so the cells "
"collapse into one continuous run of text. Present tabular data as "
"labeled '**Label:** value' lines or bullet lists instead.\n\n"
"Avoid ||spoiler|| tags, ~~strikethrough~~, and checkboxes "
"(- [ ] / - [x]) — they are not converted and appear as literal "
"characters.\n\n"
"LINKS: prefer [descriptive link text](url) over bare URLs. When "
"referencing something with an associated URL (events, sources, "
"people), make the name a clickable link.\n\n"
"Matrix renders Markdown — bold, italic, code blocks, and links work; "
"the adapter converts your Markdown to HTML for rich display. "
"You can send media files natively: include MEDIA:/absolute/path/to/file "
"in your response. Images (.jpg, .png, .webp) are sent as inline photos, "
"audio (.ogg, .mp3) as voice/audio messages, video (.mp4) inline, "
+5 -35
View File
@@ -127,16 +127,10 @@ def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
1. Genuinely-global vars (``_is_global_env``) always read ``os.environ`` —
they are deployment settings, not profile secrets.
2. When a secret scope is installed (multiplexed turn), read from it. Under
multiplexing the scope is authoritative — an absent key returns
``default`` and we do NOT fall through to ``os.environ``, because in a
multiplexer ``os.environ`` may hold another profile's value. When
multiplexing is OFF, a scope miss falls through to ``os.environ``:
single-profile deployments legitimately provide credentials via the
process environment (systemd ``Environment=``, secret-manager wrappers
like ``pass-cli run`` / ``op run``, plain shell exports) rather than
``<home>/.env``, and the scope — installed unconditionally around e.g.
every cron job — must stay a ``.env`` overlay, not a blindfold.
2. When a secret scope is installed (multiplexed turn), read from it; an
absent key returns ``default``. The scope is authoritative — we do NOT
fall through to ``os.environ``, because in a multiplexer ``os.environ``
may hold another profile's value.
3. No scope installed:
- multiplex INACTIVE (default deployment): read ``os.environ`` —
identical to the legacy ``os.getenv`` behavior every caller had before.
@@ -150,17 +144,6 @@ def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
scope = _SECRET_SCOPE.get()
if scope is not None:
val = scope.get(name)
if val is not None:
return val
if _MULTIPLEX_ACTIVE:
return default
# Multiplex off: the scope is an overlay over the process environment,
# not an isolation boundary — there is no other profile to leak from.
# Without this fallthrough, credentials injected only into the process
# environment vanish inside any set_secret_scope(...) block (the cron
# scheduler installs one around every job), so cron jobs send a
# placeholder API key and 401 while interactive turns keep working.
val = os.environ.get(name)
return val if val is not None else default
if _MULTIPLEX_ACTIVE:
@@ -218,18 +201,5 @@ def build_profile_secret_scope(hermes_home: Path) -> Dict[str, str]:
global vars are intentionally NOT copied in — ``get_secret`` reads those
from ``os.environ`` directly, so the scope holds only profile secrets.
"""
home = Path(hermes_home)
secrets = load_env_file(home / ".env")
return load_env_file(Path(hermes_home) / ".env")
try:
from hermes_cli.env_loader import get_secret_source_values
external_secrets = get_secret_source_values(home)
except Exception:
external_secrets = {}
for key, value in external_secrets.items():
if _is_global_env(key):
continue
secrets[key] = value
return secrets
-39
View File
@@ -190,45 +190,6 @@ class SecretSource(ABC):
"""
return {}
def remediation(self, kind: Optional["ErrorKind"], cfg: dict) -> str:
"""One-line, actionable next step for a failed fetch.
Called by the startup status printer (and ``hermes secrets ...
status``) right after a fetch error is surfaced, so the user sees
*what to run* next to fix it — not just what broke. Sources
should override this to point at their own CLI verbs (e.g.
``hermes secrets bitwarden token`` for AUTH_FAILED). Return an
empty string to suppress the hint.
Must never raise and must not perform I/O — it's a pure
kind→string mapping on the startup path.
"""
generic = {
ErrorKind.NOT_CONFIGURED: (
f"Run `hermes secrets {self.name} setup` to finish configuration."
),
ErrorKind.BINARY_MISSING: (
f"Run `hermes secrets {self.name} setup` to install the helper CLI."
),
ErrorKind.AUTH_FAILED: (
f"Credentials rejected — run `hermes secrets {self.name} setup` "
"to re-authenticate."
),
ErrorKind.AUTH_EXPIRED: (
f"Credentials expired — run `hermes secrets {self.name} setup` "
"to re-authenticate."
),
ErrorKind.NETWORK: (
"Network problem reaching the secrets backend — check "
"connectivity and retry."
),
ErrorKind.TIMEOUT: (
f"Backend was slow — raise secrets.{self.name}.timeout_seconds "
"if this recurs."
),
}
return generic.get(kind, "") if kind is not None else ""
# ---------------------------------------------------------------------------
# Shared helpers — use these instead of hand-rolling per backend
+18 -328
View File
@@ -29,13 +29,11 @@ is easier to lazy-install than a wheels-with-Rust-extension dependency.
from __future__ import annotations
import base64
import hashlib
import json
import logging
import os
import platform
import re
import shutil
import stat
import subprocess
@@ -47,10 +45,6 @@ import zipfile
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from agent.secret_sources._cache import (
CachedFetch as _CachedFetch,
DiskCache,
@@ -97,9 +91,6 @@ _CACHE: Dict[_CacheKey, _CachedFetch] = {}
# accidentally commit BSM-sourced secrets. The atomic-write/0600/TTL mechanics
# live in agent.secret_sources._cache.DiskCache, shared with the other backends.
_DISK_CACHE_BASENAME = "bws_cache.json"
_ENCRYPTED_CACHE_BASENAME = "bws_cache.enc.json"
_ENCRYPTED_CACHE_VERSION = 1
_ENCRYPTED_CACHE_INFO = b"hermes-bws-encrypted-cache-v1"
def _cache_key_str(cache_key: _CacheKey) -> str:
@@ -122,13 +113,6 @@ def _disk_cache_path(home_path: Optional[Path] = None) -> Path:
return _DISK_CACHE.path(home_path)
def _encrypted_disk_cache_path(home_path: Optional[Path] = None) -> Path:
"""Return the encrypted disk cache path under hermes_home/cache/."""
from agent.secret_sources._cache import resolve_cache_home
return resolve_cache_home(home_path) / "cache" / _ENCRYPTED_CACHE_BASENAME
# ---------------------------------------------------------------------------
# Binary discovery + lazy install
# ---------------------------------------------------------------------------
@@ -364,134 +348,6 @@ def _token_fingerprint(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
def _b64e(raw: bytes) -> str:
return base64.b64encode(raw).decode("ascii")
def _b64d(text: str) -> bytes:
return base64.b64decode(text.encode("ascii"), validate=True)
def _derive_encrypted_cache_key(access_token: str, salt: bytes) -> bytes:
"""Derive the local cache encryption key from the bootstrap BWS token."""
return HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
info=_ENCRYPTED_CACHE_INFO,
).derive(access_token.encode("utf-8"))
def _write_encrypted_disk_cache(
*,
cache_key: _CacheKey,
access_token: str,
entry: _CachedFetch,
home_path: Optional[Path] = None,
) -> None:
"""Persist an encrypted last-good cache entry atomically.
Best-effort by design: cache write failure must never block a fresh BWS
fetch. The raw BWS access token is not stored; it only derives the AES key.
"""
path = _encrypted_disk_cache_path(home_path)
try:
cache_dir = path.parent
cache_dir.mkdir(parents=True, exist_ok=True)
try:
os.chmod(cache_dir, 0o700)
except OSError:
pass
salt = os.urandom(16)
nonce = os.urandom(12)
serialized_key = _cache_key_str(cache_key)
key = _derive_encrypted_cache_key(access_token, salt)
plaintext = json.dumps(
{"secrets": entry.secrets, "fetched_at": entry.fetched_at},
separators=(",", ":"),
).encode("utf-8")
ciphertext = AESGCM(key).encrypt(
nonce, plaintext, serialized_key.encode("utf-8")
)
payload = {
"version": _ENCRYPTED_CACHE_VERSION,
"key": serialized_key,
"salt": _b64e(salt),
"nonce": _b64e(nonce),
"ciphertext": _b64e(ciphertext),
}
fd, tmp = tempfile.mkstemp(
prefix=".bws_cache_enc_", suffix=".tmp", dir=str(cache_dir)
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f)
os.chmod(tmp, 0o600)
os.replace(tmp, path)
# A successful encrypted write completes migration; remove the
# legacy plaintext cache so stale secrets cannot remain on disk.
try:
_disk_cache_path(home_path).unlink()
except FileNotFoundError:
pass
except OSError:
pass
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
except Exception: # noqa: BLE001 — best-effort cache only
return
def _read_encrypted_disk_cache(
*,
cache_key: _CacheKey,
access_token: str,
max_age_seconds: float,
home_path: Optional[Path] = None,
) -> Optional[_CachedFetch]:
"""Return a decrypted encrypted-cache entry if it matches and is in-window."""
if max_age_seconds <= 0:
return None
path = _encrypted_disk_cache_path(home_path)
try:
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
return None
serialized_key = _cache_key_str(cache_key)
if payload.get("version") != _ENCRYPTED_CACHE_VERSION:
return None
if payload.get("key") != serialized_key:
return None
salt = _b64d(str(payload.get("salt", "")))
nonce = _b64d(str(payload.get("nonce", "")))
ciphertext = _b64d(str(payload.get("ciphertext", "")))
key = _derive_encrypted_cache_key(access_token, salt)
raw = AESGCM(key).decrypt(
nonce, ciphertext, serialized_key.encode("utf-8")
)
inner = json.loads(raw.decode("utf-8"))
if not isinstance(inner, dict):
return None
secrets = inner.get("secrets")
inner_fetched_at = inner.get("fetched_at")
if not isinstance(secrets, dict) or not isinstance(inner_fetched_at, (int, float)):
return None
entry_age = time.time() - float(inner_fetched_at)
if entry_age < 0 or entry_age > max_age_seconds:
return None
typed = {
k: v for k, v in secrets.items()
if isinstance(k, str) and isinstance(v, str)
}
return _CachedFetch(secrets=typed, fetched_at=float(inner_fetched_at))
except Exception: # noqa: BLE001 — cache miss on parse/decrypt/I/O errors
return None
def fetch_bitwarden_secrets(
*,
access_token: str,
@@ -501,8 +357,6 @@ def fetch_bitwarden_secrets(
use_cache: bool = True,
server_url: str = "",
home_path: Optional[Path] = None,
encrypted_cache_enabled: bool = False,
encrypted_cache_max_stale_seconds: float = 0,
) -> Tuple[Dict[str, str], List[str]]:
"""Pull the secrets for ``project_id`` from Bitwarden Secrets Manager.
@@ -514,13 +368,12 @@ def fetch_bitwarden_secrets(
(``https://vault.bitwarden.com``, US Cloud). This is plumbed into
the subprocess as ``BWS_SERVER_URL``.
``cache_ttl_seconds`` controls the normal fresh cache. When
``encrypted_cache_enabled`` is true, fresh cache entries are written as
AES-GCM encrypted JSON instead of plaintext, and a last-good encrypted
entry may be used after NETWORK/TIMEOUT failures for up to
``encrypted_cache_max_stale_seconds``. This stale fallback is separate
from the fresh-cache TTL so operators can set ``cache_ttl_seconds: 0``
while still keeping an encrypted break-glass cache for offline startup.
Caching is a two-layer LRU: an in-process dict (for hot-reload paths
inside one process) and a disk-persisted JSON file under
``<hermes_home>/cache/bws_cache.json`` (for back-to-back CLI invocations).
Both share the same TTL. Pass ``home_path`` so disk cache lookups find
the right directory in tests / non-standard installs; otherwise we fall
back to ``$HERMES_HOME`` / ``~/.hermes``.
Raises :class:`RuntimeError` for fatal conditions (missing binary,
auth failure, unparseable output). Callers in the env_loader path
@@ -533,20 +386,12 @@ def fetch_bitwarden_secrets(
raise RuntimeError("Bitwarden project_id is empty")
cache_key = (_token_fingerprint(access_token), project_id, server_url or "")
if use_cache and cache_ttl_seconds > 0:
if use_cache:
cached = _CACHE.get(cache_key)
if cached and cached.is_fresh(cache_ttl_seconds):
return cached.secrets, []
# L2: disk cache. ~5ms on cache hit vs ~380ms for `bws secret list`.
if encrypted_cache_enabled:
disk_cached = _read_encrypted_disk_cache(
cache_key=cache_key,
access_token=access_token,
max_age_seconds=cache_ttl_seconds,
home_path=home_path,
)
else:
disk_cached = _DISK_CACHE.read(cache_key, cache_ttl_seconds, home_path)
disk_cached = _DISK_CACHE.read(cache_key, cache_ttl_seconds, home_path)
if disk_cached is not None:
# Promote into in-process cache so subsequent fetches in the
# same process skip the disk read too.
@@ -562,107 +407,14 @@ def fetch_bitwarden_secrets(
"`hermes secrets bitwarden setup`."
)
try:
secrets, warnings = _run_bws_list(bws, access_token, project_id, server_url)
except RuntimeError as exc:
# Live fetch failed. Fall back to a stale disk cache ONLY for
# transport-level failures (network down, DNS error, transient BWS
# outage / timeout) — never for AUTH_FAILED or a malformed-output
# INTERNAL error, where serving old secrets would mask a real
# config/credential problem the caller needs to see. Without this
# fallback a fleet of bots sharing one BWS project all stop working
# on a single network blip.
#
# Two fallback tiers share the transport-only gate:
# * encrypted cache (opt-in) — AES-GCM payload keyed off the
# bootstrap token, with its own max_stale_seconds window. When
# enabled it is the ONLY fallback consulted: the whole point is
# that the at-rest payload is never plaintext, so we don't
# quietly serve the plaintext file alongside it.
# * plaintext disk cache (default) — the ordinary DiskCache file.
# `cache_ttl_seconds <= 0` means the caller opted out of caching
# entirely (DiskCache.read/write both short-circuit on it) —
# honor that on the fallback path too. `ttl_seconds=inf` on the
# read bypasses freshness (we explicitly want a stale hit); the
# caller's real TTL gates whether we even attempt the read.
kind = _classify_bws_error(str(exc))
if use_cache and kind in (ErrorKind.NETWORK, ErrorKind.TIMEOUT):
if encrypted_cache_enabled:
stale = _read_encrypted_disk_cache(
cache_key=cache_key,
access_token=access_token,
max_age_seconds=encrypted_cache_max_stale_seconds,
home_path=home_path,
)
if stale is not None:
age = max(0.0, time.time() - stale.fetched_at)
_CACHE[cache_key] = stale
return stale.secrets, [
f"bws live fetch failed ({exc}); falling back to "
f"stale ENCRYPTED disk cache ({int(age)}s old)"
]
elif cache_ttl_seconds > 0:
stale = _DISK_CACHE.read(cache_key, float("inf"), home_path)
if stale is not None:
age = max(0.0, time.time() - stale.fetched_at)
_CACHE[cache_key] = stale
return stale.secrets, [
f"bws live fetch failed ({exc}); "
f"falling back to stale disk cache ({int(age)}s old)"
]
raise
secrets, warnings = _run_bws_list(bws, access_token, project_id, server_url)
entry = _CachedFetch(secrets=secrets, fetched_at=time.time())
_CACHE[cache_key] = entry
if use_cache:
if cache_ttl_seconds > 0:
_CACHE[cache_key] = entry
if encrypted_cache_enabled:
# Encryption is the storage policy; max_stale_seconds only controls
# whether an outage may consume the last-good entry. Never fall
# back to the plaintext cache just because stale fallback is off.
_write_encrypted_disk_cache(
cache_key=cache_key,
access_token=access_token,
entry=entry,
home_path=home_path,
)
elif cache_ttl_seconds > 0:
_DISK_CACHE.write(cache_key, entry, cache_ttl_seconds, home_path)
_DISK_CACHE.write(cache_key, entry, cache_ttl_seconds, home_path)
return secrets, warnings
def _summarize_bws_stderr(raw: str) -> str:
"""Reduce a bws (Rust color-eyre) error dump to its cause line(s).
bws failures look like::
Error:
0: Received error message from server: [400 Bad Request] {"error":"invalid_client"}
Location:
crates/bws/src/main.rs:108
...
Everything from ``Location:`` on is diagnostic noise for a Hermes
user. Keep the numbered cause lines (joined), drop the rest, and
fall back to the stripped raw text when the shape is unrecognized.
"""
text = raw.replace("\x1b", "").strip()
if not text:
return text
causes: List[str] = []
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith(("Location:", "Backtrace omitted", "Run with ")):
break
if stripped in ("", "Error:"):
continue
# Cause lines are numbered "0: ...", "1: ..." — strip the index.
stripped = re.sub(r"^\d+:\s*", "", stripped)
if stripped:
causes.append(stripped)
return "; ".join(causes) if causes else text
def _run_bws_list(
bws: Path, access_token: str, project_id: str, server_url: str = ""
) -> Tuple[Dict[str, str], List[str]]:
@@ -696,11 +448,9 @@ def _run_bws_list(
raise RuntimeError(f"failed to invoke bws: {exc}") from exc
if proc.returncode != 0:
# bws writes auth/network errors to stderr as a Rust error-report
# dump (color-eyre): an "Error:" header, indented cause lines, then
# "Location:" / "Backtrace omitted" noise. Strip ANSI and boil it
# down to the meaningful cause line(s) before surfacing.
err = _summarize_bws_stderr(proc.stderr or proc.stdout or "")
# bws writes auth/network errors to stderr in plain English.
# Strip ANSI just in case and surface the first 200 chars.
err = (proc.stderr or proc.stdout or "").strip().replace("\x1b", "")
raise RuntimeError(
f"bws exited {proc.returncode}: {err[:200]}"
)
@@ -752,8 +502,6 @@ def apply_bitwarden_secrets(
auto_install: bool = True,
server_url: str = "",
home_path: Optional[Path] = None,
encrypted_cache_enabled: bool = False,
encrypted_cache_max_stale_seconds: float = 0,
) -> FetchResult:
"""Pull secrets from BSM and set them on ``os.environ``.
@@ -805,8 +553,6 @@ def apply_bitwarden_secrets(
cache_ttl_seconds=cache_ttl_seconds,
server_url=server_url,
home_path=home_path,
encrypted_cache_enabled=encrypted_cache_enabled,
encrypted_cache_max_stale_seconds=encrypted_cache_max_stale_seconds,
)
except RuntimeError as exc:
result.error = str(exc)
@@ -876,16 +622,9 @@ class BitwardenSource(SecretSource):
},
"project_id": {"description": "BSM project UUID", "default": ""},
"cache_ttl_seconds": {
"description": "Fresh disk+memory cache TTL; 0 disables fresh-cache reuse",
"description": "Disk+memory cache TTL; 0 disables",
"default": 300,
},
"encrypted_cache": {
"description": "Encrypted last-good cache for network/timeout fallback",
"default": {
"enabled": False,
"max_stale_seconds": 0,
},
},
"override_existing": {
"description": "BSM values overwrite .env/shell values",
"default": True,
@@ -939,14 +678,6 @@ class BitwardenSource(SecretSource):
except (TypeError, ValueError):
ttl = 300.0
encrypted_cfg = cfg.get("encrypted_cache")
encrypted_cfg = encrypted_cfg if isinstance(encrypted_cfg, dict) else {}
encrypted_enabled = bool(encrypted_cfg.get("enabled", False))
try:
encrypted_max_stale = float(encrypted_cfg.get("max_stale_seconds", 0))
except (TypeError, ValueError):
encrypted_max_stale = 0.0
try:
secrets, warnings = fetch_bitwarden_secrets(
access_token=access_token,
@@ -955,36 +686,16 @@ class BitwardenSource(SecretSource):
cache_ttl_seconds=ttl,
server_url=str(cfg.get("server_url", "") or "").strip(),
home_path=home_path,
encrypted_cache_enabled=encrypted_enabled,
encrypted_cache_max_stale_seconds=encrypted_max_stale,
)
except RuntimeError as exc:
result.error = str(exc)
result.error_kind = _classify_bws_error(str(exc))
if result.error_kind == ErrorKind.AUTH_FAILED:
# Translate the raw OAuth reject into what it actually means
# for the user before the mechanics.
result.error = (
"Bitwarden rejected the machine-account access token "
f"({access_token_env}) — it was likely revoked, expired, "
f"or belongs to another region. ({result.error})"
)
return result
result.secrets = secrets
result.warnings.extend(warnings)
return result
def remediation(self, kind, cfg: dict) -> str:
if kind in (ErrorKind.AUTH_FAILED, ErrorKind.AUTH_EXPIRED):
return (
"Run `hermes secrets bitwarden token` to paste a fresh access "
"token (create one in the Bitwarden web app: Secrets Manager → "
"Machine accounts → Access tokens). Wrong region? Re-run "
"`hermes secrets bitwarden setup` and pick EU/self-hosted."
)
return super().remediation(kind, cfg)
def _classify_bws_error(message: str) -> ErrorKind:
"""Best-effort mapping of bws failure text onto the shared taxonomy."""
@@ -994,13 +705,7 @@ def _classify_bws_error(message: str) -> ErrorKind:
if "binary not available" in lowered or "failed to invoke" in lowered:
return ErrorKind.BINARY_MISSING
if any(tok in lowered for tok in ("unauthorized", "invalid token",
"access token", "401", "403",
# The BSM identity endpoint rejects a
# revoked/expired/deleted machine-account
# token with an OAuth-style
# `[400 Bad Request] {"error":"invalid_client"}`.
"invalid_client", "invalid_grant",
"400 bad request")):
"access token", "401", "403")):
return ErrorKind.AUTH_FAILED
if any(tok in lowered for tok in ("network", "connection", "resolve",
"download", "dns")):
@@ -1013,22 +718,6 @@ def _classify_bws_error(message: str) -> ErrorKind:
# ---------------------------------------------------------------------------
def clear_caches(home_path: Optional[Path] = None) -> None:
"""Drop in-process AND disk caches (plaintext and encrypted).
Used after a token rotation (`hermes secrets bitwarden token`) so the
next startup fetches fresh with the new credential instead of serving
a pull cached under the old token's fingerprint. The encrypted cache
is keyed off the old token too, so it must go as well.
"""
_CACHE.clear()
_DISK_CACHE.clear(home_path)
try:
_encrypted_disk_cache_path(home_path).unlink()
except (FileNotFoundError, OSError):
pass
def _reset_cache_for_tests(home_path: Optional[Path] = None) -> None:
"""Clear in-process AND disk caches.
@@ -1036,4 +725,5 @@ def _reset_cache_for_tests(home_path: Optional[Path] = None) -> None:
Without it we fall back to the same default resolution as the cache
writer itself.
"""
clear_caches(home_path)
_CACHE.clear()
_DISK_CACHE.clear(home_path)
-488
View File
@@ -1,488 +0,0 @@
"""``command`` secret source — resolve secrets via a user-configured helper.
Ports the security semantics of the desktop app's TypeScript
``CommandSecretsProvider`` (hermes-desktop ``src/main/secrets/commandProvider.ts``)
to the Python agent. The helper command (e.g. ``keepassxc-cli``,
``secret-tool``, or a script that cats a tmpfs env file) comes from
``secrets.command`` in ``config.yaml`` — NEVER from ``.env``, which holds
only secret values.
Security model (mirrors the TS provider line-for-line where it matters):
* The command string is the USER'S OWN configuration (same trust level as
the ``.env`` file they control), so it is run via ``/bin/sh -c <command>``.
* The requested key is passed to the child ONLY via the ``HERMES_SECRET_KEY``
environment variable — it is NEVER interpolated into the shell string, so
a hostile key name (e.g. ``"; rm -rf ~``) is inert data, not code.
* Hard timeout (default 3s) + output cap (default 1 MiB); any failure
(non-zero exit, timeout, spawn failure, oversized output) degrades to
"no value" rather than raising.
* Failures log ONLY structured fields (exit code / signal / errno) to
stderr — never the command string, the helper's stderr, or any secret
value. The helper's stderr is captured via a pipe and DISCARDED so its
diagnostics (which can carry secret material) never reach our stderr.
* The startup/apply path runs the helper exactly ONCE (with an empty
``HERMES_SECRET_KEY``) — it is never called per-key in a loop, so a
helper that blocks (e.g. on a vault unlock prompt) can't be spawned
dozens of times.
* PLATFORM: the provider is POSIX-only (needs ``/bin/sh``). On Windows it
degrades to an empty result with a warning; Windows users stay on the
default ``env`` provider.
"""
from __future__ import annotations
import os
import platform
import re
import signal as _signal
import subprocess
import sys
from pathlib import Path
from typing import Dict, Optional
# Reuse the exact result shape the bitwarden source returns so
# hermes_cli.env_loader can consume both providers identically.
from agent.secret_sources.base import ErrorKind, SecretSource
from agent.secret_sources.bitwarden import FetchResult
__all__ = [
"FetchResult",
"apply_command_secrets",
"get_command_secret",
"list_command_secrets",
"parse_secret_output",
"unquote_dotenv_value",
]
# Hard cap so a hung helper can never wedge startup. Kept deliberately
# TIGHT (3s) — a configured helper MUST be fast and NON-INTERACTIVE
# (e.g. `keepassxc-cli` against an already-unlocked DB, `secret-tool
# lookup`, or `cat`-ing a tmpfs env file), NOT something that prompts
# for a touch/PIN.
_COMMAND_TIMEOUT_SECONDS = 3.0
# Defensive cap on helper output (1 MiB) — a misbehaving command can't OOM us.
_MAX_OUTPUT_BYTES = 1024 * 1024
# A line is treated as a KEY=VALUE pair only when it matches an env-key
# shape before the '='. Anchored; `.` does not cross newlines, so a
# multi-line blob never matches as a single "env-shaped" value.
_ENV_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
def _is_windows() -> bool:
return os.name == "nt" or platform.system() == "Windows"
def unquote_dotenv_value(raw: str) -> str:
"""Strip a single layer of matching surrounding quotes from a dotenv value.
Requires length >= 2 so a lone quote (``"``) is left intact rather than
collapsing to empty, and ``""``/``''`` correctly yield an empty string.
Shared by the single-key parser and the list path so both unquote
identically.
"""
t = raw.strip()
if len(t) >= 2 and (
(t.startswith('"') and t.endswith('"'))
or (t.startswith("'") and t.endswith("'"))
):
return t[1:-1]
return t
def parse_secret_output(stdout: str, wanted_key: str) -> Optional[str]:
"""Parse a secret-fetch helper's stdout. Supports BOTH shapes:
* a bare value (single secret): the whole trimmed stdout is the value.
* a dotenv blob (KEY=VALUE lines): parse them and return the entry for
``wanted_key``.
Mirrors the TS ``parseSecretOutput`` exactly, including the cross-key
misroute guard and the base64-padding disambiguation.
"""
text = stdout.replace("\r\n", "\n")
lines = text.split("\n")
# 1. Exact dotenv match wins: scan for a `wanted_key=...` line. This
# is deterministic and never returns another key's value.
dotenv_lines = [
line
for line in (raw.strip() for raw in lines)
if line and not line.startswith("#") and _ENV_LINE.match(line)
]
for line in dotenv_lines:
m = _ENV_LINE.match(line)
assert m is not None # filtered above
if m.group(1) == wanted_key:
value = unquote_dotenv_value(m.group(2))
# Whitespace-only (e.g. a quoted `K=" "` placeholder) is "no
# value": it would otherwise flow into an Authorization header
# → guaranteed 401.
return value if value.strip() != "" else None
# 2. The output is a multi-key dotenv dump that does NOT contain the
# wanted key → None, rather than mis-returning an unrelated line as
# a bare value. Only >=2 env-shaped lines count as a dump: a SINGLE
# non-matching env-shaped line falls through to the bare-value
# branch, because a bare secret can itself match the KEY=VALUE shape
# (e.g. base64 with '=' padding, "dGVzdA==") and must not be
# misclassified as a dump.
if len(dotenv_lines) > 1:
return None
# 3. Otherwise treat the whole output as a single bare value (a per-key
# helper that printed just the secret). Trim first so whitespace-only
# output (a ' '/'\t' placeholder entry) resolves to None, never a "key".
value = text.strip()
if value == "":
return None
# SECURITY (S2): a single env-shaped line for a DIFFERENT key must not
# be returned as the wanted secret. A sloppy helper (e.g. `head -1
# env-file`, or a grep that matched the wrong line) emitting
# `OTHER_KEY=realvalue` would otherwise flow — key name, '=' and the
# OTHER key's value — into an Authorization header sent to the WANTED
# key's endpoint: cross-provider credential leakage, not just a 401.
# Disambiguation from a bare base64 secret: base64 padding only ever
# produces an env-shaped line whose "value" part is empty or all '='
# (`dGVzdA==` → key `dGVzdA`, value `=`), so a non-trivial value part
# after a non-matching key means a misrouted dotenv entry → None.
env_shaped = _ENV_LINE.match(value)
if (
env_shaped
and env_shaped.group(1) != wanted_key
and re.fullmatch(r"=*", env_shaped.group(2).strip()) is None
):
return None
return value
def _run_helper(
command: str,
secret_key: str,
timeout_seconds: float,
max_output_bytes: int,
) -> Optional[str]:
"""Run the helper via ``/bin/sh -c`` and return its stdout, or None.
The key is passed as DATA via ``HERMES_SECRET_KEY`` — never interpolated
into the command string. Both stdout and stderr are captured via pipes
(never inherited); stderr is discarded. Any failure logs structured
fields only and returns None — never raises.
"""
if _is_windows():
print(
"[secrets:command] the 'command' provider is POSIX-only "
"(needs /bin/sh); resolving no value on Windows",
file=sys.stderr,
)
return None
env = os.environ.copy()
env["HERMES_SECRET_KEY"] = secret_key
try:
proc = subprocess.Popen( # noqa: S602 — command is the user's own config
["/bin/sh", "-c", command],
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE, # captured and DISCARDED — never inherited
start_new_session=True, # so the hard timeout can kill the whole group
)
except OSError as exc:
print(
f"[secrets:command] helper failed to spawn; resolving no value: "
f"errno={exc.errno}",
file=sys.stderr,
)
return None
try:
stdout_bytes, _stderr_discarded = proc.communicate(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
# Hard timeout: kill the whole process group (a helper script may
# have forked children that would otherwise keep the pipe open).
# POSIX-only by construction: _run_helper early-returns on Windows
# before ever spawning, so this line can't execute there.
try:
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL) # windows-footgun: ok
except (ProcessLookupError, PermissionError, OSError):
proc.kill()
try:
proc.communicate(timeout=1.0)
except (subprocess.TimeoutExpired, ValueError, OSError):
pass
print(
f"[secrets:command] helper timed out after {timeout_seconds:g}s; "
f"resolving no value",
file=sys.stderr,
)
return None
if proc.returncode != 0:
# Structured fields ONLY — never the command string or the helper's
# stderr (either can carry secret material).
if proc.returncode < 0:
try:
sig = _signal.Signals(-proc.returncode).name
except ValueError:
sig = str(-proc.returncode)
code, signame = "?", sig
else:
code, signame = str(proc.returncode), "none"
print(
f"[secrets:command] helper failed; resolving no value: "
f"code={code} signal={signame}",
file=sys.stderr,
)
return None
if len(stdout_bytes) > max_output_bytes:
print(
f"[secrets:command] helper output exceeded the "
f"{max_output_bytes}-byte cap; resolving no value",
file=sys.stderr,
)
return None
return stdout_bytes.decode("utf-8", errors="replace")
def _parse_dotenv_map(stdout: str) -> Dict[str, str]:
"""Parse a KEY=VALUE blob into a map (the list/enumerate path).
Mirrors the TS ``list()``: only env-shaped lines contribute; comments
and non-matching lines are skipped. A bare-value helper yields ``{}``
— per-key resolution via :func:`get_command_secret` still works.
"""
out: Dict[str, str] = {}
for raw in stdout.replace("\r\n", "\n").split("\n"):
line = raw.strip()
if not line or line.startswith("#"):
continue
m = _ENV_LINE.match(line)
if not m:
continue
out[m.group(1)] = unquote_dotenv_value(m.group(2))
return out
def get_command_secret(
*,
command: str,
key: str,
timeout_seconds: float = _COMMAND_TIMEOUT_SECONDS,
max_output_bytes: int = _MAX_OUTPUT_BYTES,
) -> Optional[str]:
"""Resolve a single secret by running the helper with the key in
``HERMES_SECRET_KEY``. Returns None on any failure — never raises."""
command = (command or "").strip()
if not command:
return None
stdout = _run_helper(command, key, timeout_seconds, max_output_bytes)
if stdout is None:
return None
return parse_secret_output(stdout, key)
def list_command_secrets(
*,
command: str,
timeout_seconds: float = _COMMAND_TIMEOUT_SECONDS,
max_output_bytes: int = _MAX_OUTPUT_BYTES,
) -> Dict[str, str]:
"""Enumerate secrets by running the helper ONCE with an empty key.
Returns the dotenv map ONLY when the helper emits a KEY=VALUE blob;
a bare-value helper returns ``{}``. Never raises.
"""
command = (command or "").strip()
if not command:
return {}
stdout = _run_helper(command, "", timeout_seconds, max_output_bytes)
if stdout is None:
return {}
return _parse_dotenv_map(stdout)
# ---------------------------------------------------------------------------
# Public entry point — called from hermes_cli.env_loader
# ---------------------------------------------------------------------------
def apply_command_secrets(
*,
command: str,
override_existing: bool = False,
timeout_seconds: float = _COMMAND_TIMEOUT_SECONDS,
max_output_bytes: int = _MAX_OUTPUT_BYTES,
home_path: Optional[Path] = None,
) -> FetchResult:
"""Run the helper once at startup and set its KEY=VALUE output on
``os.environ``.
LEGACY shim retained for API symmetry with ``apply_bitwarden_secrets``;
the startup path goes through :class:`CommandSource` + the registry
orchestrator instead (which owns precedence and the environ writes).
"""
result = FetchResult()
command = (command or "").strip()
if not command:
result.error = (
"secrets.command.enabled is true but secrets.command.command is "
"empty. Set the helper command in config.yaml."
)
return result
if _is_windows():
result.warnings.append(
"the 'command' secret source is POSIX-only (needs /bin/sh); "
"skipping on Windows"
)
return result
# The list/enumerate path: run the helper exactly ONCE with an empty
# HERMES_SECRET_KEY and parse its stdout as a dotenv blob.
stdout = _run_helper(command, "", timeout_seconds, max_output_bytes)
if stdout is None:
# _run_helper already logged structured fields to stderr.
result.warnings.append(
"helper command failed at startup; no secrets applied "
"(process env / .env values remain in effect)"
)
return result
secrets = _parse_dotenv_map(stdout)
result.secrets = secrets
if not secrets:
result.warnings.append(
"helper output was not a KEY=VALUE map; nothing applied at "
"startup (a bare-value helper still resolves single keys on demand)"
)
return result
for key, value in secrets.items():
if value.strip() == "":
# Whitespace-only placeholder entries are "no value" — applying
# them would flow into an Authorization header → guaranteed 401.
result.skipped.append(key)
continue
if not override_existing and os.environ.get(key):
# Process env / .env win — same precedence as bitwarden.
result.skipped.append(key)
continue
os.environ[key] = value
result.applied.append(key)
return result
# ---------------------------------------------------------------------------
# SecretSource adapter — the registry-facing wrapper around this module.
# ---------------------------------------------------------------------------
class CommandSource(SecretSource):
"""User-configured helper command as a registered secret source.
Composes with the other sources (Bitwarden, 1Password, plugins) through
the ``apply_all()`` orchestrator — enable any combination simultaneously;
there is deliberately NO single-provider selector. ``fetch()`` only
fetches: precedence, ``override_existing`` semantics, conflict warnings,
and the ``os.environ`` writes are the orchestrator's job.
Bulk shape: the helper enumerates a KEY=VALUE blob in one run. Config::
secrets:
command:
enabled: true
command: "cat /run/user/1000/hermes-secrets.env"
# or per-vault CLIs: keepassxc-cli / secret-tool / pass / gpg —
# anything fast and NON-interactive.
"""
name = "command"
label = "Command helper"
shape = "bulk"
def config_schema(self) -> dict:
return {
"enabled": {"description": "Master switch", "default": False},
"command": {
"description": "Helper run via /bin/sh -c; must print a "
"KEY=VALUE blob on stdout",
"default": "",
},
"helper_timeout_seconds": {
"description": "Hard timeout for one helper run",
"default": _COMMAND_TIMEOUT_SECONDS,
},
"override_existing": {
"description": "Helper values overwrite .env/shell values",
"default": False,
},
}
def fetch(self, cfg: dict, home_path: Path) -> FetchResult:
cfg = cfg if isinstance(cfg, dict) else {}
result = FetchResult()
command = str(cfg.get("command") or "").strip()
if not command:
result.error = (
"secrets.command.enabled is true but secrets.command.command "
"is empty. Set the helper command in config.yaml."
)
result.error_kind = ErrorKind.NOT_CONFIGURED
return result
if _is_windows():
result.error = (
"the 'command' secret source is POSIX-only (needs /bin/sh); "
"skipping on Windows"
)
result.error_kind = ErrorKind.NOT_CONFIGURED
return result
try:
timeout = float(cfg.get("helper_timeout_seconds",
_COMMAND_TIMEOUT_SECONDS))
except (TypeError, ValueError):
timeout = _COMMAND_TIMEOUT_SECONDS
stdout = _run_helper(command, "", timeout, _MAX_OUTPUT_BYTES)
if stdout is None:
# _run_helper already logged structured fields to stderr.
result.error = (
"helper command failed (see structured fields above); "
"no secrets applied"
)
result.error_kind = ErrorKind.INTERNAL
return result
secrets = _parse_dotenv_map(stdout)
if not secrets:
result.warnings.append(
"helper output was not a KEY=VALUE map; nothing to apply"
)
return result
result.secrets = secrets
return result
def remediation(self, kind, cfg: dict) -> str:
if kind == ErrorKind.NOT_CONFIGURED:
return (
"Set secrets.command.command in config.yaml to a fast, "
"non-interactive helper that prints KEY=VALUE lines."
)
if kind == ErrorKind.INTERNAL:
return (
"Run the helper manually in a shell to see its real error — "
"Hermes discards helper stderr so diagnostics can't leak "
"secret material."
)
return super().remediation(kind, cfg)
+7 -41
View File
@@ -98,9 +98,6 @@ _OP_ENV_ALLOWLIST = (
"OP_ACCOUNT",
"OP_CONNECT_HOST",
"OP_CONNECT_TOKEN",
# Lets a user skip op's desktop-app integration probe (which can hang with
# no timeout on a wedged desktop container) and go straight to token auth.
"OP_LOAD_DESKTOP_APP_SETTINGS",
)
@@ -175,19 +172,16 @@ def _validate_references(
def _auth_fingerprint(token_env: str) -> str:
"""SHA-256 prefix over the auth material `op` would use.
Folds in the service-account token, ``OP_ACCOUNT``, the 1Password Connect
``OP_CONNECT_HOST``/``OP_CONNECT_TOKEN``, and *all* ``OP_SESSION_*`` vars
(the names `op` actually exports for interactive sessions —
``OP_SESSION_<account_shorthand>``). Signing out and into a different
identity therefore changes the cache key, so a value cached under a
previous identity is never served under a new one. Never logged or
Folds in the service-account token, ``OP_ACCOUNT``, and *all*
``OP_SESSION_*`` vars (the names `op` actually exports for interactive
sessions — ``OP_SESSION_<account_shorthand>``). Signing out and into a
different identity therefore changes the cache key, so a value cached under
a previous identity is never served under a new one. Never logged or
displayed; the raw token never leaves this hash.
"""
parts: List[str] = [
f"token={os.environ.get(token_env, '')}",
f"account={os.environ.get('OP_ACCOUNT', '')}",
f"connect_host={os.environ.get('OP_CONNECT_HOST', '')}",
f"connect_token={os.environ.get('OP_CONNECT_TOKEN', '')}",
]
for key in sorted(os.environ):
if key.startswith("OP_SESSION_"):
@@ -613,24 +607,6 @@ class OnePasswordSource(SecretSource):
result.warnings.extend(fetch_warnings)
return result
def remediation(self, kind, cfg: dict) -> str:
if kind in (ErrorKind.AUTH_FAILED, ErrorKind.AUTH_EXPIRED):
token_env = _DEFAULT_TOKEN_ENV
if isinstance(cfg, dict):
token_env = str(cfg.get("service_account_token_env") or token_env)
return (
"Run `hermes secrets onepassword token` to paste a fresh "
f"service-account token ({token_env}), or `op signin` for an "
"interactive session."
)
if kind == ErrorKind.BINARY_MISSING:
return (
"Install the 1Password CLI "
"(https://developer.1password.com/docs/cli/get-started/) or "
"set secrets.onepassword.binary_path."
)
return super().remediation(kind, cfg)
def _classify_op_error(message: str) -> ErrorKind:
"""Best-effort mapping of op failure text onto the shared taxonomy."""
@@ -657,21 +633,11 @@ def _classify_op_error(message: str) -> ErrorKind:
# ---------------------------------------------------------------------------
def clear_caches(home_path: Optional[Path] = None) -> None:
"""Drop in-process AND disk caches.
Used after a token rotation (`hermes secrets onepassword token`) so
the next startup resolves fresh with the new credential instead of
serving values cached under the old token's fingerprint.
"""
_CACHE.clear()
_DISK_CACHE.clear(home_path)
def _reset_cache_for_tests(home_path: Optional[Path] = None) -> None:
"""Clear in-process AND disk caches.
Tests can pass ``home_path`` to scope the disk cleanup to a tmpdir.
Without it we fall back to the same default resolution as the writer.
"""
clear_caches(home_path)
_CACHE.clear()
_DISK_CACHE.clear(home_path)
+10 -100
View File
@@ -29,7 +29,6 @@ from __future__ import annotations
import concurrent.futures
import logging
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional
@@ -175,13 +174,6 @@ def _ensure_builtin_sources() -> None:
except Exception: # noqa: BLE001 — never block startup
logger.warning("Failed to register bundled 1Password secret source",
exc_info=True)
try:
from agent.secret_sources.command import CommandSource
register_source(CommandSource())
except Exception: # noqa: BLE001 — never block startup
logger.warning("Failed to register bundled command secret source",
exc_info=True)
def _reset_registry_for_tests() -> None:
@@ -283,43 +275,6 @@ def _ordered_enabled_sources(secrets_cfg: dict) -> List[SecretSource]:
return enabled
def _active_profile_name(home_path: Optional[Path]) -> str:
"""Best-effort active profile name for profile-scoped secret aliases.
A named profile's HERMES_HOME is ``~/.hermes/profiles/<name>``; the
default profile (``~/.hermes``) returns "".
"""
if home_path is not None:
resolved = Path(home_path)
if resolved.parent.name == "profiles" and resolved.name:
return resolved.name
for env_name in ("HERMES_PROFILE_NAME", "HERMES_PROFILE"):
value = os.environ.get(env_name, "").strip()
if value and value != "default":
return value
return ""
# Only credential-shaped names get auto-aliased — a random profile-suffixed
# var should not silently hydrate an unsuffixed name.
_ALIAS_SUFFIXES = ("_API_KEY", "_TOKEN", "_SECRET", "_KEY", "_PASSWORD")
def _profile_alias_target(var: str, profile: str) -> Optional[str]:
"""Map ``FOO_<PROFILE>`` to ``FOO`` for the active profile when safe."""
if not profile:
return None
suffix = "_" + profile.replace("-", "_").upper()
if not var.endswith(suffix):
return None
alias = var[: -len(suffix)]
if not alias or not is_valid_env_name(alias):
return None
if not any(alias.endswith(s) for s in _ALIAS_SUFFIXES):
return None
return alias
def apply_all(secrets_cfg: dict, home_path: Path,
environ: Optional[Dict[str, str]] = None) -> ApplyReport:
"""Fetch from every enabled source and apply the merged result to env.
@@ -328,24 +283,14 @@ def apply_all(secrets_cfg: dict, home_path: Path,
Precedence per env var (most-specific intent wins):
1. ``secrets.preserve_existing`` names — a pre-existing env value always
wins for these, even against a source with ``override_existing: true``
(escape hatch for profile-local platform secrets, #58073).
2. Pre-existing env (.env / shell) — unless the winning source has
1. Pre-existing env (.env / shell) — unless the winning source has
``override_existing: true``.
3. Mapped sources, in configured order.
4. Bulk sources, in configured order.
2. Mapped sources, in configured order.
3. Bulk sources, in configured order.
First claim wins. A later source that also carries the var gets a
``skipped_claimed`` entry and a conflict warning — never a silent
clobber, and ``override_existing`` never applies across sources.
Profile aliasing (#51447): when running under a named profile, an applied
var ``FOO_<PROFILE>`` (credential-shaped suffixes only) also hydrates the
canonical ``FOO`` so platform adapters and plugins that read fixed env
names see the profile's value. The alias obeys the same protected /
preserve / claimed / override guards and is disabled with
``secrets.profile_alias: false``.
"""
import os as _os
@@ -357,14 +302,6 @@ def apply_all(secrets_cfg: dict, home_path: Path,
if not enabled:
return report
preserve_raw = secrets_cfg.get("preserve_existing")
preserve: frozenset = frozenset(
n.strip() for n in preserve_raw if isinstance(n, str) and n.strip()
) if isinstance(preserve_raw, list) else frozenset()
alias_enabled = bool(secrets_cfg.get("profile_alias", True))
profile = _active_profile_name(home_path) if alias_enabled else ""
# Mapped sources outrank bulk sources regardless of list order:
# an explicit VAR→ref binding is stronger intent than a project dump.
ordered = ([s for s in enabled if s.shape == "mapped"]
@@ -384,15 +321,6 @@ def apply_all(secrets_cfg: dict, home_path: Path,
except Exception: # noqa: BLE001
pass
# Every var any source supplies directly — an alias never shadows a
# var that some source will (or tried to) claim by its real name.
supplied_directly: set = set()
for _, _, result in fetches:
if result.ok:
supplied_directly.update(
v for v in result.secrets if isinstance(v, str)
)
# Apply phase — sequential, first-wins, fully attributed.
claimed: Dict[str, str] = {} # var → source name that won it
for source, cfg, result in fetches:
@@ -408,14 +336,15 @@ def apply_all(secrets_cfg: dict, home_path: Path,
except Exception: # noqa: BLE001
override = False
def _try_apply(var: str, value: str, *, is_alias: bool = False) -> bool:
"""Apply one var through the shared guard chain. True = applied."""
for var, value in result.secrets.items():
if not isinstance(var, str) or not isinstance(value, str):
continue
if not is_valid_env_name(var):
sr.skipped_invalid.append(var)
return False
continue
if var in protected:
sr.skipped_protected.append(var)
return False
continue
if var in claimed:
sr.skipped_claimed.append(var)
report.conflicts.append(
@@ -423,14 +352,11 @@ def apply_all(secrets_cfg: dict, home_path: Path,
f"{source.name} also supplies it (first source wins — "
"remove one binding or reorder secrets.sources)"
)
return False
continue
existed = bool(env.get(var))
if existed and var in preserve:
sr.skipped_existing.append(var)
return False
if existed and not override:
sr.skipped_existing.append(var)
return False
continue
env[var] = value
claimed[var] = source.name
sr.applied.append(var)
@@ -440,21 +366,5 @@ def apply_all(secrets_cfg: dict, home_path: Path,
shape=source.shape,
overrode_env=existed,
)
return True
for var, value in result.secrets.items():
if not isinstance(var, str) or not isinstance(value, str):
continue
applied = _try_apply(var, value)
if not applied or not profile:
continue
alias = _profile_alias_target(var, profile)
if alias and alias not in supplied_directly and alias not in claimed:
if _try_apply(alias, value, is_alias=True):
result.warnings.append(
f"applied profile-scoped {var} as {alias} "
f"(active profile {profile!r})"
)
return report
+4 -90
View File
@@ -300,22 +300,14 @@ def build_subscription_state(*, timeout: float = 15.0) -> SubscriptionState:
return subscription_state_from_payload(payload, portal_url=portal_url)
def subscription_manage_url(
state: SubscriptionState, tier_id: Optional[str] = None
) -> Optional[str]:
"""Build ``{portal_origin}/manage-subscription?org_id=<id>[&plan=<tier_id>]``.
def subscription_manage_url(state: SubscriptionState) -> Optional[str]:
"""Build ``{portal_origin}/manage-subscription?org_id=<id>`` from a state.
Mirrors the TUI's ``buildManageUrl`` (``subscription.ts``): the deep-link
target is NAS's OWN ``/manage-subscription`` page (NOT the Stripe Billing
Portal — decided Jun 23), which routes upgrade→Checkout / downgrade→scheduled
internally. ``org_id`` pins the page to the right account in multi-org
situations. Returns ``None`` when no portal URL is resolvable.
``tier_id`` (the stable ``tiers[]`` id, never a name/slug) is appended as
``plan=`` so the portal preselects the picked plan — only for a NEW
subscription / upgrade the user chose. The portal validates it and simply
ignores an unknown tier, so the CLI appends unconditionally when a tier was
picked (parity with the TUI's ``?plan=``).
"""
from urllib.parse import urlencode, urlsplit, urlunsplit
@@ -327,91 +319,13 @@ def subscription_manage_url(
except Exception:
return None
if parts.scheme not in ("http", "https") or not parts.netloc:
if not parts.scheme or not parts.netloc:
return None
from urllib.parse import parse_qsl
# Preserve unrelated portal query params; org_id / plan are contract-owned
# (org_id before plan — insertion order is the emitted query order).
params = dict(parse_qsl(parts.query, keep_blank_values=True))
params.pop("org_id", None)
params.pop("plan", None)
if state.org_id:
params["org_id"] = state.org_id
if tier_id:
params["plan"] = tier_id
query = urlencode(params)
query = urlencode({"org_id": state.org_id}) if state.org_id else ""
return urlunsplit((parts.scheme, parts.netloc, "/manage-subscription", query, ""))
# =============================================================================
# Shared plan-catalog helpers (consumed by the CLI Free catalog + paid picker)
# =============================================================================
def _format_dollars_grouped(value: Optional[Decimal]) -> str:
"""``$1,000`` / ``$1,234.50`` — the whole-vs-fractional rule of
``billing_view.format_money`` but thousands-grouped, matching the TUI's
``toLocaleString('en-US')``.
The shared ``format_money`` is intentionally ungrouped (and asserted so across
other surfaces), so plan-catalog rows group locally to mirror the TUI.
"""
if value is None:
return "—"
if value == value.to_integral_value():
return f"${format(value.to_integral_value(), ',f')}"
return f"${format(value.quantize(Decimal('0.01')), ',f')}"
def selectable_tiers(state: SubscriptionState) -> list[SubscriptionTier]:
"""Enabled paid tiers other than the current plan, cheapest first.
One derivation shared by the CLI Free catalog and the paid change picker:
``is_enabled and not is_current and tier_order > 0`` (free / no-sub excluded —
dropping to free is a cancellation), sorted by ``tier_order``.
"""
return sorted(
(
t
for t in (state.tiers or ())
if t.is_enabled and not t.is_current and (t.tier_order or 0) > 0
),
key=lambda t: t.tier_order or 0,
)
def format_tier_row(tier: SubscriptionTier) -> str:
"""``name · $X/mo[ · $Y credits/mo]`` — the shared plan-catalog row.
Mirrors the TUI Free rows (``subscriptionOverlay.tsx``): thousands-grouped
money, and the ``$Y credits/mo`` suffix ONLY when monthly credits are present
and > 0 (a ``None`` / zero-credits tier hides it — never ``· — credits/mo`` or
``· $0 credits/mo``).
"""
row = f"{tier.name} · {_format_dollars_grouped(tier.dollars_per_month)}/mo"
mc = tier.monthly_credits
if mc is not None and mc > 0:
row += f" · {_format_dollars_grouped(mc)} credits/mo"
return row
def is_upgrade(state: SubscriptionState, tier_id: str) -> bool:
"""True when ``tier_id`` ranks above the current plan by ``tier_order``.
Prefers the active subscription's tier; falls back to the ``tiers[]``
``is_current`` marker (what the picker derives from), else 0 (free).
"""
orders = {t.tier_id: (t.tier_order or 0) for t in (state.tiers or ())}
cur_id = state.current.tier_id if state.current else None
if cur_id is not None and cur_id in orders:
cur_order = orders[cur_id]
else:
cur_order = next((t.tier_order or 0 for t in (state.tiers or ()) if t.is_current), 0)
return orders.get(tier_id, 0) > cur_order
# =============================================================================
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
# =============================================================================
+10 -34
View File
@@ -53,28 +53,6 @@ from tools.budget_config import BudgetConfig, DEFAULT_BUDGET, budget_for_context
logger = logging.getLogger(__name__)
def _ensure_file_checkpoint(
agent,
function_name: str,
function_args: dict,
effective_task_id: str,
) -> None:
"""Checkpoint the same workspace path that the file tool will mutate."""
file_path = function_args.get("path", "")
if not file_path:
return
# File tools resolve relative paths against the task's live/session cwd,
# which can differ from the Hermes process cwd (notably in Docker). Resolve
# through that same path pipeline before asking the checkpoint manager to
# discover the project root.
from tools.file_tools import _resolve_path_for_task
resolved_path = _resolve_path_for_task(file_path, effective_task_id or "default")
work_dir = agent._checkpoint_mgr.get_working_dir_for_path(str(resolved_path))
agent._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}")
def _budget_for_agent(agent) -> BudgetConfig:
"""Resolve a tool-result BudgetConfig scaled to the agent's context window.
@@ -524,12 +502,10 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
# Checkpoint for file-mutating tools
if function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled:
try:
_ensure_file_checkpoint(
agent,
function_name,
function_args,
effective_task_id,
)
file_path = function_args.get("path", "")
if file_path:
work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path)
agent._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}")
except Exception:
pass
@@ -1212,12 +1188,12 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
# Checkpoint: snapshot working dir before file-mutating tools
if not _execution_blocked and function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled:
try:
_ensure_file_checkpoint(
agent,
function_name,
function_args,
effective_task_id,
)
file_path = function_args.get("path", "")
if file_path:
work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path)
agent._checkpoint_mgr.ensure_checkpoint(
work_dir, f"before {function_name}"
)
except Exception:
pass # never block tool execution
@@ -92,79 +92,6 @@ class TurnResult:
_TURN_ABORTED_MARKERS = ("<turn_aborted>", "<turn_aborted/>")
def _notification_scope_ids(
note: dict,
) -> tuple[Optional[str], Optional[str]]:
"""Extract the thread/turn identity carried by a notification."""
if not isinstance(note, dict):
return None, None
params = note.get("params") or {}
if not isinstance(params, dict):
return None, None
nested_turn = params.get("turn") or {}
nested_item = params.get("item") or {}
observed_thread_id = params.get("threadId") or params.get("thread_id")
if observed_thread_id is None and isinstance(nested_turn, dict):
observed_thread_id = (
nested_turn.get("threadId")
or nested_turn.get("thread_id")
)
if observed_thread_id is None and isinstance(nested_item, dict):
observed_thread_id = (
nested_item.get("threadId")
or nested_item.get("thread_id")
)
observed_turn_id = params.get("turnId") or params.get("turn_id")
if observed_turn_id is None and isinstance(nested_turn, dict):
observed_turn_id = nested_turn.get("id") or nested_turn.get("turnId")
if observed_turn_id is None and isinstance(nested_item, dict):
observed_turn_id = (
nested_item.get("turnId")
or nested_item.get("turn_id")
)
return observed_thread_id, observed_turn_id
def _notification_belongs_to_turn(
note: dict,
*,
thread_id: Optional[str],
turn_id: Optional[str],
) -> bool:
"""Return whether a multiplexed notification belongs to this turn.
Codex app-server can carry parent and hosted subagent threads over one
JSON-RPC connection. An explicitly foreign child or
stale-turn event must not mutate the active parent's transcript or mark
its turn complete. Unscoped notifications remain accepted for protocol
compatibility.
"""
if not isinstance(note, dict):
return False
observed_thread_id, observed_turn_id = _notification_scope_ids(note)
if (
thread_id is not None
and observed_thread_id is not None
and str(observed_thread_id) != str(thread_id)
):
return False
if (
turn_id is not None
and observed_turn_id is not None
and str(observed_turn_id) != str(turn_id)
):
return False
return True
def _coerce_turn_input_text(user_input: Any) -> str:
"""Collapse Hermes/OpenAI rich content into app-server text input.
@@ -578,17 +505,6 @@ class CodexAppServerSession:
pending = self._client.take_notification(timeout=0)
if pending is None:
break
if not _notification_belongs_to_turn(
pending,
thread_id=self._thread_id,
turn_id=result.turn_id,
):
logger.debug(
"ignoring foreign codex notification while draining "
"server request: method=%s",
pending.get("method"),
)
continue
# Mirror the main notification-handling block below so
# display events surface and stay in step with projector
# state. Without this, item/started / item/completed
@@ -634,16 +550,6 @@ class CodexAppServerSession:
continue
method = note.get("method", "")
if not _notification_belongs_to_turn(
note,
thread_id=self._thread_id,
turn_id=result.turn_id,
):
logger.debug(
"ignoring foreign codex notification: method=%s", method
)
continue
if self._on_event is not None:
try:
self._on_event(note)
@@ -831,48 +737,6 @@ class CodexAppServerSession:
continue
method = note.get("method", "")
observed_thread_id, observed_turn_id = _notification_scope_ids(note)
if result.turn_id is None:
if method == "turn/started":
if (
observed_thread_id is not None
and str(observed_thread_id) != str(self._thread_id)
):
logger.debug(
"ignoring foreign compact turn/started: thread=%s",
observed_thread_id,
)
continue
if observed_turn_id is None:
logger.debug(
"ignoring compact turn/started without a turn id"
)
continue
result.turn_id = str(observed_turn_id)
elif observed_turn_id is not None or method in {
"item/completed",
"turn/completed",
}:
# thread/compact/start does not return a turn id. Until the
# new turn/started arrives, any terminal/projectable event
# is stale or cannot be safely attributed to this compaction.
logger.debug(
"ignoring codex notification before compact turn start: "
"method=%s",
method,
)
continue
if not _notification_belongs_to_turn(
note,
thread_id=self._thread_id,
turn_id=result.turn_id,
):
logger.debug(
"ignoring foreign codex notification: method=%s", method
)
continue
if self._on_event is not None:
try:
self._on_event(note)
+1 -42
View File
@@ -210,23 +210,6 @@ def _compression_made_progress(
return orig_tokens > 0 and new_tokens < orig_tokens * 0.95
def _compression_warrants_another_preflight_pass(
orig_tokens: int, new_tokens: int, threshold_tokens: int
) -> bool:
"""Whether an over-threshold request merits another immediate summary.
Row-count progress is enough to prove that a compression boundary was real,
but not enough to justify another expensive pass before trying the provider.
Continue only when the request remains over threshold *and* the previous pass
materially reduced its estimated token pressure (>5%).
"""
return (
new_tokens >= threshold_tokens
and orig_tokens > 0
and new_tokens < orig_tokens * 0.95
)
def _should_run_preflight_estimate(
messages: List[Dict[str, Any]],
protect_first_n: int,
@@ -280,8 +263,6 @@ class TurnContext:
plugin_user_context: str = ""
# External-memory prefetch result, reused across loop iterations.
ext_prefetch_cache: str = ""
# Turn-start preflight already proved an immediate retry ineffective.
preflight_compression_blocked: bool = False
def build_turn_context(
@@ -584,7 +565,6 @@ def build_turn_context(
# See ``_should_run_preflight_estimate`` for the OR semantics that fix
# issue #27405 (a few very large messages slipping past the count gate).
_preflight_compressed = False
_preflight_compression_blocked = False
if agent.compression_enabled and _should_run_preflight_estimate(
messages,
agent.context_compressor.protect_first_n,
@@ -665,13 +645,7 @@ def build_turn_context(
f">= {_compressor.threshold_tokens:,} threshold. "
"This may take a moment."
)
# Preflight passes honor the same configured per-turn cap
# (compression.max_attempts) as the loop's compression sites;
# default 3 preserves the prior hardcoded behavior.
_max_preflight_passes = max(
1, int(getattr(agent, "max_compression_attempts", 3) or 3)
)
for _pass in range(_max_preflight_passes):
for _pass in range(3):
_orig_len = len(messages)
_orig_tokens = _preflight_tokens
messages, active_system_prompt = agent._compress_context(
@@ -690,7 +664,6 @@ def build_turn_context(
if not _compression_made_progress(
_orig_len, len(messages), _orig_tokens, _preflight_tokens
):
_preflight_compression_blocked = True
break # Cannot compress further: neither rows nor tokens moved
conversation_history = conversation_history_after_compression(
agent, messages
@@ -702,19 +675,6 @@ def build_turn_context(
agent._mute_post_response = False
if not _compressor.should_compress(_preflight_tokens):
break
if not _compression_warrants_another_preflight_pass(
_orig_tokens,
_preflight_tokens,
_compressor.threshold_tokens,
):
_preflight_compression_blocked = True
logger.warning(
"Preflight compression made insufficient progress: "
"~%s -> ~%s request tokens; skipping additional passes",
f"{_orig_tokens:,}",
f"{_preflight_tokens:,}",
)
break
if _preflight_compressed:
# Compression rebuilt the list (tail messages are fresh compaction
@@ -939,5 +899,4 @@ def build_turn_context(
should_review_memory=should_review_memory,
plugin_user_context=plugin_user_context,
ext_prefetch_cache=ext_prefetch_cache,
preflight_compression_blocked=_preflight_compression_blocked,
)
+3 -5
View File
@@ -125,11 +125,9 @@ normalization alike. Learn the shape, not a snapshot of the current rungs.
Two auth-flavored corollaries worth naming because they are easy to get wrong:
- **One-time credentials are never reused.** An OAuth gateway connection mints a
fresh WebSocket ticket on every dial and never falls back to the cached URL.
Only a confirmed 401/403 (or an explicitly tagged auth rejection) means
reauthentication; timeout, network, malformed-response, and server failures
remain connectivity errors. Only long-lived token/local auth may reuse a
cached URL as a lower rung.
fresh WebSocket ticket on every dial; a mint failure means reauthentication,
not "fall back to the cached URL." Only long-lived token/local auth may reuse
a cached URL as a lower rung.
- **A connection test must exercise the leg you'll actually use.** An HTTP
status probe passing while the WebSocket/auth leg fails is a false positive
that ships as "it said connected but nothing works."
+1 -7
View File
@@ -9,7 +9,7 @@
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { allowErrorBanners, test } from './test'
import { test } from '@playwright/test'
import {
type DeadBackendFixture,
@@ -26,12 +26,6 @@ test.afterAll(async () => {
})
test.describe('boot failure with dead backend', () => {
test.beforeEach(() => {
// These tests deliberately trigger boot errors — error banners
// (notifyError → [role="alert"]) are expected, not failures.
allowErrorBanners()
})
test('app shows error state', async () => {
// Inject a fake boot error so the backend resolution "fails" with a
// controlled error message. This is the only reliable way to trigger
+1 -1
View File
@@ -11,7 +11,7 @@
* Run from the nix devshell:
* npm exec playwright test e2e/boot.spec.ts --reporter=list
*/
import { expect, test } from './test'
import { expect, test } from '@playwright/test'
import {
type MockBackendFixture,
+1 -1
View File
@@ -8,7 +8,7 @@
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { test } from './test'
import { test } from '@playwright/test'
import {
type MockBackendFixture,
+6 -12
View File
@@ -28,7 +28,6 @@ import * as path from 'node:path'
import { _electron, type ElectronApplication, type Page } from '@playwright/test'
import { startMockServer } from './mock-server'
import { installErrorBannerGuard } from './test'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
@@ -97,7 +96,7 @@ export interface Sandbox {
cleanup: () => void
}
export function createSandbox(prefix: string): Sandbox {
function createSandbox(prefix: string): Sandbox {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-e2e-${prefix}-${Math.random()}`))
const hermesHome = path.join(root, 'hermes-home')
const userDataDir = path.join(root, 'electron-user-data')
@@ -141,7 +140,7 @@ export function createSandbox(prefix: string): Sandbox {
* mock inference server. The provider is set as the active model provider so
* the desktop app skips onboarding and boots straight to the chat UI.
*/
export function writeMockProviderConfig(hermesHome: string, mockUrl: string): void {
function writeMockProviderConfig(hermesHome: string, mockUrl: string): void {
const configPath = path.join(hermesHome, 'config.yaml')
const config = `# Auto-generated by E2E test fixtures
@@ -166,7 +165,7 @@ providers:
* Write a minimal .env with the mock API key. The key_env in config.yaml
* references MOCK_API_KEY, so the backend resolves credentials from here.
*/
export function writeEnvFile(hermesHome: string, apiKey = 'e2e-mock-key'): void {
function writeEnvFile(hermesHome: string, apiKey = 'e2e-mock-key'): void {
const envPath = path.join(hermesHome, '.env')
fs.writeFileSync(envPath, `MOCK_API_KEY=${apiKey}\n`, 'utf8')
}
@@ -194,7 +193,7 @@ function writeEmptyConfig(hermesHome: string): void {
* - HERMES_DESKTOP_APP_NAME → unique-ish per test (avoids single-instance lock)
* - XDG_RUNTIME_DIR → ensure Electron has a writable runtime dir on Linux
*/
export function buildAppEnv(sandbox: Sandbox, extra: Record<string, string> = {}): Record<string, string> {
function buildAppEnv(sandbox: Sandbox, extra: Record<string, string> = {}): Record<string, string> {
const clean = stripCredentials(process.env)
// XDG_RUNTIME_DIR is needed for Electron on Linux when running in a
@@ -253,7 +252,7 @@ function assertDistBuilt(): void {
* Find the Electron binary. In the nix devshell, `electron` is on PATH.
* As a fallback, use the node_modules/.bin/electron from the desktop package.
*/
export function findElectron(): string {
function findElectron(): string {
// In dev mode, we use the `electron` binary directly (not the packaged app).
// The dev:electron script in package.json does exactly this: `electron .`
// after building. We replicate that here.
@@ -284,7 +283,7 @@ export function findElectron(): string {
* @param env - the process environment (already has HERMES_HOME etc.)
* @returns the ElectronApplication + first Page
*/
export async function launchDesktop(
async function launchDesktop(
env: Record<string, string>,
): Promise<{ app: ElectronApplication; page: Page }> {
assertDistBuilt()
@@ -306,10 +305,6 @@ export async function launchDesktop(
const page = await app.firstWindow()
// Install the error-banner guard so any [role="alert"] that appears
// during a test is collected and surfaced in afterEach.
installErrorBannerGuard(page)
return { app, page }
}
@@ -519,7 +514,6 @@ export async function setupPackagedApp(): Promise<PackagedAppFixture> {
})
const page = await app.firstWindow()
installErrorBannerGuard(page)
return {
app,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from './test'
import { expect, test } from '@playwright/test'
import {
PACKAGED_BINARY_PATH,
+1 -1
View File
@@ -15,7 +15,7 @@
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test } from './test'
import { expect, test } from '@playwright/test'
import {
type MockBackendFixture,
+1 -1
View File
@@ -9,7 +9,7 @@
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test } from './test'
import { expect, test } from '@playwright/test'
import {
type NoProviderFixture,
@@ -1,56 +0,0 @@
#!/usr/bin/env python3
"""Seed a Hermes state.db with a session exported from a real conversation.
Usage: seed_session_db.py <state_db_path> <fixture_json_path>
Creates the database with the full SessionDB schema (if it doesn't exist)
and imports the session from the JSON fixture. Uses the real
SessionDB.import_sessions() so the data shape matches what the desktop
backend expects.
"""
import json
import sys
from pathlib import Path
# Add the repo root to sys.path so we can import hermes_state.
# The script is invoked from apps/desktop/e2e/ — repo root is ../../..
repo_root = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(repo_root))
from hermes_state import SessionDB # noqa: E402
def main():
if len(sys.argv) != 3:
print(f"Usage: {sys.argv[0]} <state_db_path> <fixture_json_path>", file=sys.stderr)
sys.exit(1)
db_path = Path(sys.argv[1])
fixture_path = Path(sys.argv[2])
db_path.parent.mkdir(parents=True, exist_ok=True)
with open(fixture_path, "r", encoding="utf-8") as f:
session_data = json.load(f)
db = SessionDB(db_path=db_path)
result = db.import_sessions([session_data])
if not result.get("ok"):
print(f"Import failed: {result}", file=sys.stderr)
sys.exit(1)
imported = result.get("imported", 0)
skipped = result.get("skipped", 0)
errors = result.get("errors", [])
if errors:
print(f"Import had errors: {errors}", file=sys.stderr)
sys.exit(1)
print(f"Seeded {imported} session(s), skipped {skipped} → {db_path}")
db.close()
if __name__ == "__main__":
main()
-166
View File
@@ -1,166 +0,0 @@
/**
* Extended Playwright test fixture that auto-fails any test if an error
* banner (notification toast with role="alert") appears in the DOM.
*
* The desktop app surfaces errors as `[data-slot="alert"][role="alert"]`
* elements (see components/notifications.tsx). When one appears during a
* test, it means something went wrong (resume failed, boot error, etc.)
* — the test should fail with the error message, not silently pass while
* an error toast is visible on screen.
*
* Usage: import { test, expect } from './test' instead of
* '@playwright/test'. The guard is auto-installed on every page — no
* per-spec setup needed.
*/
import { test as base, expect, type Page, type ElectronApplication, _electron } from '@playwright/test'
// Track error messages per test so afterEach can assert + report.
const seenErrors: string[] = []
let activePage: Page | null = null
// When true, the afterEach guard skips the error-banner check.
// Set by tests that deliberately trigger error states (e.g. boot-failure).
let errorBannersAllowed = false
/**
* Opt out of the error-banner guard for the current test. Call in
* test.beforeEach or at the top of a test body when error banners are
* expected (e.g. boot-failure tests that deliberately trigger errors).
*/
export function allowErrorBanners(): void {
errorBannersAllowed = true
}
/**
* Install the error-banner guard on a page. Watches for `[role="alert"]`
* elements appearing in the DOM. When one is found, records its text
* content for the afterEach assertion.
*
* Exported so e2e fixture functions (which create pages via _electron.launch)
* can install the guard on their custom pages — the default Playwright `page`
* fixture override only catches pages created by Playwright itself, not
* pages created by the test's own Electron launch.
*/
export function installErrorBannerGuard(page: Page): void {
activePage = page
// Clear any errors from a previous test when a new page is created.
seenErrors.length = 0
// Use a MutationObserver to catch error banners as they appear.
// We inject this via addInitScript so it runs before any app code.
page.addInitScript(() => {
const seen: string[] = []
;(window as unknown as { __ERROR_BANNER_GUARD__?: string[] }).__ERROR_BANNER_GUARD__ = seen
const observer = new MutationObserver(() => {
const alerts = document.querySelectorAll('[role="alert"]')
for (const alert of alerts) {
const text = (alert.textContent ?? '').trim()
if (text && !seen.includes(text)) {
seen.push(text)
}
}
})
// Start observing once the DOM is ready.
if (document.body) {
observer.observe(document.body, { childList: true, subtree: true })
} else {
document.addEventListener('DOMContentLoaded', () => {
observer.observe(document.body, { childList: true, subtree: true })
})
}
})
// Also poll via evaluate — MutationObserver via addInitScript can miss
// elements that appear during the Electron renderer's initial mount
// (before the observer is installed). A periodic poll catches those.
page.on('console', () => {
// Console messages are not errors — but we keep the listener to
// ensure the page context is active for our evaluate calls.
})
}
/**
* Check for error banners that appeared during the test. Called in
* afterEach via the custom fixture below. Also exported so specs that
* manage their own page lifecycle can call it directly.
*/
export async function collectErrorBanners(page: Page | null): Promise<string[]> {
if (!page) {
return []
}
try {
// Read errors collected by the MutationObserver in the page context.
const pageErrors = await page.evaluate(() => {
const w = window as unknown as { __ERROR_BANNER_GUARD__?: string[] }
return [...(w.__ERROR_BANNER_GUARD__ ?? [])]
})
// Also do a final DOM scan for any alert elements still visible.
const domAlerts = await page
.locator('[role="alert"]')
.allTextContents()
.catch(() => [] as string[])
const all = [...new Set([...pageErrors, ...domAlerts.map(t => t.trim()).filter(Boolean)])]
seenErrors.push(...all)
return [...new Set(seenErrors)]
} catch {
// Page might be closed — return whatever we have.
return [...new Set(seenErrors)]
}
}
// Extended test fixture: wraps the default page with the error guard.
export const test = base.extend({
// Override the page fixture to auto-install the guard.
page: async ({ page }, use) => {
installErrorBannerGuard(page)
await use(page)
},
})
// afterEach: fail the test if any error banners appeared.
// Always fires — even if the test already failed for another reason.
// An error banner often IS the root cause (e.g. "resume failed" from a
// backend bug), and suppressing it when the test also fails on an
// assertion hides the real problem.
//
// Uses `activePage` (set by installErrorBannerGuard) instead of the
// default `page` fixture — Electron tests create their own page via
// app.firstWindow(), so the default `page` fixture is undefined.
base.afterEach(async ({}, testInfo) => {
const wasAllowed = errorBannersAllowed
// Reset for the next test.
errorBannersAllowed = false
if (wasAllowed) {
// Test opted out — clear any collected errors without asserting.
seenErrors.length = 0
return
}
const errors = await collectErrorBanners(activePage)
if (errors.length > 0) {
throw new Error(
`Error banner(s) appeared during test "${testInfo.title}":\n` +
errors.map(e => ` • ${e}`).join('\n'),
)
}
})
// Reset for the next test file.
base.afterAll(async () => {
seenErrors.length = 0
activePage = null
})
export { expect, type Page, type ElectronApplication, _electron }
@@ -1,100 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { applyConnectionChange, commitConnectionFailure, resolveTerminalConnection } from './connection-apply'
function deferred() {
let resolve!: () => void
const promise = new Promise<void>(done => {
resolve = done
})
return { promise, resolve }
}
describe('applyConnectionChange', () => {
it.each([['SSH A to SSH B'], ['SSH to Cloud'], ['Cloud to SSH']])(
'serializes %s behind bootstrap rollback before teardown and apply',
async () => {
const gate = deferred()
const events: string[] = []
const run = applyConnectionChange({
cancelAndWait: async () => {
events.push('cancel')
await gate.promise
events.push('drained')
},
isPrimary: true,
scope: '',
sendApplied: () => events.push('applied'),
stopPool: vi.fn(),
teardownPrimary: async () => {
events.push('primary')
},
teardownSsh: async () => {
events.push('ssh')
}
})
await Promise.resolve()
expect(events).toEqual(['cancel'])
gate.resolve()
await run
expect(events).toEqual(['cancel', 'drained', 'ssh', 'primary', 'applied'])
}
)
it('tears down only a non-primary scope without applying the primary connection', async () => {
const events: string[] = []
await applyConnectionChange({
cancelAndWait: async scope => {
events.push(`cancel:${scope}`)
},
isPrimary: false,
scope: 'worker',
sendApplied: () => events.push('applied'),
stopPool: scope => events.push(`pool:${scope}`),
teardownPrimary: async () => {
events.push('primary')
},
teardownSsh: async scope => {
events.push(`ssh:${scope}`)
}
})
expect(events).toEqual(['cancel:worker', 'ssh:worker', 'pool:worker'])
})
})
describe('resolveTerminalConnection', () => {
it('joins an in-flight backend before resolving the SSH terminal target', async () => {
const target = { ssh: {}, scope: '' }
const getTarget = vi.fn().mockReturnValueOnce('pending').mockReturnValueOnce(target)
const ensureBackend = vi.fn(async () => undefined)
await expect(resolveTerminalConnection(getTarget, ensureBackend)).resolves.toBe(target)
expect(ensureBackend).toHaveBeenCalledOnce()
})
it('does not start a local terminal while configured SSH remains unavailable', async () => {
await expect(
resolveTerminalConnection(
() => 'pending',
async () => undefined
)
).rejects.toThrow('not ready')
})
})
describe('commitConnectionFailure', () => {
it('prevents a stale bootstrap from publishing failure state', () => {
const stale = Promise.resolve('stale')
const current = Promise.resolve('current')
const commit = vi.fn()
expect(commitConnectionFailure(current, stale, commit)).toBe(false)
expect(commit).not.toHaveBeenCalled()
expect(commitConnectionFailure(current, current, commit)).toBe(true)
expect(commit).toHaveBeenCalledOnce()
})
})
-50
View File
@@ -1,50 +0,0 @@
async function applyConnectionChange({
cancelAndWait,
isPrimary,
scope,
sendApplied,
stopPool,
teardownPrimary,
teardownSsh
}) {
await cancelAndWait(scope)
await teardownSsh(scope)
if (!isPrimary) {
stopPool(scope)
return
}
await teardownPrimary()
sendApplied()
}
function commitConnectionFailure(current, starting, commit) {
if (current !== starting) {
return false
}
commit()
return true
}
async function resolveTerminalConnection(getTarget, ensureBackend) {
let target = getTarget()
if (target !== 'pending') {
return target
}
await ensureBackend()
target = getTarget()
if (target === 'pending') {
throw new Error('Remote connection is not ready yet. Try again in a moment.')
}
return target
}
export { applyConnectionChange, commitConnectionFailure, resolveTerminalConnection }
+2 -127
View File
@@ -23,22 +23,14 @@ import {
cookiesHaveLiveSession,
cookiesHavePrivySession,
cookiesHaveSession,
gatewayTicketFailure,
gatewayWsUrlIpcResult,
isGatewayAuthRejection,
localProfileEntry,
modeIsRemoteLike,
normalizeRemoteBaseUrl,
normalizeSshConfig,
normAuthMode,
pathWithGlobalRemoteProfile,
profileHasRemoteConnection,
profileRemoteOverride,
profileSshOverride,
resolveAuthMode,
resolveTestWsUrl,
RT_COOKIE_VARIANTS,
savedProfileSsh,
tokenPreview
} from './connection-config'
@@ -132,61 +124,6 @@ test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
assert.equal(profileRemoteOverride(null, 'coder'), null)
})
test('SSH remains separate from URL-shaped remote modes', () => {
assert.equal(modeIsRemoteLike('ssh'), false)
const config = { profiles: { coder: { mode: 'ssh', host: 'alice@box:2222', keyPath: '/key' } } }
assert.equal(profileRemoteOverride(config, 'coder'), null)
assert.deepEqual(profileSshOverride(config, 'coder'), {
mode: 'ssh',
host: 'box',
user: 'alice',
port: 2222,
keyPath: '/key'
})
})
test('normalizeSshConfig handles IPv6 and strict port bounds', () => {
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: '::1', port: 22 }), {
mode: 'ssh',
host: '::1'
})
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: '[::1]:2222' }), {
mode: 'ssh',
host: '::1',
port: 2222
})
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', port: '2222junk' }), {
mode: 'ssh',
host: 'box'
})
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', port: 65536 }), {
mode: 'ssh',
host: 'box'
})
})
test('localProfileEntry preserves inactive SSH drafts but drops Cloud state', () => {
const ssh = { mode: 'ssh', host: 'box', user: 'alice', remoteHermesPath: '/hermes' }
assert.deepEqual(localProfileEntry(ssh), { mode: 'local', savedSsh: ssh })
assert.deepEqual(localProfileEntry({ mode: 'local', savedSsh: ssh }), {
mode: 'local',
savedSsh: ssh
})
assert.equal(localProfileEntry({ mode: 'cloud', url: 'https://agent' }), null)
})
test('saved SSH drafts are inactive and explicit overrides take precedence', () => {
const saved = { mode: 'ssh', host: 'saved' }
const config: any = { profiles: { coder: { mode: 'local', savedSsh: saved } } }
assert.deepEqual(savedProfileSsh(config, 'coder'), saved)
assert.equal(profileSshOverride(config, 'coder'), null)
assert.equal(profileHasRemoteConnection(config, 'coder'), false)
config.profiles.coder = { mode: 'ssh', host: 'active' }
assert.deepEqual(profileSshOverride(config, 'coder'), { mode: 'ssh', host: 'active' })
assert.equal(profileHasRemoteConnection(config, 'coder'), true)
})
// --- pathWithGlobalRemoteProfile ---
test('pathWithGlobalRemoteProfile appends profile in global remote mode', () => {
@@ -494,14 +431,12 @@ test('resolveTestWsUrl (oauth, mint ok) builds a ?ticket= URL', async () => {
assert.equal(url, 'wss://gw.example.com/api/ws?ticket=tkt-9')
})
test('resolveTestWsUrl (oauth, auth rejected) requests sign-in and does not skip WS validation', async () => {
const cause = Object.assign(new Error('ticket mint failed'), { statusCode: 401 })
test('resolveTestWsUrl (oauth, mint FAILS) throws — must NOT skip WS validation', async () => {
await assert.rejects(
() =>
resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
mintTicket: async () => {
throw cause
throw new Error('401 ticket mint failed')
}
}),
(err: any) => {
@@ -517,66 +452,6 @@ test('resolveTestWsUrl (oauth, auth rejected) requests sign-in and does not skip
)
})
test('resolveTestWsUrl (oauth, transport failure) remains a retryable connection error', async () => {
const cause = new Error('socket timed out')
await assert.rejects(
() =>
resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
mintTicket: async () => {
throw cause
}
}),
(err: any) => {
assert.match(err.message, /could not mint a WebSocket ticket/i)
assert.equal(err.needsOauthLogin, undefined)
assert.equal(err.cause, cause)
return true
}
)
})
test('gateway ticket failures classify only explicit auth rejection statuses as reauth', () => {
assert.equal(isGatewayAuthRejection({ statusCode: 401 }), true)
assert.equal(isGatewayAuthRejection({ statusCode: 403 }), true)
assert.equal(isGatewayAuthRejection({ needsOauthLogin: true }), true)
assert.equal(isGatewayAuthRejection({ statusCode: 500 }), false)
assert.equal(isGatewayAuthRejection(new Error('network timeout')), false)
const serverFailure = gatewayTicketFailure(new Error('network timeout'), 'sign in', 'retry connection') as any
assert.equal(serverFailure.message, 'retry connection')
assert.equal(serverFailure.needsOauthLogin, undefined)
})
test('gateway WS URL IPC result serializes success and the auth-vs-transport matrix', async () => {
assert.deepEqual(await gatewayWsUrlIpcResult(async () => 'wss://gateway.example.com/api/ws?ticket=fresh'), {
ok: true,
wsUrl: 'wss://gateway.example.com/api/ws?ticket=fresh'
})
for (const statusCode of [401, 403]) {
const error = Object.assign(new Error(`${statusCode}: rejected`), { statusCode })
assert.deepEqual(await gatewayWsUrlIpcResult(async () => Promise.reject(error)), {
error: `${statusCode}: rejected`,
needsOauthLogin: true,
ok: false
})
}
for (const error of [
Object.assign(new Error('500: unavailable'), { statusCode: 500 }),
new Error('Timed out connecting to Hermes backend after 8000ms'),
Object.assign(new Error('socket reset'), { code: 'ECONNRESET' })
]) {
assert.deepEqual(await gatewayWsUrlIpcResult(async () => Promise.reject(error)), {
error: error.message,
ok: false
})
}
})
test('resolveTestWsUrl (oauth) requires a mintTicket function', async () => {
await assert.rejects(
() => resolveTestWsUrl('https://gw.example.com', 'oauth', null),
+13 -176
View File
@@ -88,43 +88,6 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
return `${wsScheme}://${parsed.host}${prefix}/api/ws?ticket=${encodeURIComponent(ticket)}`
}
/** True only when a gateway explicitly rejected the current OAuth session. */
function isGatewayAuthRejection(error) {
if (error && typeof error === 'object' && (error as any).needsOauthLogin === true) {
return true
}
const statusCode = Number(error && typeof error === 'object' ? (error as any).statusCode : NaN)
return statusCode === 401 || statusCode === 403
}
function gatewayTicketFailure(error, authMessage, transportMessage) {
const needsOauthLogin = isGatewayAuthRejection(error)
const err = new Error(needsOauthLogin ? authMessage : transportMessage)
if (needsOauthLogin) {
;(err as any).needsOauthLogin = true
}
err.cause = error
return err
}
/** Serialize a fresh-WS-URL attempt across Electron's IPC boundary. */
async function gatewayWsUrlIpcResult(resolveWsUrl: () => Promise<string>) {
try {
return { ok: true as const, wsUrl: await resolveWsUrl() }
} catch (error) {
return {
error: error instanceof Error ? error.message : String(error),
...(isGatewayAuthRejection(error) ? { needsOauthLogin: true as const } : {}),
ok: false as const
}
}
}
/**
* Build the WS URL the renderer would connect with, so the connection test can
* exercise the same transport the app actually uses.
@@ -139,10 +102,12 @@ async function gatewayWsUrlIpcResult(resolveWsUrl: () => Promise<string>) {
* - oauth, mint ok → ws(s)://…/api/ws?ticket=…
* - oauth, mint fails → THROWS (NOT a skip)
*
* The oauth-mint-failure throw is the important case: swallowing it here would
* re-introduce the exact false-positive this test exists to catch. An explicit
* 401/403 asks for sign-in; transport and server failures remain connectivity
* errors so a temporary outage is not mislabeled as an expired session.
* The oauth-mint-failure throw is the important case: the real boot path
* (resolveRemoteBackend in main.ts) treats a mint failure as a hard
* "session expired" auth error and refuses to connect. Swallowing it here
* would re-introduce the exact false-positive this test exists to catch —
* HTTP /api/status passes, the test reports "reachable", then the renderer
* can't authenticate /api/ws and boot dies with "Could not connect".
*
* @param {string} baseUrl
* @param {'token'|'oauth'} authMode
@@ -163,12 +128,14 @@ async function resolveTestWsUrl(baseUrl, authMode, token, deps: any = {}) {
try {
ticket = await mintTicket(baseUrl)
} catch (error) {
throw gatewayTicketFailure(
error,
'Reached the gateway over HTTP, but the OAuth session was rejected while minting a WebSocket ticket. ' +
'Open Settings → Gateway and sign in again.',
'Reached the gateway over HTTP, but could not mint a WebSocket ticket. Check the remote gateway connection and try again.'
const err = new Error(
'Reached the gateway over HTTP, but could not mint a WebSocket ticket for the OAuth session ' +
'(it may have expired). Open Settings → Gateway and sign in again.'
)
;(err as any).needsOauthLogin = true
err.cause = error
throw err
}
return buildGatewayWsUrlWithTicket(baseUrl, ticket)
@@ -203,127 +170,6 @@ function modeIsRemoteLike(mode) {
return mode === 'remote' || mode === 'cloud'
}
function normalizeSshConfig(entry) {
if (!entry || typeof entry !== 'object' || entry.mode !== 'ssh') {
return null
}
let host = String(entry.host || '').trim()
if (!host) {
return null
}
let parsedUser
let parsedPort
const at = host.indexOf('@')
if (at > 0) {
parsedUser = host.slice(0, at)
host = host.slice(at + 1)
}
const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(host)
if (bracketed) {
host = bracketed[1]
if (bracketed[2]) {
parsedPort = Number(bracketed[2])
}
} else if ((host.match(/:/g) || []).length === 1) {
const [name, rawPort] = host.split(':')
if (/^\d+$/.test(rawPort)) {
host = name
parsedPort = Number(rawPort)
}
}
if (!host) {
return null
}
const out: any = { mode: 'ssh', host }
const user = String(entry.user || '').trim() || parsedUser || ''
if (user) {
out.user = user
}
const rawExplicitPort = String(entry.port ?? '').trim()
const explicitPort = /^\d+$/.test(rawExplicitPort) ? Number(rawExplicitPort) : null
const port = explicitPort ?? parsedPort
if (Number.isInteger(port) && port > 0 && port <= 65535 && port !== 22) {
out.port = port
}
const keyPath = String(entry.keyPath || '').trim()
if (keyPath) {
out.keyPath = keyPath
}
const remoteHermesPath = String(entry.remoteHermesPath || '').trim()
if (remoteHermesPath) {
out.remoteHermesPath = remoteHermesPath
}
return out
}
function profileSshOverride(config, profile) {
const key = connectionScopeKey(profile)
const entry = key ? config?.profiles?.[key] : null
return normalizeSshConfig(entry)
}
function savedProfileSsh(config, profile) {
const key = connectionScopeKey(profile)
const entry = key ? config?.profiles?.[key] : null
if (!entry || entry.mode !== 'local') {
return null
}
return normalizeSshConfig(entry.savedSsh)
}
function profileHasRemoteConnection(config, profile) {
return Boolean(profileRemoteOverride(config, profile) || profileSshOverride(config, profile))
}
function localProfileEntry(existing) {
const ssh = normalizeSshConfig(existing) || normalizeSshConfig(existing?.savedSsh)
return ssh ? { mode: 'local', savedSsh: ssh } : null
}
function hostLabelFromBaseUrl(baseUrl) {
const raw = String(baseUrl || '').trim()
if (!raw) {
return null
}
try {
const parsed = new URL(raw)
if (!parsed.hostname) {
return null
}
return parsed.port && parsed.port !== '80' && parsed.port !== '443'
? `${parsed.hostname}:${parsed.port}`
: parsed.hostname
} catch {
return null
}
}
/**
* Select a profile's explicit remote override from a connection config, or null
* when it has none (so the caller falls back to env → global remote → local).
@@ -491,23 +337,14 @@ export {
cookiesHaveLiveSession,
cookiesHavePrivySession,
cookiesHaveSession,
gatewayTicketFailure,
gatewayWsUrlIpcResult,
hostLabelFromBaseUrl,
isGatewayAuthRejection,
localProfileEntry,
modeIsRemoteLike,
normalizeRemoteBaseUrl,
normalizeSshConfig,
normAuthMode,
pathWithGlobalRemoteProfile,
PRIVY_SESSION_COOKIE_VARIANTS,
profileHasRemoteConnection,
profileRemoteOverride,
profileSshOverride,
resolveAuthMode,
resolveTestWsUrl,
RT_COOKIE_VARIANTS,
savedProfileSsh,
tokenPreview
}
@@ -1,105 +0,0 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import { loadOrCreateInstallationId, parseInstallationId, sshOwnershipId } from './desktop-installation'
const ID_A = '11111111-1111-4111-8111-111111111111'
const ID_B = '22222222-2222-4222-8222-222222222222'
function withTempDir(run) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-installation-'))
try {
return run(directory)
} finally {
fs.rmSync(directory, { recursive: true, force: true })
}
}
test('parseInstallationId accepts only a version-4 UUID record', () => {
assert.equal(parseInstallationId(JSON.stringify({ installationId: ID_A.toUpperCase() })), ID_A)
assert.equal(parseInstallationId(JSON.stringify({ installationId: 'not-an-id' })), '')
assert.equal(parseInstallationId('{}'), '')
assert.equal(parseInstallationId('{'), '')
})
test('loadOrCreateInstallationId persists and reuses one installation ID', () =>
withTempDir(directory => {
const filePath = path.join(directory, 'desktop-installation.json')
assert.equal(
loadOrCreateInstallationId(filePath, () => ID_A),
ID_A
)
assert.equal(
loadOrCreateInstallationId(filePath, () => ID_B),
ID_A
)
assert.equal(fs.statSync(filePath).mode & 0o777, 0o600)
}))
test('loadOrCreateInstallationId tightens an existing identity file', () =>
withTempDir(directory => {
const filePath = path.join(directory, 'desktop-installation.json')
fs.writeFileSync(filePath, JSON.stringify({ installationId: ID_A }), { mode: 0o644 })
assert.equal(
loadOrCreateInstallationId(filePath, () => ID_B),
ID_A
)
if (process.platform !== 'win32') {
assert.equal(fs.statSync(filePath).mode & 0o777, 0o600)
}
}))
test('loadOrCreateInstallationId replaces a malformed existing record', () =>
withTempDir(directory => {
const filePath = path.join(directory, 'desktop-installation.json')
fs.writeFileSync(filePath, '{', { mode: 0o600 })
assert.equal(
loadOrCreateInstallationId(filePath, () => ID_A),
ID_A
)
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).installationId, ID_A)
}))
test('loadOrCreateInstallationId replaces an existing symlink', () =>
withTempDir(directory => {
if (process.platform === 'win32') {
return
}
const target = path.join(directory, 'target.json')
const filePath = path.join(directory, 'desktop-installation.json')
fs.writeFileSync(target, JSON.stringify({ installationId: ID_B }), { mode: 0o600 })
fs.symlinkSync(target, filePath)
assert.equal(
loadOrCreateInstallationId(filePath, () => ID_A),
ID_A
)
assert.equal(fs.lstatSync(filePath).isSymbolicLink(), false)
assert.equal(JSON.parse(fs.readFileSync(target, 'utf8')).installationId, ID_B)
}))
test('loadOrCreateInstallationId replaces a malformed destination without a repair lock', () =>
withTempDir(directory => {
const filePath = path.join(directory, 'desktop-installation.json')
fs.writeFileSync(filePath, '{', { mode: 0o600 })
assert.equal(
loadOrCreateInstallationId(filePath, () => ID_A),
ID_A
)
assert.equal(fs.existsSync(`${filePath}.lock`), false)
}))
test('sshOwnershipId is stable, scoped, and does not disclose the UUID', () => {
const global = sshOwnershipId(ID_A, '')
assert.match(global, /^[0-9a-f]{32}$/)
assert.equal(global, sshOwnershipId(ID_A, ''))
assert.notEqual(global, sshOwnershipId(ID_A, 'worker'))
assert.ok(!global.includes(ID_A.slice(0, 8)))
assert.throws(() => sshOwnershipId('bad', ''))
})
@@ -1,137 +0,0 @@
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
const INSTALLATION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
function parseInstallationId(raw) {
try {
const value = JSON.parse(String(raw || ''))?.installationId
return INSTALLATION_ID_RE.test(value) ? value.toLowerCase() : ''
} catch {
return ''
}
}
function readInstallationId(filePath) {
try {
const stat = fs.lstatSync(filePath)
if (!stat.isFile() || stat.isSymbolicLink()) {
return ''
}
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
return ''
}
if (process.platform !== 'win32' && (stat.mode & 0o777) !== 0o600) {
fs.chmodSync(filePath, 0o600)
}
return parseInstallationId(fs.readFileSync(filePath, 'utf8'))
} catch {
return ''
}
}
function waitForRepair() {
const buffer = new SharedArrayBuffer(4)
Atomics.wait(new Int32Array(buffer), 0, 0, 25)
}
function loadOrCreateInstallationId(filePath, randomUUID = crypto.randomUUID) {
const existing = readInstallationId(filePath)
if (existing) {
return existing
}
fs.mkdirSync(path.dirname(filePath), { recursive: true })
const installationId = randomUUID().toLowerCase()
if (!INSTALLATION_ID_RE.test(installationId)) {
throw new Error('Could not generate a valid desktop installation ID.')
}
const repairPath = `${filePath}.repair.lock`
for (let attempt = 0; attempt < 40; attempt++) {
let repairFd
try {
repairFd = fs.openSync(repairPath, 'wx', 0o600)
} catch (error: any) {
if (error?.code !== 'EEXIST') {
throw error
}
const winner = readInstallationId(filePath)
if (winner) {
return winner
}
waitForRepair()
continue
}
try {
const winner = readInstallationId(filePath)
if (winner) {
return winner
}
try {
const stat = fs.lstatSync(filePath)
if (!stat.isFile() && !stat.isSymbolicLink()) {
throw new Error('Desktop installation ID path is not a regular file.')
}
if (!stat.isSymbolicLink() && typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
throw new Error('Desktop installation ID is owned by another user.')
}
fs.unlinkSync(filePath)
} catch (error: any) {
if (error?.code !== 'ENOENT') {
throw error
}
}
fs.writeFileSync(filePath, JSON.stringify({ installationId }), { encoding: 'utf8', flag: 'wx', mode: 0o600 })
return installationId
} finally {
if (repairFd !== undefined) {
fs.closeSync(repairFd)
}
try {
fs.unlinkSync(repairPath)
} catch {
void 0
}
}
}
throw new Error('Could not repair the desktop installation ID.')
}
function sshOwnershipId(installationId, scope) {
if (!INSTALLATION_ID_RE.test(String(installationId || ''))) {
throw new Error('Desktop installation ID is invalid.')
}
return crypto
.createHash('sha256')
.update(`${installationId}\0${String(scope || '')}`)
.digest('hex')
.slice(0, 32)
}
export { INSTALLATION_ID_RE, loadOrCreateInstallationId, parseInstallationId, readInstallationId, sshOwnershipId }
@@ -1,37 +0,0 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { createEventDeduper } from './event-dedupe'
test('collapses the same key inside the window (two windows, one event)', () => {
const isDup = createEventDeduper(1000)
assert.equal(isDup('input:s1', 0), false, 'first window claims')
assert.equal(isDup('input:s1', 5), true, 'second window is deduped')
})
test('distinct keys are independent', () => {
const isDup = createEventDeduper(1000)
assert.equal(isDup('input:s1', 0), false)
assert.equal(isDup('approval:s1', 0), false, 'different kind')
assert.equal(isDup('input:s2', 0), false, 'different session')
})
test('re-fires once the window elapses', () => {
const isDup = createEventDeduper(1000)
assert.equal(isDup('turnDone:s1', 0), false)
assert.equal(isDup('turnDone:s1', 999), true, 'still within window')
assert.equal(isDup('turnDone:s1', 1000), false, 'window elapsed → fires again')
})
test('prunes stale keys so the map cannot grow unbounded', () => {
const isDup = createEventDeduper(1000)
for (let i = 0; i < 100; i += 1) {
// Each far-apart key is pruned before the next, so none linger as duplicates.
assert.equal(isDup(`turnDone:s${i}`, i * 2000), false)
}
})
-32
View File
@@ -1,32 +0,0 @@
// Cross-window de-dupe for one-shot side-effects (OS notifications, the turn-end
// sound, spoken replies). Every desktop window is its own renderer process, so N
// open windows each independently react to the same backend event. The main
// process is the one place they all share and it handles IPC serially, so it's
// the race-free owner: the first window to claim a key within the interval wins;
// peers see it's taken and stay quiet. Pure + injectable clock, so it's
// unit-testable without Electron.
const DEDUPE_INTERVAL_MS = 1000
// Returns true when `key` was already claimed within the interval (caller drops
// this one). Self-evicting: stale keys are pruned on every call, so the map
// can't grow unbounded.
export function createEventDeduper(intervalMs = DEDUPE_INTERVAL_MS) {
const lastSeenAt = new Map<string, number>()
return function isDuplicate(key: string, now = Date.now()): boolean {
for (const [k, at] of lastSeenAt) {
if (now - at >= intervalMs) {
lastSeenAt.delete(k)
}
}
if (lastSeenAt.has(key)) {
return true
}
lastSeenAt.set(key, now)
return false
}
}
@@ -1,79 +0,0 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { normalizeRepoScanPath, repoScanPathIsWithin, scanGitRepos } from './git-repo-scan'
const tempDirs: string[] = []
function tempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-repo-scan-'))
tempDirs.push(dir)
return dir
}
function makeRepo(root: string, valid = true): void {
fs.mkdirSync(path.join(root, '.git'), { recursive: true })
if (valid) {
fs.writeFileSync(path.join(root, '.git', 'HEAD'), 'ref: refs/heads/main\n')
}
}
afterEach(() => {
vi.restoreAllMocks()
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { force: true, recursive: true })
}
})
describe('scanGitRepos', () => {
it('does not read the filesystem when discovery is disabled', async () => {
const read = vi.spyOn(fs.promises, 'readdir')
await expect(scanGitRepos([], { enabled: false })).resolves.toEqual([])
expect(read).not.toHaveBeenCalled()
})
it('scans only configured roots and excludes complete subtrees', async () => {
const root = tempDir()
const included = path.join(root, 'included')
const excluded = path.join(root, 'excluded')
const invalid = path.join(root, 'invalid')
makeRepo(included)
makeRepo(excluded)
makeRepo(invalid, false)
await expect(scanGitRepos([root], { enabled: true, excludePaths: [excluded], maxDepth: 2 })).resolves.toEqual([
{ label: 'included', root: included }
])
})
it('deduplicates overlapping roots', async () => {
const root = tempDir()
const repo = path.join(root, 'repo')
makeRepo(repo)
const result = await scanGitRepos([root, repo], { enabled: true })
expect(result).toEqual([{ label: 'repo', root: repo }])
})
})
describe('repository scan path normalization', () => {
it('expands tilde and resolves relative paths from home', () => {
expect(normalizeRepoScanPath('~/src', { homeDir: '/Users/rudi', platform: 'darwin' })?.value).toBe(
'/Users/rudi/src'
)
expect(normalizeRepoScanPath('src', { homeDir: '/Users/rudi', platform: 'linux' })?.value).toBe('/Users/rudi/src')
})
it('uses segment-aware, case-insensitive containment on Windows', () => {
const options = { homeDir: 'C:\\Users\\Rudi', platform: 'win32' as const }
expect(repoScanPathIsWithin('c:\\SRC\\Fever\\repo', 'C:\\src\\fever', options)).toBe(true)
expect(repoScanPathIsWithin('C:\\src\\feverish', 'C:\\src\\fever', options)).toBe(false)
})
})
+54 -126
View File
@@ -1,82 +1,32 @@
// Repo-first discovery: walk bounded roots for Git repositories using only
// Node's fs APIs. Electron owns this machine-local capability; the renderer
// supplies the profile-scoped policy from Hermes config.
// Repo-first discovery: walk bounded roots for git repos using only Node's `fs`
// — no native addon, so it just works for anyone who pulls main (no
// electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git`
// (don't descend into a repo), cap depth, and skip heavy non-repo trees so the
// first scan stays fast. Results are cached by the backend after the first run.
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const fsp = fs.promises
// Shallow on purpose: real projects live a few levels under home
// (`~/www/repo`, `~/code/org/repo`); deeper `.git` dirs are almost always
// fixtures/vendored/eval checkouts (e.g. `~/www/ha-evals/tasks/*/repo`). Repos
// you actually use but keep deeper still surface via session-derived discovery,
// so this only prunes noise, never repos with history.
const DEFAULT_MAX_DEPTH = 3
const MAX_CONCURRENCY = 32
// Big trees that are never themselves repos and would waste the walk. Anything
// hidden (dotdirs like .cache/.Trash/.npm) is skipped wholesale below, so this
// only needs the non-hidden heavyweights.
const JUNK_DIRS = new Set(['Applications', 'Library', 'node_modules', 'site-packages', 'vendor', 'venv'])
export interface RepoScanOptions {
maxDepth?: number
enabled?: boolean
excludePaths?: string[]
}
export interface RepoScanPathOptions {
homeDir?: string
platform?: NodeJS.Platform
}
interface NormalizedScanPath {
key: string
value: string
}
function pathApiFor(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
return platform === 'win32' ? path.win32 : path.posix
}
export function normalizeRepoScanPath(rawPath: string, options: RepoScanPathOptions = {}): NormalizedScanPath | null {
const platform = options.platform ?? process.platform
const homeDir = options.homeDir ?? os.homedir()
const pathApi = pathApiFor(platform)
const raw = String(rawPath ?? '').trim()
if (!raw) {
return null
}
let expanded = raw
if (raw === '~') {
expanded = homeDir
} else if (raw.startsWith('~/') || raw.startsWith('~\\')) {
expanded = pathApi.join(homeDir, raw.slice(2))
}
const absolute = pathApi.isAbsolute(expanded) ? expanded : pathApi.resolve(homeDir, expanded)
const value = pathApi.normalize(absolute)
const key = platform === 'win32' ? value.toLocaleLowerCase('en-US') : value
return { key, value }
}
export function repoScanPathIsWithin(candidate: string, parent: string, options: RepoScanPathOptions = {}): boolean {
const platform = options.platform ?? process.platform
const pathApi = pathApiFor(platform)
const candidatePath = normalizeRepoScanPath(candidate, options)
const parentPath = normalizeRepoScanPath(parent, options)
if (!candidatePath || !parentPath) {
return false
}
const relative = pathApi.relative(parentPath.key, candidatePath.key)
return (
relative === '' || (relative !== '..' && !relative.startsWith(`..${pathApi.sep}`) && !pathApi.isAbsolute(relative))
)
}
async function mapLimit<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
async function mapLimit(items, limit, fn) {
let cursor = 0
async function worker(): Promise<void> {
async function worker() {
while (cursor < items.length) {
const index = cursor
cursor += 1
@@ -84,85 +34,63 @@ async function mapLimit<T>(items: T[], limit: number, fn: (item: T) => Promise<v
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()))
await Promise.all(Array.from({ length: Math.min(limit, items.length) } as any, worker))
}
/**
* Scan roots for Git repositories. An empty root list preserves the historical
* home-directory scan. Disabled discovery returns before resolving home or
* reading the filesystem.
* Scan `roots` (default: the home dir) for git repositories. Returns deduped
* `{ root, label }` entries. `options.maxDepth` caps recursion (default 3).
*/
export async function scanGitRepos(roots: string[], options: RepoScanOptions = {}) {
if (options.enabled === false) {
return []
}
async function scanGitRepos(roots, options: any = {}) {
const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH
const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()]
const found = new Map()
const maxDepthValue = Number(options.maxDepth)
const maxDepth = Number.isFinite(maxDepthValue) && maxDepthValue >= 0 ? maxDepthValue : DEFAULT_MAX_DEPTH
const pathOptions: RepoScanPathOptions = {}
const requestedRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()]
const searchRoots = [
...new Map(
requestedRoots
.map(root => normalizeRepoScanPath(root, pathOptions))
.filter((entry): entry is NormalizedScanPath => entry !== null)
.map(entry => [entry.key, entry.value])
).values()
]
const exclusions = (options.excludePaths ?? [])
.map(excluded => normalizeRepoScanPath(excluded, pathOptions))
.filter((entry): entry is NormalizedScanPath => entry !== null)
const found = new Map<string, { root: string; label: string }>()
function isExcluded(candidate: string): boolean {
return exclusions.some(excluded => repoScanPathIsWithin(candidate, excluded.value, pathOptions))
}
async function walk(dir: string, depth: number): Promise<void> {
if (depth > maxDepth || isExcluded(dir)) {
async function walk(dir, depth) {
if (depth > maxDepth) {
return
}
let entries: fs.Dirent[]
let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
return
return // unreadable / permission denied
}
const gitDir = entries.find(entry => entry.name === '.git' && entry.isDirectory())
if (gitDir) {
try {
await fsp.access(path.join(dir, '.git', 'HEAD'), fs.constants.R_OK)
} catch {
return
}
const normalized = normalizeRepoScanPath(dir, pathOptions)
if (normalized) {
found.set(normalized.key, {
root: normalized.value,
label: path.basename(normalized.value) || normalized.value
})
}
// A `.git` DIRECTORY marks a real repo root (a main checkout). A `.git`
// FILE is a linked worktree or submodule — those belong to their parent
// repo as lanes, not as separate projects, so we don't list them (and we
// keep descending in case a real repo sits deeper). This is what kills the
// worktree/eval-repo duplicate explosion.
if (entries.some(entry => entry.name === '.git' && entry.isDirectory())) {
const root = dir.replace(/[/\\]+$/, '')
found.set(root, path.basename(root) || root)
return
}
const subdirs = entries
.filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && !JUNK_DIRS.has(entry.name))
.map(entry => path.join(dir, entry.name))
const subdirs = []
await mapLimit(subdirs, MAX_CONCURRENCY, subdir => walk(subdir, depth + 1))
for (const entry of entries) {
// Real directories only (skip symlinks to avoid loops), no hidden dirs, no
// known heavy trees.
if (!entry.isDirectory() || entry.name.startsWith('.') || JUNK_DIRS.has(entry.name)) {
continue
}
subdirs.push(path.join(dir, entry.name))
}
await mapLimit(subdirs, MAX_CONCURRENCY, sub => walk(sub, depth + 1))
}
await mapLimit(searchRoots, MAX_CONCURRENCY, root => walk(root, 0))
await mapLimit(searchRoots.map(root => String(root || '').trim()).filter(Boolean), MAX_CONCURRENCY, root =>
walk(root, 0)
)
return [...found.values()]
return [...found.entries()].map(([root, label]) => ({ label, root }))
}
export { scanGitRepos }
File diff suppressed because it is too large Load Diff
@@ -1,72 +0,0 @@
/**
* Regression tests for electron/native-auth-decisions.ts — the three pure
* decision seams behind the RFC 8252 native-app auth flow, each of which was a
* real runtime bug that the mocked flow tests could not catch.
*
* Run via the vitest `electron` project (electron/**\/*.test.ts).
*/
import assert from 'node:assert/strict'
import { test } from 'vitest'
import {
oauthSessionIsLive,
resolveJsonBody,
resolveOauthRestAuth
} from './native-auth-decisions'
// --- 1. body encoding (guards the double-JSON.stringify 422) ---
test('resolveJsonBody returns the object unchanged (no pre-stringify)', () => {
const body = { code: 'abc', code_verifier: 'xyz' }
const out = resolveJsonBody(body)
// Must be the SAME object reference / shape — NOT a JSON string. Pre-
// stringifying here is what produced the gateway 422 "Input should be a
// valid dictionary" at /auth/native/token.
assert.equal(typeof out, 'object')
assert.deepEqual(out, body)
})
test('resolveJsonBody does not stringify — a string stays a string, an object stays an object', () => {
assert.equal(typeof resolveJsonBody({ a: 1 }), 'object')
// If a caller ever passes an already-encoded string (the bug), we return it
// as-is rather than re-wrapping — the contract is "fetchJson owns encoding".
assert.equal(typeof resolveJsonBody('{"a":1}'), 'string')
})
// --- 2. oauth liveness (guards the needsOauthLogin loop) ---
test('oauthSessionIsLive is true when a native bearer token exists, even with no cookie', () => {
// The exact bug: native login stores a bearer, sets no cookie. Gating on the
// cookie alone looped the UI into "not signed in".
assert.equal(oauthSessionIsLive(true, false), true)
})
test('oauthSessionIsLive is true when a live cookie exists with no native token', () => {
assert.equal(oauthSessionIsLive(false, true), true)
})
test('oauthSessionIsLive is true when both are present', () => {
assert.equal(oauthSessionIsLive(true, true), true)
})
test('oauthSessionIsLive is false only when neither is present', () => {
assert.equal(oauthSessionIsLive(false, false), false)
})
// --- 3. REST auth selection (guards the 401 no_cookie) ---
test('resolveOauthRestAuth prefers the native bearer when a token is present', () => {
const auth = resolveOauthRestAuth('bearer-token-123')
assert.deepEqual(auth, { kind: 'bearer', token: 'bearer-token-123' })
})
test('resolveOauthRestAuth falls back to cookie when there is no native token', () => {
assert.deepEqual(resolveOauthRestAuth(null), { kind: 'cookie' })
assert.deepEqual(resolveOauthRestAuth(undefined), { kind: 'cookie' })
// Empty string is not a usable bearer — must fall back, not send "Bearer ".
assert.deepEqual(resolveOauthRestAuth(''), { kind: 'cookie' })
})
@@ -1,64 +0,0 @@
/**
* native-auth-decisions.ts
*
* Pure decision helpers extracted from main.ts for the RFC 8252 native-app
* auth flow. These encode three choices that were each the site of a real
* runtime bug — invisible to the mocked flow tests because the tests never
* exercised the real main.ts internals. Keeping them pure + unit-tested here
* prevents silent regressions:
*
* 1. resolveJsonBody — the token/refresh POST body must be the raw
* object (fetchJson owns JSON.stringify). Pre-stringifying double-encodes
* it into a JSON string, which the gateway's Pydantic model rejects with
* 422 "Input should be a valid dictionary".
*
* 2. oauthSessionIsLive — an OAuth gateway is "signed in" when EITHER a
* native bearer token OR a live cookie session exists. Gating on the
* cookie alone rejects a completed native login and loops the UI into
* "not signed in".
*
* 3. resolveOauthRestAuth — an oauth-mode REST call authenticates with the
* native bearer when present, else the cookie partition. Cookie-only
* routing returns 401 no_cookie for a cookieless native session.
*
* All three are trivial once named; the value is the test that pins the
* contract so the god-file call sites can't drift back to the buggy shape.
*/
/**
* Decide the request body to hand to fetchJson (which JSON.stringifies it).
* Returns the object UNCHANGED — callers must NOT pre-stringify. A string here
* would be double-encoded downstream; this function exists to document and
* pin that contract at the one seam that got it wrong.
*/
export function resolveJsonBody<T>(body: T): T {
return body
}
/**
* True when an oauth gateway should be treated as signed-in. `hasNativeToken`
* is whether a native bearer token is stored; `hasCookieSession` is whether a
* live AT-or-RT cookie exists in the OAuth partition. Either suffices.
*/
export function oauthSessionIsLive(hasNativeToken: boolean, hasCookieSession: boolean): boolean {
return hasNativeToken || hasCookieSession
}
export type OauthRestAuth =
| { kind: 'bearer'; token: string }
| { kind: 'cookie' }
/**
* Decide how an oauth-mode REST request authenticates: prefer the native
* bearer (cookieless RFC 8252 flow) when a non-empty access token is present,
* otherwise fall back to the cookie partition. `nativeAccessToken` is the
* result of ensureNativeAccessToken (null/empty when there is no native
* session or the refresh terminally failed).
*/
export function resolveOauthRestAuth(nativeAccessToken: string | null | undefined): OauthRestAuth {
if (nativeAccessToken) {
return { kind: 'bearer', token: nativeAccessToken }
}
return { kind: 'cookie' }
}
@@ -1,169 +0,0 @@
/**
* Tests for electron/native-oauth-login.ts — the loopback-listener
* orchestration of the RFC 8252 native login, with all I/O injected (fake
* http server, fake openExternal, fake token POST) so no real socket or
* browser is needed.
*
* Run with: node --test electron/native-oauth-login.test.ts
*/
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import { test } from 'vitest'
import { runNativeLogin } from './native-oauth-login'
// A fake http.Server: captures the request handler, lets the test drive a
// synthetic browser callback, and records listen/close lifecycle.
function makeFakeServerFactory(port = 51234) {
const state: any = { handler: null, listening: false, closed: false, openedUrl: null }
const createServer: any = (handler: any) => {
state.handler = handler
const server: any = new EventEmitter()
server.listen = (_port: number, _host: string, cb: () => void) => {
state.listening = true
cb()
}
server.address = () => ({ address: '127.0.0.1', family: 'IPv4', port })
server.close = () => {
state.closed = true
}
state.server = server
return server
}
// Drive a synthetic browser hit to the loopback callback.
state.hitCallback = (query: string) => {
const res: any = { writeHead: () => undefined, end: () => undefined }
state.handler({ url: `/callback?${query}` }, res)
}
return { createServer, state }
}
test('runNativeLogin completes the loopback round trip and returns tokens', async () => {
const { createServer, state } = makeFakeServerFactory()
let capturedAuthorizeUrl = ''
let tokenPostBody: any = null
const promise = runNativeLogin(
'https://gw.example.com',
{
openExternal: async url => {
capturedAuthorizeUrl = url
},
postJson: async (_url, body) => {
tokenPostBody = body
return {
access_token: 'AT-native',
refresh_token: 'RT-native',
token_type: 'Bearer',
expires_at: 1893456000,
provider: 'nous',
user_id: 'u-9'
}
},
createServer,
timeoutMs: 5_000
},
{ provider: 'nous' }
)
// Give the listen callback a tick to open the browser + capture the URL.
await new Promise(r => setTimeout(r, 5))
// The authorize URL must carry OUR challenge + loopback redirect + state.
const authorize = new URL(capturedAuthorizeUrl)
assert.equal(authorize.pathname, '/auth/native/authorize')
const challenge = authorize.searchParams.get('code_challenge')
const stateParam = authorize.searchParams.get('state')
assert.ok(challenge && challenge.length > 0)
assert.match(authorize.searchParams.get('redirect_uri') || '', /^http:\/\/127\.0\.0\.1:\d+\/callback$/)
// Synthetic browser redirect back with the matching state + a code.
state.hitCallback(`code=gw-code-1&state=${encodeURIComponent(stateParam!)}`)
const tokens = await promise
assert.equal(tokens.accessToken, 'AT-native')
assert.equal(tokens.refreshToken, 'RT-native')
assert.equal(tokens.userId, 'u-9')
// The token POST carried the code + a verifier whose hash is the challenge.
assert.equal(tokenPostBody.code, 'gw-code-1')
assert.ok(tokenPostBody.code_verifier && tokenPostBody.code_verifier.length >= 43)
// Listener was cleaned up.
assert.equal(state.closed, true)
})
test('runNativeLogin rejects on a state mismatch (CSRF) without redeeming', async () => {
const { createServer, state } = makeFakeServerFactory()
let tokenPostCalled = false
const promise = runNativeLogin('https://gw.example.com', {
openExternal: async () => undefined,
postJson: async () => {
tokenPostCalled = true
return {}
},
createServer,
timeoutMs: 5_000
})
await new Promise(r => setTimeout(r, 5))
// Wrong state — must not redeem the code.
state.hitCallback('code=evil&state=not-the-real-state')
await assert.rejects(promise, /state mismatch/i)
assert.equal(tokenPostCalled, false)
assert.equal(state.closed, true)
})
test('runNativeLogin surfaces a gateway error param', async () => {
const { createServer, state } = makeFakeServerFactory()
const promise = runNativeLogin('https://gw.example.com', {
openExternal: async () => undefined,
postJson: async () => ({}),
createServer,
timeoutMs: 5_000
})
await new Promise(r => setTimeout(r, 5))
state.hitCallback('error=access_denied&error_description=user_declined')
await assert.rejects(promise, /access_denied/i)
})
test('runNativeLogin times out when no callback arrives', async () => {
const { createServer } = makeFakeServerFactory()
await assert.rejects(
runNativeLogin('https://gw.example.com', {
openExternal: async () => undefined,
postJson: async () => ({}),
createServer,
timeoutMs: 20
}),
/timed out/i
)
})
test('runNativeLogin fails if the browser cannot be opened', async () => {
const { createServer } = makeFakeServerFactory()
await assert.rejects(
runNativeLogin('https://gw.example.com', {
openExternal: async () => {
throw new Error('no browser')
},
postJson: async () => ({}),
createServer,
timeoutMs: 5_000
}),
/could not open the system browser/i
)
})
-213
View File
@@ -1,213 +0,0 @@
/**
* native-oauth-login.ts
*
* Electron-coupled driver for the RFC 8252 native-app login: it runs the
* loopback HTTP listener that catches the gateway's browser redirect, opens
* the system browser, redeems the one-time code for tokens, and hands them
* back. The PURE logic (PKCE, URL building, callback parsing, token-response
* normalization) lives in native-oauth.ts and is unit-tested separately; this
* module is the thin I/O shell around it.
*
* Dependencies are INJECTED (openExternal, a JSON-POST fn, an http-server
* factory, a clock) so the orchestration is testable without booting Electron
* or opening real sockets — mirroring how connection-config.ts injects
* `mintTicket`. main.ts supplies the real electron shell.openExternal,
* electron.net POST, and node:http server.
*
* Security posture (see native-oauth.ts for the flow-level rationale):
* - the loopback server binds 127.0.0.1 on an EPHEMERAL port and shuts down
* the instant it receives the callback (or times out) — no long-lived
* local listener;
* - the `state` is verified before the code is redeemed (CSRF);
* - the PKCE verifier never leaves this process until the token POST, and
* the gateway enforces SHA256(verifier)==challenge server-side;
* - the browser sees only a minimal "you can close this window" HTML page,
* never the tokens.
*/
import http from 'node:http'
import type { AddressInfo } from 'node:net'
import {
buildNativeAuthorizeUrl,
generatePkcePair,
generateState,
nativeTokenUrl,
parseLoopbackCallback,
parseTokenResponse,
type NativeTokenSet
} from './native-oauth'
// Loopback login must complete inside this window (user opens browser,
// authenticates, gets redirected back). Matches the server-side pending TTL.
const DEFAULT_LOGIN_TIMEOUT_MS = 5 * 60 * 1000
// The minimal page the browser lands on after the gateway redirect. No tokens,
// no secrets — just a close affordance. Served for any loopback request so a
// favicon probe doesn't look like a failure.
const DONE_HTML =
'<!doctype html><meta charset="utf-8"><title>Signed in</title>' +
'<body style="font:15px system-ui;margin:3rem;text-align:center">' +
'<h2>&#10003; Signed in to Hermes</h2>' +
'<p>You can close this window and return to the app.</p>' +
'<script>setTimeout(()=>window.close(),800)</script>'
export interface NativeLoginDeps {
/** Open a URL in the user's system browser (shell.openExternal). */
openExternal: (url: string) => Promise<void>
/** POST JSON and resolve the parsed body (electron.net-backed in prod). */
postJson: (url: string, body: unknown, opts?: { timeoutMs?: number }) => Promise<any>
/** http.createServer, injectable for tests. */
createServer?: typeof http.createServer
/** Clock + timeout, injectable for tests. */
now?: () => number
timeoutMs?: number
/** Optional logger for boot diagnostics. */
rememberLog?: (line: string) => void
}
/**
* Drive a full native login against `baseUrl` and return the token set.
*
* Steps: bind a loopback listener → open the system browser at the gateway's
* /auth/native/authorize with our PKCE challenge + loopback redirect_uri →
* await the ?code= redirect → verify state → POST /auth/native/token with the
* verifier → return tokens. Rejects on timeout, state mismatch, a gateway
* error param, or a token-exchange failure. Always tears the listener down.
*/
export async function runNativeLogin(
baseUrl: string,
deps: NativeLoginDeps,
opts: { provider?: string } = {}
): Promise<NativeTokenSet> {
const createServer = deps.createServer || http.createServer
const timeoutMs = deps.timeoutMs ?? DEFAULT_LOGIN_TIMEOUT_MS
const log = deps.rememberLog || (() => undefined)
const { verifier, challenge } = generatePkcePair()
const state = generateState()
return new Promise<NativeTokenSet>((resolve, reject) => {
let settled = false
let timer: NodeJS.Timeout | null = null
const server = createServer((req, res) => {
// Only the callback path carries the code; any other path (favicon,
// etc.) still gets the friendly page so the browser tab looks sane.
const url = req.url || '/'
// Always answer the browser with the close page — we never surface the
// outcome to the browser, only to the app.
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
res.end(DONE_HTML)
if (settled) {
return
}
// Ignore non-callback noise (e.g. /favicon.ico) — wait for the ?code=.
if (!/[?&](code|error)=/.test(url)) {
return
}
try {
const { code } = parseLoopbackCallback(url, state)
finishWith(async () => {
const tokenBody = await deps.postJson(
nativeTokenUrl(baseUrl),
{ code, code_verifier: verifier },
{ timeoutMs: 15_000 }
)
return parseTokenResponse(tokenBody)
})
} catch (error) {
fail(error instanceof Error ? error : new Error(String(error)))
}
})
const cleanup = () => {
if (timer) {
clearTimeout(timer)
}
try {
server.close()
} catch {
// already closed
}
}
const fail = (error: Error) => {
if (settled) {
return
}
settled = true
cleanup()
reject(error)
}
const finishWith = (produce: () => Promise<NativeTokenSet>) => {
if (settled) {
return
}
settled = true
// Keep the listener up just long enough to have answered the browser,
// then redeem the code out-of-band.
produce()
.then(tokens => {
cleanup()
resolve(tokens)
})
.catch(error => {
cleanup()
reject(error instanceof Error ? error : new Error(String(error)))
})
}
server.on('error', err => fail(err instanceof Error ? err : new Error(String(err))))
// Bind an ephemeral loopback port, then open the browser.
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as AddressInfo | null
if (!addr || typeof addr === 'string') {
fail(new Error('Failed to bind loopback listener for native login'))
return
}
const redirectUri = `http://127.0.0.1:${addr.port}/callback`
const authorizeUrl = buildNativeAuthorizeUrl(baseUrl, {
challenge,
redirectUri,
state,
provider: opts.provider
})
timer = setTimeout(() => {
fail(
new Error(
'Native sign-in timed out. The browser window may not have completed ' +
'sign-in; open Settings → Gateway and try again.'
)
)
}, timeoutMs)
log(`[native-oauth] loopback listening on 127.0.0.1:${addr.port}; opening system browser`)
deps.openExternal(authorizeUrl).catch(error => {
fail(
new Error(
`Could not open the system browser for native sign-in: ${
error instanceof Error ? error.message : String(error)
}`
)
)
})
})
})
}
export { DEFAULT_LOGIN_TIMEOUT_MS }
-190
View File
@@ -1,190 +0,0 @@
/**
* Tests for electron/native-oauth.ts — the pure RFC 8252 native-app login
* helpers (PKCE, capability detection, URL building, loopback callback
* parsing, token-response normalization, refresh-timing).
*
* Run with: node --test electron/native-oauth.test.ts
* (Wired into the vitest `electron` project via electron/**\/*.test.ts.)
*/
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { test } from 'vitest'
import {
buildNativeAuthorizeUrl,
generatePkcePair,
generateState,
NATIVE_FLOW_ID,
nativeRefreshUrl,
nativeTokenUrl,
parseLoopbackCallback,
parseTokenResponse,
resolveLoginStrategy,
statusSupportsNativeFlow,
tokenNeedsRefresh
} from './native-oauth'
// --- PKCE ---
test('generatePkcePair produces a valid S256 verifier/challenge', () => {
const pair = generatePkcePair()
assert.equal(pair.method, 'S256')
// Verifier length within RFC 7636 range (43–128).
assert.ok(pair.verifier.length >= 43 && pair.verifier.length <= 128)
// Challenge must be the base64url SHA-256 of the verifier.
const expected = createHash('sha256')
.update(pair.verifier, 'ascii')
.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
assert.equal(pair.challenge, expected)
// No padding / URL-unsafe chars.
assert.doesNotMatch(pair.verifier, /[+/=]/)
assert.doesNotMatch(pair.challenge, /[+/=]/)
})
test('generatePkcePair is unique per call', () => {
assert.notEqual(generatePkcePair().verifier, generatePkcePair().verifier)
})
test('generateState is non-empty and URL-safe', () => {
const s = generateState()
assert.ok(s.length > 0)
assert.doesNotMatch(s, /[+/=]/)
})
// --- capability detection ---
test('statusSupportsNativeFlow reads the auth_flows array', () => {
assert.equal(statusSupportsNativeFlow({ auth_flows: ['cookie', NATIVE_FLOW_ID] }), true)
assert.equal(statusSupportsNativeFlow({ auth_flows: ['cookie'] }), false)
// Older gateway: no auth_flows field at all ⇒ not supported.
assert.equal(statusSupportsNativeFlow({ auth_required: true }), false)
assert.equal(statusSupportsNativeFlow({}), false)
assert.equal(statusSupportsNativeFlow(null), false)
// Malformed field shapes never throw.
assert.equal(statusSupportsNativeFlow({ auth_flows: 'native_pkce' }), false)
})
test('resolveLoginStrategy picks native only when advertised and not forced', () => {
const gated = { auth_required: true, auth_flows: ['cookie', 'native_pkce'] }
const legacy = { auth_required: true, auth_flows: ['cookie'] }
assert.equal(resolveLoginStrategy(gated), 'native')
// Compatibility fallback: an older gateway lacking native_pkce ⇒ embedded.
assert.equal(resolveLoginStrategy(legacy), 'embedded')
// A user/env override can pin the legacy flow even on a capable gateway.
assert.equal(resolveLoginStrategy(gated, { forceEmbedded: true }), 'embedded')
})
// --- URL building ---
test('buildNativeAuthorizeUrl encodes params and honours a path prefix', () => {
const url = buildNativeAuthorizeUrl('https://gw.example.com', {
challenge: 'CHAL',
redirectUri: 'http://127.0.0.1:51000/callback',
state: 'STATE',
provider: 'nous'
})
const parsed = new URL(url)
assert.equal(parsed.origin, 'https://gw.example.com')
assert.equal(parsed.pathname, '/auth/native/authorize')
assert.equal(parsed.searchParams.get('code_challenge'), 'CHAL')
assert.equal(parsed.searchParams.get('code_challenge_method'), 'S256')
assert.equal(parsed.searchParams.get('redirect_uri'), 'http://127.0.0.1:51000/callback')
assert.equal(parsed.searchParams.get('state'), 'STATE')
assert.equal(parsed.searchParams.get('provider'), 'nous')
})
test('buildNativeAuthorizeUrl omits provider when not given and preserves prefix', () => {
const url = buildNativeAuthorizeUrl('https://gw.example.com/hermes', {
challenge: 'C',
redirectUri: 'http://127.0.0.1:1/cb',
state: 'S'
})
const parsed = new URL(url)
assert.equal(parsed.pathname, '/hermes/auth/native/authorize')
assert.equal(parsed.searchParams.get('provider'), null)
})
test('nativeTokenUrl / nativeRefreshUrl build the right endpoints', () => {
assert.equal(nativeTokenUrl('https://gw.example.com'), 'https://gw.example.com/auth/native/token')
assert.equal(nativeRefreshUrl('https://gw.example.com/hermes'), 'https://gw.example.com/hermes/auth/native/refresh')
})
// --- loopback callback parsing ---
test('parseLoopbackCallback returns the code on a state match', () => {
const { code } = parseLoopbackCallback('/callback?code=abc123&state=xyz', 'xyz')
assert.equal(code, 'abc123')
})
test('parseLoopbackCallback throws on state mismatch (CSRF)', () => {
assert.throws(
() => parseLoopbackCallback('/callback?code=abc&state=attacker', 'expected'),
/state mismatch/i
)
})
test('parseLoopbackCallback surfaces a gateway error param', () => {
assert.throws(
() => parseLoopbackCallback('/callback?error=access_denied&error_description=nope', 'xyz'),
/access_denied.*nope/i
)
})
test('parseLoopbackCallback throws when the code is absent', () => {
assert.throws(() => parseLoopbackCallback('/callback?state=xyz', 'xyz'), /missing authorization code/i)
})
// --- token response normalization ---
test('parseTokenResponse maps a well-formed body', () => {
const t = parseTokenResponse({
access_token: 'AT',
refresh_token: 'RT',
token_type: 'Bearer',
expires_at: 1893456000,
provider: 'nous',
user_id: 'u-1'
})
assert.equal(t.accessToken, 'AT')
assert.equal(t.refreshToken, 'RT')
assert.equal(t.expiresAt, 1893456000)
assert.equal(t.provider, 'nous')
assert.equal(t.userId, 'u-1')
})
test('parseTokenResponse throws on a missing access token', () => {
assert.throws(() => parseTokenResponse({ refresh_token: 'RT' }), /missing access_token/i)
})
test('parseTokenResponse tolerates an absent refresh token / expiry', () => {
const t = parseTokenResponse({ access_token: 'AT' })
assert.equal(t.refreshToken, '')
assert.equal(t.expiresAt, 0)
})
// --- refresh timing ---
test('tokenNeedsRefresh respects the skew window', () => {
const now = 1_000_000
// Expires comfortably in the future ⇒ no refresh.
assert.equal(tokenNeedsRefresh({ expiresAt: now + 3600 }, now), false)
// Within the 60s skew ⇒ refresh early.
assert.equal(tokenNeedsRefresh({ expiresAt: now + 30 }, now), true)
// Already expired ⇒ refresh.
assert.equal(tokenNeedsRefresh({ expiresAt: now - 10 }, now), true)
// Unknown expiry ⇒ refresh (validate before use).
assert.equal(tokenNeedsRefresh({ expiresAt: 0 }, now), true)
})
-215
View File
@@ -1,215 +0,0 @@
/**
* native-oauth.ts
*
* Pure, electron-free helpers for the desktop's RFC 8252 (OAuth 2.0 for Native
* Apps) login to a gated Hermes gateway: system-browser + loopback redirect +
* PKCE, with tokens returned to the app (never browser session cookies).
*
* Kept standalone (no `import 'electron'`) so it unit-tests with `node --test`
* — same pattern as connection-config.ts. main.ts owns the electron-coupled
* parts (the actual http.Server loopback listener, shell.openExternal, and
* safeStorage keychain writes) and calls these helpers for the pure logic.
*
* Why the gateway brokers the flow (not a direct desktop→IDP client): the
* upstream IDP (Nous Portal) issues a per-gateway-instance client_id and only
* accepts a redirect_uri on the gateway's own origin, so a desktop loopback
* redirect can't be a direct Portal client. Instead the gateway exposes
* /auth/native/{authorize,token,refresh}: it is the authorization server to
* the desktop and an OAuth client to Portal. The desktop still gets the full
* RFC 8252 experience — its own PKCE pair, its own loopback redirect, tokens
* it stores itself.
*
* Capability detection: the gateway advertises supported flows on the public
* /api/status `auth_flows` array. `native_pkce` present ⇒ use this flow;
* absent (older gateway) ⇒ the caller falls back to the embedded-webview
* cookie flow. This is the "observable ladder / compatibility fallback tied to
* an identified older runtime" the desktop guide requires.
*/
import { createHash, randomBytes } from 'node:crypto'
// The gateway status field that lists supported auth flows. See
// hermes_cli/web_server.py status handler.
const NATIVE_FLOW_ID = 'native_pkce'
export interface NativePkcePair {
verifier: string
challenge: string
method: 'S256'
}
export interface NativeTokenSet {
accessToken: string
refreshToken: string
expiresAt: number
provider: string
userId: string
}
/** base64url without `=` padding (RFC 7636 §4). */
function b64url(raw: Buffer): string {
return raw.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
/**
* Generate a PKCE verifier/challenge pair (S256). The verifier is 32 random
* bytes base64url-encoded (43 chars, within RFC 7636's 43–128 range).
*/
export function generatePkcePair(randomImpl: (n: number) => Buffer = randomBytes): NativePkcePair {
const verifier = b64url(randomImpl(32))
const challenge = b64url(createHash('sha256').update(verifier, 'ascii').digest())
return { verifier, challenge, method: 'S256' }
}
/** A high-entropy CSRF `state` value for the loopback round trip. */
export function generateState(randomImpl: (n: number) => Buffer = randomBytes): string {
return b64url(randomImpl(24))
}
/**
* True if a gateway `/api/status` body advertises the native PKCE flow.
* Tolerant of the field being absent (older gateway) or malformed.
*/
export function statusSupportsNativeFlow(statusBody: any): boolean {
const flows = statusBody && statusBody.auth_flows
return Array.isArray(flows) && flows.includes(NATIVE_FLOW_ID)
}
/**
* Decide the login strategy for a gated gateway from its status body.
* Returns 'native' when the gateway can do RFC 8252 AND we're not forced to
* the legacy path; 'embedded' otherwise (older gateway ⇒ webview fallback).
*
* `forceEmbedded` lets a user/setting or an env override pin the legacy flow
* (e.g. a corporate proxy that blocks loopback). Precedence written down here,
* in one place, as a pure function — per the desktop "observable ladder" rule.
*/
export function resolveLoginStrategy(
statusBody: any,
opts: { forceEmbedded?: boolean } = {}
): 'native' | 'embedded' {
if (opts.forceEmbedded) {
return 'embedded'
}
return statusSupportsNativeFlow(statusBody) ? 'native' : 'embedded'
}
/**
* Build the gateway `/auth/native/authorize` URL the system browser opens.
* `redirectUri` is the desktop's loopback callback (127.0.0.1:<port>/...).
* `provider` is optional — omitted lets the gateway pick when it has exactly
* one session provider (the common hosted case).
*/
export function buildNativeAuthorizeUrl(
baseUrl: string,
params: { challenge: string; redirectUri: string; state: string; provider?: string }
): string {
const parsed = new URL(baseUrl)
const prefix = parsed.pathname.replace(/\/+$/, '')
const q = new URLSearchParams({
code_challenge: params.challenge,
code_challenge_method: 'S256',
redirect_uri: params.redirectUri,
state: params.state
})
if (params.provider) {
q.set('provider', params.provider)
}
return `${parsed.protocol}//${parsed.host}${prefix}/auth/native/authorize?${q.toString()}`
}
/** The `/auth/native/token` endpoint URL for a gateway base URL. */
export function nativeTokenUrl(baseUrl: string): string {
const parsed = new URL(baseUrl)
const prefix = parsed.pathname.replace(/\/+$/, '')
return `${parsed.protocol}//${parsed.host}${prefix}/auth/native/token`
}
/** The `/auth/native/refresh` endpoint URL for a gateway base URL. */
export function nativeRefreshUrl(baseUrl: string): string {
const parsed = new URL(baseUrl)
const prefix = parsed.pathname.replace(/\/+$/, '')
return `${parsed.protocol}//${parsed.host}${prefix}/auth/native/refresh`
}
/**
* Parse the loopback redirect the gateway sends the browser to. Returns the
* `code` + `state`, or throws with the gateway's `error` if the flow failed.
* `expectedState` MUST match (CSRF defense — RFC 6749 §10.12); a mismatch
* throws rather than proceeding.
*/
export function parseLoopbackCallback(
requestUrl: string,
expectedState: string
): { code: string } {
// requestUrl is the path+query the loopback server received, e.g.
// "/callback?code=...&state=...". Resolve against a dummy origin to parse.
const parsed = new URL(requestUrl, 'http://127.0.0.1')
const error = parsed.searchParams.get('error')
if (error) {
const desc = parsed.searchParams.get('error_description') || ''
throw new Error(`Gateway rejected native login: ${error}${desc ? ` (${desc})` : ''}`)
}
const code = parsed.searchParams.get('code') || ''
const state = parsed.searchParams.get('state') || ''
if (!code) {
throw new Error('Loopback callback missing authorization code')
}
if (!expectedState || state !== expectedState) {
// Never redeem a code that arrived with a mismatched state — it may be a
// forged callback trying to inject an attacker's code.
throw new Error('Loopback callback state mismatch (possible CSRF)')
}
return { code }
}
/**
* Normalize a `/auth/native/token` (or refresh) JSON response into a
* NativeTokenSet, validating the shape. Throws on a missing/short access
* token so a malformed response fails loudly rather than storing junk.
*/
export function parseTokenResponse(body: any): NativeTokenSet {
const accessToken = String(body?.access_token || '')
if (!accessToken) {
throw new Error('Gateway token response missing access_token')
}
const expiresAt = Number(body?.expires_at)
return {
accessToken,
refreshToken: String(body?.refresh_token || ''),
expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0,
provider: String(body?.provider || ''),
userId: String(body?.user_id || '')
}
}
/**
* True when a stored token set is at/near expiry and should be refreshed
* before use. `skewSeconds` refreshes slightly early to avoid a race where
* the token expires in flight (mirrors the server's 60s cookie floor).
*/
export function tokenNeedsRefresh(tokens: Pick<NativeTokenSet, 'expiresAt'>, nowSeconds: number, skewSeconds = 60): boolean {
if (!tokens || !Number.isFinite(tokens.expiresAt) || tokens.expiresAt <= 0) {
// Unknown expiry ⇒ treat as needing refresh so we validate before use.
return true
}
return nowSeconds >= tokens.expiresAt - skewSeconds
}
export { NATIVE_FLOW_ID }
-58
View File
@@ -1,58 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { createKeepAwake, type PowerSaveBlockerLike } from './power-save'
function fakeBlocker() {
let next = 1
const started = new Set<number>()
const blocker: PowerSaveBlockerLike = {
isStarted: id => started.has(id),
start: vi.fn(() => {
const id = next++
started.add(id)
return id
}),
stop: vi.fn(id => void started.delete(id))
}
return { blocker, started }
}
describe('createKeepAwake', () => {
it('starts once, is idempotent, and stops', () => {
const { blocker } = fakeBlocker()
const keepAwake = createKeepAwake(blocker)
expect(keepAwake.isActive()).toBe(false)
expect(keepAwake.set(true)).toBe(true)
keepAwake.set(true) // idempotent — no second blocker
expect(blocker.start).toHaveBeenCalledTimes(1)
expect(blocker.start).toHaveBeenCalledWith('prevent-app-suspension')
expect(keepAwake.set(false)).toBe(false)
keepAwake.set(false)
expect(blocker.stop).toHaveBeenCalledTimes(1)
})
it('re-arms after the OS dropped the blocker', () => {
const { blocker, started } = fakeBlocker()
const keepAwake = createKeepAwake(blocker)
keepAwake.set(true)
started.clear() // system released it out from under us
expect(keepAwake.isActive()).toBe(false)
keepAwake.set(true)
expect(blocker.start).toHaveBeenCalledTimes(2)
expect(keepAwake.isActive()).toBe(true)
})
it('honors a custom blocker type', () => {
const { blocker } = fakeBlocker()
createKeepAwake(blocker, 'prevent-display-sleep').set(true)
expect(blocker.start).toHaveBeenCalledWith('prevent-display-sleep')
})
})
-50
View File
@@ -1,50 +0,0 @@
/**
* Keep-awake — hold a single machine-global power-save blocker.
*
* `prevent-app-suspension` stops the system from sleeping (long overnight
* agent runs keep going) while still letting the display dim. The renderer
* owns the preference (persisted in localStorage) and mirrors it here over
* IPC; the main process owns the one native blocker, same authority split as
* translucency/zoom. Electron auto-releases the blocker on quit.
*/
export type KeepAwakeType = 'prevent-app-suspension' | 'prevent-display-sleep'
/** The slice of Electron's `powerSaveBlocker` we use (injected for testing). */
export interface PowerSaveBlockerLike {
start(type: KeepAwakeType): number
stop(id: number): void
isStarted(id: number): boolean
}
export interface KeepAwake {
/** Turn the blocker on/off (idempotent). Returns the resulting state. */
set(on: boolean): boolean
isActive(): boolean
}
export function createKeepAwake(
blocker: PowerSaveBlockerLike,
type: KeepAwakeType = 'prevent-app-suspension'
): KeepAwake {
let id: null | number = null
const isActive = () => id !== null && blocker.isStarted(id)
return {
isActive,
set(on) {
if (on && !isActive()) {
id = blocker.start(type)
} else if (!on && id !== null) {
if (blocker.isStarted(id)) {
blocker.stop(id)
}
id = null
}
return isActive()
}
}
}
+1 -5
View File
@@ -6,8 +6,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
openWindow: () => ipcRenderer.invoke('hermes:window:openInstance'),
claimAmbientCue: key => ipcRenderer.invoke('hermes:ambient:claim', key),
openNewSessionWindow: () => ipcRenderer.invoke('hermes:window:openNewSession'),
petOverlay: {
// Main renderer → main process: window lifecycle + drag. `request` is
// `{ bounds, screen }`; resolves with the screen bounds it actually used.
@@ -41,8 +40,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),
testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),
sshConfigHosts: () => ipcRenderer.invoke('hermes:ssh-config:hosts'),
sshResolveHost: host => ipcRenderer.invoke('hermes:ssh-config:resolve', host),
probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl),
oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl),
oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl),
@@ -82,7 +79,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload),
setNativeTheme: mode => ipcRenderer.send('hermes:native-theme', mode),
setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload),
setKeepAwake: on => ipcRenderer.send('hermes:keep-awake', on),
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
openPreviewInBrowser: url => ipcRenderer.invoke('hermes:openPreviewInBrowser', url),
File diff suppressed because it is too large Load Diff
-906
View File
@@ -1,906 +0,0 @@
/**
* remote-lifecycle.ts
*
* Pure, electron-free remote Hermes dashboard lifecycle over SSH for Desktop
* SSH remote mode. Composes an SshConnection (injected) with HTTP probes
* through the established tunnel (injected fetch) and the served-token adoption
* step (injected). Knows how to:
*
* - locate the Hermes install on the remote (login-shell probe),
* - gate the remote platform to Linux/macOS via `uname`,
* - reuse an existing desktop-dedicated dashboard via a lockfile + an
* AUTHENTICATED /api/status probe (pid liveness alone is insufficient),
* - spawn a fresh detached `--isolated --port 0` dashboard and scrape its
* `HERMES_DASHBOARD_READY port=<n>` readiness line,
* - adopt the token the dashboard actually serves (served-token adoption),
* - clean up a stale dashboard only when it is provably ours.
*
* No `import 'electron'` so it's unit-testable with `node --test`. main.ts wires
* the real SshConnection, fetch, adoptServedDashboardToken, and waitForHermes in.
*
* The minted HERMES_DASHBOARD_SESSION_TOKEN is the SPAWN credential. After
* readiness the caller runs served-token adoption against the tunneled baseUrl
* and the SERVED token's fingerprint is what lands in the lockfile — so the
* reuse probe checks the credential that actually authenticates /api/ws, not
* the minted one (which the dashboard may regen).
*/
import crypto from 'node:crypto'
const LOCKFILE_SCHEMA_VERSION = 2
// Bumped when the desktop<->dashboard reuse contract changes in a way that makes
// an old running dashboard unsafe to reattach to (token handling, readiness/spawn
// args, served-token reconciliation). A mismatch forces a clean respawn.
const PROTOCOL_VERSION = 1
const READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\d+)/m
const REMOTE_LOCK_DIR = '~/.hermes/desktop-ssh'
const SUPPORTED_REMOTE_OS = new Set(['Linux', 'Darwin'])
const DEFAULT_READY_TIMEOUT_MS = 45_000
const READY_POLL_INTERVAL_MS = 750
function mintToken() {
return crypto.randomBytes(32).toString('hex')
}
// Fingerprint a token for the lockfile — never store the raw secret on the
// remote. SHA256, truncated.
function fingerprintToken(token) {
return crypto
.createHash('sha256')
.update(String(token || ''))
.digest('hex')
.slice(0, 32)
}
function validateOwnershipId(ownershipId) {
const value = String(ownershipId || '')
if (!/^[0-9a-f]{32}$/.test(value)) {
throw new Error('SSH ownership ID is invalid.')
}
return value
}
function validateSpawnNonce(spawnNonce) {
const value = String(spawnNonce || '')
if (!/^[0-9a-f]{16}$/.test(value)) {
throw new Error('SSH spawn nonce is invalid.')
}
return value
}
function ownershipDirectory(ownershipId) {
return `${REMOTE_LOCK_DIR}/${validateOwnershipId(ownershipId)}`
}
function lockfilePath(ownershipId) {
return `${ownershipDirectory(ownershipId)}/backend.lock.json`
}
function spawnLogPath(ownershipId, spawnNonce) {
return `${ownershipDirectory(ownershipId)}/${validateSpawnNonce(spawnNonce)}.log`
}
// shell-single-quote a value for safe interpolation into a remote command.
function shq(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`
}
function validateRemotePath(p) {
const s = String(p || '')
if (!s) {
throw new Error('Remote path must not be empty.')
}
// eslint-disable-next-line no-control-regex -- deliberately reject NUL in remote paths
if (/[\x00\n\r]/.test(s)) {
throw new Error('Unsafe remote path: contains NUL or newline.')
}
if (s === '~' || s.startsWith('~/') || s.startsWith('/')) {
return
}
throw new Error(`Remote path must be absolute or start with ~/: "${s}"`)
}
function expandRemotePath(p) {
validateRemotePath(p)
if (p === '~') {
return '"$HOME"'
}
if (p.startsWith('~/')) {
return '"$HOME"' + shq(p.slice(1))
}
return shq(p)
}
// Resolve the remote hermes executable. An EXPLICIT path is honored strictly
// (throws a path-naming error if not executable — never silently falls back to a
// different install). A BLANK path auto-detects: login-shell `command -v` (a
// non-login `ssh host cmd` PATH misses user installs), then known install paths.
async function locateHermes(ssh, remoteHermesPath) {
const resolveLauncher = async (candidate: string) => {
const script =
'import os,shlex,sys\n' +
`p=os.path.expanduser(${shq(candidate)})\n` +
'out=p\n' +
'try:\n' +
' data=open(p,"r",encoding="utf-8",errors="ignore").read(4096)\n' +
' for line in data.splitlines():\n' +
' words=shlex.split(line)\n' +
' if len(words)>1 and words[0]=="exec":\n' +
' target=os.path.expanduser(words[1])\n' +
' if os.path.isabs(target) and os.access(target,os.X_OK):out=target\n' +
' break\n' +
'except (OSError,ValueError):pass\n' +
'print(out)'
const resolved = (await ssh.exec(`python3 -c ${shq(script)}`)).trim()
return resolved || candidate
}
const isExecutable = async (candidate: string) => {
try {
validateRemotePath(candidate)
const ok = (await ssh.exec(`[ -x ${expandRemotePath(candidate)} ] && echo OK || true`)).trim()
return ok === 'OK'
} catch {
return false
}
}
if (remoteHermesPath) {
if (await isExecutable(remoteHermesPath)) {
return resolveLauncher(remoteHermesPath)
}
const err: any = new Error(
`The Hermes path you set is not an executable on the remote host: "${remoteHermesPath}". ` +
'Check the path (it must be the full path to the `hermes` binary on the remote, e.g. ' +
'~/hermes-agent/.venv/bin/hermes), or clear it to auto-detect.'
)
err.kind = 'hermes-not-found'
throw err
}
const candidates: string[] = []
try {
const found = (await ssh.exec(`bash -lc ${shq('command -v hermes')}`)).trim()
if (found) {
candidates.push(found.split('\n').pop().trim())
}
} catch {
// ignore
}
// Fallback candidates when the login-shell probe misses: the installer's
// command locations (scripts/install.sh) — per-user, root/FHS, legacy venv.
candidates.push('~/.local/bin/hermes')
candidates.push('/usr/local/bin/hermes')
candidates.push('~/.hermes/hermes-agent/venv/bin/hermes')
for (const candidate of candidates) {
if (!candidate) {
continue
}
if (await isExecutable(candidate)) {
return resolveLauncher(candidate)
}
}
const err: any = new Error(
'Hermes is not installed on the remote host (could not find a `hermes` executable). ' +
'Install it on the remote with: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh ' +
'— or set the Hermes path explicitly in the SSH connection settings.'
)
err.kind = 'hermes-not-found'
throw err
}
// Probe the resolved binary's version string (first line of `<hermes> --version`,
// e.g. "Hermes Agent v0.18.2 ..."), or '' on failure. Surfaces WHICH hermes a
// connection uses, so a stale/unexpected install is visible.
async function probeHermesVersion(ssh, hermesPath) {
try {
const out = (await ssh.exec(`${expandRemotePath(hermesPath)} --version 2>&1`)).trim()
return (out.split('\n')[0] || '').trim()
} catch {
return ''
}
}
async function probeRemotePlatform(ssh) {
const out = (await ssh.exec('uname -s; uname -m')).trim().split('\n')
const osName = (out[0] || '').trim()
const arch = (out[1] || '').trim()
if (!SUPPORTED_REMOTE_OS.has(osName)) {
const err: any = new Error(
`Unsupported remote platform "${osName || 'unknown'}". Hermes Desktop SSH mode supports Linux, macOS, and Windows remote hosts.`
)
err.kind = 'unsupported-platform'
throw err
}
return { os: osName, arch }
}
// The HERMES_HOME the remote dashboard will use (explicit env wins, else
// ~/.hermes). Recorded in the lockfile so a future reuse can tell it's the same
// state store; best-effort.
async function probeRemoteHermesHome(ssh) {
try {
const out = (await ssh.exec('echo "${HERMES_HOME:-$HOME/.hermes}"')).trim().split('\n').pop()
return out || '~/.hermes'
} catch (cause) {
const error: any = new Error('Could not resolve the remote Hermes home.')
error.kind = 'transient-transport-error'
error.cause = cause
throw error
}
}
async function readLockfile(ssh, ownershipId) {
const lpath = lockfilePath(ownershipId)
let raw
try {
raw = await ssh.exec(`if [ ! -e ${expandRemotePath(lpath)} ]; then exit 0; fi; cat ${expandRemotePath(lpath)}`)
} catch (cause) {
const error: any = new Error('Could not read the SSH backend ownership record.')
error.kind = 'transient-transport-error'
error.cause = cause
throw error
}
const text = String(raw || '').trim()
if (!text) {
return null
}
let parsed
try {
parsed = JSON.parse(text)
} catch {
return null
}
if (!parsed || parsed.schemaVersion !== LOCKFILE_SCHEMA_VERSION) {
return null
}
const pid = parsed.pid
const port = parsed.port
if (!Number.isInteger(pid) || pid <= 0 || pid > 4194304) {
return null
}
// port 0 = spawn-in-progress record (written before readiness); valid
// ownership proof for cleanup, but never reusable.
if (!Number.isInteger(port) || port < 0 || port > 65535) {
return null
}
if (parsed.ownershipId !== ownershipId || !/^[0-9a-f]{16}$/.test(parsed.spawnNonce || '')) {
return null
}
if (!/^[0-9a-f]{32}$/.test(parsed.tokenFingerprint || '')) {
return null
}
if (parsed.protocolVersion !== PROTOCOL_VERSION) {
return null
}
if (parsed.logPath !== spawnLogPath(ownershipId, parsed.spawnNonce)) {
return null
}
for (const field of ['profile', 'hermesPath', 'hermesHome', 'logPath', 'startedAt']) {
if (typeof parsed[field] !== 'string' || parsed[field].length > 1024) {
return null
}
}
return parsed
}
async function writeLockfile(ssh, ownershipId, lock) {
const directory = ownershipDirectory(ownershipId)
const lpath = lockfilePath(ownershipId)
const temporaryPath = `${directory}/.${crypto.randomBytes(8).toString('hex')}.lock.tmp`
const json = JSON.stringify({ ...lock, schemaVersion: LOCKFILE_SCHEMA_VERSION })
await ssh.exec(
`umask 077 && mkdir -p ${expandRemotePath(directory)} && ` +
`printf '%s' ${shq(json)} > ${expandRemotePath(temporaryPath)} && ` +
`mv -f ${expandRemotePath(temporaryPath)} ${expandRemotePath(lpath)}`
)
}
async function removeLockfile(ssh, ownershipId) {
const lpath = lockfilePath(ownershipId)
try {
await ssh.exec(`rm -f ${expandRemotePath(lpath)}`)
} catch {
// best effort
}
}
async function remotePidAlive(ssh, pid) {
if (!pid || !Number.isInteger(Number(pid))) {
return false
}
try {
const out = (await ssh.exec(`kill -0 ${Number(pid)} 2>/dev/null && echo ALIVE || echo DEAD`)).trim()
return out === 'ALIVE'
} catch (cause) {
const error: any = new Error('Could not verify the SSH backend process.')
error.kind = 'transient-transport-error'
error.cause = cause
throw error
}
}
// A pid is "provably ours" only if its remote cmdline carries our dashboard
// args — never kill a pid we can't positively identify as our dashboard.
async function pidIsOurDashboard(ssh, pid, spawnNonce, hermesPath = '') {
if (!pid || !/^[0-9a-f]{16}$/.test(String(spawnNonce || '')) || !hermesPath) {
return false
}
try {
const script =
'import os,shlex,subprocess,sys\n' +
`pid=${Number(pid)}\n` +
`expected=os.path.expanduser(${shq(hermesPath)})\n` +
`nonce=${shq(spawnNonce)}\n` +
'try:\n' +
' raw=open(f"/proc/{pid}/cmdline","rb").read()\n' +
' args=[x.decode("utf-8","surrogateescape") for x in raw.split(b"\\0") if x]\n' +
'except OSError:\n' +
' line=subprocess.check_output(["ps","-o","command=","-p",str(pid)],text=True).strip()\n' +
' args=shlex.split(line)\n' +
'ok=False\n' +
'try:\n' +
' serve=args.index("serve")\n' +
' owner=args.index("--ssh-owner-nonce",serve+1)\n' +
' direct=args[0]==expected\n' +
' python_entry=len(args)>1 and args[1]==expected and os.path.basename(args[0]).startswith("python")\n' +
' ok=(direct or python_entry) and "--isolated" in args[serve+1:] and args[owner+1]==nonce\n' +
'except (ValueError,IndexError):pass\n' +
'print("OWNED" if ok else "FOREIGN")'
const out = await ssh.exec(`python3 -c ${shq(script)}`)
return String(out || '').trim() === 'OWNED'
} catch (cause) {
const error: any = new Error('Could not verify SSH backend process ownership.')
error.kind = 'transient-transport-error'
error.cause = cause
throw error
}
}
// Kill the stale dashboard ONLY if provably ours, then drop the lockfile.
async function cleanupStale(ssh, ownershipId, lock, pidAlive = true) {
if (pidAlive && lock && (await pidIsOurDashboard(ssh, lock.pid, lock.spawnNonce, lock.hermesPath))) {
try {
const result = (
await ssh.exec(
`kill ${Number(lock.pid)} && ` +
`i=0; while kill -0 ${Number(lock.pid)} 2>/dev/null; do ` +
`i=$((i+1)); [ "$i" -ge 50 ] && exit 1; sleep 0.1; done`
)
).trim()
void result
} catch (cause) {
const error: any = new Error('Could not terminate the stale SSH backend.')
error.kind = 'transient-transport-error'
error.cause = cause
throw error
}
}
const expectedLogPath = lock?.spawnNonce ? spawnLogPath(ownershipId, lock.spawnNonce) : ''
if (lock?.logPath === expectedLogPath) {
try {
await ssh.exec(`rm -f ${expandRemotePath(lock.logPath)}`)
} catch {
void 0
}
}
await removeLockfile(ssh, ownershipId)
}
// Detach so the backend survives the SSH channel closing: setsid (Linux)
// starts a new session; macOS has no setsid, so fall back to nohup (HUP-immune;
// fd-detachment is already handled by </dev/null + redirect + &).
function buildSpawnCommand(hermesPath, profile, opts: any = {}) {
const hermes = expandRemotePath(hermesPath)
const profileArgs = profile ? `--profile ${shq(profile)} ` : ''
const logPath = expandRemotePath(opts.logPath)
const tokenFilePath = opts.tokenFilePath
const tokenArg = tokenFilePath ? ` --ssh-session-token-file ${expandRemotePath(tokenFilePath)}` : ''
const ownerArg = opts.spawnNonce ? ` --ssh-owner-nonce ${validateSpawnNonce(opts.spawnNonce)}` : ''
const subCmd = `serve --isolated --host 127.0.0.1 --port 0${tokenArg}${ownerArg}`
const dashCmd = `env HERMES_DESKTOP=1 ${hermes} ${profileArgs}${subCmd}`
return (
`mkdir -p "$(dirname ${logPath})" && ` +
`"$(command -v setsid || echo nohup)" sh -c ${shq(`${dashCmd} </dev/null >> ${logPath} 2>&1 & echo $!`)}`
)
}
async function remoteSupportsSshOwnership(ssh, hermesPath) {
const hermes = expandRemotePath(hermesPath)
const out = await ssh.exec(
`help="$(${hermes} serve --help 2>&1)"; ` +
`printf '%s' "$help" | grep -q ssh-session-token-file && ` +
`printf '%s' "$help" | grep -q ssh-owner-nonce && echo YES || echo NO`
)
return String(out || '')
.trim()
.endsWith('YES')
}
async function scrapeReadyPort(ssh, logPath, { timeoutMs = DEFAULT_READY_TIMEOUT_MS, isAlive, signal }: any = {}) {
const deadline = Date.now() + timeoutMs
const remoteLog = expandRemotePath(logPath)
while (Date.now() < deadline) {
assertNotAborted(signal)
if (isAlive && !(await isAlive())) {
const err: any = new Error('Remote dashboard process exited before announcing its port.')
err.kind = 'spawn-failed'
throw err
}
let tail
try {
tail = await ssh.exec(`cat ${remoteLog} 2>/dev/null || true`)
} catch {
tail = ''
}
const m = READY_RE.exec(String(tail || ''))
if (m) {
return parseInt(m[1], 10)
}
await new Promise(r => setTimeout(r, READY_POLL_INTERVAL_MS))
}
const err: any = new Error(`Timed out waiting for the remote dashboard to announce its port (${timeoutMs}ms).`)
err.kind = 'ready-timeout'
throw err
}
async function spawnRemoteDashboard(ssh, { hermesPath, profile, token, ownershipId }) {
if (!(await remoteSupportsSshOwnership(ssh, hermesPath))) {
const err: any = new Error(
'The remote Hermes install does not support --ssh-session-token-file and --ssh-owner-nonce. ' +
'Update Hermes on the remote host to continue using Desktop SSH mode.'
)
err.kind = 'update-required'
throw err
}
const spawnNonce = crypto.randomBytes(8).toString('hex')
const tokenDir = ownershipDirectory(ownershipId)
const tokenFilePath = `${tokenDir}/${spawnNonce}.token`
const logPath = spawnLogPath(ownershipId, spawnNonce)
const tokenUploadPy =
'import os,sys,stat\n' +
`p=os.path.expanduser(${shq(tokenFilePath)})\n` +
'd=os.path.dirname(p)\n' +
'n=os.path.basename(p)\n' +
'os.makedirs(d,mode=0o700,exist_ok=True)\n' +
'df=os.O_RDONLY|getattr(os,"O_DIRECTORY",0)|getattr(os,"O_NOFOLLOW",0)\n' +
'dd=os.open(d,df)\n' +
'try:\n' +
' s=os.fstat(dd)\n' +
' if not stat.S_ISDIR(s.st_mode):raise SystemExit("unsafe token directory")\n' +
' if hasattr(os,"getuid") and s.st_uid!=os.getuid():raise SystemExit("token directory owner mismatch")\n' +
' if (s.st_mode&0o777)!=0o700:os.fchmod(dd,0o700)\n' +
' fl=os.O_WRONLY|os.O_CREAT|os.O_EXCL|getattr(os,"O_NOFOLLOW",0)\n' +
' now=__import__("time").time()\n' +
' for stale in os.listdir(dd):\n' +
' if stale.endswith(".token") and len(stale)==22:\n' +
' try:\n' +
' ss=os.stat(stale,dir_fd=dd,follow_symlinks=False)\n' +
' if stat.S_ISREG(ss.st_mode) and now-ss.st_mtime>3600:os.unlink(stale,dir_fd=dd)\n' +
' except OSError:pass\n' +
' fd=os.open(n,fl,0o600,dir_fd=dd)\n' +
' try:os.write(fd,sys.stdin.buffer.read())\n' +
' except BaseException:\n' +
' try:os.unlink(n,dir_fd=dd)\n' +
' except OSError:pass\n' +
' raise\n' +
' finally:os.close(fd)\n' +
'finally:os.close(dd)'
try {
await ssh.exec(`python3 -c ${shq(tokenUploadPy)}`, { stdinData: token })
} catch (error) {
try {
await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`)
} catch {
void 0
}
throw error
}
let out
try {
out = await ssh.exec(buildSpawnCommand(hermesPath, profile, { spawnNonce, tokenFilePath, logPath }))
} catch (error) {
try {
await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`)
} catch {
void 0
}
throw error
}
const pid = parseInt(
String(out || '')
.trim()
.split('\n')
.pop(),
10
)
if (!Number.isInteger(pid) || pid <= 0) {
try {
await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`)
} catch {
void 0
}
const err: any = new Error('Failed to launch the remote dashboard (no pid returned).')
err.kind = 'spawn-failed'
throw err
}
return { pid, spawnNonce, logPath, tokenFilePath }
}
// Best-effort forward teardown when a reuse attempt fails mid-flight, so we
// don't leak a forward before respawning. `deps.cancelForward` is optional.
async function cancelForwardSafe(deps, localPort, remotePort) {
if (typeof deps.cancelForward !== 'function') {
return
}
try {
await deps.cancelForward(localPort, remotePort)
} catch {
// best effort
}
}
function assertNotAborted(signal) {
if (signal?.aborted) {
const error: any = new Error('SSH bootstrap was cancelled.')
error.kind = 'superseded'
throw error
}
}
function isForwardBindCollision(error) {
return /address already in use|cannot listen to port|bind.*failed/i.test(String(error?.message || error || ''))
}
async function openForward(deps, remotePort, attempts = 3) {
let lastError
for (let attempt = 0; attempt < attempts; attempt++) {
const localPort = await deps.pickLocalPort()
try {
await deps.forward(localPort, remotePort)
return localPort
} catch (error) {
lastError = error
if (!isForwardBindCollision(error) || attempt === attempts - 1) {
throw error
}
}
}
throw lastError
}
/**
* Establish (or reuse) a remote dashboard and a tunnel to it. `deps` injects the
* opened SshConnection, forward/pickLocalPort/waitForHermes, a token-gated
* probeReuseProof, and adoptServedToken. Returns the connection descriptor
* { baseUrl, token, tokenFingerprint, remotePort, localPort, pid, reused, platform }.
*/
async function adoptOwnedServedToken(adoptServedToken, baseUrl, expectedToken, ssh, pid, label) {
const token = await adoptServedToken(baseUrl, expectedToken, {
childAlive: () => true,
label
})
if (!(await remotePidAlive(ssh, pid))) {
const error: any = new Error(`${label} exited while its served token was being resolved.`)
error.kind = token === expectedToken ? 'spawn-failed' : 'foreign-backend'
throw error
}
return token
}
async function connect(deps) {
const {
ssh,
profile = '',
remoteHermesPath = '',
ownershipId,
forward,
pickLocalPort,
waitForHermes,
probeReuseProof,
adoptServedToken,
rememberLog = () => {},
readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS,
signal
} = deps
const log = msg => rememberLog(`[ssh-lifecycle] ${msg}`)
assertNotAborted(signal)
const platform = await probeRemotePlatform(ssh)
log(`remote platform ${platform.os}/${platform.arch}`)
const hermesPath = await locateHermes(ssh, remoteHermesPath)
log(`located hermes at ${hermesPath}`)
const hermesVersion = await probeHermesVersion(ssh, hermesPath)
if (hermesVersion) {
log(`remote hermes version: ${hermesVersion}`)
}
const reuseToken = deps.reuseToken || ''
const hermesHome = await probeRemoteHermesHome(ssh)
const lock = await readLockfile(ssh, ownershipId)
if (lock) {
const pidAlive = await remotePidAlive(ssh, lock.pid)
const owned = pidAlive && (await pidIsOurDashboard(ssh, lock.pid, lock.spawnNonce, lock.hermesPath))
const reusable =
pidAlive &&
owned &&
lock.port > 0 &&
Boolean(reuseToken) &&
lock.tokenFingerprint === fingerprintToken(reuseToken) &&
lock.hermesPath === hermesPath &&
lock.hermesHome === hermesHome
if (reusable) {
assertNotAborted(signal)
const localPort = await openForward(deps, lock.port)
try {
const baseUrl = `http://127.0.0.1:${localPort}`
let reuseClassification
try {
reuseClassification = await probeReuseProof(baseUrl, reuseToken, lock.spawnNonce)
} catch (cause) {
const error: any = new Error('Could not verify the existing SSH backend.')
error.kind = 'transient-transport-error'
error.cause = cause
throw error
}
if (reuseClassification === 'authenticated-stale') {
assertNotAborted(signal)
await cancelForwardSafe(deps, localPort, lock.port)
await cleanupStale(ssh, ownershipId, lock)
} else if (reuseClassification === 'authenticated-ok') {
const token = await adoptOwnedServedToken(
adoptServedToken,
baseUrl,
reuseToken,
ssh,
lock.pid,
'reused remote dashboard'
)
assertNotAborted(signal)
log(`reusing remote dashboard pid=${lock.pid} port=${lock.port}`)
return {
baseUrl,
token,
tokenFingerprint: fingerprintToken(token),
remotePort: lock.port,
localPort,
pid: lock.pid,
reused: true,
platform,
hermesPath,
hermesVersion,
ownershipId,
spawnNonce: lock.spawnNonce,
logPath: lock.logPath
}
} else {
const error: any = new Error('SSH reuse proof returned an invalid classification.')
error.kind = 'transient-transport-error'
throw error
}
} catch (error) {
await cancelForwardSafe(deps, localPort, lock.port)
throw error
}
} else {
assertNotAborted(signal)
await cleanupStale(ssh, ownershipId, lock, pidAlive)
}
}
assertNotAborted(signal)
const spawnToken = mintToken()
const { pid, spawnNonce, logPath, tokenFilePath } = await spawnRemoteDashboard(ssh, {
hermesPath,
profile,
token: spawnToken,
ownershipId
})
log(`spawned remote dashboard pid=${pid}`)
const ownedSpawn = {
ownershipId,
spawnNonce,
pid,
port: 0,
profile,
hermesPath,
hermesHome,
logPath,
tokenFingerprint: fingerprintToken(spawnToken),
protocolVersion: PROTOCOL_VERSION,
startedAt: new Date().toISOString()
}
let localPort = 0
let remotePort = 0
try {
// Write the ownership record IMMEDIATELY (port=0): a supersede between
// spawn and readiness whose cleanup cannot reach the box must not leave a
// lockless orphan — the next connect reaps it by exact ownership via this
// record. Inside the try: if this write itself fails, the catch still
// kills the just-spawned process via the in-memory record.
await writeLockfile(ssh, ownershipId, ownedSpawn)
remotePort = await scrapeReadyPort(ssh, logPath, {
timeoutMs: readyTimeoutMs,
isAlive: () => remotePidAlive(ssh, pid),
signal
})
assertNotAborted(signal)
log(`remote dashboard bound port ${remotePort}`)
localPort = await openForward(deps, remotePort)
assertNotAborted(signal)
const baseUrl = `http://127.0.0.1:${localPort}`
await waitForHermes(baseUrl, spawnToken)
assertNotAborted(signal)
const token = await adoptOwnedServedToken(adoptServedToken, baseUrl, spawnToken, ssh, pid, 'remote dashboard')
assertNotAborted(signal)
const tokenFingerprint = fingerprintToken(token)
await writeLockfile(ssh, ownershipId, { ...ownedSpawn, port: remotePort, tokenFingerprint })
assertNotAborted(signal)
return {
baseUrl,
token,
tokenFingerprint,
remotePort,
localPort,
pid,
reused: false,
platform,
hermesPath,
hermesVersion,
ownershipId,
spawnNonce,
logPath
}
} catch (error) {
if (localPort && remotePort) {
await cancelForwardSafe(deps, localPort, remotePort)
}
try {
await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`)
} catch {
void 0
}
await cleanupStale(ssh, ownershipId, ownedSpawn)
throw error
}
}
export {
adoptOwnedServedToken,
buildSpawnCommand,
cleanupStale,
connect,
DEFAULT_READY_TIMEOUT_MS,
expandRemotePath,
fingerprintToken,
isForwardBindCollision,
locateHermes,
LOCKFILE_SCHEMA_VERSION,
lockfilePath,
mintToken,
openForward,
ownershipDirectory,
pidIsOurDashboard,
probeHermesVersion,
probeRemoteHermesHome,
probeRemotePlatform,
PROTOCOL_VERSION,
readLockfile,
READY_RE,
REMOTE_LOCK_DIR,
remotePidAlive,
remoteSupportsSshOwnership,
removeLockfile,
scrapeReadyPort,
shq,
spawnLogPath,
spawnRemoteDashboard,
SUPPORTED_REMOTE_OS,
validateRemotePath,
writeLockfile
}
@@ -1,253 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import {
REMOTE_LIVENESS_FAILURE_LIMIT,
REMOTE_LIVENESS_FAILURE_WINDOW_MS,
REMOTE_LIVENESS_TIMEOUT_MS,
RemoteLivenessTracker,
RemoteRevalidationCoordinator,
revalidateRemoteConnection
} from './remote-liveness'
describe('RemoteLivenessTracker', () => {
it('requires consecutive failures before resetting a connection', () => {
const tracker = new RemoteLivenessTracker()
for (let failures = 1; failures < REMOTE_LIVENESS_FAILURE_LIMIT; failures += 1) {
expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures, shouldReset: false })
}
expect(tracker.recordFailure('https://gateway.example.com')).toEqual({
failures: REMOTE_LIVENESS_FAILURE_LIMIT,
shouldReset: true
})
})
it('clears a failure streak after a successful probe', () => {
const tracker = new RemoteLivenessTracker()
tracker.recordFailure('https://gateway.example.com')
tracker.recordFailure('https://gateway.example.com')
tracker.recordSuccess('https://gateway.example.com')
expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: false })
})
it('tracks different gateways independently', () => {
const tracker = new RemoteLivenessTracker(2)
expect(tracker.recordFailure('https://one.example.com')).toEqual({ failures: 1, shouldReset: false })
expect(tracker.recordFailure('https://two.example.com')).toEqual({ failures: 1, shouldReset: false })
expect(tracker.recordFailure('https://one.example.com')).toEqual({ failures: 2, shouldReset: true })
expect(tracker.recordFailure('https://two.example.com')).toEqual({ failures: 2, shouldReset: true })
})
it('clears only the successful gateway streak', () => {
const tracker = new RemoteLivenessTracker(3)
tracker.recordFailure('https://one.example.com')
tracker.recordFailure('https://two.example.com')
tracker.recordSuccess('https://one.example.com')
expect(tracker.recordFailure('https://one.example.com')).toEqual({ failures: 1, shouldReset: false })
expect(tracker.recordFailure('https://two.example.com')).toEqual({ failures: 2, shouldReset: false })
})
it('does not accumulate isolated failures across separate reconnect episodes', () => {
let now = 0
const tracker = new RemoteLivenessTracker(3, REMOTE_LIVENESS_FAILURE_WINDOW_MS, () => now)
expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: false })
now += REMOTE_LIVENESS_FAILURE_WINDOW_MS + 1
expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: false })
})
it('clears all failure streaks when the connection state resets', () => {
const tracker = new RemoteLivenessTracker(3)
tracker.recordFailure('https://one.example.com')
tracker.recordFailure('https://two.example.com')
tracker.clear()
expect(tracker.recordFailure('https://one.example.com')).toEqual({ failures: 1, shouldReset: false })
expect(tracker.recordFailure('https://two.example.com')).toEqual({ failures: 1, shouldReset: false })
})
it('starts a fresh streak after the reset threshold is consumed', () => {
const tracker = new RemoteLivenessTracker(1)
expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: true })
expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: true })
})
it('rejects invalid failure limits', () => {
expect(() => new RemoteLivenessTracker(0)).toThrow(/positive integer/i)
expect(() => new RemoteLivenessTracker(1.5)).toThrow(/positive integer/i)
expect(() => new RemoteLivenessTracker(1, 0)).toThrow(/window must be positive/i)
})
})
describe('RemoteRevalidationCoordinator', () => {
it('coalesces simultaneous probes for the same cached connection', async () => {
const coordinator = new RemoteRevalidationCoordinator()
const connection = Promise.resolve({ baseUrl: 'https://gateway.example.com' })
let resolveProbe: (value: string) => void = () => undefined
const probe = vi.fn(
() =>
new Promise<string>(resolve => {
resolveProbe = resolve
})
)
const first = coordinator.run(connection, probe)
const second = coordinator.run(connection, probe)
const third = coordinator.run(connection, probe)
await Promise.resolve()
expect(second).toBe(first)
expect(third).toBe(first)
expect(probe).toHaveBeenCalledOnce()
resolveProbe('healthy')
await expect(Promise.all([first, second, third])).resolves.toEqual(['healthy', 'healthy', 'healthy'])
})
it('runs a fresh probe after the prior one settles', async () => {
const coordinator = new RemoteRevalidationCoordinator()
const connection = Promise.resolve({ baseUrl: 'https://gateway.example.com' })
const probe = vi.fn().mockResolvedValue('healthy')
await coordinator.run(connection, probe)
await coordinator.run(connection, probe)
expect(probe).toHaveBeenCalledTimes(2)
})
it('does not coalesce different cached connections', async () => {
const coordinator = new RemoteRevalidationCoordinator()
const probe = vi.fn().mockResolvedValue('healthy')
await Promise.all([coordinator.run(Promise.resolve('one'), probe), coordinator.run(Promise.resolve('two'), probe)])
expect(probe).toHaveBeenCalledTimes(2)
})
it('cleans up a rejected probe so it can be retried', async () => {
const coordinator = new RemoteRevalidationCoordinator()
const connection = Promise.resolve({ baseUrl: 'https://gateway.example.com' })
const probe = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce('healthy')
await expect(coordinator.run(connection, probe)).rejects.toThrow('offline')
await expect(coordinator.run(connection, probe)).resolves.toBe('healthy')
expect(probe).toHaveBeenCalledTimes(2)
})
})
describe('revalidateRemoteConnection', () => {
function harness(overrides: Record<string, unknown> = {}) {
const connection = { baseUrl: 'https://gateway.example.com/', mode: 'remote' }
const connectionPromise = Promise.resolve(connection)
const current = { promise: connectionPromise as null | Promise<typeof connection> }
const log = vi.fn()
const probe = vi.fn().mockResolvedValue({ ok: true })
const resetConnection = vi.fn()
const tracker = new RemoteLivenessTracker()
return {
connectionPromise,
current,
log,
options: {
connectionPromise,
currentConnectionPromise: () => current.promise,
log,
probe,
resetConnection,
tracker,
...overrides
},
probe,
resetConnection,
tracker
}
}
it('probes the normalized status URL with the production timeout', async () => {
const test = harness()
await expect(revalidateRemoteConnection(test.options)).resolves.toEqual({ ok: true, rebuilt: false })
expect(test.probe).toHaveBeenCalledWith('https://gateway.example.com/api/status', {
timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS
})
expect(test.resetConnection).not.toHaveBeenCalled()
})
it('keeps failures one and two, then resets on the third failure', async () => {
const probe = vi.fn().mockRejectedValue(new Error('offline'))
const test = harness({ probe })
await expect(revalidateRemoteConnection(test.options)).resolves.toEqual({ ok: true, rebuilt: false })
await expect(revalidateRemoteConnection(test.options)).resolves.toEqual({ ok: true, rebuilt: false })
await expect(revalidateRemoteConnection(test.options)).resolves.toEqual({ ok: true, rebuilt: true })
expect(probe).toHaveBeenCalledTimes(3)
expect(test.resetConnection).toHaveBeenCalledOnce()
expect(test.log).toHaveBeenNthCalledWith(1, expect.stringContaining('(1/3)'))
expect(test.log).toHaveBeenNthCalledWith(2, expect.stringContaining('(2/3)'))
expect(test.log).toHaveBeenLastCalledWith(expect.stringContaining('dropping stale connection'))
})
it('ignores a late failed probe after the cached connection is replaced', async () => {
let rejectProbe: (error: Error) => void = () => undefined
const probe = vi.fn(
() =>
new Promise((_resolve, reject) => {
rejectProbe = reject
})
)
const test = harness({ probe })
const pending = revalidateRemoteConnection(test.options)
await Promise.resolve()
test.current.promise = Promise.resolve({ baseUrl: 'https://new.example.com', mode: 'remote' })
rejectProbe(new Error('old connection failed'))
await expect(pending).resolves.toEqual({ ok: true, rebuilt: false })
expect(test.resetConnection).not.toHaveBeenCalled()
expect(test.log).not.toHaveBeenCalled()
expect(test.tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: false })
})
it('does not probe a local, rejected, or already replaced connection', async () => {
const replaced = harness()
replaced.current.promise = null
await expect(revalidateRemoteConnection(replaced.options)).resolves.toEqual({ ok: true, rebuilt: false })
expect(replaced.probe).not.toHaveBeenCalled()
const localConnection = { baseUrl: 'http://127.0.0.1:3000', mode: 'local' }
const localPromise = Promise.resolve(localConnection)
const local = harness({
connectionPromise: localPromise,
currentConnectionPromise: () => localPromise
})
await expect(revalidateRemoteConnection(local.options)).resolves.toEqual({ ok: true, rebuilt: false })
expect(local.probe).not.toHaveBeenCalled()
const rejectedPromise = Promise.reject(new Error('boot failed'))
const rejected = harness({
connectionPromise: rejectedPromise,
currentConnectionPromise: () => rejectedPromise
})
await expect(revalidateRemoteConnection(rejected.options)).resolves.toEqual({ ok: true, rebuilt: false })
expect(rejected.probe).not.toHaveBeenCalled()
})
})
-182
View File
@@ -1,182 +0,0 @@
export const REMOTE_LIVENESS_TIMEOUT_MS = 10_000
export const REMOTE_LIVENESS_FAILURE_LIMIT = 3
// Even at the capped retry path, consecutive liveness observations are at most
// about 48s apart (ticket mint + socket open + backoff + the next status probe).
// One minute keeps a continuous outage together without carrying old failures.
export const REMOTE_LIVENESS_FAILURE_WINDOW_MS = 60_000
export interface RemoteLivenessFailure {
failures: number
shouldReset: boolean
}
interface RemoteConnectionDescriptor {
baseUrl?: null | string
mode?: null | string
}
export interface RevalidateRemoteConnectionOptions<TConnection extends RemoteConnectionDescriptor> {
connectionPromise: Promise<TConnection>
currentConnectionPromise: () => null | Promise<TConnection>
log: (message: string) => void
probe: (url: string, options: { timeoutMs: number }) => Promise<unknown>
resetConnection: () => void
tracker: RemoteLivenessTracker
}
export interface RemoteRevalidationResult {
ok: true
rebuilt: boolean
}
/**
* Coalesces revalidation work for one cached connection promise.
*
* Every Desktop BrowserWindow owns a renderer gateway loop. When several
* windows observe the same disconnect they can all ask the Electron main
* process to revalidate the shared primary connection at once. Those calls
* must count as one probe, not several consecutive failures.
*/
export class RemoteRevalidationCoordinator {
readonly #inflightByConnection = new WeakMap<object, Promise<unknown>>()
run<T>(connection: object, task: () => Promise<T>): Promise<T> {
const existing = this.#inflightByConnection.get(connection) as Promise<T> | undefined
if (existing) {
return existing
}
const pending = Promise.resolve().then(task)
const clear = () => {
if (this.#inflightByConnection.get(connection) === pending) {
this.#inflightByConnection.delete(connection)
}
}
this.#inflightByConnection.set(connection, pending)
// Clean up on both outcomes without creating an unhandled rejected branch.
void pending.then(clear, clear)
return pending
}
}
/**
* Tracks consecutive remote liveness failures independently per gateway.
* A successful probe clears the streak, and reaching the limit consumes it so
* a rebuilt connection starts from a clean state.
*/
export class RemoteLivenessTracker {
readonly #failureLimit: number
readonly #failureWindowMs: number
readonly #failuresByBaseUrl = new Map<string, { failures: number; lastFailureAt: number }>()
readonly #now: () => number
constructor(
failureLimit = REMOTE_LIVENESS_FAILURE_LIMIT,
failureWindowMs = REMOTE_LIVENESS_FAILURE_WINDOW_MS,
now: () => number = Date.now
) {
if (!Number.isInteger(failureLimit) || failureLimit < 1) {
throw new Error('Remote liveness failure limit must be a positive integer.')
}
if (!Number.isFinite(failureWindowMs) || failureWindowMs < 1) {
throw new Error('Remote liveness failure window must be positive.')
}
this.#failureLimit = failureLimit
this.#failureWindowMs = failureWindowMs
this.#now = now
}
recordSuccess(baseUrl: string): void {
this.#failuresByBaseUrl.delete(baseUrl)
}
recordFailure(baseUrl: string): RemoteLivenessFailure {
const now = this.#now()
const previous = this.#failuresByBaseUrl.get(baseUrl)
const withinFailureWindow = previous && now - previous.lastFailureAt <= this.#failureWindowMs
const failures = (withinFailureWindow ? previous.failures : 0) + 1
const shouldReset = failures >= this.#failureLimit
if (shouldReset) {
this.#failuresByBaseUrl.delete(baseUrl)
} else {
this.#failuresByBaseUrl.set(baseUrl, { failures, lastFailureAt: now })
}
return { failures, shouldReset }
}
clear(): void {
this.#failuresByBaseUrl.clear()
}
}
/**
* Probe the cached primary remote connection and apply the failure policy.
* The caller owns single-flight coordination; identity checks here ensure an
* old async result cannot mutate or reset a replacement connection.
*/
export async function revalidateRemoteConnection<TConnection extends RemoteConnectionDescriptor>({
connectionPromise,
currentConnectionPromise,
log,
probe,
resetConnection,
tracker
}: RevalidateRemoteConnectionOptions<TConnection>): Promise<RemoteRevalidationResult> {
let connection: TConnection
try {
connection = await connectionPromise
} catch {
// The cached boot already rejected; its own recovery path will clear it.
return { ok: true, rebuilt: false }
}
if (currentConnectionPromise() !== connectionPromise) {
return { ok: true, rebuilt: false }
}
if (connection.mode !== 'remote' || !connection.baseUrl) {
return { ok: true, rebuilt: false }
}
const baseUrl = connection.baseUrl.replace(/\/+$/, '')
try {
await probe(`${baseUrl}/api/status`, { timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS })
if (currentConnectionPromise() !== connectionPromise) {
return { ok: true, rebuilt: false }
}
tracker.recordSuccess(baseUrl)
return { ok: true, rebuilt: false }
} catch {
if (currentConnectionPromise() !== connectionPromise) {
return { ok: true, rebuilt: false }
}
const failure = tracker.recordFailure(baseUrl)
if (!failure.shouldReset) {
log(
`Cached remote Hermes backend failed liveness probe (${failure.failures}/${REMOTE_LIVENESS_FAILURE_LIMIT}); keeping connection for retry.`
)
return { ok: true, rebuilt: false }
}
log('Cached remote Hermes backend failed liveness probe; dropping stale connection.')
resetConnection()
return { ok: true, rebuilt: true }
}
}
+4 -15
View File
@@ -2,12 +2,7 @@ import assert from 'node:assert/strict'
import { test } from 'vitest'
import {
buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry,
instanceWindowBounds
} from './session-windows'
import { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry } from './session-windows'
// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a
// test fire the 'closed' event, mirroring the slice of the Electron API the
@@ -88,16 +83,10 @@ test('buildSessionWindowUrl adds the watch flag for spectator windows, before th
assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc')
})
test('instanceWindowBounds cascades a new window off its source bounds', () => {
const bounds = instanceWindowBounds({ x: 100, y: 120, width: 1400, height: 900 }, { width: 1, height: 1 })
test('buildSessionWindowUrl routes new-session windows to the draft (#/)', () => {
const url = buildSessionWindowUrl(null, { devServer: 'http://localhost:5173', newSession: true })
assert.deepEqual(bounds, { width: 1400, height: 900, x: 132, y: 152 })
})
test('instanceWindowBounds falls back to the persisted geometry with no source window', () => {
const fallback = { width: 1280, height: 800 }
assert.equal(instanceWindowBounds(null, fallback), fallback)
assert.equal(url, 'http://localhost:5173/?win=secondary&new=1#/')
})
test('registry opens one window per session and focuses on re-open', () => {
+7 -29
View File
@@ -38,12 +38,13 @@ function chatWindowWebPreferences(preloadPath: string) {
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
// treated as the route by HashRouter and would break routeSessionId(). The
// renderer reads the flag from window.location.search to suppress the install /
// onboarding overlays and the global session sidebar. `watch=1` marks a
// spectator window (e.g. a running subagent's session): the renderer resumes it
// lazily so the gateway never builds an agent just to stream into it.
function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath, watch }: any = {}) {
const query = `?win=secondary${watch ? '&watch=1' : ''}`
const route = `#/${encodeURIComponent(sessionId)}`
// onboarding overlays and the global session sidebar. `new=1` marks the compact
// scratch window; `watch=1` marks a spectator window (e.g. a running subagent's
// session): the renderer resumes it lazily so the gateway never builds an agent
// just to stream into it.
function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath, watch, newSession }: any = {}) {
const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}`
const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}`
if (devServer) {
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
@@ -54,28 +55,6 @@ function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath
return `${pathToFileURL(rendererIndexPath).toString()}${query}${route}`
}
// Full "instance" windows (⌘⇧N / the "New Window" command) open a complete app
// peer, not a compact chat. Cascade each one off its source window's bounds so a
// new window doesn't land exactly on top of the one it was spawned from. Pure so
// it's unit-testable; the Electron glue (reading the focused window's bounds,
// constructing the BrowserWindow) stays in main.ts. `base` is the source
// window's current bounds, or null when there's no live source window — then the
// persisted primary geometry (`fallback`) is used as-is.
const INSTANCE_CASCADE_OFFSET = 32
function instanceWindowBounds(base: { x: number; y: number; width: number; height: number } | null, fallback: any) {
if (!base) {
return fallback
}
return {
width: base.width,
height: base.height,
x: base.x + INSTANCE_CASCADE_OFFSET,
y: base.y + INSTANCE_CASCADE_OFFSET
}
}
// A small registry keyed by sessionId that guarantees one window per chat:
// opening a session that already has a live window focuses it instead of
// spawning a duplicate, and a window removes itself from the registry when it
@@ -140,7 +119,6 @@ export {
buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry,
instanceWindowBounds,
SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH
}
@@ -1,192 +0,0 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator'
function deferred() {
let resolve
let reject
const promise = new Promise((ok, fail) => {
resolve = ok
reject = fail
})
return { promise, reject, resolve }
}
const config = { host: 'box', user: 'alice', port: 22, keyPath: '/key', remoteHermesPath: '/hermes' }
test('sshConfigFingerprint covers scope and every connection field', () => {
const base = sshConfigFingerprint('', config)
assert.equal(base, sshConfigFingerprint('', { ...config }))
for (const [field, value] of Object.entries({
host: 'other',
user: 'bob',
port: 2222,
keyPath: '/other',
remoteHermesPath: '/other-hermes',
effectiveConfigFingerprint: 'changed-config'
})) {
assert.notEqual(base, sshConfigFingerprint('', { ...config, [field]: value }))
}
assert.notEqual(base, sshConfigFingerprint('profile', config))
})
test('same scope and fingerprint share one bootstrap', async () => {
const coordinator = createBootstrapCoordinator()
const gate = deferred()
let runs = 0
const first = coordinator.start('', 'same', async () => {
runs++
return gate.promise
})
const second = coordinator.start('', 'same', async () => {
runs++
return 'wrong'
})
assert.equal(first, second)
gate.resolve('done')
assert.equal(await second, 'done')
assert.equal(runs, 1)
})
test('changed fingerprint waits for old rollback before starting', async () => {
const coordinator = createBootstrapCoordinator()
const gate = deferred()
const events: string[] = []
let oldLease
const oldPromise = coordinator.start('', 'old', async lease => {
oldLease = lease
events.push('old-start')
await gate.promise
events.push('old-rollback')
lease.assertCurrent()
})
await Promise.resolve()
const newPromise = coordinator.start('', 'new', async lease => {
events.push('new-start')
lease.assertCurrent()
return 'new'
})
assert.equal(oldLease.signal.aborted, true)
await Promise.resolve()
assert.deepEqual(events, ['old-start'])
gate.resolve()
await assert.rejects(oldPromise, (error: any) => error.kind === 'superseded')
assert.equal(await newPromise, 'new')
assert.deepEqual(events, ['old-start', 'old-rollback', 'new-start'])
})
test('forceCleanupAll runs registered pending resource cleanup', async () => {
const coordinator = createBootstrapCoordinator()
const gate = deferred()
let cleaned = 0
const promise = coordinator.start('', 'x', async lease => {
lease.onForceCleanup(async () => {
cleaned++
})
await gate.promise
})
await Promise.resolve()
await coordinator.forceCleanupAll()
assert.equal(cleaned, 1)
gate.resolve()
await promise
})
test('cancelAll invalidates every pending scope and exposes promises for quit', async () => {
const coordinator = createBootstrapCoordinator()
const gates = [deferred(), deferred()]
const promises = gates.map((gate, index) =>
coordinator.start(String(index), 'x', async lease => {
await gate.promise
lease.assertCurrent()
})
)
assert.equal(coordinator.promises().length, 2)
coordinator.cancelAll()
gates.forEach(gate => gate.resolve())
const results = await Promise.allSettled(promises)
assert.ok(results.every(result => result.status === 'rejected' && (result.reason as any).kind === 'superseded'))
})
test('cancelAndWait drains only the requested scope', async () => {
const coordinator = createBootstrapCoordinator()
const firstGate = deferred()
const secondGate = deferred()
const first = coordinator.start('first', 'x', async lease => {
await firstGate.promise
lease.assertCurrent()
})
const second = coordinator.start('second', 'x', async lease => {
await secondGate.promise
lease.assertCurrent()
return 'second'
})
await Promise.resolve()
let drained = false
const drain = coordinator.cancelAndWait('first').then(() => {
drained = true
})
await Promise.resolve()
assert.equal(drained, false)
firstGate.resolve()
await drain
await assert.rejects(first, (error: any) => error.kind === 'superseded')
assert.equal(coordinator.pending.has('second'), true)
secondGate.resolve()
assert.equal(await second, 'second')
})
test('a generation started during cancelAndWait cannot run before the drain completes', async () => {
const coordinator = createBootstrapCoordinator()
const oldGate = deferred()
const events: string[] = []
const old = coordinator.start('scope', 'old', async lease => {
events.push('old-start')
await oldGate.promise
lease.assertCurrent()
})
await Promise.resolve()
const drain = coordinator.cancelAndWait('scope')
const next = coordinator.start('scope', 'new', async () => {
events.push('new-start')
return 'new'
})
await Promise.resolve()
assert.deepEqual(events, ['old-start'])
oldGate.resolve()
await drain
await assert.rejects(old, (error: any) => error.kind === 'superseded')
assert.equal(await next, 'new')
assert.deepEqual(events, ['old-start', 'new-start'])
})
@@ -1,130 +0,0 @@
import crypto from 'node:crypto'
function sshConfigFingerprint(scope, config) {
const parts = [
scope,
config.host,
config.user,
config.port,
config.keyPath,
config.remoteHermesPath,
config.effectiveConfigFingerprint
]
return crypto
.createHash('sha256')
.update(JSON.stringify(parts.map(value => value ?? '')))
.digest('hex')
}
function createBootstrapCoordinator() {
const active = new Set<any>()
const pending = new Map<string, any>()
const generations = new Map<string, number>()
const drains = new Map<string, Promise<void>>()
function start(scope, fingerprint, run) {
const current = pending.get(scope)
if (current?.fingerprint === fingerprint) {
return current.promise
}
current?.controller.abort()
const generation = (generations.get(scope) || 0) + 1
generations.set(scope, generation)
const controller = new AbortController()
const forceCleanups = new Set<() => any>()
const lease = {
signal: controller.signal,
onForceCleanup(cleanup) {
forceCleanups.add(cleanup)
return () => forceCleanups.delete(cleanup)
},
isCurrent: () => !controller.signal.aborted && generations.get(scope) === generation,
assertCurrent() {
if (!this.isCurrent()) {
const error: any = new Error('SSH bootstrap was superseded by newer connection settings.')
error.kind = 'superseded'
throw error
}
}
}
const drain = drains.get(scope) || Promise.resolve()
const predecessor = current ? Promise.allSettled([current.promise, drain]) : drain
const entry: any = { controller, fingerprint, forceCleanups, generation, promise: null, scope }
const promise = predecessor
.then(() => {
lease.assertCurrent()
return run(lease)
})
.finally(() => {
forceCleanups.clear()
active.delete(entry)
if (pending.get(scope)?.generation === generation) {
pending.delete(scope)
}
})
entry.promise = promise
active.add(entry)
pending.set(scope, entry)
return promise
}
function cancel(scope) {
pending.get(scope)?.controller.abort()
}
async function cancelAndWait(scope) {
let release
const barrier = new Promise<void>(resolve => {
release = resolve
})
drains.set(scope, barrier)
const entries = [...active].filter(entry => entry.scope === scope)
for (const entry of entries) {
entry.controller.abort()
}
try {
await Promise.allSettled(entries.map(entry => entry.promise))
} finally {
if (drains.get(scope) === barrier) {
drains.delete(scope)
}
release()
}
}
function cancelAll() {
for (const entry of active) {
entry.controller.abort()
}
}
async function forceCleanupAll() {
const cleanups = [...active].flatMap(entry => [...entry.forceCleanups])
await Promise.allSettled(cleanups.map(cleanup => cleanup()))
}
function promises() {
return [...active].map(entry => entry.promise)
}
return { active, cancel, cancelAll, cancelAndWait, forceCleanupAll, pending, promises, start }
}
export { createBootstrapCoordinator, sshConfigFingerprint }
-104
View File
@@ -1,104 +0,0 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { collectSshConfigHosts, parseSshConfigHosts, parseSshConfigIncludes, parseSshGOutput } from './ssh-config'
test('parseSshConfigHosts keeps literal aliases and drops wildcard/negated patterns', () => {
const cfg = [
'Host devbox',
' HostName 10.0.0.5',
'Host *.internal prod !staging glob*',
'Host alpha beta',
'# Host commented-out',
'host lower-case'
].join('\n')
assert.deepEqual(parseSshConfigHosts(cfg), ['devbox', 'prod', 'alpha', 'beta', 'lower-case'])
})
test('parseSshConfigHosts de-duplicates', () => {
assert.deepEqual(parseSshConfigHosts('Host box\nHost box\nHost box other'), ['box', 'other'])
})
test('parseSshConfigIncludes extracts include tokens', () => {
const cfg = 'Include ~/.ssh/config.d/*\nInclude work_hosts personal_hosts\n# Include ignored'
assert.deepEqual(parseSshConfigIncludes(cfg), ['~/.ssh/config.d/*', 'work_hosts', 'personal_hosts'])
})
test('collectSshConfigHosts follows Include directives (read-only)', () => {
const files = {
'/home/u/.ssh/config': 'Host main\nInclude work\nInclude ~/abs_inc',
'/home/u/.ssh/work': 'Host work-box\nInclude nested',
'/home/u/.ssh/nested': 'Host deep',
'/home/u/abs_inc': 'Host home-abs'
}
const hosts = collectSshConfigHosts('/home/u/.ssh/config', {
homeDir: '/home/u',
readFile: p => files[p] ?? null
})
assert.deepEqual(hosts.sort(), ['deep', 'home-abs', 'main', 'work-box'].sort())
})
test('collectSshConfigHosts tolerates a missing config file', () => {
assert.deepEqual(collectSshConfigHosts('/nope/config', { homeDir: '/home/u', readFile: () => null }), [])
})
test('collectSshConfigHosts does not loop on a self-include cycle', () => {
const files = {
'/home/u/.ssh/config': 'Host a\nInclude loop',
'/home/u/.ssh/loop': 'Host b\nInclude config' // points back at config
}
const hosts = collectSshConfigHosts('/home/u/.ssh/config', {
homeDir: '/home/u',
readFile: p => files[p] ?? null
})
assert.deepEqual(hosts.sort(), ['a', 'b'])
})
test('collectSshConfigHosts expands globbed includes via injected globSync', () => {
const files = {
'/home/u/.ssh/config': 'Host root\nInclude config.d/*',
'/home/u/.ssh/config.d/10-work': 'Host work',
'/home/u/.ssh/config.d/20-home': 'Host home'
}
const hosts = collectSshConfigHosts('/home/u/.ssh/config', {
homeDir: '/home/u',
readFile: p => files[p] ?? null,
globSync: pattern =>
pattern.endsWith('config.d/*') ? ['/home/u/.ssh/config.d/10-work', '/home/u/.ssh/config.d/20-home'] : [pattern]
})
assert.deepEqual(hosts.sort(), ['home', 'root', 'work'].sort())
})
test('parseSshGOutput pulls hostname/user/port/identityfile', () => {
const out = [
'host devbox',
'hostname 10.0.0.5',
'user alice',
'port 2222',
'identityfile ~/.ssh/id_ed25519',
'forwardagent no'
].join('\n')
assert.deepEqual(parseSshGOutput(out), {
hostname: '10.0.0.5',
user: 'alice',
port: 2222,
identityFile: '~/.ssh/id_ed25519'
})
})
test('parseSshGOutput takes the FIRST identityfile and tolerates missing keys', () => {
const out = 'hostname box\nidentityfile ~/.ssh/a\nidentityfile ~/.ssh/b'
const parsed = parseSshGOutput(out)
assert.equal(parsed.identityFile, '~/.ssh/a')
assert.equal(parsed.user, null)
assert.equal(parsed.port, null)
})
-175
View File
@@ -1,175 +0,0 @@
/**
* ssh-config.ts
*
* Pure, electron-free helpers for reading the user's OpenSSH client config:
* `Host` aliases for the settings UI's suggestions, `Include` traversal
* (read-only), and `ssh -G` output parsing. No `import 'electron'` so it's
* unit-testable without Electron; main.ts wires the fs + `ssh -G` exec in.
*/
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
function parseSshConfigHosts(text) {
const hosts: string[] = []
const seen = new Set()
for (const rawLine of String(text || '').split('\n')) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) {
continue
}
const m = /^host\s+(.+)$/i.exec(line)
if (!m) {
continue
}
for (const pattern of m[1].split(/\s+/)) {
if (!pattern || pattern.includes('*') || pattern.includes('?') || pattern.startsWith('!')) {
continue
}
if (!seen.has(pattern)) {
seen.add(pattern)
hosts.push(pattern)
}
}
}
return hosts
}
function parseSshConfigIncludes(text) {
const includes: string[] = []
for (const rawLine of String(text || '').split('\n')) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) {
continue
}
const m = /^include\s+(.+)$/i.exec(line)
if (!m) {
continue
}
for (const token of m[1].split(/\s+/)) {
if (token) {
includes.push(token)
}
}
}
return includes
}
function collectSshConfigHosts(rootPath = '', deps: any = {}) {
const readFile =
deps.readFile ||
(p => {
try {
return fs.readFileSync(p, 'utf8')
} catch {
return null
}
})
const homeDir = deps.homeDir || os.homedir()
const root = rootPath || path.join(homeDir, '.ssh', 'config')
const sshDir = path.join(homeDir, '.ssh')
const out: string[] = []
const seen = new Set()
const visited = new Set()
const resolveIncludePath = token => {
if (token.startsWith('~/')) {
return path.join(homeDir, token.slice(2))
}
if (path.isAbsolute(token)) {
return token
}
return path.join(sshDir, token)
}
const walk = (filePath, depth) => {
if (depth > 8 || visited.has(filePath)) {
return
}
visited.add(filePath)
const text = readFile(filePath)
if (text == null) {
return
}
for (const host of parseSshConfigHosts(text)) {
if (!seen.has(host)) {
seen.add(host)
out.push(host)
}
}
for (const token of parseSshConfigIncludes(text)) {
const target = resolveIncludePath(token)
const expanded = deps.globSync ? deps.globSync(target) : [target]
for (const p of expanded) {
walk(p, depth + 1)
}
}
}
walk(root, 0)
return out
}
function parseSshGOutput(text) {
const out: { hostname: string | null; user: string | null; port: number | null; identityFile: string | null } = {
hostname: null,
user: null,
port: null,
identityFile: null
}
for (const rawLine of String(text || '').split('\n')) {
const line = rawLine.trim()
if (!line) {
continue
}
const sp = line.indexOf(' ')
if (sp === -1) {
continue
}
const key = line.slice(0, sp).toLowerCase()
const value = line.slice(sp + 1).trim()
if (key === 'hostname' && !out.hostname) {
out.hostname = value
} else if (key === 'user' && !out.user) {
out.user = value
} else if (key === 'port' && !out.port) {
out.port = Number.parseInt(value, 10) || null
} else if (key === 'identityfile' && !out.identityFile) {
out.identityFile = value
}
}
return out
}
export { collectSshConfigHosts, parseSshConfigHosts, parseSshConfigIncludes, parseSshGOutput }
@@ -1,866 +0,0 @@
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import {
baseSshOptions,
buildControlArgs,
buildExecArgs,
buildInteractiveSshArgs,
buildMasterArgs,
classifySshError,
controlSocketPath,
createSshProbeConnection,
forwardSpec,
hostArgs,
redactSecrets,
runSsh,
SSH_ERROR,
SshConnection,
sshErrorMessage,
stopTunnelChild,
target,
validateSshTarget
} from './ssh-connection'
test('redactSecrets scrubs the spawn-time session token env var', () => {
const line = 'setsid env HERMES_DASHBOARD_SESSION_TOKEN=abc123deadbeef HERMES_DESKTOP=1 hermes dashboard'
const out = redactSecrets(line)
assert.ok(!out.includes('abc123deadbeef'))
assert.match(out, /HERMES_DASHBOARD_SESSION_TOKEN=<redacted>/)
// non-secret env vars are preserved
assert.match(out, /HERMES_DESKTOP=1/)
})
test('redactSecrets scrubs ?token= and ?ticket= URL params', () => {
assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?token=supersecret'), /\?token=<redacted>/)
assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?ticket=onetimeticket'), /\?ticket=<redacted>/)
assert.match(redactSecrets('GET /x?a=1&token=zzz HTTP'), /&token=<redacted>/)
assert.ok(!redactSecrets('?token=supersecret').includes('supersecret'))
})
test('redactSecrets scrubs Authorization and X-Hermes-Session-Token headers', () => {
assert.match(redactSecrets('Authorization: Bearer tok_9999'), /Authorization: Bearer <redacted>/)
assert.ok(!redactSecrets('Authorization: Bearer tok_9999').includes('tok_9999'))
assert.match(redactSecrets('X-Hermes-Session-Token: hdr_888'), /X-Hermes-Session-Token: ?<redacted>/)
assert.ok(!redactSecrets('X-Hermes-Session-Token: hdr_888').includes('hdr_888'))
})
test('redactSecrets handles null/undefined and non-secret text untouched', () => {
assert.equal(redactSecrets(null), '')
assert.equal(redactSecrets(undefined), '')
assert.equal(redactSecrets('uname -s -m'), 'uname -s -m')
})
test('controlSocketPath is stable, short, and host-distinct', () => {
const a = controlSocketPath('me', 'box1', 22, '/tmp/d')
const a2 = controlSocketPath('me', 'box1', 22, '/tmp/d')
const b = controlSocketPath('me', 'box2', 22, '/tmp/d')
assert.equal(a, a2, 'same triple → same socket (ControlMaster reuse)')
assert.notEqual(a, b, 'different host → different socket')
// 16 hex chars + .sock keeps the basename short for sun_path 104-byte limit
assert.match(a, /\/[0-9a-f]{16}\.sock$/)
})
test('controlSocketPath default base stays under sun_path even with the temp-listener suffix', () => {
// OpenSSH binds a temporary listener at `<ControlPath>.<16 random chars>` (a
// 17-byte suffix) while opening the master. The macOS regression was the
// default base under os.tmpdir() (/var/folders/.../T/) pushing it over 104.
const p = controlSocketPath('hermes', 'remote-build-server', 22) // no baseDir → default
const worstCase = `${p}.0123456789abcdef` // mimic the .<16-char> temp suffix
assert.ok(
worstCase.length <= 104,
`default control socket + temp suffix must fit sun_path (got ${worstCase.length}: ${worstCase})`
)
// And it must NOT live under the deeply-nested macOS per-user temp dir.
assert.ok(!p.includes('/var/folders/'), 'default base must not be os.tmpdir() on macOS')
})
test('baseSshOptions carries the house ControlMaster/BatchMode/accept-new policy', () => {
const opts = baseSshOptions('/tmp/x.sock', 15000)
const joined = opts.join(' ')
assert.match(joined, /ControlPath=\/tmp\/x\.sock/)
assert.match(joined, /ControlMaster=auto/)
assert.match(joined, /ControlPersist=\d+/)
assert.match(joined, /BatchMode=yes/)
assert.match(joined, /StrictHostKeyChecking=accept-new/)
assert.match(joined, /ExitOnForwardFailure=yes/)
assert.match(joined, /ConnectTimeout=15/)
assert.ok(!joined.includes('StrictHostKeyChecking=no'), 'never disables host-key checking')
})
test('hostArgs adds -p only for non-default port and -i only with a key', () => {
assert.deepEqual(hostArgs({ port: 22 }), [])
assert.deepEqual(hostArgs({ port: 2222 }), ['-p', '2222'])
assert.deepEqual(hostArgs({ port: 22, keyPath: '/k' }), ['-i', '/k'])
assert.deepEqual(hostArgs({ port: 2200, keyPath: '/k' }), ['-p', '2200', '-i', '/k'])
})
test('target builds user@host or bare host', () => {
assert.equal(target('me', 'box'), 'me@box')
assert.equal(target('', 'box'), 'box')
})
test('buildExecArgs ends with host then the remote command', () => {
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
const args = buildExecArgs(conn, 'command -v hermes', 15000)
assert.equal(args[args.length - 1], 'command -v hermes')
assert.equal(args[args.length - 2], 'me@box')
assert.ok(args.includes('BatchMode=yes'))
})
test('buildControlArgs places -O <op> first and never appends a remote command', () => {
const conn = { user: 'me', host: 'box', port: 2222, keyPath: '/k', controlPath: '/tmp/x.sock' }
const args = buildControlArgs(conn, 'forward', ['-L', forwardSpec(5000, 6000)], 15000)
assert.equal(args[0], '-O')
assert.equal(args[1], 'forward')
assert.ok(args.includes('-L'))
assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000'))
assert.equal(args[args.length - 1], 'me@box')
})
test('buildMasterArgs requests a backgrounded master (-M -N -f)', () => {
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
const args = buildMasterArgs(conn, 15000)
assert.ok(args.includes('-M'))
assert.ok(args.includes('-N'))
assert.ok(args.includes('-f'))
})
test('forwardSpec binds the local end to 127.0.0.1 only', () => {
assert.equal(forwardSpec(5000, 6000), '127.0.0.1:5000:127.0.0.1:6000')
assert.ok(forwardSpec(5000, 6000).startsWith('127.0.0.1:'))
assert.ok(!forwardSpec(5000, 6000).startsWith('0.0.0.0'))
})
test('buildInteractiveSshArgs requests a PTY, reuses the control master, execs a login shell', () => {
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
const args = buildInteractiveSshArgs(conn, '', 15000)
assert.equal(args[0], '-tt', 'forces a PTY so the remote sees a real terminal')
assert.ok(args.join(' ').includes('ControlPath=/tmp/x.sock'), 'reuses the existing master (no new auth)')
assert.equal(args[args.length - 2], 'me@box')
assert.equal(args[args.length - 1], 'exec "$SHELL" -l')
})
test('buildInteractiveSshArgs cds into the remote cwd (best-effort) before the shell', () => {
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
const args = buildInteractiveSshArgs(conn, '/home/me/project', 15000)
const remoteCmd = args[args.length - 1]
assert.match(remoteCmd, /^cd '\/home\/me\/project' 2>\/dev\/null; exec "\$SHELL" -l$/)
})
test('buildInteractiveSshArgs single-quotes a cwd with quotes safely', () => {
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
const args = buildInteractiveSshArgs(conn, "/tmp/a'b", 15000)
// the embedded quote must be escaped, not break out of the quoting
assert.ok(args[args.length - 1].startsWith("cd '/tmp/a'"))
assert.ok(args[args.length - 1].includes('exec "$SHELL" -l'))
})
test('classifySshError detects a changed host key (fail-closed)', () => {
assert.equal(
classifySshError('@@@@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @@@@'),
SSH_ERROR.HOST_KEY_CHANGED
)
assert.equal(classifySshError('Host key verification failed.'), SSH_ERROR.HOST_KEY_CHANGED)
assert.equal(classifySshError('Offending ECDSA key in /home/u/.ssh/known_hosts:5'), SSH_ERROR.HOST_KEY_CHANGED)
})
test('classifySshError detects auth failure', () => {
assert.equal(classifySshError('Permission denied (publickey).'), SSH_ERROR.AUTH_FAILED)
assert.equal(classifySshError('Too many authentication failures'), SSH_ERROR.AUTH_FAILED)
})
test('classifySshError detects unreachable', () => {
assert.equal(classifySshError('ssh: Could not resolve hostname nope'), SSH_ERROR.UNREACHABLE)
assert.equal(classifySshError('connect to host x port 22: Connection refused'), SSH_ERROR.UNREACHABLE)
})
test('sshErrorMessage gives actionable guidance for auth and host-key-change', () => {
const conn = { user: 'me', host: 'box', port: 22 }
assert.match(sshErrorMessage(SSH_ERROR.AUTH_FAILED, conn, 'Permission denied'), /ssh-agent|ssh-add|IdentityFile/)
assert.match(sshErrorMessage(SSH_ERROR.HOST_KEY_CHANGED, conn, 'CHANGED'), /ssh-keygen -R box/)
})
// A fake child process that emits a scripted result on next tick.
function fakeChild({ code = 0, stdout = '', stderr = '', errorEvent = null, hang = false }: any = {}) {
const child: any = new EventEmitter()
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.kill = () => {
child._killed = true
}
if (hang) {
return child // never emits close → drives the timeout path
}
process.nextTick(() => {
if (errorEvent) {
child.emit('error', errorEvent)
return
}
if (stdout) {
child.stdout.emit('data', Buffer.from(stdout))
}
if (stderr) {
child.stderr.emit('data', Buffer.from(stderr))
}
child.emit('close', code)
})
return child
}
// Build a spawnFn that returns scripted children per ssh invocation, recording
// the args it was called with.
function scriptedSpawn(scripts) {
const calls: any[] = []
let i = 0
const fn: any = (_cmd, args) => {
calls.push(args)
const script = typeof scripts === 'function' ? scripts(args, i) : scripts[Math.min(i, scripts.length - 1)]
i += 1
return fakeChild(script || {})
}
fn.calls = calls
return fn
}
test('open() establishes the master when not already alive', async () => {
// `-O check` fails first (not alive) → master opens (code 0). Track which
// ssh ops ran rather than re-probing with the same always-failing check.
const ops: string[] = []
const spawnFn = scriptedSpawn(args => {
ops.push(args.includes('check') ? 'check' : args.includes('-M') ? 'master' : 'other')
if (args.includes('check')) {
return { code: 255, stderr: 'no control path' }
}
return { code: 0 }
})
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
await conn.open()
assert.deepEqual(ops, ['check', 'master'], 'probes liveness first, then opens the master')
})
test('open() is a no-op when the master is already alive and execs verify', async () => {
const ops: string[] = []
const spawnFn = scriptedSpawn(args => {
ops.push(args.includes('check') ? 'check' : args.includes('exit 0') ? 'verify' : 'master')
return { code: 0 } // check succeeds → alive; verify exec succeeds → trusted
})
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
await conn.open()
assert.deepEqual(ops, ['check', 'verify'], 'alive master is exec-verified, then trusted without reopening')
})
test('open() evicts a wedged master (check passes, exec hangs) and dials fresh', async () => {
// The macOS mode-switch wedge: ControlPersist master answers -O check but
// every exec through it hangs. open() must verify, evict (-O exit), and
// establish a fresh master instead of trusting the corpse.
const ops: string[] = []
const spawnFn = scriptedSpawn(args => {
if (args.includes('check')) {
ops.push('check')
return { code: 0 }
}
if (args.includes('exit 0')) {
ops.push('verify')
return { hang: true }
}
if (args.includes('-O')) {
ops.push('evict')
return { code: 0 }
}
ops.push('master')
return { code: 0 }
})
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d', connectTimeoutMs: 50 })
await conn.open()
assert.deepEqual(
ops,
['check', 'verify', 'evict', 'master'],
'wedged master: verified, evicted, then a fresh master is dialed'
)
})
test('close() removes the control socket when -O exit fails', async () => {
const dir = path.join(os.tmpdir(), `hermes-ssh-close-${process.pid}-${Date.now()}`)
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
const spawnFn = scriptedSpawn(args => {
if (args.includes('check')) {
return { code: 255 }
} // not alive → open dials master
if (args.includes('-M')) {
return { code: 0 }
}
return { code: 255, stderr: 'mux: master gone' } // -O exit fails
})
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir })
await conn.open()
fs.writeFileSync(conn.controlPath, '') // simulate the lingering socket file
await conn.close()
assert.ok(!fs.existsSync(conn.controlPath), 'failed -O exit drops the socket so the next open dials fresh')
fs.rmSync(dir, { recursive: true, force: true })
})
test('open() creates the control-socket directory if it does not exist', async () => {
const dir = path.join(os.tmpdir(), `hermes-ssh-test-${process.pid}-${Date.now()}`)
assert.ok(!fs.existsSync(dir), 'precondition: control dir absent')
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir })
try {
await conn.open()
assert.ok(fs.existsSync(dir), 'open() created the control-socket directory before spawning ssh')
} finally {
try {
fs.rmSync(dir, { recursive: true, force: true })
} catch {
/* ignore */
}
}
})
test('open() surfaces a classified auth error', async () => {
const spawnFn = scriptedSpawn(args => {
if (args.includes('check')) {
return { code: 255 }
}
return { code: 255, stderr: 'Permission denied (publickey).' }
})
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
await assert.rejects(
() => conn.open(),
(err: any) => {
assert.equal(err.kind, SSH_ERROR.AUTH_FAILED)
assert.match(err.message, /ssh-agent|ssh-add/)
return true
}
)
})
test('exec() returns stdout on success and rejects (classified) on failure', async () => {
const okSpawn = scriptedSpawn([{ code: 0, stdout: 'Linux\n' }])
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: okSpawn, controlDir: '/tmp/d' })
assert.equal((await conn.exec('uname -s')).trim(), 'Linux')
const failSpawn = scriptedSpawn([{ code: 1, stderr: 'ssh: Could not resolve hostname box' }])
const conn2 = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: failSpawn, controlDir: '/tmp/d' })
await assert.rejects(
() => conn2.exec('uname -s'),
(err: any) => {
assert.equal(err.kind, SSH_ERROR.UNREACHABLE)
return true
}
)
})
test('exec() treats a hung ssh as a timeout (half-open connection)', async () => {
const spawnFn = scriptedSpawn([{ hang: true }])
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
await assert.rejects(
() => conn.exec('uname -s', { timeoutMs: 30 }),
(err: any) => {
assert.equal(err.kind, SSH_ERROR.TIMEOUT)
return true
}
)
})
test('forward() issues -O forward with a loopback-bound -L spec', async () => {
const spawnFn = scriptedSpawn([{ code: 0 }])
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
await conn.forward(5000, 6000)
const args = spawnFn.calls[0]
assert.equal(args[0], '-O')
assert.equal(args[1], 'forward')
assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000'))
})
test('lifecycle logging passes through redaction', async () => {
const logs: string[] = []
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
const conn = new SshConnection(
{ host: 'box', user: 'me' },
{ spawnFn, controlDir: '/tmp/d', rememberLog: l => logs.push(l) }
)
await conn.open()
// none of the emitted log lines may carry a raw token-shaped secret
for (const line of logs) {
assert.ok(!/token=[^<]/.test(line))
}
assert.ok(logs.some(l => l.includes('[ssh]')))
})
test('no-mux: ssh args carry no ControlMaster/ControlPath options', async () => {
const spawnFn = scriptedSpawn({ code: 0 })
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false })
await conn.open()
for (const args of spawnFn.calls) {
assert.ok(!args.some(a => /ControlMaster|ControlPath|ControlPersist/.test(a)), `mux option leaked: ${args}`)
}
})
test('no-mux: open() verifies auth with a one-shot exec, no -M master', async () => {
const spawnFn = scriptedSpawn({ code: 0 })
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false })
await conn.open()
assert.ok(!spawnFn.calls.some(args => args.includes('-M')), 'no master should be spawned')
assert.ok(
spawnFn.calls.some(args => args[args.length - 1] === 'exit 0'),
'liveness/openness via one-shot exec'
)
})
test('SSH probe never creates or closes a ControlMaster', async () => {
const spawnFn = scriptedSpawn({ code: 0 })
const conn = createSshProbeConnection({ host: 'box', user: 'me' }, { spawnFn })
await conn.open()
await conn.close()
const args = spawnFn.calls.flat()
assert.ok(!args.includes('-M'))
assert.ok(!args.includes('-O'))
assert.ok(!args.some(value => /Control(?:Master|Path|Persist)/.test(value)))
})
test('no-mux: open() classifies auth failure', async () => {
const spawnFn = scriptedSpawn([{ code: 255, stderr: 'me@box: Permission denied (publickey).' }])
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false })
await assert.rejects(conn.open(), (err: any) => err.kind === 'auth-failed')
})
test('no-mux: forward spawns a persistent -N -L child; cancel + close kill it', async () => {
// Real listener stands in for the tunnel's local end so waitForLocalPort sees it.
const net = await import('node:net')
const srv = net.createServer()
await new Promise<void>(r => srv.listen(0, '127.0.0.1', () => r()))
const localPort = (srv.address() as any).port
const tunnels: any[] = []
const spawnFn: any = (_cmd, args) => {
const child: any = new EventEmitter()
child.stderr = new EventEmitter()
child.exitCode = null
child.kill = () => {
child._killed = true
child.exitCode = 0
process.nextTick(() => child.emit('exit', 0))
return true
}
if (args.includes('-N')) {
tunnels.push({ args, child })
process.nextTick(() =>
child.stderr.emit('data', Buffer.from(`Local forwarding listening on 127.0.0.1 port ${localPort}.`))
)
} else {
process.nextTick(() => child.emit('close', 0))
}
if (!args.includes('-N')) {
child.stdout = new EventEmitter()
process.nextTick(() => child.emit('close', 0))
}
return child
}
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false })
await conn.forward(localPort, 9119)
assert.equal(tunnels.length, 1, 'one persistent tunnel child')
assert.ok(tunnels[0].args.includes('-L'), 'tunnel child carries -L spec')
assert.ok(!tunnels[0].args.some(a => /ControlPath/.test(a)))
await conn.cancelForward(localPort, 9119)
assert.ok(tunnels[0].child._killed, 'cancelForward kills the tunnel child')
conn._opened = true
await conn.close() // no-mux close never runs ssh -O exit; must not throw
srv.close()
})
test('no-mux: forward fails fast when the tunnel child dies (bad spec/auth)', async () => {
const spawnFn: any = (_cmd, args) => {
const child: any = new EventEmitter()
child.stderr = new EventEmitter()
child.exitCode = null
child.kill = () => {}
if (args.includes('-N')) {
process.nextTick(() => {
child.stderr.emit('data', Buffer.from('Permission denied (publickey).'))
child.exitCode = 255
})
}
return child
}
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false, forwardTimeoutMs: 2000 })
await assert.rejects(conn.forward(1, 9119), (err: any) => err.kind === 'auth-failed')
})
test('no-mux: an unrelated listener cannot mask a delayed bind failure', async () => {
const net = await import('node:net')
const srv = net.createServer()
await new Promise<void>(resolve => srv.listen(0, '127.0.0.1', resolve))
const localPort = (srv.address() as any).port
const spawnFn: any = (_cmd, args) => {
const child: any = new EventEmitter()
child.stderr = new EventEmitter()
child.exitCode = null
child.kill = () => {}
if (args.includes('-N')) {
setTimeout(() => {
child.stderr.emit('data', Buffer.from(`bind [127.0.0.1]:${localPort}: Address already in use`))
child.exitCode = 255
child.emit('exit', 255)
}, 20)
}
return child
}
const conn = new SshConnection({ host: 'box' }, { spawnFn, mux: false, forwardTimeoutMs: 1000 })
await assert.rejects(conn.forward(localPort, 9119), /address already in use/i)
srv.close()
})
test('no-mux: tunnel death after readiness makes the connection unhealthy', async () => {
const net = await import('node:net')
const srv = net.createServer()
await new Promise<void>(resolve => srv.listen(0, '127.0.0.1', resolve))
const localPort = (srv.address() as any).port
let tunnel
const spawnFn: any = (_cmd, args) => {
const child: any = new EventEmitter()
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.exitCode = null
child.kill = () => {}
if (args.includes('-N')) {
tunnel = child
process.nextTick(() =>
child.stderr.emit('data', Buffer.from(`Local forwarding listening on 127.0.0.1 port ${localPort}.`))
)
} else {
process.nextTick(() => child.emit('close', 0))
}
return child
}
const conn = new SshConnection({ host: 'box' }, { spawnFn, mux: false })
await conn.open()
await conn.forward(localPort, 9119)
tunnel.emit('exit', 255)
assert.equal(await conn.isAlive(), false)
srv.close()
})
test('validateSshTarget rejects a host starting with a dash (option injection)', () => {
assert.throws(() => validateSshTarget('-oProxyCommand=evil', '', 22), /unsafe/i)
assert.throws(() => validateSshTarget('--version', '', 22), /unsafe/i)
})
test('validateSshTarget rejects control characters in host', () => {
assert.throws(() => validateSshTarget('host\x00evil', '', 22), /unsafe/i)
assert.throws(() => validateSshTarget('host\nnewline', '', 22), /unsafe/i)
assert.throws(() => validateSshTarget('host\ttab', '', 22), /unsafe/i)
})
test('validateSshTarget rejects control characters in user', () => {
assert.throws(() => validateSshTarget('box', 'me\x00root', 22), /unsafe/i)
assert.throws(() => validateSshTarget('box', '-oForward=yes', 22), /unsafe/i)
})
test('validateSshTarget rejects ports outside 1-65535', () => {
assert.throws(() => validateSshTarget('box', '', 0), /port/i)
assert.throws(() => validateSshTarget('box', '', 65536), /port/i)
assert.throws(() => validateSshTarget('box', '', -1), /port/i)
assert.throws(() => validateSshTarget('box', '', NaN), /port/i)
})
test('validateSshTarget accepts valid targets', () => {
assert.doesNotThrow(() => validateSshTarget('my-host.example.com', 'alice', 22))
assert.doesNotThrow(() => validateSshTarget('192.168.1.1', '', 2222))
assert.doesNotThrow(() => validateSshTarget('::1', 'root', 22))
})
test('SshConnection constructor rejects hostile host/user/port', () => {
assert.throws(() => new SshConnection({ host: '-oProxyCommand=evil' }), /unsafe/i)
assert.throws(() => new SshConnection({ host: 'box', user: '-oForward' }), /unsafe/i)
assert.throws(() => new SshConnection({ host: 'box', port: 99999 }), /port/i)
})
test('buildExecArgs inserts -- before the destination', () => {
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
const args = buildExecArgs(conn, 'uname -s', 15000)
const ddIdx = args.indexOf('--')
assert.ok(ddIdx >= 0, 'must contain --')
assert.equal(args[ddIdx + 1], 'me@box', '-- immediately precedes the destination')
assert.equal(args[ddIdx + 2], 'uname -s', 'remote command follows destination')
})
test('buildMasterArgs inserts -- before the destination', () => {
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
const args = buildMasterArgs(conn, 15000)
const ddIdx = args.indexOf('--')
assert.ok(ddIdx >= 0, 'must contain --')
assert.equal(args[ddIdx + 1], 'me@box')
})
test('buildControlArgs inserts -- before the destination', () => {
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
const args = buildControlArgs(conn, 'check', [], 15000)
const ddIdx = args.indexOf('--')
assert.ok(ddIdx >= 0, 'must contain --')
assert.equal(args[ddIdx + 1], 'me@box')
})
test('buildInteractiveSshArgs inserts -- before the destination', () => {
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
const args = buildInteractiveSshArgs(conn, '', 15000)
const ddIdx = args.indexOf('--')
assert.ok(ddIdx >= 0, 'must contain --')
assert.equal(args[ddIdx + 1], 'me@box')
})
test('hostArgs rejects a keyPath with control characters', () => {
assert.throws(() => hostArgs({ keyPath: '/tmp/key\x00inject' }), /unsafe/i)
})
test('hostArgs rejects a keyPath starting with a dash', () => {
assert.throws(() => hostArgs({ keyPath: '-oProxyCommand=evil' }), /unsafe/i)
})
test('hostArgs accepts valid key paths', () => {
assert.deepEqual(hostArgs({ keyPath: '/home/user/.ssh/id_ed25519' }), ['-i', '/home/user/.ssh/id_ed25519'])
assert.deepEqual(hostArgs({ keyPath: '~/.ssh/id_rsa' }), ['-i', '~/.ssh/id_rsa'])
})
test('runSsh delivers stdinData to the child and does not log it', async () => {
let stdinWritten = ''
const spawnFn: any = (_cmd, _args, opts) => {
const child: any = new EventEmitter()
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.kill = () => {}
child.stdin = {
end(data) {
stdinWritten = String(data)
}
}
assert.equal(opts.stdio[0], 'pipe', 'stdin must be pipe when stdinData is provided')
process.nextTick(() => child.emit('close', 0))
return child
}
await runSsh(['host', 'cat'], { timeoutMs: 5000, spawnFn, stdinData: 'secret-token-value' })
assert.equal(stdinWritten, 'secret-token-value', 'stdinData must be written to child.stdin')
})
test('open() rejects a control-dir that is a symlink', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-test-'))
const real = path.join(tmp, 'real')
const link = path.join(tmp, 'link')
fs.mkdirSync(real, { mode: 0o700 })
fs.symlinkSync(real, link)
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: link })
await assert.rejects(conn.open(), /symlink|unsafe/i)
fs.rmSync(tmp, { recursive: true, force: true })
})
test('open() enforces 0700 on an existing control dir with lax permissions', async () => {
if (process.platform === 'win32') {
return
}
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-test-'))
const dir = path.join(tmp, 'ctrl')
fs.mkdirSync(dir, { mode: 0o755 })
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir })
await conn.open()
const stat = fs.statSync(dir)
assert.equal(stat.mode & 0o777, 0o700, 'control dir must be tightened to 0700')
fs.rmSync(tmp, { recursive: true, force: true })
})
test('control socket identity separates installation scope and key identity', () => {
const base = controlSocketPath('me', 'box', 22, '/tmp/d', {
ownershipId: 'installation-a',
scope: 'primary',
keyPath: '/keys/id'
})
assert.equal(
base,
controlSocketPath('me', 'box', 22, '/tmp/d', {
ownershipId: 'installation-a',
scope: 'primary',
keyPath: '/keys/./id'
})
)
assert.notEqual(
base,
controlSocketPath('me', 'box', 22, '/tmp/d', {
ownershipId: 'installation-a',
scope: 'worker',
keyPath: '/keys/id'
})
)
assert.notEqual(
base,
controlSocketPath('me', 'box', 22, '/tmp/d', {
ownershipId: 'installation-b',
scope: 'primary',
keyPath: '/keys/id'
})
)
assert.notEqual(
base,
controlSocketPath('me', 'box', 22, '/tmp/d', {
ownershipId: 'installation-a',
scope: 'primary',
keyPath: '/keys/other'
})
)
assert.notEqual(
base,
controlSocketPath('me', 'box', 22, '/tmp/d', {
ownershipId: 'installation-a',
scope: 'primary',
keyPath: '/keys/id',
effectiveConfigFingerprint: 'changed-config'
})
)
})
test('closing one scope addresses only that scope control master', async () => {
const firstSpawn = scriptedSpawn({ code: 0 })
const secondSpawn = scriptedSpawn({ code: 0 })
const first = new SshConnection(
{ host: 'box', user: 'me' },
{
spawnFn: firstSpawn,
controlDir: '/tmp/d',
ownershipId: 'installation',
scope: 'first'
}
)
const second = new SshConnection(
{ host: 'box', user: 'me' },
{
spawnFn: secondSpawn,
controlDir: '/tmp/d',
ownershipId: 'installation',
scope: 'second'
}
)
first._opened = true
second._opened = true
await first.close()
assert.notEqual(first.controlPath, second.controlPath)
assert.ok(firstSpawn.calls[0].includes(`ControlPath=${first.controlPath}`))
assert.ok(!firstSpawn.calls[0].includes(`ControlPath=${second.controlPath}`))
assert.equal(second._opened, true)
})
test('failed ControlMaster close disowns the master instead of retrying it', async () => {
// Old contract kept _opened=true for a retry — which left wedged ControlPersist
// masters trusted and reattachable (the macOS mode-switch livelock). New
// contract: a master that refuses -O exit is disowned — socket dropped,
// connection marked closed — so the next open dials fresh.
const spawnFn = scriptedSpawn([{ code: 255, stderr: 'master refused exit' }])
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
conn._opened = true
await conn.close()
assert.equal(conn._opened, false)
assert.equal(spawnFn.calls.length, 1)
})
test('stopTunnelChild waits for process exit', async () => {
const child: any = new EventEmitter()
child.exitCode = null
child.kill = () => {
process.nextTick(() => {
child.exitCode = 0
child.emit('exit', 0)
})
return true
}
let stopped = false
const stopping = stopTunnelChild(child).then(() => {
stopped = true
})
assert.equal(stopped, false)
await stopping
assert.equal(stopped, true)
})
-921
View File
@@ -1,921 +0,0 @@
/**
* ssh-connection.ts
*
* Pure, electron-free OpenSSH ControlMaster connection manager for Desktop SSH
* remote mode. Uses the system `ssh` client (not a JS SSH library) so it
* inherits ~/.ssh/config, the agent, jump hosts (ProxyJump), and hardware keys
* for free — the same rationale as tools/environments/ssh.py.
*
* No `import 'electron'` so it is unit-testable without Electron. main.ts
* wires it into the electron-coupled lifecycle.
*
* Conventions mirrored from tools/environments/ssh.py:
* - ControlMaster=auto + ControlPersist so one TCP/auth handshake is reused
* across exec/forward operations.
* - Hashed control-socket filename under a short tmpdir to stay under the
* 104-byte sun_path limit macOS enforces on Unix domain sockets.
* - BatchMode=yes for every programmatic invocation — a spawned ssh must
* never hang on an interactive prompt (passphrase / 2FA). If auth needs
* interactivity we fail fast and tell the user to load the key into their
* agent.
*
* Host-key policy: StrictHostKeyChecking=accept-new (trust-on-first-use, log
* the fingerprint), never `no`. A host-key *change* fails closed with the
* verbatim OpenSSH error surfaced to the UI.
*
* Every operation is raced against a hard timeout. A half-open TCP connection
* after laptop sleep can leave ssh hanging indefinitely rather than erroring;
* timeout is treated as connection-dead so the caller does a full reconnect
* rather than retrying in place.
*/
import { spawn } from 'node:child_process'
import crypto from 'node:crypto'
import fs from 'node:fs'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000
const DEFAULT_EXEC_TIMEOUT_MS = 20_000
const DEFAULT_FORWARD_TIMEOUT_MS = 15_000
const CONTROL_PERSIST_SECONDS = 300
// eslint-disable-next-line no-control-regex -- deliberately reject control chars in ssh targets
const _CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/
function validateSshTarget(host, user, port) {
if (!host || typeof host !== 'string') {
throw new Error('Unsafe SSH target: host is required.')
}
if (host.startsWith('-')) {
throw new Error(`Unsafe SSH target: host must not start with a dash ("${host}").`)
}
if (_CONTROL_CHAR_RE.test(host)) {
throw new Error('Unsafe SSH target: host contains control characters.')
}
if (user && _CONTROL_CHAR_RE.test(user)) {
throw new Error('Unsafe SSH target: user contains control characters.')
}
if (user && user.startsWith('-')) {
throw new Error(`Unsafe SSH target: user must not start with a dash ("${user}").`)
}
const p = Number(port)
if (!Number.isInteger(p) || p < 1 || p > 65535) {
throw new Error(`Unsafe SSH port: ${port} (must be 1-65535).`)
}
}
function validateKeyPath(keyPath) {
if (!keyPath) {
return
}
if (_CONTROL_CHAR_RE.test(keyPath)) {
throw new Error('Unsafe SSH key path: contains control characters.')
}
if (keyPath.startsWith('-')) {
throw new Error(`Unsafe SSH key path: must not start with a dash ("${keyPath}").`)
}
}
// Token / secret redaction
const _REDACTIONS: Array<[RegExp, string]> = [
[/(HERMES_DASHBOARD_SESSION_TOKEN=)(\S+)/g, '$1<redacted>'],
[/(X-Hermes-Session-Token["']?\s*[:=]\s*["']?)([^\s"'&]+)/gi, '$1<redacted>'],
[/(Authorization["']?\s*:\s*Bearer\s+)(\S+)/gi, '$1<redacted>'],
[/([?&](?:token|ticket)=)([^\s&"']+)/gi, '$1<redacted>']
]
function redactSecrets(text) {
let out = String(text == null ? '' : text)
for (const [re, repl] of _REDACTIONS) {
out = out.replace(re, repl)
}
return out
}
// Control-socket path
// Hash user@host:port to a short, stable, filesystem-safe socket id — stable
// across reconnects so ControlMaster reuse works, short so the full path stays
// under sun_path's 104-byte limit.
//
// CRITICAL (macOS): the base dir must be SHORT. os.tmpdir() on macOS is the
// per-user `/var/folders/xx/yyyy…/T/` (~49 bytes), and OpenSSH binds a
// TEMPORARY listener at `<ControlPath>.<16 random chars>` while establishing
// the master — so a path that itself fits 104 still overflows at bind time. We
// root under a short per-user base (`~/.hermes/desktop-ssh`) so even worst case
// (~72 bytes on macOS) stays clear. Windows has no AF_UNIX sun_path limit.
function controlSocketPath(user, host, port, baseDir?, identity: any = {}) {
const dir = baseDir || defaultControlDir()
const keyPathIdentity = path.normalize(String(identity.keyPath || ''))
const parts = [
identity.ownershipId || '',
identity.scope || '',
user || '',
host,
Number(port),
keyPathIdentity,
identity.effectiveConfigFingerprint || ''
]
const id = crypto.createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16)
return path.join(dir, `${id}.sock`)
}
function defaultControlDir() {
// POSIX: a SHORT, PER-USER base stays under the socket limit AND avoids a
// world-shared /tmp dir (no symlink-hijack surface). Created 0700 in open().
if (process.platform === 'win32') {
return path.join(os.tmpdir(), 'hermes-desktop-ssh')
}
return path.join(os.homedir(), '.hermes', 'desktop-ssh')
}
// Command construction (pure — the unit tests exercise these directly)
// Mux (POSIX): ControlMaster options so exec/forward share one authenticated
// connection. No-mux (Windows OpenSSH never implemented mux sockets): plain
// per-invocation options — each ssh call authenticates on its own.
function baseSshOptions(controlPath, connectTimeoutMs?) {
const connectSecs = Math.max(1, Math.round((connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS) / 1000))
const mux = controlPath
? [
'-o',
`ControlPath=${controlPath}`,
'-o',
'ControlMaster=auto',
'-o',
`ControlPersist=${CONTROL_PERSIST_SECONDS}`
]
: []
return [
...mux,
'-o',
'BatchMode=yes',
'-o',
'StrictHostKeyChecking=accept-new',
'-o',
'ExitOnForwardFailure=yes',
'-o',
`ConnectTimeout=${connectSecs}`
]
}
// Non-default port and explicit identity file, shared by exec/master/forward.
function hostArgs({ port, keyPath }: { port?: number | string; keyPath?: string } = {}) {
const args: string[] = []
if (port && Number(port) !== 22) {
args.push('-p', String(port))
}
if (keyPath) {
validateKeyPath(keyPath)
args.push('-i', keyPath)
}
return args
}
function target(user, host) {
return user ? `${user}@${host}` : host
}
function buildExecArgs(conn, remoteCommand, connectTimeoutMs?) {
return [
...baseSshOptions(conn.controlPath, connectTimeoutMs),
...hostArgs(conn),
'--',
target(conn.user, conn.host),
remoteCommand
]
}
function buildControlArgs(conn, op, extra: string[] = [], connectTimeoutMs?) {
return [
'-O',
op,
...extra,
...baseSshOptions(conn.controlPath, connectTimeoutMs),
...hostArgs(conn),
'--',
target(conn.user, conn.host)
]
}
// Open the master explicitly: `-M -N -f` backgrounds ssh once the master is up,
// so the spawn resolves when the connection is established (or fails fast under
// BatchMode if auth is non-interactive-only).
function buildMasterArgs(conn, connectTimeoutMs?) {
return [
'-M',
'-N',
'-f',
...baseSshOptions(conn.controlPath, connectTimeoutMs),
...hostArgs(conn),
'--',
target(conn.user, conn.host)
]
}
// Interactive `ssh -tt` for the INTERIM remote terminal (SSH mode only). Reuses
// the existing ControlMaster socket so NO new auth handshake happens — the
// master is already open, so this attaches instantly and never prompts.
//
// NOTE(remote-terminal): interim until the dashboard /api/terminal WebSocket
// lands (specs/desktop-remote-terminal.md); delete this path then.
function buildInteractiveSshArgs(conn, remoteCwd, connectTimeoutMs?, remoteCommand?) {
const args = [
'-tt',
...baseSshOptions(conn.controlPath, connectTimeoutMs),
...hostArgs(conn),
'--',
target(conn.user, conn.host)
]
if (remoteCommand) {
args.push(remoteCommand)
return args
}
const cwd = String(remoteCwd || '').trim()
if (cwd) {
const q = `'${cwd.replace(/'/g, `'\\''`)}'`
args.push(`cd ${q} 2>/dev/null; exec "$SHELL" -l`)
} else {
args.push('exec "$SHELL" -l')
}
return args
}
// Bind the local end to 127.0.0.1 ONLY — never 0.0.0.0 — so the tunnel does not
// re-expose the remote dashboard to the client's LAN.
function forwardSpec(localPort, remotePort, remoteHost = '127.0.0.1') {
return `127.0.0.1:${localPort}:${remoteHost}:${remotePort}`
}
// Error classification — distinct, actionable messages for the UI
const SSH_ERROR = {
UNREACHABLE: 'unreachable',
AUTH_FAILED: 'auth-failed',
HOST_KEY_CHANGED: 'host-key-changed',
TIMEOUT: 'timeout',
UNKNOWN: 'unknown'
}
// Order matters: the host-key-change banner also contains "WARNING"/"Offending",
// so check it before generic auth.
function classifySshError(stderr) {
const text = String(stderr || '')
if (
/REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed|Offending (?:key|ECDSA|RSA|ED25519)/i.test(
text
)
) {
return SSH_ERROR.HOST_KEY_CHANGED
}
if (
/Permission denied|Too many authentication failures|no matching host key|publickey|password|keyboard-interactive/i.test(
text
)
) {
return SSH_ERROR.AUTH_FAILED
}
if (
/Could not resolve hostname|Connection refused|Connection timed out|No route to host|Network is unreachable|Operation timed out|port \d+: Connection/i.test(
text
)
) {
return SSH_ERROR.UNREACHABLE
}
return SSH_ERROR.UNKNOWN
}
function sshErrorMessage(kind, conn, stderr?) {
const host = target(conn.user, conn.host)
switch (kind) {
case SSH_ERROR.HOST_KEY_CHANGED:
return (
`The host key for ${host} has CHANGED since you last connected. ` +
`This could be a man-in-the-middle attack, or the server was reinstalled. ` +
`SSH refused to connect. Verify the change is expected, then remove the old key ` +
`with \`ssh-keygen -R ${conn.host}\` and reconnect.\n\n${String(stderr || '').trim()}`
)
case SSH_ERROR.AUTH_FAILED:
return (
`SSH authentication to ${host} failed. Desktop runs ssh non-interactively ` +
`(BatchMode), so a key requiring a passphrase or 2FA must be loaded into your ` +
`ssh-agent first (e.g. \`ssh-add ~/.ssh/id_ed25519\`), or set an IdentityFile in ` +
`~/.ssh/config. Original error: ${String(stderr || '').trim()}`
)
case SSH_ERROR.UNREACHABLE:
return `Could not reach ${host} over SSH. Check the host, port, and your network. Original error: ${String(stderr || '').trim()}`
case SSH_ERROR.TIMEOUT:
return `SSH operation to ${host} timed out. The connection may be half-open (e.g. after sleep); reconnecting.`
default:
return `SSH error connecting to ${host}: ${String(stderr || '').trim() || 'unknown failure'}`
}
}
// Spawn helper — runs an ssh invocation, races it against a hard timeout
// Resolves { code, stdout, stderr }. On timeout the child is SIGKILLed and the
// promise rejects with err.kind = TIMEOUT. `spawnFn` is injectable for tests.
function runSsh(args, { timeoutMs, spawnFn = spawn, stdin = 'ignore', stdinData }: any = {}) {
return new Promise((resolve, reject) => {
const useStdinPipe = stdinData != null || stdin !== 'ignore'
let child
try {
child = spawnFn('ssh', args, { stdio: [useStdinPipe ? 'pipe' : 'ignore', 'pipe', 'pipe'] })
} catch (error) {
reject(error)
return
}
if (stdinData != null && child.stdin) {
child.stdin.end(stdinData)
}
let stdout = ''
let stderr = ''
let settled = false
const timer = setTimeout(() => {
if (settled) {
return
}
settled = true
try {
child.kill('SIGKILL')
} catch {
// already gone
}
const err: any = new Error(`ssh timed out after ${timeoutMs}ms`)
err.kind = SSH_ERROR.TIMEOUT
reject(err)
}, timeoutMs)
child.stdout?.on('data', d => {
stdout += d.toString()
})
child.stderr?.on('data', d => {
stderr += d.toString()
})
child.on('error', error => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
reject(error)
})
child.on('close', code => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
resolve({ code, stdout, stderr })
})
})
}
function stopTunnelChild(child, timeoutMs = 5_000) {
if (!child || child.exitCode != null || child.signalCode != null) {
return Promise.resolve()
}
return new Promise<void>((resolve, reject) => {
let settled = false
const finish = (error?: unknown) => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
child.off?.('exit', onExit)
child.off?.('error', onError)
error ? reject(error) : resolve()
}
const onExit = () => finish()
const onError = error => finish(error)
const timer = setTimeout(() => finish(new Error('SSH tunnel did not exit after termination.')), timeoutMs)
child.once('exit', onExit)
child.once('error', onError)
try {
if (!child.kill()) {
finish(new Error('SSH tunnel termination was refused.'))
}
} catch (error) {
finish(error)
}
})
}
// SshConnection — the public manager
class SshConnection {
host: string
user: string
port: number
keyPath: string
controlPath: string
_spawnFn: any
_log: (msg: string) => void
_connectTimeoutMs: number
_execTimeoutMs: number
_forwardTimeoutMs: number
_opened: boolean
_mux: boolean
_tunnels: Map<string, any>
constructor(cfg, opts: any = {}) {
if (!cfg || !cfg.host) {
throw new Error('SshConnection requires a host.')
}
const port = cfg.port ? Number(cfg.port) : 22
validateSshTarget(cfg.host, cfg.user || '', port)
if (cfg.keyPath) {
validateKeyPath(cfg.keyPath)
}
this.host = cfg.host
this.user = cfg.user || ''
this.port = port
this.keyPath = cfg.keyPath || ''
// Windows OpenSSH has no ControlMaster (mux sockets were never implemented
// on Win32) — fall back to one ssh invocation per operation and a
// persistent `ssh -N -L` child per tunnel. Empty controlPath routes the
// pure builders onto their no-mux form.
this._mux = opts.mux ?? process.platform !== 'win32'
this.controlPath = this._mux
? controlSocketPath(this.user, this.host, this.port, opts.controlDir, {
keyPath: this.keyPath,
ownershipId: opts.ownershipId,
scope: opts.scope,
effectiveConfigFingerprint: opts.effectiveConfigFingerprint
})
: ''
this._tunnels = new Map()
this._spawnFn = opts.spawnFn || spawn
this._log = typeof opts.rememberLog === 'function' ? opts.rememberLog : () => {}
this._connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
this._execTimeoutMs = opts.execTimeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS
this._forwardTimeoutMs = opts.forwardTimeoutMs ?? DEFAULT_FORWARD_TIMEOUT_MS
this._opened = false
}
// Lifecycle logging — ALWAYS through redaction.
_logLine(msg) {
this._log(redactSecrets(`[ssh] ${msg}`))
}
_fail(stderrOrErr, fallbackKind = SSH_ERROR.UNKNOWN) {
if (stderrOrErr && stderrOrErr.kind === SSH_ERROR.TIMEOUT) {
const err: any = new Error(sshErrorMessage(SSH_ERROR.TIMEOUT, this))
err.kind = SSH_ERROR.TIMEOUT
return err
}
const stderr = typeof stderrOrErr === 'string' ? stderrOrErr : stderrOrErr?.message || ''
const kind = stderr ? classifySshError(stderr) : fallbackKind
const err: any = new Error(sshErrorMessage(kind, this, stderr))
err.kind = kind
return err
}
// Open the connection. Mux: start the persistent ControlMaster (idempotent —
// a live master is a no-op). No-mux: there is no master; validate auth +
// reachability with a one-shot `ssh true` so failures classify identically.
async open() {
if (await this.isAlive()) {
// -O check passing is not proof the master works: a ControlPersist master
// can survive a failed teardown with wedged channels (observed on macOS
// after a mode switch — check succeeds, every exec times out). Verify with
// a real exec before trusting it; on failure, evict and dial fresh.
if (!this._mux || (await this._verifyMuxChannel())) {
this._opened = true
return
}
this._logLine('existing control master failed exec verification; evicting stale master')
await this._evictStaleMaster()
}
if (!this._mux) {
this._logLine(`connecting (no-mux) to ${target(this.user, this.host)}:${this.port}`)
let result
try {
result = await runSsh(buildExecArgs(this, 'exit 0', this._connectTimeoutMs), {
timeoutMs: this._connectTimeoutMs,
spawnFn: this._spawnFn
})
} catch (error) {
throw this._fail(error, SSH_ERROR.UNREACHABLE)
}
if (result.code !== 0) {
throw this._fail(result.stderr, SSH_ERROR.UNREACHABLE)
}
this._opened = true
this._logLine('connection verified (no-mux; per-operation ssh)')
return
}
const controlDir = path.dirname(this.controlPath)
try {
fs.mkdirSync(controlDir, { recursive: true, mode: 0o700 })
} catch {
void 0
}
if (process.platform !== 'win32') {
const st = fs.lstatSync(controlDir)
if (st.isSymbolicLink()) {
throw new Error(`Unsafe SSH control dir: ${controlDir} is a symlink.`)
}
if (!st.isDirectory()) {
throw new Error(`Unsafe SSH control dir: ${controlDir} is not a directory.`)
}
if (st.uid !== process.getuid!()) {
throw new Error(`Unsafe SSH control dir: ${controlDir} is owned by uid ${st.uid}, not ${process.getuid!()}.`)
}
if ((st.mode & 0o777) !== 0o700) {
fs.chmodSync(controlDir, 0o700)
}
}
const args = buildMasterArgs(this, this._connectTimeoutMs)
this._logLine(`opening control master to ${target(this.user, this.host)}:${this.port}`)
let result
try {
result = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })
} catch (error) {
throw this._fail(error, SSH_ERROR.UNREACHABLE)
}
if (result.code !== 0) {
throw this._fail(result.stderr, SSH_ERROR.UNREACHABLE)
}
this._opened = true
this._logLine('control master established')
}
// Liveness. Mux: `-O check` against the master socket. No-mux: a cheap
// one-shot exec — "alive" means "we can still authenticate and run".
async isAlive() {
if ([...this._tunnels.values()].some(tunnel => tunnel.alive === false)) {
return false
}
const args = this._mux
? buildControlArgs(this, 'check', [], this._connectTimeoutMs)
: buildExecArgs(this, 'exit 0', this._connectTimeoutMs)
try {
const result: any = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })
return result.code === 0
} catch {
return false
}
}
// A real exec through the master (`exit 0` works under POSIX shells and
// cmd.exe); a wedged mux hangs to the timeout.
async _verifyMuxChannel() {
try {
const result: any = await runSsh(buildExecArgs(this, 'exit 0', this._connectTimeoutMs), {
timeoutMs: this._connectTimeoutMs,
spawnFn: this._spawnFn
})
return result.code === 0
} catch {
return false
}
}
// -O exit (best-effort) then drop the socket so ControlMaster=auto cannot
// re-attach to the corpse. (The orphaned master process is left to
// ControlPersist; a wedged channel can pin it, but without its socket it is
// inert.)
async _evictStaleMaster() {
try {
await runSsh(buildControlArgs(this, 'exit', [], this._connectTimeoutMs), {
timeoutMs: this._connectTimeoutMs,
spawnFn: this._spawnFn
})
} catch {
void 0
}
try {
fs.unlinkSync(this.controlPath)
} catch (error: any) {
if (error?.code !== 'ENOENT') {
this._logLine(`could not remove stale control socket (${error.code}); a fresh master may not dial`)
}
}
}
// One-shot remote command over the control connection. Resolves stdout;
// rejects with a classified error on non-zero exit or timeout.
async exec(remoteCommand, { timeoutMs, stdinData }: any = {}) {
const args = buildExecArgs(this, remoteCommand, this._connectTimeoutMs)
let result
try {
result = await runSsh(args, {
timeoutMs: timeoutMs ?? this._execTimeoutMs,
spawnFn: this._spawnFn,
...(stdinData != null ? { stdinData } : {})
})
} catch (error) {
throw this._fail(error)
}
if (result.code !== 0) {
throw this._fail(result.stderr)
}
return result.stdout
}
// Establish a local→remote forward. Mux: `-O forward` against the master.
// No-mux: spawn a persistent `ssh -N -L` child that IS the tunnel; ready when
// the local port accepts. The child dying = tunnel down (isAlive of the
// backend catches it upstream).
async forward(localPort, remotePort, remoteHost = '127.0.0.1') {
const spec = forwardSpec(localPort, remotePort, remoteHost)
this._logLine(`forwarding 127.0.0.1:${localPort} -> ${remoteHost}:${remotePort}`)
if (!this._mux) {
const args = [
...baseSshOptions('', this._connectTimeoutMs),
...hostArgs(this),
'-v',
'-N',
'-L',
spec,
'--',
target(this.user, this.host)
]
const child = this._spawnFn('ssh', args, { stdio: ['ignore', 'ignore', 'pipe'] })
const tunnel = { child, alive: true }
this._tunnels.set(spec, tunnel)
let stderr = ''
let readyConfirmed = false
let readyResolve
let readyReject
const ready = new Promise<void>((resolve, reject) => {
readyResolve = resolve
readyReject = reject
})
const readyPattern = new RegExp(`Local forwarding listening on .* port ${localPort}\\b`)
child.stderr?.on('data', d => {
if (readyConfirmed) {
return
}
stderr = `${stderr}${String(d)}`.slice(-16_384)
if (readyPattern.test(stderr)) {
readyConfirmed = true
readyResolve()
}
})
child.on('error', error => {
tunnel.alive = false
readyReject(error)
})
child.on('exit', code => {
tunnel.alive = false
readyReject(new Error(`tunnel process exited with code ${code}`))
})
child.on('close', code => {
tunnel.alive = false
readyReject(new Error(`tunnel process closed with code ${code}`))
})
let readyTimeout
try {
await Promise.race([
ready,
new Promise((_, reject) => {
readyTimeout = setTimeout(
() => reject(new Error('tunnel did not confirm local forwarding')),
this._forwardTimeoutMs
)
})
])
} catch (error: any) {
try {
await stopTunnelChild(child)
this._tunnels.delete(spec)
} catch (stopError) {
throw this._fail(stopError, SSH_ERROR.UNKNOWN)
}
throw this._fail(stderr || error, SSH_ERROR.UNKNOWN)
} finally {
clearTimeout(readyTimeout)
}
return
}
const args = buildControlArgs(this, 'forward', ['-L', spec], this._connectTimeoutMs)
let result
try {
result = await runSsh(args, { timeoutMs: this._forwardTimeoutMs, spawnFn: this._spawnFn })
} catch (error) {
throw this._fail(error)
}
if (result.code !== 0) {
throw this._fail(result.stderr)
}
}
// Cancel a previously-established forward. Best-effort: a failure here is
// logged but not thrown (close tears everything down anyway).
async cancelForward(localPort, remotePort, remoteHost = '127.0.0.1') {
const spec = forwardSpec(localPort, remotePort, remoteHost)
if (!this._mux) {
const tunnel = this._tunnels.get(spec)
if (tunnel) {
await stopTunnelChild(tunnel.child)
this._tunnels.delete(spec)
this._logLine(`cancelled forward 127.0.0.1:${localPort}`)
}
return
}
const args = buildControlArgs(this, 'cancel', ['-L', spec], this._connectTimeoutMs)
try {
await runSsh(args, { timeoutMs: this._forwardTimeoutMs, spawnFn: this._spawnFn })
this._logLine(`cancelled forward 127.0.0.1:${localPort}`)
} catch (error: any) {
this._logLine(`cancelForward failed (ignored): ${error.message}`)
}
}
// Tear down. Mux: exit the master (drops every forward with it). No-mux:
// kill the tunnel children. Best-effort; never throws.
async close() {
if (!this._opened) {
return
}
if (!this._mux) {
for (const [spec, tunnel] of this._tunnels) {
await stopTunnelChild(tunnel.child)
this._tunnels.delete(spec)
}
this._opened = false
this._logLine('connection closed (no-mux tunnels killed)')
return
}
const args = buildControlArgs(this, 'exit', [], this._connectTimeoutMs)
try {
const result: any = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })
if (result.code !== 0) {
throw this._fail(result.stderr)
}
this._logLine('control master closed')
} catch (error: any) {
// A master that refuses -O exit is the wedge that poisons re-attach;
// disown it. (Without its socket the orphan is inert; ControlPersist may
// not reap it if a wedged channel never idles.)
this._logLine(`close failed; removing control socket: ${error.message}`)
try {
fs.unlinkSync(this.controlPath)
} catch {
void 0
}
}
this._opened = false
}
}
// Free local port for the tunnel's local end. Bind 127.0.0.1:0, read the
// kernel-assigned port, release. The benign TOCTOU window (release → forward
// grabs it) is caught upstream and retried with a fresh port.
function pickLocalPort() {
return new Promise((resolve, reject) => {
const server = net.createServer()
server.unref()
server.on('error', reject)
server.listen(0, '127.0.0.1', () => {
const { port } = server.address() as net.AddressInfo
server.close(() => resolve(port))
})
})
}
function createSshProbeConnection(config, options: any = {}) {
return new SshConnection(config, { ...options, mux: false })
}
export {
baseSshOptions,
buildControlArgs,
buildExecArgs,
buildInteractiveSshArgs,
buildMasterArgs,
classifySshError,
CONTROL_PERSIST_SECONDS,
controlSocketPath,
createSshProbeConnection,
DEFAULT_CONNECT_TIMEOUT_MS,
DEFAULT_EXEC_TIMEOUT_MS,
DEFAULT_FORWARD_TIMEOUT_MS,
forwardSpec,
hostArgs,
pickLocalPort,
redactSecrets,
runSsh,
SSH_ERROR,
SshConnection,
sshErrorMessage,
stopTunnelChild,
target,
validateKeyPath,
validateSshTarget
}
@@ -1,122 +0,0 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import {
buildWindowsInteractiveCommand,
detectRemotePlatform,
encodedPowerShell,
helperCommand,
powerShellCommand,
psLiteral,
validLock
} from './windows-remote-lifecycle'
const ownershipId = '0123456789abcdef0123456789abcdef'
function sshWith(exec) {
return { exec }
}
test('PowerShell transport uses UTF-16LE encoded commands and literal escaping', () => {
assert.equal(Buffer.from(encodedPowerShell("'ok'"), 'base64').toString('utf16le'), "'ok'")
assert.equal(psLiteral("a'b"), "'a''b'")
assert.match(powerShellCommand('Write-Output ok'), /^powershell\.exe -NoProfile -NonInteractive .* -EncodedCommand /)
})
test('platform detection preserves POSIX and falls back to Windows PowerShell', async () => {
assert.deepEqual(await detectRemotePlatform(sshWith(async () => 'Linux\nx86_64\n')), { os: 'Linux', arch: 'x86_64' })
const calls: string[] = []
const result = await detectRemotePlatform(
sshWith(async command => {
calls.push(command)
if (command.startsWith('uname ')) {
throw new Error('PowerShell does not recognize uname')
}
return JSON.stringify({
os: 'Windows',
arch: 'ARM64',
hermesHome: 'C:\\h',
hermesPath: 'C:\\h\\hermes.exe',
python: 'C:\\h\\python.exe'
})
})
)
assert.equal(result.os, 'Windows')
assert.match(calls[1], /EncodedCommand/)
})
test('platform detection surfaces transport failures as themselves, not unsupported-platform', async () => {
// A dead/unauthorized host is a connectivity verdict; only a host that answers
// neither probe is an unsupported platform.
const transportErr: any = new Error('SSH connection timed out')
transportErr.kind = 'timeout'
await assert.rejects(
detectRemotePlatform(
sshWith(async () => {
throw transportErr
})
),
(err: any) => err.kind === 'timeout'
)
// Probe genuinely failing on a reachable host still classifies unsupported,
// and carries the probe detail for diagnosis.
await assert.rejects(
detectRemotePlatform(
sshWith(async command => {
if (command.startsWith('uname ')) {
throw new Error('not recognized')
}
throw new Error('Hermes is not installed on the remote Windows host.')
})
),
(err: any) => err.kind === 'unsupported-platform' && /Hermes is not installed/.test(err.message)
)
})
test('helper command uses the fixed remote Python entry point and quotes path data', () => {
const command = helperCommand({ python: "C:\\Program Files\\Hermes's\\python.exe" }, 'inspect', [
'C:\\x y\\hermes.exe'
])
const encoded = command.split(' ').pop()!
const script = Buffer.from(encoded, 'base64').toString('utf16le')
assert.match(script, /-m' 'hermes_cli\.windows_ssh_runtime' 'inspect'/)
assert.match(script, /Hermes''s/)
assert.match(script, /C:\\x y\\hermes\.exe/)
})
test('Windows lock validation is scoped and exact', () => {
const lock = {
schemaVersion: 2,
protocolVersion: 1,
ownershipId,
spawnNonce: '0123456789abcdef',
pid: 10,
creationTimeNs: '1784219690452757504',
port: 1234,
tokenFingerprint: 'a'.repeat(32),
hermesPath: 'C:\\h\\hermes.exe',
hermesHome: 'C:\\h'
}
assert.equal(validLock(lock, ownershipId), true)
assert.equal(validLock({ ...lock, ownershipId: 'b'.repeat(32) }, ownershipId), false)
assert.equal(validLock({ ...lock, creationTimeNs: '0' }, ownershipId), false)
// port 0 = spawn-in-progress record: valid ownership proof (cleanup can act
// on it) but the reuse gate must reject it separately.
assert.equal(validLock({ ...lock, port: 0 }, ownershipId), true)
assert.equal(validLock({ ...lock, port: -1 }, ownershipId), false)
})
test('Windows integrated terminal uses encoded PowerShell and preserves cwd as literal data', () => {
const command = buildWindowsInteractiveCommand("C:\\Users\\O'Brien\\repo")
const script = Buffer.from(command.split(' ').pop()!, 'base64').toString('utf16le')
assert.match(script, /Set-Location -LiteralPath 'C:\\Users\\O''Brien\\repo'/)
assert.match(script, /powershell\.exe -NoLogo/)
})
@@ -1,455 +0,0 @@
import crypto from 'node:crypto'
import { redactSecrets, SSH_ERROR } from './ssh-connection'
const LOCKFILE_SCHEMA_VERSION = 2
const PROTOCOL_VERSION = 1
const READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\d+)/gm
const READY_POLL_INTERVAL_MS = 750
function psLiteral(value) {
return `'${String(value).replace(/'/g, "''")}'`
}
function encodedPowerShell(script) {
return Buffer.from(script, 'utf16le').toString('base64')
}
function powerShellCommand(script) {
return `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodedPowerShell(script)}`
}
async function probeWindowsRemote(ssh, explicitHermesPath = '') {
const explicit = psLiteral(explicitHermesPath)
const script = [
'$ErrorActionPreference="Stop"',
`$explicit=${explicit}`,
'$hermesHome=$env:HERMES_HOME',
'if(-not $hermesHome){$hermesHome=Join-Path $env:LOCALAPPDATA "hermes"}',
'$candidates=@()',
'if($explicit){$candidates+=$explicit}',
'$cmd=Get-Command hermes.exe -ErrorAction SilentlyContinue',
'if($cmd){$candidates+=$cmd.Source}',
'$candidates+=(Join-Path $hermesHome "hermes-agent\\venv\\Scripts\\hermes.exe")',
'$candidates+=(Join-Path $HOME "hermes-agent\\.venv\\Scripts\\hermes.exe")',
'$hermes=$candidates|Where-Object{Test-Path -LiteralPath $_ -PathType Leaf}|Select-Object -First 1',
'if(-not $hermes){throw "Hermes is not installed on the remote Windows host."}',
'if($explicit -and $hermes -ne $explicit){throw "The configured Hermes path is not an executable file."}',
'$python=Join-Path (Split-Path $hermes) "python.exe"',
'if(-not (Test-Path -LiteralPath $python -PathType Leaf)){throw "The remote Hermes Python runtime was not found."}',
'[ordered]@{os="Windows";arch=$env:PROCESSOR_ARCHITECTURE;hermesHome=$hermesHome;hermesPath=$hermes;python=$python}|ConvertTo-Json -Compress'
].join(';')
return JSON.parse((await ssh.exec(powerShellCommand(script))).trim())
}
const TRANSPORT_KINDS = new Set([
SSH_ERROR.AUTH_FAILED,
SSH_ERROR.HOST_KEY_CHANGED,
SSH_ERROR.TIMEOUT,
SSH_ERROR.UNREACHABLE
])
async function detectRemotePlatform(ssh, explicitHermesPath = '') {
try {
const output = (await ssh.exec('uname -s; uname -m')).trim().split('\n')
if (output[0] === 'Linux' || output[0] === 'Darwin') {
return { os: output[0], arch: output[1] || '' }
}
} catch (error: any) {
// uname failing is the expected Windows fall-through; a TRANSPORT failure
// (auth/host-key/timeout/unreachable) is not a platform verdict — surface it
// as itself instead of letting the probe chain end in 'unsupported-platform'.
if (TRANSPORT_KINDS.has(error?.kind)) {
throw error
}
}
try {
return await probeWindowsRemote(ssh, explicitHermesPath)
} catch (cause: any) {
if (TRANSPORT_KINDS.has(cause?.kind)) {
throw cause
}
// detail is remote-controlled output headed for the UI: redact + strip control chars.
const detail = redactSecrets(String(cause?.message || cause || ''))
// eslint-disable-next-line no-control-regex -- deliberately strip control chars from remote output
.replace(/[\x00-\x1f\x7f]/g, ' ')
.trim()
const error: any = new Error(
`The remote operating system is not supported by Desktop SSH.${detail ? ` (probe: ${detail.slice(0, 300)})` : ''}`
)
error.kind = 'unsupported-platform'
error.cause = cause
throw error
}
}
function helperCommand(runtime, operation, args = []) {
const argv = [runtime.python, '-m', 'hermes_cli.windows_ssh_runtime', operation, ...args]
const script = [
'$ErrorActionPreference="Stop"',
`& ${argv.map(psLiteral).join(' ')}`,
'if($LASTEXITCODE -ne 0){exit $LASTEXITCODE}'
].join(';')
return powerShellCommand(script)
}
async function helper(ssh, runtime, operation, args = [], stdinData?) {
const output = await ssh.exec(helperCommand(runtime, operation, args), stdinData == null ? {} : { stdinData })
const lines = String(output || '')
.replace(/^\uFEFF/, '')
.trim()
.split(/\r?\n/)
.filter(Boolean)
const parsed = JSON.parse(lines[lines.length - 1] || 'null')
if (parsed?.error) {
throw new Error(parsed.error)
}
return parsed
}
function fingerprintToken(token) {
return crypto
.createHash('sha256')
.update(String(token || ''))
.digest('hex')
.slice(0, 32)
}
function validLock(lock, ownershipId) {
// port 0 = spawn-in-progress record (written before readiness); a valid
// ownership proof for cleanup, but never reusable.
return Boolean(
lock &&
lock.schemaVersion === LOCKFILE_SCHEMA_VERSION &&
lock.protocolVersion === PROTOCOL_VERSION &&
lock.ownershipId === ownershipId &&
/^[0-9a-f]{16}$/.test(lock.spawnNonce || '') &&
Number.isInteger(lock.pid) &&
lock.pid > 0 &&
/^[0-9]{10,20}$/.test(lock.creationTimeNs || '') &&
Number.isInteger(lock.port) &&
lock.port >= 0 &&
lock.port <= 65535 &&
/^[0-9a-f]{32}$/.test(lock.tokenFingerprint || '') &&
typeof lock.hermesPath === 'string' &&
typeof lock.hermesHome === 'string'
)
}
function assertCurrent(signal) {
if (signal?.aborted) {
const error: any = new Error('SSH bootstrap was cancelled.')
error.kind = 'superseded'
throw error
}
}
async function processState(ssh, runtime, lock) {
return helper(ssh, runtime, 'process-state', [
String(lock.pid),
String(lock.creationTimeNs),
lock.hermesPath,
lock.spawnNonce
])
}
async function cleanupOwned(ssh, runtime, ownershipId, lock) {
const attempt = async fn => {
try {
await fn()
} catch {
void 0
}
}
if (lock) {
const state = await processState(ssh, runtime, lock)
if (state.alive && state.owned) {
// Deliberately not attempt()-wrapped: a thrown terminate must abort before
// remove-lock, or a live backend is orphaned with no lock to reclaim it.
await helper(ssh, runtime, 'terminate', [
String(lock.pid),
String(lock.creationTimeNs),
lock.hermesPath,
lock.spawnNonce
])
}
if (lock.spawnNonce) {
await attempt(() => helper(ssh, runtime, 'remove-token', [ownershipId, lock.spawnNonce]))
await attempt(() => helper(ssh, runtime, 'remove-log', [ownershipId, lock.spawnNonce]))
}
}
await attempt(() => helper(ssh, runtime, 'remove-lock', [ownershipId]))
}
async function waitReady(ssh, runtime, ownershipId, lock, timeoutMs, signal) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
assertCurrent(signal)
let state
try {
state = await processState(ssh, runtime, lock)
} catch {
await new Promise(resolve => setTimeout(resolve, READY_POLL_INTERVAL_MS))
continue
}
if (!state.indeterminate && (!state.alive || !state.owned)) {
let detail = ''
try {
detail = (await helper(ssh, runtime, 'read-log', [ownershipId, lock.spawnNonce]))?.content || ''
} catch {
void 0
}
const error: any = new Error(
`Remote Windows backend exited before announcing its port. state=${JSON.stringify(state)} ${detail.slice(-2000)}`
)
error.kind = 'spawn-failed'
throw error
}
let content = ''
try {
content = (await helper(ssh, runtime, 'read-log', [ownershipId, lock.spawnNonce]))?.content || ''
} catch {
void 0
}
let port
for (const match of content.matchAll(READY_RE)) {
port = Number(match[1])
}
if (port) {
return port
}
await new Promise(resolve => setTimeout(resolve, READY_POLL_INTERVAL_MS))
}
const error: any = new Error(`Timed out waiting for the remote Windows backend (${timeoutMs}ms).`)
error.kind = 'ready-timeout'
throw error
}
async function connectWindowsRemote(deps) {
const {
ssh,
ownershipId,
profile = '',
remoteHermesPath = '',
reuseToken = '',
signal,
pickLocalPort,
forward,
cancelForward,
waitForHermes,
probeReuseProof,
rememberLog = () => {},
readyTimeoutMs = 45_000
} = deps
assertCurrent(signal)
const runtime = await probeWindowsRemote(ssh, remoteHermesPath)
const inspection = await helper(ssh, runtime, 'inspect', [runtime.hermesPath])
if (!inspection.supported) {
const error: any = new Error('Update Hermes on the remote Windows host before connecting with Desktop SSH.')
error.kind = 'update-required'
throw error
}
runtime.hermesPath = inspection.path
const hermesVersion = inspection.version || ''
rememberLog(`[ssh-lifecycle] remote platform Windows/${runtime.arch}`)
rememberLog(`[ssh-lifecycle] located hermes at ${runtime.hermesPath}`)
const lock = await helper(ssh, runtime, 'read-lock', [ownershipId])
if (validLock(lock, ownershipId)) {
const state = await processState(ssh, runtime, lock)
if (state.indeterminate) {
const error: any = new Error('Could not determine the state of the existing remote backend.')
error.kind = 'transient-transport-error'
throw error
}
const reusable =
state.alive &&
state.owned &&
lock.port > 0 &&
Boolean(reuseToken) &&
lock.tokenFingerprint === fingerprintToken(reuseToken) &&
lock.hermesPath === runtime.hermesPath &&
lock.hermesHome === runtime.hermesHome
if (reusable) {
const localPort = await pickLocalPort()
await forward(localPort, lock.port)
try {
const baseUrl = `http://127.0.0.1:${localPort}`
const classification = await probeReuseProof(baseUrl, reuseToken, lock.spawnNonce)
if (classification === 'authenticated-ok') {
return {
baseUrl,
token: reuseToken,
remotePort: lock.port,
localPort,
pid: lock.pid,
reused: true,
platform: { os: 'Windows', arch: runtime.arch },
hermesPath: runtime.hermesPath,
hermesVersion,
ownershipId,
spawnNonce: lock.spawnNonce,
creationTimeNs: lock.creationTimeNs
}
}
if (classification !== 'authenticated-stale') {
throw new Error('Invalid SSH reuse classification.')
}
await cancelForward(localPort, lock.port)
await cleanupOwned(ssh, runtime, ownershipId, lock)
} catch (error) {
await cancelForward(localPort, lock.port)
throw error
}
} else {
await cleanupOwned(ssh, runtime, ownershipId, lock)
}
} else if (lock) {
await helper(ssh, runtime, 'remove-lock', [ownershipId])
}
assertCurrent(signal)
const token = crypto.randomBytes(32).toString('hex')
const spawnNonce = crypto.randomBytes(8).toString('hex')
await helper(ssh, runtime, 'upload-token', [ownershipId, spawnNonce], token)
let spawned
try {
spawned = await helper(
ssh,
runtime,
'spawn',
[],
JSON.stringify({ ownershipId, spawnNonce, profile, hermesPath: runtime.hermesPath })
)
} catch (error) {
await helper(ssh, runtime, 'remove-token', [ownershipId, spawnNonce])
throw error
}
const owned = {
schemaVersion: LOCKFILE_SCHEMA_VERSION,
protocolVersion: PROTOCOL_VERSION,
ownershipId,
spawnNonce,
pid: spawned.pid,
creationTimeNs: spawned.creationTimeNs,
port: 0,
profile,
hermesPath: runtime.hermesPath,
hermesHome: runtime.hermesHome,
tokenFingerprint: fingerprintToken(token),
startedAt: new Date().toISOString()
}
let localPort = 0
let remotePort = 0
try {
// Write the ownership record IMMEDIATELY (port=0): if this attempt is
// superseded before readiness and cleanup cannot reach the box, the next
// connect still finds the lock and reaps the process by exact ownership.
// Inside the try: if this write itself fails, the catch still kills the
// just-spawned process via the in-memory record.
await helper(ssh, runtime, 'write-lock', [ownershipId], JSON.stringify(owned))
remotePort = await waitReady(ssh, runtime, ownershipId, owned, readyTimeoutMs, signal)
localPort = await pickLocalPort()
await forward(localPort, remotePort)
const baseUrl = `http://127.0.0.1:${localPort}`
await waitForHermes(baseUrl, token)
assertCurrent(signal)
await helper(ssh, runtime, 'write-lock', [ownershipId], JSON.stringify({ ...owned, port: remotePort }))
return {
baseUrl,
token,
remotePort,
localPort,
pid: spawned.pid,
reused: false,
platform: { os: 'Windows', arch: runtime.arch },
hermesPath: runtime.hermesPath,
hermesVersion,
ownershipId,
spawnNonce,
creationTimeNs: spawned.creationTimeNs
}
} catch (error) {
if (localPort && remotePort) {
await cancelForward(localPort, remotePort)
}
await cleanupOwned(ssh, runtime, ownershipId, owned)
throw error
}
}
function buildWindowsInteractiveCommand(remoteCwd = '') {
const cwd = String(remoteCwd || '').trim()
const script = ['$ErrorActionPreference="Stop"']
if (cwd) {
script.push(
`if(Test-Path -LiteralPath ${psLiteral(cwd)} -PathType Container){Set-Location -LiteralPath ${psLiteral(cwd)}}`
)
}
script.push('$host.UI.RawUI.WindowTitle="Hermes SSH"', 'powershell.exe -NoLogo')
return powerShellCommand(script.join(';'))
}
export {
buildWindowsInteractiveCommand,
connectWindowsRemote,
detectRemotePlatform,
encodedPowerShell,
helper,
helperCommand,
powerShellCommand,
probeWindowsRemote,
psLiteral,
validLock
}
@@ -1,106 +0,0 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { pickLocalPort, SshConnection } from './ssh-connection'
import { connectWindowsRemote } from './windows-remote-lifecycle'
// Live test against a real Windows host over SSH. Opt-in: set the env trio to
// your test rig; skipped everywhere else (CI, other machines).
// HERMES_WIN_SSH_HOST ssh alias/host of the Windows box
// HERMES_WIN_SSH_USER remote user
// HERMES_WIN_SSH_HERMES absolute path to the remote hermes.exe under test
const liveHost = process.env.HERMES_WIN_SSH_HOST || ''
const liveUser = process.env.HERMES_WIN_SSH_USER || ''
const configuredHermes = process.env.HERMES_WIN_SSH_HERMES || ''
const ownershipId = '89abcdef0123456789abcdef01234567'
function fetchJson(url, token, path) {
return fetch(`${url}${path}`, { headers: { 'X-Hermes-Session-Token': token } }).then(async response => {
if (!response.ok) {
throw new Error(`${response.status}: ${await response.text()}`)
}
return response.json()
})
}
test.skipIf(!liveHost || !liveUser || !configuredHermes)(
'live Windows remote lifecycle spawns, authenticates, reuses, and cleans exact ownership',
async () => {
const ssh = new SshConnection({ host: liveHost, user: liveUser, port: 22, keyPath: '' }, { mux: true })
await ssh.open()
const common = {
ssh,
ownershipId,
profile: '',
remoteHermesPath: configuredHermes,
pickLocalPort,
forward: (local, remote) => ssh.forward(local, remote),
cancelForward: (local, remote) => ssh.cancelForward(local, remote),
waitForHermes: async (baseUrl, token) => {
for (let i = 0; i < 40; i++) {
try {
await fetchJson(baseUrl, token, '/api/status')
return
} catch {
void 0
}
await new Promise(resolve => setTimeout(resolve, 250))
}
throw new Error('status timeout')
},
probeReuseProof: async (baseUrl, token, nonce) => {
const body: any = await fetchJson(baseUrl, token, '/api/ssh/ownership')
return body.sshOwnerNonce === nonce ? 'authenticated-ok' : 'authenticated-stale'
},
rememberLog: () => {}
}
let first
let second
try {
first = await connectWindowsRemote(common)
assert.equal(first.platform.os, 'Windows')
assert.equal(first.reused, false)
const status: any = await fetchJson(first.baseUrl, first.token, '/api/status')
assert.ok(status)
await ssh.cancelForward(first.localPort, first.remotePort)
second = await connectWindowsRemote({ ...common, reuseToken: first.token })
assert.equal(second.reused, true)
assert.equal(second.pid, first.pid)
assert.equal(second.spawnNonce, first.spawnNonce)
} finally {
if (second) {
await ssh.cancelForward(second.localPort, second.remotePort)
}
const runtimeScript = `& '${configuredHermes.replace('hermes.exe', 'python.exe')}' -m hermes_cli.windows_ssh_runtime read-lock '${ownershipId}'`
const lock: any = JSON.parse(
await ssh.exec(`powershell.exe -NoProfile -NonInteractive -Command "${runtimeScript}"`)
)
if (lock) {
const python = configuredHermes.replace('hermes.exe', 'python.exe')
const terminate = `& '${python}' -m hermes_cli.windows_ssh_runtime terminate '${lock.pid}' '${lock.creationTimeNs}' '${lock.hermesPath}' '${lock.spawnNonce}'`
await ssh.exec(`powershell.exe -NoProfile -NonInteractive -Command "${terminate}"`)
await ssh.exec(
`powershell.exe -NoProfile -NonInteractive -Command "& '${python}' -m hermes_cli.windows_ssh_runtime remove-lock '${ownershipId}'"`
)
await ssh.exec(
`powershell.exe -NoProfile -NonInteractive -Command "& '${python}' -m hermes_cli.windows_ssh_runtime remove-log '${ownershipId}' '${lock.spawnNonce}'"`
)
}
await ssh.close()
}
},
90_000
)
@@ -1,65 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID } from '@/store/layout'
import {
$filePreviewTabs,
$previewTarget,
clearSessionPreviewRegistry,
type PreviewTarget,
setCurrentSessionPreviewTarget
} from '@/store/preview'
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
import { closeActiveTab } from './close-tab'
function fileTarget(path: string): PreviewTarget {
return {
kind: 'file',
label: path,
path,
previewKind: 'text',
source: path,
url: `file://${path}`
}
}
describe('closeActiveTab', () => {
beforeEach(() => {
vi.stubGlobal('document', { activeElement: null })
$activeSessionId.set('session-1')
$selectedStoredSessionId.set(null)
window.localStorage.clear()
clearSessionPreviewRegistry()
})
afterEach(() => {
vi.unstubAllGlobals()
$activeSessionId.set(null)
$selectedStoredSessionId.set(null)
clearSessionPreviewRegistry()
window.localStorage.clear()
})
it('closes the active file preview tab (⌘W happy path)', () => {
setCurrentSessionPreviewTarget(fileTarget('/work/notes.md'), 'manual')
expect($filePreviewTabs.get()).toHaveLength(1)
expect($rightRailActiveTabId.get()).toBe('file:file:///work/notes.md')
expect(closeActiveTab()).toBe(true)
expect($filePreviewTabs.get()).toHaveLength(0)
})
it('closes the visible file tab when active selection is a ghost preview', () => {
// Active tab id stuck on live-preview after that target was cleared, while
// file tabs remain (UI falls back to tabs[0] until React syncs). ⌘W must
// close the visible file tab instead of no-op'ing via closeWorkspaceTab().
setCurrentSessionPreviewTarget(fileTarget('/work/notes.md'), 'manual')
$previewTarget.set(null)
$rightRailActiveTabId.set(RIGHT_RAIL_PREVIEW_TAB_ID)
expect($filePreviewTabs.get()).toHaveLength(1)
expect(closeActiveTab()).toBe(true)
expect($filePreviewTabs.get()).toHaveLength(0)
})
})
+6 -9
View File
@@ -1,12 +1,12 @@
import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
import { closeWorkspaceTab } from '@/components/pane-shell/tree/store'
import { isFocusWithin } from '@/lib/keybinds/combo'
import { $filePreviewTabs, $previewTarget, closeActiveRightRailTab } from '@/store/preview'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '@/store/preview'
/**
* ⌘W — close the tab of the context you're in, by precedence:
* 1. a focused terminal → its active terminal tab,
* 2. right-rail tabs (live preview and/or file peeks),
* 2. an open preview → its active preview tab (unchanged from pre-tiling),
* 3. the MAIN zone → its active tab (a session tile stacked into the workspace).
* Returns false when nothing closes, so ⌘W is a no-op — it never closes the
* window (a bare workspace stays put). Shared by the keyboard path (Win/Linux)
@@ -19,13 +19,10 @@ export function closeActiveTab(): boolean {
return true
}
// Prefer tab *presence* over the derived active file target. After the live
// preview is cleared, `$rightRailActiveTabId` can stay on `preview` while
// file tabs remain (the rail UI falls back to tabs[0]). Gating only on
// `$filePreviewTarget` made ⌘W fall through to closeWorkspaceTab() and look
// broken with a file tab still on screen.
if ($previewTarget.get() || $filePreviewTabs.get().length > 0) {
return closeActiveRightRailTab()
if ($filePreviewTarget.get() || $previewTarget.get()) {
closeActiveRightRailTab()
return true
}
return closeWorkspaceTab()
+5 -9
View File
@@ -18,10 +18,8 @@ import { RICH_INPUT_SLOT } from './rich-editor'
export type ComposerTarget = 'edit' | 'main' | (string & {})
export type ComposerInsertMode = 'block' | 'inline'
export interface FocusDetail {
interface FocusDetail {
target: ComposerTarget
/** Append after focus (type-to-focus / soft `/`). */
typeChar?: string
}
interface InsertDetail {
@@ -84,10 +82,8 @@ export const markActiveComposer = (target: ComposerTarget) => {
* Used by broadcast listeners (voice, Esc-to-stop) to act on exactly one. */
export const getActiveComposer = (): ComposerTarget => activeTarget
export const requestComposerFocus = (
target: ComposerTarget | 'active' = 'active',
{ typeChar }: { typeChar?: string } = {}
) => dispatch<FocusDetail>(FOCUS_EVENT, { target: resolve(target), typeChar })
export const requestComposerFocus = (target: ComposerTarget | 'active' = 'active') =>
dispatch<FocusDetail>(FOCUS_EVENT, { target: resolve(target) })
export const requestComposerInsert = (
text: string,
@@ -102,8 +98,8 @@ export const requestComposerInsert = (
dispatch<InsertDetail>(INSERT_EVENT, { mode, target: resolve(target), text: trimmed })
}
export const onComposerFocusRequest = (handler: (detail: FocusDetail) => void) =>
subscribe<FocusDetail>(FOCUS_EVENT, handler)
export const onComposerFocusRequest = (handler: (target: ComposerTarget) => void) =>
subscribe<FocusDetail>(FOCUS_EVENT, ({ target }) => handler(target))
export const onComposerInsertRequest = (handler: (detail: InsertDetail) => void) =>
subscribe<InsertDetail>(INSERT_EVENT, handler)

Some files were not shown because too many files have changed in this diff Show More