Merge origin/main into feat/hermes-relay-shared-metrics

Signed-off-by: Alex Fournier <afournier@nvidia.com>

# Conflicts:
#	agent/tool_executor.py
This commit is contained in:
Alex Fournier 2026-07-21 11:22:28 -07:00
commit 93e2998f89
405 changed files with 52409 additions and 3370 deletions

View file

@ -7,7 +7,7 @@ description: >-
inputs:
github-token:
description: Token for the GitHub API (gh CLI). Pass secrets.AUTOFIX_BOT_PAT from the calling workflow.
description: Token for the GitHub API (gh CLI). Pass steps.app-token.outputs.token from the calling workflow.
required: false
default: ${{ github.token }}

View file

@ -0,0 +1,59 @@
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"

View file

@ -17,7 +17,7 @@ on:
permissions:
contents: read
pull-requests: write # needed by lint (PR comment) + supply-chain (PR comment)
pull-requests: write # needed by lint (PR comment) + supply-chain review_status
actions: read # needed by osv-scanner (SARIF upload)
security-events: write # needed by osv-scanner (SARIF upload)
packages: write # needed by docker build
@ -49,13 +49,19 @@ 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:
# 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 }}
# 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 }}
# ─────────────────────────────────────────────────────────────────────
# Lane-gated sub-workflows. Each runs in parallel after detect finishes.
@ -73,11 +79,10 @@ jobs:
lint:
name: Python lints
needs: detect
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.ci_review == 'true'
if: needs.detect.outputs.python == '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:
@ -87,6 +92,12 @@ jobs:
uses: ./.github/workflows/js-tests.yml
secrets: inherit
e2e-desktop:
name: Desktop E2E
needs: detect
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true'
uses: ./.github/workflows/e2e-desktop.yml
docs-site:
name: Docs Site
needs: detect
@ -138,12 +149,20 @@ 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' || needs.detect.outputs.mcp_catalog == 'true')
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == '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
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.ci_review == 'true' || needs.detect.outputs.mcp_catalog == 'true')
uses: ./.github/workflows/review-labels.yml
with:
ci_review: ${{ needs.detect.outputs.ci_review == 'true' }}
mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }}
secrets: inherit
@ -152,19 +171,99 @@ 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
- e2e-desktop
- docs-site
- history-check
- contributor-check
@ -172,20 +271,30 @@ 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']
@ -202,6 +311,9 @@ 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
@ -213,6 +325,13 @@ 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
@ -229,24 +348,28 @@ 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.
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
# 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 }}
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
--summary-out ci-timings-summary.md \
--review-status-out review-status.json
- name: Upload HTML report
- name: Upload HTML report + review status
# 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
path: |
ci-timings-report.html
review-status.json
retention-days: 14
archive: false
- name: Output summary
env:

View file

@ -2,6 +2,10 @@ 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
@ -10,12 +14,15 @@ 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)
@ -25,6 +32,7 @@ jobs:
if [ -z "$NEW_EMAILS" ]; then
echo "No new commits to check."
echo "review_status=[]" >> "$GITHUB_OUTPUT"
exit 0
fi
@ -67,6 +75,16 @@ 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."

View file

@ -56,6 +56,13 @@ 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
@ -73,8 +80,8 @@ jobs:
- name: Prepare skills index (unified multi-source catalog)
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
SKILLS_INDEX_RUN_ID: ${{ github.event.inputs.skills_index_run_id || '' }}
REBUILD_SKILLS_INDEX: ${{ github.event.inputs.rebuild_skills_index || 'false' }}
run: |

212
.github/workflows/e2e-desktop.yml vendored Normal file
View file

@ -0,0 +1,212 @@
name: E2E Desktop
on:
workflow_call:
permissions:
contents: read
concurrency:
group: e2e-desktop-${{ github.ref }}
cancel-in-progress: true
jobs:
e2e:
name: Playwright E2E (Linux)
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# ── System deps for Electron on headless Ubuntu ───────────────────
# Electron needs GTK, NSS,atk, etc. even under xvfb. Playwright's
# install-deps covers browsers; for Electron we install the apt
# packages directly.
- name: Install system dependencies for Electron
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq \
xvfb \
libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 \
xdg-utils libatspi2.0-0 libdrm2 libgbm1 libasound2t64
# ── Node ───────────────────────────────────────────────────────────
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
# Full npm ci (not --ignore-scripts): electron's postinstall
# downloads the binary we launch, and node-pty's native build is
# needed for the terminal pane.
- uses: ./.github/actions/retry
with:
command: npm ci
# ── Python (for the hermes serve backend) ──────────────────────────
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
with:
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
- name: Set up Python 3.11
run: uv python install 3.11
- name: Install Python dependencies
uses: ./.github/actions/retry
with:
command: uv sync --locked --python 3.11 --extra all --extra dev
# ── Build desktop app ─────────────────────────────────────────────
- run: npm run --prefix apps/desktop build
# ── Restore visual baseline screenshots from main ──────────────────
# Baselines are generated on main (via --update-snapshots) and cached.
# On PRs, we restore them so toHaveScreenshot has something to compare
# against. The cache key is keyed on the desktop source files so a
# UI change naturally invalidates it — but we fall back to the main
# cache to avoid cold starts on unrelated PRs.
- name: Restore visual baseline screenshots
id: restore-baselines
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: apps/desktop/e2e/*-snapshots
key: visual-baselines-${{ github.ref_name }}
restore-keys: |
visual-baselines-main
# ── Run Playwright E2E under xvfb ─────────────────────────────────
# xvfb runs at a fixed 1280x1024 screen so the 1220x800 Electron
# window always has a consistent viewport for screenshot comparison.
# On main, we run with --update-snapshots to generate baselines.
- name: Run Playwright E2E tests
working-directory: apps/desktop
run: |
if [ "${{ github.ref_name }}" = "main" ]; then
echo "On main — generating/updating baseline screenshots"
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
npx playwright test --reporter=list --update-snapshots
else
echo "On PR — comparing against cached baselines"
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
npx playwright test --reporter=list
fi
env:
CI: "true"
# Ensure no real API keys leak into the test env.
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
# ── Save updated baselines to cache (main only) ───────────────────
- name: Save updated baselines to cache
if: github.ref_name == 'main' && always()
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: apps/desktop/e2e/*-snapshots
key: visual-baselines-main
# ── Upload Playwright report (HTML + traces) ──────────────────────
- name: Upload Playwright report
id: upload-report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-${{ github.sha }}
path: apps/desktop/playwright-report
retention-days: 14
overwrite: true
# ── Upload test results (screenshots, traces, diffs) ───────────────
- name: Upload test results
id: upload-results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-test-results-${{ github.sha }}
path: apps/desktop/test-results
retention-days: 14
overwrite: true
# ── Upload just the visual diffs (small, fast to review) ──────────
- name: Upload visual diffs
id: upload-diffs
if: always() && github.ref_name != 'main'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: visual-diffs-${{ github.sha }}
path: |
apps/desktop/test-results/**/*-diff.png
apps/desktop/test-results/**/*-actual.png
apps/desktop/test-results/**/*-expected.png
retention-days: 14
overwrite: true
if-no-files-found: ignore
# ── Generate step summary with visual diff info ───────────────────
# Parse the JSON report + scan for diff images, then post a summary
# to the GitHub Actions step output so reviewers can see what changed
# without downloading artifacts. Runs AFTER uploads so it can link
# the artifact download URLs from their step outputs.
- name: Generate visual diff summary
if: always()
working-directory: apps/desktop
env:
REPORT_URL: ${{ steps.upload-report.outputs.artifact-url }}
RESULTS_URL: ${{ steps.upload-results.outputs.artifact-url }}
DIFFS_URL: ${{ steps.upload-diffs.outputs.artifact-url }}
run: |
echo "## Desktop E2E — Visual Diff Report" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
# Count diff images (playwright writes *-diff.png on mismatch)
DIFF_COUNT=$(find test-results -name '*-diff.png' 2>/dev/null | wc -l)
ACTUAL_COUNT=$(find test-results -name '*-actual.png' 2>/dev/null | wc -l)
if [ "$DIFF_COUNT" -eq 0 ]; then
echo "✅ All $ACTUAL_COUNT screenshot(s) matched their baselines (or no baselines existed yet)." >> "$GITHUB_STEP_SUMMARY"
else
echo "📸 **$DIFF_COUNT of $ACTUAL_COUNT screenshot(s) differ from baseline:**" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Test | Diff | Actual | Expected |" >> "$GITHUB_STEP_SUMMARY"
echo "|------|------|--------|----------|" >> "$GITHUB_STEP_SUMMARY"
# List each diff image with a link to the artifact
for diff in $(find test-results -name '*-diff.png' 2>/dev/null | sort); do
base=$(echo "$diff" | sed 's/-diff\.png$//')
test_name=$(basename "$base")
echo "| $test_name | [diff]($diff) | [actual](${base}-actual.png) | [expected](${base}-expected.png) |" >> "$GITHUB_STEP_SUMMARY"
done
fi
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "📥 **Artifacts:**" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ -n "$RESULTS_URL" ]; then
echo "- [playwright-test-results]($RESULTS_URL) — all screenshots (actual + expected + diff) + traces" >> "$GITHUB_STEP_SUMMARY"
fi
if [ -n "$REPORT_URL" ]; then
echo "- [playwright-report]($REPORT_URL) — interactive HTML report" >> "$GITHUB_STEP_SUMMARY"
fi
if [ -n "$DIFFS_URL" ]; then
echo "- [visual-diffs]($DIFFS_URL) — just the diffed screenshots (small, fast to review)" >> "$GITHUB_STEP_SUMMARY"
fi
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "**To update baselines:** merge to main (baselines auto-update on main runs) or run \`npx playwright test --update-snapshots\` locally." >> "$GITHUB_STEP_SUMMARY"
# Also parse the JSON report for pass/fail counts
if [ -f playwright-report/results.json ]; then
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### Test Results" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
node -e "
const r = require('./playwright-report/results.json');
const stats = r.stats || {};
console.log('| Status | Count |');
console.log('|--------|-------|');
console.log('| ✅ Passed | ' + (stats.expected || 0) + ' |');
console.log('| ❌ Failed | ' + (stats.unexpected || 0) + ' |');
console.log('| ⏭️ Skipped | ' + (stats.skipped || 0) + ' |');
console.log('| 🔄 Flaky | ' + (stats.flaky || 0) + ' |');
" >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
fi

View file

@ -15,6 +15,10 @@ 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
@ -23,18 +27,23 @@ 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
- name: Reject PRs with no common ancestor on main
- id: merge-base-check
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 ""
@ -56,3 +65,4 @@ jobs:
exit 1
fi
echo "::notice::Common ancestor with main: $BASE"
echo "review_status=[]" >> "$GITHUB_OUTPUT"

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: AUTOFIX_BOT_PAT pushes DO trigger further workflow runs (unlike
# NOTE: App token 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,6 +128,13 @@ 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:
@ -170,7 +177,7 @@ jobs:
- name: Create/update PR and enable auto-merge
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
BOT_BRANCH: bot/js-autofix
run: |
set -euo pipefail
@ -193,7 +200,7 @@ jobs:
- name: Wait for merge, auto-close on failure or stale
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
START_SHA: ${{ github.sha }}
run: |
set -euo pipefail

81
.github/workflows/label-rerun.yml vendored Normal file
View file

@ -0,0 +1,81 @@
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."

View file

@ -2,11 +2,14 @@ name: Lint (ruff + ty)
# Two things here:
# 1. Advisory diff — ruff + ty diagnostics as a diff vs the target branch.
# Posts a Markdown summary and a PR comment. Exit zero always.
# Writes a Markdown summary to the run page. 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 and posts even when
# enforcement fails.
# 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.
on:
workflow_call:
@ -15,14 +18,9 @@ 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 }}
@ -162,115 +160,3 @@ 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

View file

@ -7,22 +7,25 @@ name: Lockfile diff
# the ``packages`` map at the merge base and at HEAD and set-diffs the
# {install path: version} maps instead.
#
# 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).
# 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.
#
# Never blocking — this is review signal, not enforcement. Exit is 0 even
# when commenting fails (fork PRs get a read-only GITHUB_TOKEN).
# Never blocking — this is review signal, not enforcement.
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 }}
@ -33,6 +36,9 @@ 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
@ -54,45 +60,36 @@ jobs:
--output /tmp/lockfile-diff.md
if [ -s /tmp/lockfile-diff.md ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
cat /tmp/lockfile-diff.md >> "$GITHUB_STEP_SUMMARY"
{
echo "## package-lock.json semantic diff"
echo ""
cat /tmp/lockfile-diff.md
} >> "$GITHUB_STEP_SUMMARY"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
: > /tmp/lockfile-diff.md
fi
- 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 }}
- name: Emit review_status
id: emit-status
run: |
set -euo pipefail
MARKER='<!-- hermes-lockfile-diff -->'
CHANGED="${{ steps.diff.outputs.changed }}"
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
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)"
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
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)"
STATUS="[]"
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

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.
#
# 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.
# Uses Google's officially-recommended reusable workflow, pinned by SHA.
# 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,62 +40,85 @@ permissions:
jobs:
scan:
name: Scan lockfiles
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
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
- name: 'Run scanner'
uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
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
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
name: osv-results
path: /tmp/osv-results
continue-on-error: true
- 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() }}
- name: Emit review_status
id: emit
run: |
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 }}
set -euo pipefail
STATUS="[]"
- 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
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"

92
.github/workflows/review-labels.yml vendored Normal file
View file

@ -0,0 +1,92 @@
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
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
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 }}
LABEL_PRESENT: ${{ steps.label-check.outputs.ci_reviewed }}
run: |
set -euo pipefail
ARGS=""
if [ "$CI_REVIEW" = "true" ]; then ARGS="$ARGS --ci-review"; fi
if [ "$MCP_CATALOG" = "true" ]; then ARGS="$ARGS --mcp-catalog"; fi
if [ "$LABEL_PRESENT" = "true" ]; then ARGS="$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

View file

@ -108,10 +108,18 @@ 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: ${{ secrets.AUTOFIX_BOT_PAT }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
STATUS: ${{ steps.probe.outputs.status }}
DETAIL: ${{ steps.probe.outputs.detail }}
run: |

View file

@ -24,6 +24,13 @@ 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"
@ -35,7 +42,7 @@ jobs:
- name: Build skills index
env:
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
run: python scripts/build_skills_index.py
- name: Upload index artifact
@ -54,7 +61,13 @@ 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: ${{ secrets.AUTOFIX_BOT_PAT }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: gh workflow run deploy-site.yml --repo ${{ github.repository }} -f skills_index_run_id=${{ github.run_id }}

View file

@ -10,9 +10,17 @@ 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`` /
# ``mcp_catalog`` booleans as inputs; this workflow's jobs gate on those
# inputs instead of re-computing the diff.
# ``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).
# Each job (``scan``, ``dep-bounds``) emits its own
# array; an ``aggregate`` job merges them into the
# workflow-level output.
on:
workflow_call:
@ -29,10 +37,10 @@ on:
description: Whether pyproject.toml changed.
type: boolean
required: true
mcp_catalog:
description: Whether the MCP catalog / installer 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 }}
permissions:
pull-requests: write
@ -44,16 +52,25 @@ jobs:
if: inputs.scan
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
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: ${{ secrets.AUTOFIX_BOT_PAT }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail
@ -61,7 +78,7 @@ jobs:
HEAD="${{ github.event.pull_request.head.sha }}"
# Added lines only, excluding lockfiles.
# Three-dot diff (base...head) diffs from the merge base to HEAD,
# Three-point 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)
@ -139,26 +156,41 @@ jobs:
echo "found=false" >> "$GITHUB_OUTPUT"
fi
- name: Post critical finding comment
if: steps.scan.outputs.found == 'true'
- name: Emit review_status
id: emit-status
if: always()
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
FOUND: ${{ steps.scan.outputs.found }}
run: |
BODY="## 🚨 CRITICAL Supply Chain Risk Detected
python3 - <<'PYEOF'
import json, os
This PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging.
found = os.environ.get("FOUND", "") == "true"
$(cat /tmp/findings.md)
if found:
with open("/tmp/findings.md", encoding="utf-8") as f:
detail = f.read()
status = [{
"source": "supply chain",
"results": [{
"kind": "error",
"title": "Critical supply chain risk",
"summary": "Critical supply chain risk patterns detected in this PR.",
"detail": detail,
"how_to_fix": "Review the flagged code carefully. If intentional, add the `ci-reviewed` label."
}]
}]
else:
status = []
---
*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)"
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
f.write(f"review_status={json.dumps(status)}\n")
PYEOF
- 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."
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the review comment for details."
exit 1
dep-bounds:
@ -166,6 +198,8 @@ jobs:
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
@ -188,7 +222,7 @@ jobs:
exit 0
fi
# Match PyPI dep specs that have >= but no < ceiling.
# Match PyPI dep specs that have >= and 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)
@ -200,26 +234,36 @@ jobs:
echo "found=false" >> "$GITHUB_OUTPUT"
fi
- name: Post unbounded dep warning
if: steps.bounds.outputs.found == 'true'
- name: Emit review_status
id: emit-status
if: always()
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
FOUND: ${{ steps.bounds.outputs.found }}
run: |
BODY="## ⚠️ Unbounded PyPI Dependency Detected
python3 - <<'PYEOF'
import json, os
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\`.
found = os.environ.get("FOUND", "") == "true"
**Unbounded specs found:**
\`\`\`
$(cat /tmp/unbounded.txt)
\`\`\`
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 = []
**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)"
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
f.write(f"review_status={json.dumps(status)}\n")
PYEOF
- name: Fail on unbounded deps
if: steps.bounds.outputs.found == 'true'
@ -227,45 +271,36 @@ jobs:
echo "::error::PyPI dependencies without upper bounds detected. Add <next_major ceiling per CONTRIBUTING.md policy."
exit 1
mcp-catalog-review:
name: MCP catalog security review
if: inputs.mcp_catalog
aggregate:
name: Aggregate review statuses
needs: [scan, dep-bounds]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
review_status: ${{ steps.merge.outputs.review_status }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Require explicit MCP catalog review label
- name: Merge review statuses
id: merge
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 }}
SCAN_STATUS: ${{ needs.scan.outputs.review_status }}
DEP_STATUS: ${{ needs.dep-bounds.outputs.review_status }}
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 'mcp-catalog-reviewed'; then
echo "MCP catalog review label present."
exit 0
fi
python3 - <<'PYEOF'
import json, os
BODY="## ⚠️ MCP catalog security review required
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)
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
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
f.write(f"review_status={json.dumps(merged)}\n")
PYEOF

View file

@ -143,9 +143,16 @@ 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: ${{ secrets.AUTOFIX_BOT_PAT }}
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
# release.py creates the GitHub Release after pushing the tag,
# but this workflow starts from the tag push — wait for it.
run: |
@ -171,7 +178,7 @@ jobs:
- name: Attach signed artifacts to GitHub Release
if: env.skip_sign != 'true'
env:
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
# release.py already created the GitHub Release — just upload
# the Sigstore signatures alongside the existing assets.
run: >-

View file

@ -45,6 +45,10 @@ 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
@ -58,6 +62,8 @@ 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
@ -73,6 +79,7 @@ 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
@ -117,5 +124,9 @@ 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"

6
.gitignore vendored
View file

@ -4,6 +4,8 @@
/_pycache/
*.pyc*
__pycache__/
act/
.act-sandbox-agent.*
.venv/
.venv
.vscode/
@ -54,6 +56,10 @@ __pycache__/
hermes_agent.egg-info/
wandb/
testlogs
playwright-report/
test-results/
# Playwright visual regression baselines — cached from main in CI, not committed
*-snapshots/
# CLI config (may contain sensitive SSH paths)
cli-config.yaml

View file

@ -1,7 +1,7 @@
{
"id": "hermes-agent",
"name": "Hermes Agent",
"version": "0.18.2",
"version": "0.19.0",
"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.18.2",
"package": "hermes-agent[acp]==0.19.0",
"args": ["hermes-acp"]
}
}

View file

@ -68,18 +68,28 @@ def _ra():
return run_agent
def _build_codex_gpt5_autoraise_notice(autoraise: Dict[str, Any]) -> str:
def _build_codex_gpt5_autoraise_notice(
autoraise: Dict[str, Any], context_length: Optional[int] = None
) -> str:
"""Build the one-time notice shown when Codex gpt-5.x raises compaction.
``autoraise`` is ``{"model": <slug>, "from": <old_ratio>, "to": <new_ratio>}``.
The same text is printed inline for CLI users and replayed via
``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
``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]
# 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"
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"
from_pct = int(round(autoraise["from"] * 100))
to_pct = int(round(autoraise["to"] * 100))
return (
@ -2116,7 +2126,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)
_autoraise = getattr(agent, "_compression_threshold_autoraised", None) or {}
_show_autoraise_notice = (
bool(_autoraise)
and compression_enabled
@ -2139,7 +2149,10 @@ def init_agent(
# 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))
print(_build_codex_gpt5_autoraise_notice(
_autoraise,
context_length=getattr(agent.context_compressor, "context_length", None),
))
# Check immediately so CLI users see the warning at startup.
# Gateway status_callback is not yet wired, so any warning is stored
@ -2149,7 +2162,10 @@ 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)
agent._compression_warning = _build_codex_gpt5_autoraise_notice(
_autoraise,
context_length=getattr(agent.context_compressor, "context_length", None),
)
# 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

View file

@ -1,4 +1,4 @@
"""Surface-agnostic core for the Phase 2b terminal-billing screens.
"""Surface-agnostic core for the Phase 2b Remote Spending screens.
One fetch/parse per concern, consumed identically by the CLI handler
(``cli.py::_show_billing``), the TUI JSON-RPC methods

View file

@ -1236,8 +1236,29 @@ 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)
agent._flush_messages_to_session_db(
messages,
conversation_history=persisted_history,
)
except Exception:
pass # best-effort — don't block compression on a flush error
# Propagate title to the new session with auto-numbering

View file

@ -18,9 +18,15 @@ into it via :func:`agent.lsp.manager.LSPService.touch_file`.
Implementation notes:
- 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.
- 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.
- Whole-document sync. Even when the server advertises incremental
sync, we send a single ``contentChanges`` entry replacing the
@ -45,6 +51,7 @@ 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
@ -124,6 +131,40 @@ 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.
@ -186,18 +227,10 @@ class LSPClient:
# is silently dropped by default.
}
# 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()
# 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] = {}
# Capability registrations — only diagnostic ones are tracked.
self._diagnostic_registrations: Dict[str, Dict[str, Any]] = {}
@ -647,25 +680,25 @@ class LSPClient:
if not isinstance(diagnostics, list):
diagnostics = []
version = params.get("version")
loop_time = asyncio.get_event_loop().time()
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
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
return
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)
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
# 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
@ -694,16 +727,16 @@ class LSPClient:
raise LSPProtocolError(f"cannot read {abs_path}: {e}") from e
uri = file_uri(abs_path)
existing = self._files.get(abs_path)
doc = self._docs.get(abs_path)
if existing is not None:
if doc is not None and doc.version >= 0:
# Re-open: bump version, fire didChangeWatchedFiles + didChange.
await self._send_notification(
"workspace/didChangeWatchedFiles",
{"changes": [{"uri": uri, "type": 2}]}, # 2 = CHANGED
)
new_version = existing["version"] + 1
old_text = existing["text"]
new_version = doc.version + 1
old_text = doc.text
content_changes: List[Dict[str, Any]]
if self._sync_kind == 2:
content_changes = [
@ -724,7 +757,11 @@ class LSPClient:
"contentChanges": content_changes,
},
)
self._files[abs_path] = {"version": new_version, "text": text}
# 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
return new_version
# First open: didChangeWatchedFiles CREATED + didOpen.
@ -732,12 +769,9 @@ class LSPClient:
"workspace/didChangeWatchedFiles",
{"changes": [{"uri": uri, "type": 1}]}, # 1 = CREATED
)
# 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)
# 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)
await self._send_notification(
"textDocument/didOpen",
{
@ -749,7 +783,6 @@ class LSPClient:
}
},
)
self._files[abs_path] = {"version": 0, "text": text}
return 0
async def save_file(self, path: str) -> None:
@ -769,12 +802,19 @@ class LSPClient:
async def _pull_document_diagnostics(self, path: str) -> None:
"""Send ``textDocument/diagnostic`` for one file.
Stores results into :attr:`_pull_diagnostics`. Silently
no-ops on errors (server may not support the pull endpoint).
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).
"""
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(os.path.abspath(path))}
"textDocument": {"uri": file_uri(abs_path)}
}
result = await self._send_request_with_retry(
"textDocument/diagnostic",
@ -788,7 +828,9 @@ class LSPClient:
return
items = result.get("items")
if isinstance(items, list):
self._pull_diagnostics[os.path.abspath(path)] = items
doc = self._docs.setdefault(abs_path, _DocState(version=-1))
doc.pull = items
doc.pull_version = sent_version
related = result.get("relatedDocuments")
if isinstance(related, dict):
for uri, sub in related.items():
@ -796,7 +838,11 @@ class LSPClient:
continue
sub_items = sub.get("items")
if isinstance(sub_items, list):
self._pull_diagnostics[uri_to_path(uri)] = sub_items
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
async def wait_for_diagnostics(
self,
@ -804,22 +850,36 @@ class LSPClient:
version: int,
*,
mode: str = "document",
) -> None:
timeout: Optional[float] = None,
) -> bool:
"""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). Best-effort
returns silently on timeout. Does NOT throw if the server
doesn't support pull diagnostics; we still get the push side.
``"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.
"""
budget = DIAGNOSTICS_FULL_WAIT if mode == "full" else DIAGNOSTICS_DOCUMENT_WAIT
if timeout is not None and timeout > 0:
budget = timeout
else:
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
return False
# Concurrent: document pull + push wait.
pull_task = asyncio.create_task(self._pull_document_diagnostics(abs_path))
@ -838,26 +898,24 @@ class LSPClient:
pass
# If we got a fresh push for our version, we're done.
current_v = self._published_version.get(abs_path)
if abs_path in self._published and (
current_v is None or current_v >= version
):
return
doc = self._docs.get(abs_path)
if doc and doc.fresh_push(version):
return True
# Pull may have populated _pull_diagnostics — that's also
# success.
if abs_path in self._pull_diagnostics:
return
# Pull may have answered for the current version — that's
# also success.
if doc and doc.fresh_pull(version):
return True
# Loop until budget runs out.
async def _wait_for_fresh_push(self, path: str, version: int, timeout: float) -> None:
"""Wait until a publishDiagnostics arrives for ``path`` at ``version``+."""
"""Wait until a fresh publishDiagnostics arrives for ``path`` at ``version``+."""
deadline = asyncio.get_event_loop().time() + timeout
baseline = self._push_counter
while True:
current_v = self._published_version.get(path)
if path in self._published and (current_v is None or current_v >= version):
doc = self._docs.get(path)
if doc and doc.fresh_push(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
@ -888,17 +946,28 @@ class LSPClient:
except asyncio.TimeoutError:
continue
def diagnostics_for(self, path: str) -> List[Dict[str, Any]]:
def diagnostics_for(self, path: str, *, fresh_only: bool = False) -> 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.
"""
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)
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)
def _dedupe(*lists: List[Dict[str, Any]]) -> List[Dict[str, Any]]:

View file

@ -292,7 +292,10 @@ class LSPService:
if not self.enabled_for(file_path):
return
try:
diags = self._loop.run(self._snapshot_async(file_path), timeout=8.0)
# 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)
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)
@ -341,7 +344,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) or []
diags = self._loop.run(self._open_and_wait_async(file_path), timeout=t)
except asyncio.TimeoutError as e:
eventlog.log_timeout(server_id, file_path)
logger.debug("LSP diagnostics timeout for %s: %s", file_path, e)
@ -353,6 +356,17 @@ 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 []
@ -452,26 +466,43 @@ class LSPService:
return []
try:
version = await client.open_file(file_path, language_id=language_id_for(file_path))
await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
fresh = 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()
return list(client.diagnostics_for(file_path))
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))
async def _open_and_wait_async(self, file_path: str) -> List[Dict[str, Any]]:
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.
"""
client = await self._get_or_spawn(file_path)
if client is None:
return []
return None
try:
version = await client.open_file(file_path, language_id=language_id_for(file_path))
await client.save_file(file_path)
await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
fresh = await client.wait_for_diagnostics(
file_path, version, mode=self._wait_mode, timeout=self._wait_timeout
)
except Exception as e: # noqa: BLE001
logger.debug("open/wait failed for %s: %s", file_path, e)
return []
return None
self._last_used[(client.server_id, client.workspace_root)] = time.time()
return list(client.diagnostics_for(file_path))
if not fresh:
return None
return list(client.diagnostics_for(file_path, fresh_only=True))
async def _current_diags_async(self, file_path: str) -> List[Dict[str, Any]]:
ws, gated = resolve_workspace_for_file(file_path)
@ -482,7 +513,7 @@ class LSPService:
client = self._clients.get((srv.server_id, ws))
if client is None:
return []
return list(client.diagnostics_for(file_path))
return list(client.diagnostics_for(file_path, fresh_only=True))
async def _get_or_spawn(self, file_path: str) -> Optional[LSPClient]:
srv = find_server_for_file(file_path)

View file

@ -4,6 +4,8 @@ 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
@ -581,8 +583,13 @@ 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.
"""
return provider == "lmstudio"
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"}
def _maybe_cache_local_context_length(
@ -1918,32 +1925,72 @@ _CODEX_OAUTH_CONTEXT_FALLBACK: Dict[str, int] = {
}
_codex_oauth_context_cache: Dict[str, int] = {}
_codex_oauth_context_cache_time: float = 0.0
_codex_oauth_context_cache: Dict[str, Tuple[Dict[str, int], float]] = {}
_CODEX_OAUTH_CONTEXT_CACHE_TTL = 3600 # 1 hour
def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]:
"""Probe the ChatGPT Codex /models endpoint for per-slug context windows.
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]
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.
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.
"""
global _codex_oauth_context_cache, _codex_oauth_context_cache_time
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
now = time.time()
if (
_codex_oauth_context_cache
and now - _codex_oauth_context_cache_time < _CODEX_OAUTH_CONTEXT_CACHE_TTL
):
return _codex_oauth_context_cache
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
try:
resp = requests.get(
"https://chatgpt.com/backend-api/codex/models?client_version=1.0.0",
headers={"Authorization": f"Bearer {access_token}"},
headers=headers,
timeout=(5, 10),
verify=_resolve_requests_verify(),
)
@ -1952,11 +1999,11 @@ def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]:
"Codex /models probe returned HTTP %s; falling back to hardcoded defaults",
resp.status_code,
)
return {}
return {}, False
data = resp.json()
except Exception as exc:
logger.debug("Codex /models probe failed: %s", exc)
return {}
return {}, False
entries = data.get("models", []) if isinstance(data, dict) else []
result: Dict[str, int] = {}
@ -1969,32 +2016,50 @@ def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]:
result[slug.strip()] = ctx
if result:
_codex_oauth_context_cache = result
_codex_oauth_context_cache_time = now
_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)
return result
def _resolve_codex_oauth_context_length(
def _resolve_codex_oauth_context_length_with_source(
model: str, access_token: str = ""
) -> Optional[int]:
) -> Tuple[Optional[int], str]:
"""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 = _fetch_codex_oauth_context_lengths(access_token)
live, fresh_probe = _fetch_codex_oauth_context_lengths_with_source(access_token)
live_source = "live" if fresh_probe else "memory"
if model_bare in live:
return live[model_bare]
return live[model_bare], live_source
# 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
return ctx, live_source
# Fallback: longest-key-first substring match over hardcoded defaults.
model_lower = model_bare.lower()
@ -2002,9 +2067,19 @@ def _resolve_codex_oauth_context_length(
_CODEX_OAUTH_CONTEXT_FALLBACK.items(), key=lambda x: len(x[0]), reverse=True
):
if slug in model_lower:
return ctx
return ctx, "fallback"
return None
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
def _resolve_nous_context_length(
@ -2094,9 +2169,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
bypass the cache here so step 5b can always reconcile against
the authoritative portal /v1/models response.
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.
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)
@ -2196,24 +2271,13 @@ 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.
elif cached <= 32768 and _model_name_suggests_kimi(model):
if 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",
@ -2452,9 +2516,14 @@ 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 = _resolve_codex_oauth_context_length(model, access_token=api_key or "")
codex_ctx, codex_source = _resolve_codex_oauth_context_length_with_source(
model, access_token=api_key or "",
)
if codex_ctx:
if base_url:
# 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":
save_context_length(model, base_url, codex_ctx)
return codex_ctx
if effective_provider == "gmi" and base_url:

View file

@ -805,8 +805,19 @@ PLATFORM_HINTS = {
),
"matrix": (
"You are in a Matrix room communicating with your user. "
"Matrix renders Markdown — bold, italic, code blocks, and links work; "
"the adapter converts your Markdown to HTML for rich display. "
"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"
"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, "

View file

@ -190,6 +190,45 @@ 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
kindstring 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

View file

@ -34,6 +34,7 @@ import json
import logging
import os
import platform
import re
import shutil
import stat
import subprocess
@ -415,6 +416,39 @@ def fetch_bitwarden_secrets(
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]]:
@ -448,9 +482,11 @@ 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 in plain English.
# Strip ANSI just in case and surface the first 200 chars.
err = (proc.stderr or proc.stdout or "").strip().replace("\x1b", "")
# 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 "")
raise RuntimeError(
f"bws exited {proc.returncode}: {err[:200]}"
)
@ -690,12 +726,30 @@ class BitwardenSource(SecretSource):
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."""
@ -705,7 +759,13 @@ 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")):
"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")):
return ErrorKind.AUTH_FAILED
if any(tok in lowered for tok in ("network", "connection", "resolve",
"download", "dns")):
@ -718,6 +778,17 @@ def _classify_bws_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 bitwarden token`) so the
next startup fetches fresh with the new credential instead of serving
a pull 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.
@ -725,5 +796,4 @@ 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.
"""
_CACHE.clear()
_DISK_CACHE.clear(home_path)
clear_caches(home_path)

View file

@ -607,6 +607,24 @@ 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."""
@ -633,11 +651,21 @@ 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.
"""
_CACHE.clear()
_DISK_CACHE.clear(home_path)
clear_caches(home_path)

View file

@ -54,6 +54,28 @@ 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.
@ -519,12 +541,12 @@ def _begin_tool_execution(
if function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled:
try:
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}"
)
_ensure_file_checkpoint(
agent,
function_name,
function_args,
effective_task_id,
)
except Exception:
pass
@ -1275,6 +1297,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
middleware_trace: list[dict[str, Any]] = []
_execution_blocked = False
tool_start_time = time.time()
if function_name == "todo":

View file

@ -125,9 +125,11 @@ 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; 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.
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.
- **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."

View file

@ -0,0 +1,47 @@
/**
* E2E boot-failure tests verify the app shows an error overlay when the
* backend can't start.
*
* Injects a fake boot error (HERMES_DESKTOP_BOOT_FAKE_ERROR) so the backend
* resolution fails with a controlled error message. The app should show the
* BootFailureOverlay with retry/repair actions.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { test } from '@playwright/test'
import {
type DeadBackendFixture,
setupDeadBackend,
waitForBootFailure,
} from './fixtures'
import { expectVisualSnapshot } from './visual-snapshot'
let fixture: DeadBackendFixture | null = null
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test.describe('boot failure with dead backend', () => {
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
// BootFailureOverlay in dev mode.
fixture = await setupDeadBackend({ fakeError: true })
await waitForBootFailure(fixture.page, 90_000)
})
test('screenshot of error state', async () => {
if (!fixture) {
test.skip(true, 'Previous test failed — no app running')
return
}
await expectVisualSnapshot(fixture!.page, { name: 'boot-failure-error-state', app: fixture.app })
})
})

View file

@ -0,0 +1,63 @@
/**
* E2E smoke tests for the dev-mode desktop app.
*
* These tests launch the Electron app from the built dist/ (not the
* packaged binary) with a real `hermes serve` backend pointed at a mock
* inference server. The full chain is exercised:
*
* electron hermes serve (python) mock provider renderer
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
* Run from the nix devshell:
* npm exec playwright test e2e/boot.spec.ts --reporter=list
*/
import { expect, test } from '@playwright/test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { expectVisualSnapshot } from './visual-snapshot'
let fixture: MockBackendFixture | null = null
test.beforeAll(async () => {
fixture = await setupMockBackend()
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test.describe('dev-mode boot with mock backend', () => {
test('window opens with Hermes title', async () => {
const title = await fixture!.page.title()
expect(title).toContain('Hermes')
})
test('renderer mounts and shows DOM content', async () => {
const page = fixture!.page
// Wait for the React root to mount. The app renders into #root
// (see src/main.tsx), but content may arrive through portals — so
// check the body for any interactive content instead.
await page.waitForSelector('body', { state: 'attached' })
// Wait for the main app shell — the composer is always present.
await page.waitForSelector('textarea, [contenteditable="true"]', {
state: 'attached',
timeout: 30_000,
})
})
test('backend boots and app becomes ready', async () => {
// This is the big one — wait for the full boot chain to complete:
// electron starts → hermes serve is spawned → WS connects → config
// loaded → sessions loaded → boot overlay dismissed → composer visible.
await waitForAppReady(fixture!, 120_000)
})
test('screenshot after boot', async () => {
await expectVisualSnapshot(fixture!.page, { name: 'boot-ready', app: fixture!.app })
})
})

View file

@ -0,0 +1,91 @@
/**
* E2E chat tests send a message and verify a response appears.
*
* Requires the full boot chain to complete (hermes serve + mock inference
* provider). The mock server returns a canned reply, so we verify the
* response text shows up in the chat transcript.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { test } from '@playwright/test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { expectVisualSnapshot } from './visual-snapshot'
let fixture: MockBackendFixture | null = null
test.beforeAll(async () => {
fixture = await setupMockBackend()
await waitForAppReady(fixture!, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test.describe('chat interaction with mock backend', () => {
test('send a message and receive a response', async () => {
const page = fixture!.page
// Find the composer — it's a contenteditable textbox.
const composer = page.locator('[contenteditable="true"]').first()
await composer.waitFor({ state: 'visible', timeout: 10_000 })
// Click to focus, then type the message character by character.
// Using `type` instead of `fill` because the composer is a
// contenteditable div with custom keydown handling that tracks
// IME composition state — `fill` bypasses the event chain.
await composer.click()
await composer.type('Hello, can you hear me?', { delay: 20 })
// Submit with Enter — the composer's keydown handler intercepts
// plain Enter (without Shift) and calls submitDraft().
await page.keyboard.press('Enter')
// Wait for the user's message to appear in the transcript.
// The message renders as an assistant-ui message in the chat view.
await page.waitForFunction(
() => {
const body = document.body
if (!body) {
return false
}
return (body.textContent ?? '').includes('Hello, can you hear me?')
},
undefined,
{ timeout: 15_000 },
)
// Wait for the mock response to appear. The canned reply is:
// "Hello from the mock inference server! The full boot chain is working."
// Give it a generous timeout — the inference request goes through the
// gateway → hermes serve → mock server → streaming SSE back.
await page.waitForFunction(
() => {
const body = document.body
if (!body) {
return false
}
const text = body.textContent ?? ''
return text.includes('mock inference server') || text.includes('boot chain is working')
},
undefined,
{ timeout: 60_000 },
)
})
test('screenshot of chat with messages', async () => {
await expectVisualSnapshot(fixture!.page, { name: 'chat-with-messages', app: fixture!.app })
})
})

View file

@ -0,0 +1,72 @@
/**
* Monkey-patch: playwright's test runner never calls tracing.start() on
* Electron's internal BrowserContext because:
* 1. Playwright._allContexts() only returns [chromium, firefox, webkit]
* contexts Electron's context is excluded.
* 2. ArtifactsRecorder.didCreateBrowserContext runs in willStartTest, before
* beforeAll launches the electron app.
* 3. The runAfterCreateBrowserContext hook doesn't exist on the Electron
* class (only on BrowserType).
*
* As a result, trace screenshots (screencast) and DOM snapshots are never
* captured for electron tests.
*
* This patch:
* 1. Patches _allContexts() to include electron contexts, so the test
* runner's didFinishTest() cleanup calls _stopTracing() stopChunk()
* on the electron context (saving the trace chunk + merging it into
* the final trace.zip).
* 2. Manually calls tracing.start() + startChunk() after launch.
* 3. Wraps tracing.start to become startChunk after the first call,
* so the test runner's willStartTest doesn't throw "already started".
*
* Imported from playwright.config.ts so it runs before any test.
*
* Pinned dependency: this file reaches into Playwright internals (_playwright,
* _allContexts, _context) that have no public contract. @playwright/test is
* pinned exact (=1.58.2 in package.json) so a bump can't silently break the
* monkeypatch. When bumping, re-verify these private symbols still exist on
* the Electron / PlaywrightInternal classes and that tracing still merges.
*/
import { _electron as electron, type BrowserContext } from '@playwright/test'
import * as crypto from 'node:crypto'
const electronContexts = new Set<BrowserContext>()
const originalLaunch = electron.launch.bind(electron)
electron.launch = async (options: any) => {
const app = await originalLaunch(options)
const ctx = (app as any)._context as BrowserContext
electronContexts.add(ctx)
ctx.once('close', () => electronContexts.delete(ctx))
// Patch _allContexts so the test runner sees the electron context
// (didFinishTest cleanup → _stopTracing → stopChunk → merge into trace.zip).
const pw = (electron as any)._playwright as any
if (pw && !pw.__electronTracingPatched) {
pw.__electronTracingPatched = true
const original = pw._allContexts.bind(pw)
pw._allContexts = () => [...original(), ...electronContexts]
}
// Start tracing — mirrors ArtifactsRecorder.didCreateBrowserContext.
const traceName = crypto.randomUUID()
await ctx.tracing.start({
screenshots: true,
snapshots: true,
sources: true,
}).catch(() => {})
await ctx.tracing.startChunk({ title: 'electron', name: traceName }).catch(() => {})
// Wrap tracing.start to redirect to startChunk after the first call.
// The test runner's willStartTest calls tracing.start() on all contexts
// in _allContexts(). Since we already started, redirect to startChunk
// to avoid "Tracing has been already started" errors.
const tracing = ctx.tracing as any
tracing.start = async (opts: any) => {
return tracing.startChunk(opts)
}
return app
}

View file

@ -0,0 +1,674 @@
/**
* Shared E2E fixtures for the Hermes desktop Playwright suite.
*
* Two fixture modes:
*
* 1. `mockBackend` starts a mock inference server, writes a config.yaml
* that points at it, and launches the desktop app so the full chain
* (electron hermes serve provider inference renderer) is
* exercised with a real backend but a fake LLM.
*
* 2. `noProvider` launches the app with an empty config (no provider
* configured). The onboarding overlay should appear. Used to test the
* first-run flow without real credentials.
*
* Both modes launch the *dev* Electron app (`electron .` against the built
* `dist/`), not the packaged binary. This avoids the multi-minute
* `electron-builder --dir` step and matches `hermes desktop --source`. The
* packaged-binary path is already covered by `launch.spec.ts`.
*
* Prerequisite: `npm run build` must have been run so that `dist/` exists.
*/
import { spawnSync } from 'node:child_process'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { _electron, type ElectronApplication, type Page } from '@playwright/test'
import { startMockServer } from './mock-server'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
// ─── Credential stripping (matches launch.spec.ts) ──────────────────────
const CREDENTIAL_SUFFIXES: string[] = [
'_API_KEY',
'_TOKEN',
'_SECRET',
'_PASSWORD',
'_CREDENTIALS',
'_ACCESS_KEY',
'_PRIVATE_KEY',
'_OAUTH_TOKEN',
]
const CREDENTIAL_NAMES = new Set([
'ANTHROPIC_BASE_URL',
'ANTHROPIC_TOKEN',
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_SESSION_TOKEN',
'CUSTOM_API_KEY',
'GEMINI_BASE_URL',
'OPENAI_BASE_URL',
'OPENROUTER_BASE_URL',
'OLLAMA_BASE_URL',
'GROQ_BASE_URL',
'XAI_BASE_URL',
])
function isCredentialEnvVar(name: string): boolean {
if (CREDENTIAL_NAMES.has(name)) {
return true
}
return CREDENTIAL_SUFFIXES.some((suffix) => name.endsWith(suffix))
}
function stripCredentials(env: Record<string, string | undefined>): Record<string, string> {
const clean: Record<string, string> = {}
for (const [key, value] of Object.entries(env)) {
if (!value) {
continue
}
if (isCredentialEnvVar(key)) {
continue
}
clean[key] = value
}
return clean
}
// ─── Sandbox creation ──────────────────────────────────────────────────
export interface Sandbox {
root: string
hermesHome: string
userDataDir: string
cleanup: () => void
}
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')
fs.mkdirSync(hermesHome, { recursive: true })
fs.mkdirSync(userDataDir, { recursive: true })
// Write a fixed window-state.json so the Electron window opens at a
// consistent size — helps with visual regression screenshots. The
// exact size is also enforced right before each screenshot (see
// expectVisualSnapshot in visual-snapshot.ts) because window managers
// may resize after launch.
fs.writeFileSync(
path.join(userDataDir, 'window-state.json'),
JSON.stringify(
{ x: 0, y: 0, width: 1220, height: 800, isMaximized: false },
null,
2,
),
'utf8',
)
return {
root,
hermesHome,
userDataDir,
cleanup: () => {
try {
fs.rmSync(root, { recursive: true, force: true })
} catch {
// best-effort
}
},
}
}
// ─── Config writing ─────────────────────────────────────────────────────
/**
* Write a config.yaml that pre-configures a mock provider pointing at the
* 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.
*/
function writeMockProviderConfig(hermesHome: string, mockUrl: string): void {
const configPath = path.join(hermesHome, 'config.yaml')
const config = `# Auto-generated by E2E test fixtures
model:
default: mock-model
provider: mock
providers:
mock:
api: ${mockUrl}/v1
name: Mock
api_mode: chat_completions
key_env: MOCK_API_KEY
models:
mock-model: {}
context_length: 4096
`
fs.writeFileSync(configPath, config, 'utf8')
}
/**
* 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.
*/
function writeEnvFile(hermesHome: string, apiKey = 'e2e-mock-key'): void {
const envPath = path.join(hermesHome, '.env')
fs.writeFileSync(envPath, `MOCK_API_KEY=${apiKey}\n`, 'utf8')
}
/**
* Write an empty config (no providers). The desktop app should show the
* onboarding overlay because no inference provider is configured.
*/
function writeEmptyConfig(hermesHome: string): void {
const configPath = path.join(hermesHome, 'config.yaml')
fs.writeFileSync(configPath, '# Auto-generated by E2E test fixtures — no providers configured\n', 'utf8')
}
// ─── Env building ──────────────────────────────────────────────────────
/**
* Build the environment for the Electron app process.
*
* Key env vars:
* - HERMES_HOME sandbox hermes-home (isolated config/sessions)
* - HERMES_DESKTOP_USER_DATA_DIR sandbox electron-user-data
* - HERMES_DESKTOP_IGNORE_EXISTING=1 don't pick up `hermes` from PATH
* (we want the dev checkout at REPO_ROOT)
* - HERMES_DESKTOP_HERMES_ROOT REPO_ROOT (dev checkout resolution)
* - HERMES_DESKTOP_APP_NAME unique-ish per test (avoids single-instance lock)
* - XDG_RUNTIME_DIR ensure Electron has a writable runtime dir on Linux
*/
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
// headless/CI context — without it the zygote may fail to initialize.
if (!clean.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR) {
clean.XDG_RUNTIME_DIR = process.env.XDG_RUNTIME_DIR
}
// DISPLAY — needed for Electron to open a window.
if (!clean.DISPLAY && process.env.DISPLAY) {
clean.DISPLAY = process.env.DISPLAY
}
return {
...clean,
HERMES_HOME: sandbox.hermesHome,
HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir,
HERMES_DESKTOP_IGNORE_EXISTING: '1',
HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT,
HERMES_DESKTOP_APP_NAME: `HermesE2E-${Date.now()}`,
// Clear dev-server override — we want the built dist/, not a vite server.
// The dev-server check in main.ts looks for this env var; if it's set,
// it loads from the vite URL instead of the local file.
...extra,
}
}
// ─── Electron launch ────────────────────────────────────────────────────
/**
* Verify that the desktop app has been built (dist/ exists). Playwright
* tests can't run without it the Electron main process loads
* dist/electron-main.mjs and the renderer loads dist/index.html.
*/
function assertDistBuilt(): void {
const distDir = path.join(DESKTOP_ROOT, 'dist')
const electronMain = path.join(distDir, 'electron-main.mjs')
const indexHtml = path.join(distDir, 'index.html')
if (!fs.existsSync(electronMain)) {
throw new Error(
`Desktop dist not built. Run 'cd apps/desktop && npm run build' first.\n` +
`Missing: ${electronMain}`,
)
}
if (!fs.existsSync(indexHtml)) {
throw new Error(
`Desktop dist/index.html not found. Run 'cd apps/desktop && npm run build' first.\n` +
`Missing: ${indexHtml}`,
)
}
}
/**
* 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.
*/
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.
const localElectron = path.join(REPO_ROOT, 'node_modules', 'electron', 'dist', 'electron')
if (fs.existsSync(localElectron)) {
return localElectron
}
// Fall back to PATH
const result = spawnSync('which', ['electron'], {
encoding: 'utf8',
})
if (result.status === 0 && result.stdout.trim()) {
return result.stdout.trim()
}
throw new Error(
'Electron binary not found. Run "npm install" from the repo root to install devDependencies.',
)
}
/**
* Launch the desktop app in dev mode.
*
* @param sandbox - isolated HERMES_HOME + userData
* @param env - the process environment (already has HERMES_HOME etc.)
* @returns the ElectronApplication + first Page
*/
async function launchDesktop(
env: Record<string, string>,
): Promise<{ app: ElectronApplication; page: Page }> {
assertDistBuilt()
const electronBin = findElectron()
// `electron .` loads from the package.json `main` field
// (dist/electron-main.mjs after build).
const app = await _electron.launch({
executablePath: electronBin,
args: [
DESKTOP_ROOT, // `electron .` — the `.` is the desktop package dir
'--disable-gpu',
'--no-sandbox',
],
env,
cwd: DESKTOP_ROOT,
})
const page = await app.firstWindow()
return { app, page }
}
// ─── Public fixtures ────────────────────────────────────────────────────
export interface MockBackendFixture {
app: ElectronApplication
page: Page
mockUrl: string
sandbox: Sandbox
cleanup: () => Promise<void>
}
/**
* Set up a full mock-backend E2E environment:
* 1. Start the mock inference server
* 2. Create a sandbox with config.yaml pointing at it
* 3. Launch the desktop app
* 4. Return handles for test interaction
*/
export async function setupMockBackend(): Promise<MockBackendFixture> {
// 1. Start mock server
const mock = await startMockServer()
// 2. Create sandbox + write config
const sandbox = createSandbox('mock')
writeMockProviderConfig(sandbox.hermesHome, mock.url)
writeEnvFile(sandbox.hermesHome)
// 3. Build env + launch
const env = buildAppEnv(sandbox)
const { app, page } = await launchDesktop(env)
return {
app,
page,
mockUrl: mock.url,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
await mock.close()
sandbox.cleanup()
},
}
}
export interface NoProviderFixture {
app: ElectronApplication
page: Page
sandbox: Sandbox
cleanup: () => Promise<void>
}
/**
* Launch the app with no provider configured. The onboarding overlay should
* appear because there's no inference provider in config.yaml.
*/
export async function setupNoProvider(): Promise<NoProviderFixture> {
const sandbox = createSandbox('noprovider')
writeEmptyConfig(sandbox.hermesHome)
const env = buildAppEnv(sandbox)
const { app, page } = await launchDesktop(env)
return {
app,
page,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
sandbox.cleanup()
},
}
}
export interface DeadBackendFixture {
app: ElectronApplication
page: Page
sandbox: Sandbox
cleanup: () => Promise<void>
}
export interface DeadBackendOptions {
/**
* When true, inject a fake boot error via HERMES_DESKTOP_BOOT_FAKE_ERROR
* so the backend resolution itself "fails" with a controlled error message.
* This is the only reliable way to trigger BootFailureOverlay in dev mode
* (the real backend always resolves via SOURCE_REPO_ROOT).
*/
fakeError?: boolean
}
/**
* Launch the app with a provider pointing at a dead endpoint (port 1, which
* nothing listens on). By default the backend still boots (`hermes serve`
* starts fine the dead endpoint only matters at chat time). Pass
* `{ fakeError: true }` to inject a fake boot failure, triggering the
* BootFailureOverlay.
*/
export async function setupDeadBackend(options: DeadBackendOptions = {}): Promise<DeadBackendFixture> {
const sandbox = createSandbox('dead')
const configPath = path.join(sandbox.hermesHome, 'config.yaml')
fs.writeFileSync(
configPath,
`# Auto-generated by E2E test fixtures — dead provider
model:
default: mock-model
provider: mock
providers:
mock:
api: http://127.0.0.1:1/v1
name: Mock
api_mode: chat_completions
key_env: MOCK_API_KEY
models:
mock-model: {}
context_length: 4096
`,
'utf8',
)
writeEnvFile(sandbox.hermesHome)
const env = buildAppEnv(sandbox, options.fakeError ? { HERMES_DESKTOP_BOOT_FAKE_ERROR: 'Failed to connect to Hermes backend: connection refused' } : {})
const { app, page } = await launchDesktop(env)
return {
app,
page,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
sandbox.cleanup()
},
}
}
// ─── Packaged-binary fixture ───────────────────────────────────────────
/**
* Resolve the packaged Electron binary path, per-platform, matching
* electron-builder's output layout under release/.
*/
function resolvePackagedBinaryPath(): string {
if (process.platform === 'win32') {
return path.join(RELEASE_ROOT, 'win-unpacked', 'Hermes.exe')
}
if (process.platform === 'darwin') {
const arch = process.arch === 'arm64' ? 'arm64' : 'x64'
return path.join(RELEASE_ROOT, `mac-${arch}`, 'Hermes.app', 'Contents', 'MacOS', 'Hermes')
}
return path.join(RELEASE_ROOT, 'linux-unpacked', 'hermes')
}
export const PACKAGED_BINARY_PATH = resolvePackagedBinaryPath()
export function packagedBinaryExists(): boolean {
return fs.existsSync(PACKAGED_BINARY_PATH)
}
export interface PackagedAppFixture {
app: ElectronApplication
page: Page
sandbox: Sandbox
cleanup: () => Promise<void>
}
/**
* Launch the *packaged* Electron binary (from `npm run pack`
* `electron-builder --dir`) with `BOOT_FAKE=1` so it simulates boot
* progress without spawning a real Hermes backend.
*
* Uses the same sandbox isolation (credential stripping, isolated
* HERMES_HOME + userData, unique app name) as the dev-mode fixtures.
*
* Skips if the packaged binary doesn't exist run `npm run pack` first.
*/
export async function setupPackagedApp(): Promise<PackagedAppFixture> {
if (!packagedBinaryExists()) {
throw new Error(
`Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`,
)
}
const sandbox = createSandbox('packaged')
// Build the sandbox env using the shared helpers, then add the
// packaged-binary-specific overrides.
const env = buildAppEnv(sandbox, {
// Fake boot: simulates progress steps without spawning the real backend.
HERMES_DESKTOP_BOOT_FAKE: '1',
HERMES_DESKTOP_BOOT_FAKE_STEP_MS: '120',
})
// Clear dev-server + hermes-root overrides — the packaged binary
// should use its own bundled renderer, not the dev checkout.
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_DEV_SERVER
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_HERMES
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_HERMES_ROOT
const app = await _electron.launch({
executablePath: PACKAGED_BINARY_PATH,
args: ['--disable-gpu', '--no-sandbox'],
env,
})
const page = await app.firstWindow()
return {
app,
page,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
sandbox.cleanup()
},
}
}
// ─── Wait helpers ──────────────────────────────────────────────────────
/**
* Wait for the desktop app to finish booting and show the main chat UI.
*
* The boot overlay disappears when `completeDesktopBoot()` fires in the
* renderer at that point the gateway is open, config is loaded, and
* sessions are loaded. We detect this by waiting for the boot/connecting
* overlay to become invisible and the main app shell to be present.
*
* Two things must both be true before we return:
* 1. The composer (chat input) is visible it's disabled until the
* gateway is open.
* 2. No full-screen overlay (onboarding Preparing, connecting overlay,
* boot-failure) covers the viewport center. The composer can be
* "visible" in Playwright's eyes (non-zero bounding box, not
* display:none) even when a z-1300+ overlay is painted on top of it,
* so checking the composer alone catches the app mid-boot at ~92%
* with the loading bar still showing.
*/
export async function waitForAppReady(fixture: MockBackendFixture | NoProviderFixture | DeadBackendFixture, timeoutMs = 60_000): Promise<void> {
const { page, app } = fixture
// Wait for the composer to exist in the DOM (not necessarily interactive yet).
await page.waitForSelector('textarea, [contenteditable="true"]', {
state: 'attached',
timeout: timeoutMs,
})
// Now poll until no full-screen overlay covers the viewport center.
// elementFromPoint returns the topmost element at a point — if it's part
// of a fixed inset-0 overlay (onboarding/connecting/boot-failure), the
// app isn't ready yet.
await page.waitForFunction(
() => {
const el = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2)
if (!el) {
return false
}
// Walk up to the nearest positioned ancestor — overlays are
// `position: fixed; inset: 0`. If the hit element or an ancestor
// is a full-viewport fixed overlay, we're still covered.
let node: Element | null = el
while (node) {
const cs = window.getComputedStyle(node)
if (cs.position === 'fixed') {
const rect = node.getBoundingClientRect()
if (rect.left <= 0 && rect.top <= 0 && rect.right >= window.innerWidth && rect.bottom >= window.innerHeight) {
return false
}
}
node = node.parentElement
}
return true
},
undefined,
{ timeout: timeoutMs },
)
// On Electron 40.x, ready-to-show may never fire (electron/electron#51972)
// and the window stays hidden even though the DOM is rendered. The main
// process has a TEST_WORKER_INDEX-gated fallback that force-shows the
// window, but the DOM can be ready before that fires. Poll until the
// window is actually visible so interactions (click, screenshot) don't
// hit a hidden surface.
if (app) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const visible = await app.evaluate(({ BrowserWindow }) => {
const w = BrowserWindow.getAllWindows()[0]
return w ? w.isVisible() : false
}).catch(() => false)
if (visible) {break}
await page.waitForTimeout(500)
}
}
}
/**
* Wait for the onboarding overlay to appear (no provider configured).
*/
export async function waitForOnboarding(page: Page, timeoutMs = 60_000): Promise<void> {
// The onboarding overlay contains a heading with "Choose your provider"
// or similar text. We look for any text that indicates the picker.
await page.waitForFunction(
() => {
const root = document.getElementById('root')
if (!root) {
return false
}
const text = root.textContent ?? ''
return (
text.includes('provider') ||
text.includes('Provider') ||
text.includes('Choose') ||
text.includes('API key') ||
text.includes('Sign in')
)
},
undefined,
{ timeout: timeoutMs },
)
}
/**
* Wait for the boot failure overlay to appear.
*/
export async function waitForBootFailure(page: Page, timeoutMs = 60_000): Promise<void> {
await page.waitForFunction(
() => {
// Boot failure is terminal: the backend gave up. The renderer shows
// either BootFailureOverlay (z-1400, with Retry/Repair buttons) or
// falls back to the onboarding picker (z-1300) as a recovery path.
// We wait for the failure dialog itself — the Preparing component may
// still paint its progress bar (recolored red) underneath the overlay,
// which is harmless.
const text = document.body.textContent ?? ''
// BootFailureOverlay buttons.
const hasFailureUI =
text.includes('Retry') ||
text.includes('Repair') ||
text.includes('Use local gateway') ||
text.includes('Connection settings')
// The error toast / notification that fires on failDesktopBoot().
const hasErrorToast = text.includes('Desktop boot failed')
return hasFailureUI || hasErrorToast
},
undefined,
{ timeout: timeoutMs },
)
}

View file

@ -0,0 +1,88 @@
import { expect, test } from '@playwright/test'
import {
PACKAGED_BINARY_PATH,
type PackagedAppFixture,
packagedBinaryExists,
setupPackagedApp,
} from './fixtures'
import { expectVisualSnapshot } from './visual-snapshot'
/**
* E2E smoke tests for the packaged Hermes desktop app.
*
* Launches the real packaged Electron binary (produced by `npm run pack`
* `electron-builder --dir`) with BOOT_FAKE=1 and full sandbox isolation
* (credential stripping, isolated HERMES_HOME + userData, unique app name).
*
* Skips if the packaged binary doesn't exist run `npm run pack` first.
*/
let fixture: PackagedAppFixture | null = null
test.beforeAll(async () => {
test.skip(
!packagedBinaryExists(),
`Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`,
)
fixture = await setupPackagedApp()
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test('window opens with the Hermes title', async () => {
const title = await fixture!.page.title()
expect(title).toContain('Hermes')
})
test('renderer loads and shows DOM content', async () => {
const page = fixture!.page
await page.waitForSelector('#root', { state: 'attached', timeout: 30_000 })
const childCount = await page.locator('#root > *').count()
expect(childCount).toBeGreaterThan(0)
})
test('boot progress overlay fades out or shows error state', async () => {
const page = fixture!.page
await page.waitForFunction(
() => {
const root = document.getElementById('root')
if (!root) {
return false
}
const text = root.textContent ?? ''
// Error path: boot failure overlay renders an error message.
if (text.includes('error') || text.includes('Error') || text.includes('failed')) {
return true
}
// Success path: overlay disappears and the app renders. If there's
// no "boot" / "starting" / "installing" text visible, boot has
// completed (either to the main UI or to onboarding).
const bootIndicators = ['starting', 'resolving', 'spawning', 'waiting', 'installing']
const lower = text.toLowerCase()
return !bootIndicators.some((word) => lower.includes(word))
},
undefined,
{ timeout: 60_000 },
)
})
test('can capture a screenshot for the CI artifact', async () => {
if (!fixture) {
test.skip(true, 'Previous test failed — no app running')
return
}
// Visual snapshot — won't fail on diff, just logs + generates diff image
await expectVisualSnapshot(fixture!.page, { name: 'packaged-app-booted', timeout: 10_000, app: fixture!.app })
})

View file

@ -0,0 +1,87 @@
/**
* E2E tests asserting the mock backend gets the app past the setup/onboarding
* screen.
*
* The mock backend fixture writes a config.yaml with a pre-configured mock
* provider pointing at a mock inference server. When the app boots, the
* runtime readiness check should detect the working provider and dismiss the
* onboarding overlay landing straight on the chat UI without ever showing
* the "Let's get you setup with Hermes Agent" screen.
*
* If these tests fail, the mock backend config isn't getting the app past
* onboarding the chat interaction tests (chat.spec.ts) will also fail
* because the composer is blocked by the setup overlay.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test } from '@playwright/test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { expectVisualSnapshot } from './visual-snapshot'
let fixture: MockBackendFixture | null = null
test.beforeAll(async () => {
fixture = await setupMockBackend()
await waitForAppReady(fixture!, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test.describe('mock backend gets past setup screen', () => {
test('onboarding overlay is not shown', async () => {
const page = fixture!.page
// The onboarding overlay renders "Let's get you setup with Hermes Agent"
// when the runtime check fails to find a working provider. With the mock
// backend configured, the runtime check should pass and the overlay
// returns null — this text should NOT be present in the DOM.
await page.waitForFunction(
() => {
const text = document.body.textContent ?? ''
return !text.includes("Let's get you setup")
},
undefined,
{ timeout: 30_000 },
)
})
test('chat composer is visible', async () => {
const page = fixture!.page
// The composer (contenteditable div) should be visible and not blocked
// by the onboarding overlay. If the first test passed, the overlay is
// gone and the composer is the primary interactive surface.
const composer = page.locator('[contenteditable="true"]').first()
await expect(composer).toBeVisible()
})
test('can type into the composer', async () => {
const page = fixture!.page
// If the setup overlay is truly gone, the composer accepts input.
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type('hello mock backend', { delay: 20 })
// Verify the typed text appears in the DOM.
await page.waitForFunction(
() => (document.body.textContent ?? '').includes('hello mock backend'),
undefined,
{ timeout: 10_000 },
)
})
test('screenshot shows chat UI without setup screen', async () => {
await expectVisualSnapshot(fixture!.page, { name: 'mock-backend-chat-ready', app: fixture!.app })
})
})

View file

@ -0,0 +1,203 @@
/**
* Minimal OpenAI-compatible mock inference server for E2E tests.
*
* Implements just enough of the /v1/* surface for `hermes serve` to resolve a
* provider, list models, and stream a canned chat completion back to the
* desktop app without any real LLM.
*
* Endpoints:
* GET /v1/models { data: [{ id, ... }] }
* POST /v1/chat/completions streaming (SSE) or non-streaming response
*
* The canned response is a short, deterministic assistant message. Tool-call
* requests are not simulated the E2E tests only need the chat surface to
* prove the full boot gateway inference renderer chain works.
*/
import http from 'node:http'
/** A canned assistant reply used for every chat completion request. */
const CANNED_REPLY = 'Hello from the mock inference server! The full boot chain is working.'
/**
* Start the mock server on an ephemeral port.
*
* @returns a handle with `port`, `url`, and `close()`.
*/
export function startMockServer(): Promise<{ port: number; url: string; close: () => Promise<void> }> {
return new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
// CORS headers — the Electron renderer doesn't need them, but they
// don't hurt and make the server usable from a browser context too.
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Headers', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
return
}
// GET /v1/models — return a single fake model.
if (req.method === 'GET' && req.url === '/v1/models') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
object: 'list',
data: [
{
id: 'mock-model',
object: 'model',
created: 0,
owned_by: 'mock',
},
],
}),
)
return
}
// POST /v1/chat/completions — return a canned response.
if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) {
let body = ''
req.on('data', (chunk: Buffer) => {
body += chunk.toString()
})
req.on('end', () => {
let parsed: any = {}
try {
parsed = JSON.parse(body)
} catch {
// malformed JSON — treat as non-streaming with defaults
}
const stream = parsed.stream === true
const model = parsed.model || 'mock-model'
if (stream) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
// Send the content in a few chunks to simulate streaming.
const words = CANNED_REPLY.split(' ')
let i = 0
const sendChunk = () => {
if (i >= words.length) {
// Final chunk with finish_reason
res.write(
`data: ${JSON.stringify({
id: 'mock-completion',
object: 'chat.completion.chunk',
created: 0,
model,
choices: [
{
index: 0,
delta: {},
finish_reason: 'stop',
},
],
})}\n\n`,
)
res.write('data: [DONE]\n\n')
res.end()
return
}
const word = i === 0 ? words[i] : ' ' + words[i]
res.write(
`data: ${JSON.stringify({
id: 'mock-completion',
object: 'chat.completion.chunk',
created: 0,
model,
choices: [
{
index: 0,
delta: { content: word },
finish_reason: null,
},
],
})}\n\n`,
)
i++
// Small delay between chunks to simulate real streaming.
setTimeout(sendChunk, 20)
}
sendChunk()
} else {
// Non-streaming response
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
id: 'mock-completion',
object: 'chat.completion',
created: 0,
model,
choices: [
{
index: 0,
message: { role: 'assistant', content: CANNED_REPLY },
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 20,
total_tokens: 30,
},
}),
)
}
})
req.on('error', () => {
res.writeHead(400)
res.end('Bad request')
})
return
}
// Fallback — 404 for anything else
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Not found' }))
})
server.on('error', reject)
server.listen(0, '127.0.0.1', () => {
const addr = server.address()
if (addr === null || typeof addr === 'string') {
reject(new Error('Failed to get server address'))
return
}
const port = addr.port
const url = `http://127.0.0.1:${port}`
resolve({
port,
url,
close: () =>
new Promise((resolveClose, rejectClose) => {
server.close((err) => {
if (err) {
rejectClose(err)
} else {
resolveClose()
}
})
}),
})
})
})
}

View file

@ -0,0 +1,76 @@
/**
* E2E onboarding tests verify the provider picker appears when no
* inference provider is configured.
*
* Launches the app with an empty config.yaml (no providers). The renderer
* should detect the unconfigured state and show the DesktopOnboardingOverlay
* with provider options / API key form.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test } from '@playwright/test'
import {
type NoProviderFixture,
setupNoProvider,
waitForOnboarding,
} from './fixtures'
import { expectVisualSnapshot } from './visual-snapshot'
let fixture: NoProviderFixture | null = null
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test.describe('onboarding with no provider configured', () => {
test('onboarding overlay appears on first boot', async () => {
fixture = await setupNoProvider()
// The app should boot (hermes serve starts fine even without a provider),
// but the renderer should show the onboarding overlay because no
// provider is configured.
await waitForOnboarding(fixture.page, 90_000)
})
test('onboarding shows provider options or API key form', async () => {
if (!fixture) {
test.skip(true, 'Previous test failed — no app running')
return
}
const page = fixture.page
// The onboarding overlay should contain provider-related text.
// It might show OAuth providers, an API key form, or a "choose later"
// link. Verify at least one of these is visible.
const rootText = await page.evaluate(() => {
const root = document.getElementById('root')
return root?.textContent ?? ''
})
const hasProviderText =
rootText.includes('provider') ||
rootText.includes('Provider') ||
rootText.includes('API key') ||
rootText.includes('Sign in') ||
rootText.includes('OpenRouter') ||
rootText.includes('OpenAI')
expect(hasProviderText).toBe(true)
})
test('screenshot of onboarding overlay', async () => {
if (!fixture) {
test.skip(true, 'Previous test failed — no app running')
return
}
await expectVisualSnapshot(fixture.page, { name: 'onboarding-overlay', app: fixture.app })
})
})

View file

@ -0,0 +1,150 @@
/**
* Visual snapshot helper wraps `toHaveScreenshot` so visual diffs are
* reported without failing the test suite.
*
* On CI, the JSON reporter + post-test script parse the results and post a
* summary to the GitHub Actions step output, and diff images are uploaded
* as artifacts. This keeps visual regressions visible without gating PRs
* on pixel-perfect matches.
*
* The actual screenshot is always written to the test output dir so CI
* artifacts include every screenshot not just the ones that diffed.
* When it differs, this helper also writes expected and diff images:
* <name>-actual.png, <name>-expected.png, <name>-diff.png
*/
import fs from 'node:fs'
import path from 'node:path'
import { type ElectronApplication, type Page, test } from '@playwright/test'
/** Fixed window dimensions for visual regression screenshots. */
export const VISUAL_WINDOW_WIDTH = 1220
export const VISUAL_WINDOW_HEIGHT = 800
export interface VisualSnapshotOptions {
/** Snapshot name — defaults to the test title. */
name?: string
/** Full page screenshot vs. viewport-only (default). */
fullPage?: boolean
/** Timeout in ms. */
timeout?: number
/** The Electron app handle — used to size and decode screenshots. */
app: ElectronApplication
}
/**
* Force the Electron window to a fixed size so screenshots are comparable
* across runs and CI environments. Window managers (Hyprland, etc.) may
* auto-tile or resize windows after launch; calling this right before the
* screenshot ensures the viewport is always the expected size.
*/
async function forceFixedSize(app: ElectronApplication): Promise<void> {
await app.evaluate(({ BrowserWindow }, { width, height }) => {
const win = BrowserWindow.getAllWindows()[0]
if (win) {
win.unmaximize()
// setMinimumSize must be ≤ the target, otherwise setSize is clamped.
win.setMinimumSize(width, height)
win.setSize(width, height, false)
win.setBounds({ x: 0, y: 0, width, height })
}
}, { width: VISUAL_WINDOW_WIDTH, height: VISUAL_WINDOW_HEIGHT })
}
/**
* Take a screenshot and compare it against the baseline.
*
* If the baseline doesn't exist yet (first run), Playwright creates it.
* If it differs, the test logs a soft warning but does NOT fail the diff
* images are still generated for CI to surface.
*/
export async function expectVisualSnapshot(
page: Page,
options: VisualSnapshotOptions,
): Promise<void> {
const { name, fullPage = false, timeout = 30_000, app } = options
// Force the window to a fixed size right before the screenshot so it's
// always comparable, regardless of WM resizing during the test.
await forceFixedSize(app)
// Give the renderer a moment to relayout after the resize.
await page.waitForTimeout(500)
// Playwright appends a platform suffix (e.g. "-linux") and requires
// a .png extension on the name argument. Auto-append it if missing.
const snapshotName = name ? (name.endsWith('.png') ? name : `${name}.png`) : undefined
const info = test.info()
const actual = await page.screenshot({ animations: 'disabled', caret: 'hide', fullPage, timeout })
const baselinePath = info.snapshotPath(snapshotName ?? `${info.title}.png`)
const outputName = (snapshotName ?? 'snapshot.png').replace(/\.png$/, '')
if (info.config.updateSnapshots === 'all' || info.config.updateSnapshots === 'changed') {
fs.mkdirSync(path.dirname(baselinePath), { recursive: true })
fs.writeFileSync(baselinePath, actual)
// Also write to the output dir so CI artifacts include the screenshot.
fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual)
console.log(`[visual-baseline] updated ${baselinePath}`)
return
}
if (!fs.existsSync(baselinePath)) {
fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual)
console.log(`[visual-diff] ${name ?? '(unnamed)'} — no baseline available`)
return
}
const expected = fs.readFileSync(baselinePath)
const comparison = await app.evaluate(
({ nativeImage }, images) => {
const actualImage = nativeImage.createFromBuffer(Buffer.from(images.actual, 'base64'))
const expectedImage = nativeImage.createFromBuffer(Buffer.from(images.expected, 'base64'))
const actualSize = actualImage.getSize()
const expectedSize = expectedImage.getSize()
if (actualSize.width !== expectedSize.width || actualSize.height !== expectedSize.height) {
return { mismatchRatio: 1, diff: images.actual }
}
const actualPixels = actualImage.toBitmap()
const expectedPixels = expectedImage.toBitmap()
const diffPixels = Buffer.alloc(actualPixels.length)
let mismatched = 0
for (let i = 0; i < actualPixels.length; i += 4) {
const different =
Math.abs(actualPixels[i] - expectedPixels[i]) > 51 ||
Math.abs(actualPixels[i + 1] - expectedPixels[i + 1]) > 51 ||
Math.abs(actualPixels[i + 2] - expectedPixels[i + 2]) > 51 ||
Math.abs(actualPixels[i + 3] - expectedPixels[i + 3]) > 51
if (different) {
mismatched++
diffPixels[i + 2] = 255
}
diffPixels[i + 3] = 255
}
return {
mismatchRatio: mismatched / (actualPixels.length / 4),
diff: nativeImage.createFromBitmap(diffPixels, actualSize).toPNG().toString('base64'),
}
},
{ actual: actual.toString('base64'), expected: expected.toString('base64') },
)
// Always write the actual screenshot to the output dir so CI artifacts
// include every screenshot — not just the ones that diffed.
fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual)
if (comparison.mismatchRatio <= 0.01) {
return
}
fs.writeFileSync(info.outputPath(`${outputName}-expected.png`), expected)
fs.writeFileSync(info.outputPath(`${outputName}-diff.png`), Buffer.from(comparison.diff, 'base64'))
console.log(
`[visual-diff] ${name ?? '(unnamed)'}${(comparison.mismatchRatio * 100).toFixed(2)}% of pixels differ`,
)
}

View file

@ -0,0 +1,100 @@
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()
})
})

View file

@ -0,0 +1,50 @@
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 }

View file

@ -23,14 +23,22 @@ 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'
@ -124,6 +132,61 @@ 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', () => {
@ -431,12 +494,14 @@ 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, mint FAILS) throws — must NOT skip WS validation', async () => {
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 })
await assert.rejects(
() =>
resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
mintTicket: async () => {
throw new Error('401 ticket mint failed')
throw cause
}
}),
(err: any) => {
@ -452,6 +517,66 @@ test('resolveTestWsUrl (oauth, mint FAILS) throws — must NOT skip WS validatio
)
})
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),

View file

@ -88,6 +88,43 @@ 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.
@ -102,12 +139,10 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
* - oauth, mint ok ws(s)://…/api/ws?ticket=…
* - oauth, mint fails THROWS (NOT a skip)
*
* 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".
* 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.
*
* @param {string} baseUrl
* @param {'token'|'oauth'} authMode
@ -128,14 +163,12 @@ async function resolveTestWsUrl(baseUrl, authMode, token, deps: any = {}) {
try {
ticket = await mintTicket(baseUrl)
} catch (error) {
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.'
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.'
)
;(err as any).needsOauthLogin = true
err.cause = error
throw err
}
return buildGatewayWsUrlWithTicket(baseUrl, ticket)
@ -170,6 +203,127 @@ 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).
@ -337,14 +491,23 @@ 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
}

View file

@ -0,0 +1,105 @@
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', ''))
})

View file

@ -0,0 +1,137 @@
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 }

View file

@ -0,0 +1,37 @@
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)
}
})

View file

@ -0,0 +1,32 @@
// 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
}
}

View file

@ -0,0 +1,79 @@
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)
})
})

View file

@ -1,32 +1,82 @@
// 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.
// 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.
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'])
async function mapLimit(items, limit, fn) {
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> {
let cursor = 0
async function worker() {
async function worker(): Promise<void> {
while (cursor < items.length) {
const index = cursor
cursor += 1
@ -34,63 +84,85 @@ async function mapLimit(items, limit, fn) {
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) } as any, worker))
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()))
}
/**
* Scan `roots` (default: the home dir) for git repositories. Returns deduped
* `{ root, label }` entries. `options.maxDepth` caps recursion (default 3).
* 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.
*/
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()
export async function scanGitRepos(roots: string[], options: RepoScanOptions = {}) {
if (options.enabled === false) {
return []
}
async function walk(dir, depth) {
if (depth > maxDepth) {
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)) {
return
}
let entries
let entries: fs.Dirent[]
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
return // unreadable / permission denied
return
}
// 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)
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
})
}
return
}
const subdirs = []
const subdirs = entries
.filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && !JUNK_DIRS.has(entry.name))
.map(entry => path.join(dir, entry.name))
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(subdirs, MAX_CONCURRENCY, subdir => walk(subdir, depth + 1))
}
await mapLimit(searchRoots.map(root => String(root || '').trim()).filter(Boolean), MAX_CONCURRENCY, root =>
walk(root, 0)
)
await mapLimit(searchRoots, MAX_CONCURRENCY, root => walk(root, 0))
return [...found.entries()].map(([root, label]) => ({ label, root }))
return [...found.values()]
}
export { scanGitRepos }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,58 @@
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')
})
})

View file

@ -0,0 +1,50 @@
/**
* 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()
}
}
}

View file

@ -6,7 +6,8 @@ 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),
openNewSessionWindow: () => ipcRenderer.invoke('hermes:window:openNewSession'),
openWindow: () => ipcRenderer.invoke('hermes:window:openInstance'),
claimAmbientCue: key => ipcRenderer.invoke('hermes:ambient:claim', key),
petOverlay: {
// Main renderer → main process: window lifecycle + drag. `request` is
// `{ bounds, screen }`; resolves with the screen bounds it actually used.
@ -40,6 +41,8 @@ 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),
@ -79,6 +82,7 @@ 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

View file

@ -0,0 +1,906 @@
/**
* 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
}

View file

@ -0,0 +1,253 @@
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()
})
})

View file

@ -0,0 +1,182 @@
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 }
}
}

View file

@ -2,7 +2,12 @@ import assert from 'node:assert/strict'
import { test } from 'vitest'
import { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry } from './session-windows'
import {
buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry,
instanceWindowBounds
} 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
@ -83,10 +88,16 @@ test('buildSessionWindowUrl adds the watch flag for spectator windows, before th
assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc')
})
test('buildSessionWindowUrl routes new-session windows to the draft (#/)', () => {
const url = buildSessionWindowUrl(null, { devServer: 'http://localhost:5173', newSession: true })
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 })
assert.equal(url, 'http://localhost:5173/?win=secondary&new=1#/')
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)
})
test('registry opens one window per session and focuses on re-open', () => {

View file

@ -38,13 +38,12 @@ 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. `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)}`
// 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)}`
if (devServer) {
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
@ -55,6 +54,28 @@ 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
@ -119,6 +140,7 @@ export {
buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry,
instanceWindowBounds,
SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH
}

View file

@ -0,0 +1,192 @@
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'])
})

View file

@ -0,0 +1,130 @@
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 }

View file

@ -0,0 +1,104 @@
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)
})

View file

@ -0,0 +1,175 @@
/**
* 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 }

View file

@ -0,0 +1,866 @@
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)
})

View file

@ -0,0 +1,921 @@
/**
* 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
}

View file

@ -0,0 +1,122 @@
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/)
})

View file

@ -0,0 +1,455 @@
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
}

View file

@ -0,0 +1,106 @@
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
)

View file

@ -13,6 +13,7 @@
"scripts": {
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
"dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev",
"dev:mock": "node scripts/dev-mock.mjs",
"dev:renderer": "node scripts/assert-root-install.mjs && vite --host 127.0.0.1 --port 5174",
"dev:electron": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"profile:main": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
@ -40,7 +41,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit",
"typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit && tsc -p tsconfig.e2e.json --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.ts' 'vite.config.ts'",
@ -49,7 +50,10 @@
"test:desktop:platforms": "vitest run --project electron",
"test": "vitest run",
"preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174",
"check": "npm run typecheck && npm run test && npm run test:desktop:all && npm run build"
"check": "npm run typecheck && npm run test && npm run test:desktop:all",
"test:e2e": "playwright test e2e/",
"test:e2e:visual": "WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list",
"test:e2e:update-snapshots": "WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list --update-snapshots"
},
"dependencies": {
"@assistant-ui/react": "^0.14.23",
@ -124,6 +128,7 @@
"devDependencies": {
"@electron/rebuild": "^4.0.6",
"@eslint/js": "^9.39.4",
"@playwright/test": "=1.58.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/d3-force": "^3.0.10",

View file

@ -0,0 +1,63 @@
import './e2e/fix-electron-tracing'
import { defineConfig, type ReporterDescription } from '@playwright/test'
/**
* Visual regression testing config.
*
* Screenshots are compared against baselines. On `main`, baselines are
* generated with `--update-snapshots` and cached. On PRs, the cached
* baselines are restored and screenshots are compared but tests DON'T
* fail on visual diffs (see `expectVisualSnapshot` in visual-snapshot.ts).
* Instead, diffs are surfaced in the CI step summary and uploaded as
* artifacts for human review.
*
* To update baselines after an intentional UI change:
* npx playwright test --update-snapshots
*/
const reporters: ReporterDescription[] = [
['list'],
['html', { open: 'never', outputFolder: 'playwright-report' }],
]
if (process.env.CI) {
reporters.push(['json', { outputFile: 'playwright-report/results.json' }])
}
export default defineConfig({
/* Test files live under e2e/ so they never collide with the vitest suite
* under src/ or the node:test files under electron/. */
testDir: './e2e',
/* The desktop app can take a while to bootstrap on cold CI runners 90 s
* per test gives us headroom without masking real hangs. */
timeout: 90_000,
retries: process.env.CI ? 1 : 0,
/* Each test gets its own worker so the Electron process is fully isolated. */
fullyParallel: false,
reporter: reporters,
use: {
screenshot: 'on',
trace: { mode: 'on', screenshots: true, snapshots: true, sources: true },
// Emulate prefers-reduced-motion: reduce so all CSS transitions and
// animations resolve instantly. This prevents boot/connecting overlays
// from being mid-fade when a screenshot fires, and skips JS-driven exit
// choreography in components that check matchMedia (onboarding, connecting
// overlay, DecodeText). Without this, screenshots capture the loading bar
// or overlay at a transient opacity because the text-content check fires
// before the visual transition finishes.
contextOptions: {
reducedMotion: 'reduce',
},
},
expect: {
toHaveScreenshot: {
// 1% of pixels may differ — absorbs sub-pixel font rendering variance
// between local and CI environments.
maxDiffPixelRatio: 0.01,
animations: 'disabled',
caret: 'hide',
// Per-channel threshold for "close enough" — anti-aliasing differences.
threshold: 0.2,
},
},
})

View file

@ -0,0 +1,237 @@
#!/usr/bin/env node
/**
* Launch the desktop app with a mock inference provider no real API
* keys needed. Starts a local OpenAI-compatible server that returns a
* canned reply, writes an isolated config.yaml + .env, and launches the
* built Electron app against them.
*
* This reuses the same mock-server and config format as the E2E fixtures
* (apps/desktop/e2e/mock-server.ts + fixtures.ts), so local dev and CI
* test the same chain.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*
* Usage:
* node scripts/dev-mock.mjs
* npm run dev:mock
*
* The mock server listens on an ephemeral port and replies to every
* chat completion with:
* "Hello from the mock inference server! The full boot chain is working."
*/
import http from 'node:http'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { spawn, spawnSync } from 'node:child_process'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
// ── Canned reply ───────────────────────────────────────────────────────
const CANNED_REPLY =
'Hello from the mock inference server! The full boot chain is working.'
// ── Mock server (mirrors e2e/mock-server.ts) ───────────────────────────
function startMockServer() {
return new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Headers', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
return
}
if (req.method === 'GET' && req.url === '/v1/models') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
object: 'list',
data: [{ id: 'mock-model', object: 'model', created: 0, owned_by: 'mock' }],
}),
)
return
}
if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) {
let body = ''
req.on('data', (chunk) => { body += chunk.toString() })
req.on('end', () => {
let parsed = {}
try { parsed = JSON.parse(body) } catch { /* non-streaming */ }
const stream = parsed.stream === true
const model = parsed.model || 'mock-model'
if (stream) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
const words = CANNED_REPLY.split(' ')
let i = 0
const sendChunk = () => {
if (i >= words.length) {
res.write(
`data: ${JSON.stringify({
id: 'mock-completion', object: 'chat.completion.chunk',
created: 0, model,
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
})}\n\n`,
)
res.write('data: [DONE]\n\n')
res.end()
return
}
const word = i === 0 ? words[i] : ' ' + words[i]
res.write(
`data: ${JSON.stringify({
id: 'mock-completion', object: 'chat.completion.chunk',
created: 0, model,
choices: [{ index: 0, delta: { content: word }, finish_reason: null }],
})}\n\n`,
)
i++
setTimeout(sendChunk, 20)
}
sendChunk()
} else {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
id: 'mock-completion', object: 'chat.completion',
created: 0, model,
choices: [{
index: 0,
message: { role: 'assistant', content: CANNED_REPLY },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
}),
)
}
})
req.on('error', () => { res.writeHead(400); res.end('Bad request') })
return
}
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Not found' }))
})
server.on('error', reject)
server.listen(0, '127.0.0.1', () => {
const addr = server.address()
if (addr === null || typeof addr === 'string') {
reject(new Error('Failed to get server address'))
return
}
resolve({ port: addr.port, url: `http://127.0.0.1:${addr.port}`, close: () => server.close() })
})
})
}
// ── Config + env writing (mirrors e2e/fixtures.ts) ─────────────────────
function createSandbox() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-dev-mock-${Date.now()}`))
const hermesHome = path.join(root, 'hermes-home')
const userDataDir = path.join(root, 'electron-user-data')
fs.mkdirSync(hermesHome, { recursive: true })
fs.mkdirSync(userDataDir, { recursive: true })
return { root, hermesHome, userDataDir, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) }
}
function writeMockConfig(hermesHome, mockUrl) {
fs.writeFileSync(
path.join(hermesHome, 'config.yaml'),
`# Auto-generated by dev-mock.mjs
model:
default: mock-model
provider: mock
providers:
mock:
api: ${mockUrl}/v1
name: Mock
api_mode: chat_completions
key_env: MOCK_API_KEY
models:
mock-model: {}
context_length: 4096
`,
'utf8',
)
fs.writeFileSync(path.join(hermesHome, '.env'), 'MOCK_API_KEY=e2e-mock-key\n', 'utf8')
}
// ── Electron launch ────────────────────────────────────────────────────
function findElectron() {
const local = path.join(REPO_ROOT, 'node_modules', 'electron', 'dist', 'electron')
if (fs.existsSync(local)) return local
const r = spawnSync('which', ['electron'], { encoding: 'utf8' })
if (r.status === 0 && r.stdout.trim()) return r.stdout.trim()
throw new Error('Electron binary not found. Run "npm install" from the repo root.')
}
function assertDistBuilt() {
const electronMain = path.join(DESKTOP_ROOT, 'dist', 'electron-main.mjs')
const indexHtml = path.join(DESKTOP_ROOT, 'dist', 'index.html')
if (!fs.existsSync(electronMain) || !fs.existsSync(indexHtml)) {
throw new Error(
`Desktop dist not built. Run 'cd apps/desktop && npm run build' first.\n` +
`Missing: ${electronMain}`,
)
}
}
// ── Main ───────────────────────────────────────────────────────────────
async function main() {
assertDistBuilt()
console.log('Starting mock inference server...')
const mock = await startMockServer()
console.log(` Mock server: ${mock.url}`)
const sandbox = createSandbox()
writeMockConfig(sandbox.hermesHome, mock.url)
console.log(` HERMES_HOME: ${sandbox.hermesHome}`)
const electronBin = findElectron()
const env = {
...process.env,
HERMES_HOME: sandbox.hermesHome,
HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir,
HERMES_DESKTOP_IGNORE_EXISTING: '1',
HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT,
HERMES_DESKTOP_APP_NAME: `HermesDevMock-${Date.now()}`,
}
console.log('Launching Electron...')
const child = spawn(electronBin, [DESKTOP_ROOT, '--disable-gpu', '--no-sandbox'], {
env,
cwd: DESKTOP_ROOT,
stdio: 'inherit',
})
child.on('exit', (code) => {
mock.close()
sandbox.cleanup()
process.exit(code ?? 0)
})
}
main().catch((err) => {
console.error(err)
process.exit(1)
})

View file

@ -0,0 +1,65 @@
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)
})
})

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 { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '@/store/preview'
import { $filePreviewTabs, $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. an open preview its active preview tab (unchanged from pre-tiling),
* 2. right-rail tabs (live preview and/or file peeks),
* 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,10 +19,13 @@ export function closeActiveTab(): boolean {
return true
}
if ($filePreviewTarget.get() || $previewTarget.get()) {
closeActiveRightRailTab()
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()
}
return closeWorkspaceTab()

View file

@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { useEffect, useRef } from 'react'
import { playSpeechText } from '@/lib/voice-playback'
import { ownsAmbientCue } from '@/store/ambient'
import { notifyError } from '@/store/notifications'
import { $messages } from '@/store/session'
import { $voicePlayback } from '@/store/voice-playback'
@ -65,9 +66,16 @@ export function useAutoSpeakReplies({
}
markSpoken()
void playSpeechText(reply.text, { messageId: reply.id, source: 'read-aloud' }).catch(error =>
notifyError(error, failureLabel)
)
// Only one window voices a given reply when the same chat is open in
// several (reply.id is the shared backend message id). markSpoken already
// ran in every window, so peers just stay quiet.
void ownsAmbientCue(`speak:${reply.id}`).then(owns => {
if (owns) {
void playSpeechText(reply.text, { messageId: reply.id, source: 'read-aloud' }).catch(error =>
notifyError(error, failureLabel)
)
}
})
}
// Re-check on a reply completing ($messages) and on the prior clip ending

View file

@ -0,0 +1,130 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
$parkedQueueSessions,
$queuedPromptsBySession,
enqueueQueuedPrompt,
getQueuedPrompts,
isQueueParked,
parkQueuedPrompts
} from '@/store/composer-queue'
import type { QueueEditState } from '../composer-utils'
import type { ChatBarProps } from '../types'
import { useComposerQueue } from './use-composer-queue'
// The park ↔ drain contract at the hook level. The store tests pin the pure
// pieces (shouldAutoDrain, park bookkeeping); these pin the wiring — the
// auto-drain effect honoring the park, and send-now-while-busy lifting it so
// the settle drain still flows (the regression that sank the old blanket
// interrupt latch).
const SESSION_KEY = 'stored-session-queue-hook'
function renderQueueHook(overrides: { busy?: boolean; onCancel?: () => void } = {}) {
const onSubmit = vi.fn<ChatBarProps['onSubmit']>(async () => true)
const onCancel = overrides.onCancel ?? vi.fn()
const queueEditRef: { current: QueueEditState | null } = { current: null }
const hook = renderHook(
({ busy }: { busy: boolean }) =>
useComposerQueue({
activeQueueSessionKey: SESSION_KEY,
attachments: [],
busy,
clearDraft: () => undefined,
draftRef: { current: '' },
focusInput: () => undefined,
loadIntoComposer: () => undefined,
onCancel,
onSubmit,
queueEditRef,
queueSessionKey: SESSION_KEY,
sessionId: 'rt-session-queue-hook'
}),
{ initialProps: { busy: overrides.busy ?? false } }
)
return { hook, onCancel, onSubmit }
}
describe('useComposerQueue park integration', () => {
beforeEach(() => {
window.localStorage.clear()
$queuedPromptsBySession.set({})
$parkedQueueSessions.set({})
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
$queuedPromptsBySession.set({})
$parkedQueueSessions.set({})
})
it('auto-drains an unparked queue once idle', async () => {
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'flows' })
const { onSubmit } = renderQueueHook()
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
expect(getQueuedPrompts(SESSION_KEY)).toHaveLength(0)
})
it('holds a parked queue at the idle settle (the Stop edge)', async () => {
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'halted' })
parkQueuedPrompts(SESSION_KEY)
const { hook, onSubmit } = renderQueueHook({ busy: true })
// The Stop settle: busy flips false with the park in place.
hook.rerender({ busy: false })
await act(async () => {
await Promise.resolve()
})
expect(onSubmit).not.toHaveBeenCalled()
expect(getQueuedPrompts(SESSION_KEY)).toHaveLength(1)
})
it('drainNextQueued sends a parked entry and lifts the park (manual resume)', async () => {
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'resumed' })
parkQueuedPrompts(SESSION_KEY)
const { hook, onSubmit } = renderQueueHook()
await act(async () => {
await hook.result.current.drainNextQueued()
})
expect(onSubmit).toHaveBeenCalledTimes(1)
expect(isQueueParked(SESSION_KEY)).toBe(false)
})
it('sendQueuedNow while busy unparks so the settle drain flows (no stale latch)', async () => {
const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'first' })
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'send me now' })
parkQueuedPrompts(SESSION_KEY)
const { hook, onCancel, onSubmit } = renderQueueHook({ busy: true })
const target = getQueuedPrompts(SESSION_KEY).find(e => e.id !== first!.id)!
act(() => {
hook.result.current.sendQueuedNow(target.id)
})
// The interrupt fired and the park lifted — this interrupt exists to reach
// the queue, not to halt it.
expect(onCancel).toHaveBeenCalledTimes(1)
expect(isQueueParked(SESSION_KEY)).toBe(false)
// Turn settles → the promoted entry drains.
hook.rerender({ busy: false })
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
expect(onSubmit.mock.calls[0]?.[0]).toBe('send me now')
})
})

View file

@ -1,3 +1,4 @@
import { useStore } from '@nanostores/react'
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
@ -6,6 +7,7 @@ import { useSessionSlice } from '@/lib/use-session-slice'
import { type ComposerAttachment } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
import {
$parkedQueueSessions,
$queuedPromptsBySession,
enqueueQueuedPrompt,
getQueuedPrompts,
@ -15,6 +17,7 @@ import {
type QueuedPromptEntry,
removeQueuedPrompt,
shouldAutoDrain,
unparkQueuedPrompts,
updateQueuedPrompt
} from '@/store/composer-queue'
import { notify } from '@/store/notifications'
@ -69,6 +72,12 @@ export function useComposerQueue({
// write; the keyed array does not).
const queuedPrompts = useSessionSlice($queuedPromptsBySession, activeQueueSessionKey)
// Parked = the user explicitly halted this session (Stop/Esc) while prompts
// were queued. The map is tiny (only halted sessions) so a plain subscribe
// is fine; the auto-drain effect below reads it as a gate.
const parkedSessions = useStore($parkedQueueSessions)
const queueParked = Boolean(activeQueueSessionKey && parkedSessions[activeQueueSessionKey])
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
queueEditRef.current = queueEdit
@ -217,6 +226,11 @@ export function useComposerQueue({
drainFailuresRef.current.delete(entry.id)
removeQueuedPrompt(drainQueueSessionKey, entry.id)
resetBrowseState(drainRuntimeSessionId)
// A successful drain means the queue is flowing again — lift any park
// so the remaining entries follow. Manual drains (Enter on an empty
// composer, the per-row send arrow) are exactly the resume gestures a
// parked queue waits for; the auto path only reaches here unparked.
unparkQueuedPrompts(drainQueueSessionKey)
return true
} finally {
@ -247,7 +261,10 @@ export function useComposerQueue({
// Promote to the head, then interrupt. The gateway always emits a
// settle (message.complete + session.info running:false) when the
// turn unwinds, and the busy→false auto-drain below sends this entry.
// Unpark first: this interrupt exists to REACH the queue, so the
// settle drain must flow — unlike a Stop/Esc halt, which parks.
promoteQueuedPrompt(activeQueueSessionKey, id)
unparkQueuedPrompts(activeQueueSessionKey)
triggerHaptic('selection')
void Promise.resolve(onCancel())
@ -268,7 +285,7 @@ export function useComposerQueue({
// a stale-session 404) can't strand the entry permanently nor spin-loop. The
// drain lock serializes sends; a remount/reconnect resets the failure counts.
const autoDrainNext = useCallback(() => {
if (busy || drainingQueueRef.current || !activeQueueSessionKey) {
if (busy || queueParked || drainingQueueRef.current || !activeQueueSessionKey) {
return
}
@ -299,7 +316,7 @@ export function useComposerQueue({
}
})
.catch(onFail)
}, [activeQueueSessionKey, busy, pickDrainHead, queuedPrompts, runDrain, t])
}, [activeQueueSessionKey, busy, pickDrainHead, queueParked, queuedPrompts, runDrain, t])
// Re-key on a runtime session-id change. A stable stored id (queueSessionKey)
// never churns, so a change there is a real session switch and must NOT
@ -318,12 +335,13 @@ export function useComposerQueue({
// Queued turns flow whenever the session is idle — on the busy→false settle
// edge, on mount/reconnect, and after a re-key — so a swallowed edge can't
// strand them. To cancel queued turns, the user deletes them from the panel.
// strand them. A park (explicit Stop/Esc) is the one gate: those entries wait
// for the user. To cancel queued turns, the user deletes them from the panel.
useEffect(() => {
if (shouldAutoDrain({ isBusy: busy, queueLength: queuedPrompts.length })) {
if (shouldAutoDrain({ isBusy: busy, parked: queueParked, queueLength: queuedPrompts.length })) {
autoDrainNext()
}
}, [autoDrainNext, busy, queuedPrompts.length])
}, [autoDrainNext, busy, queueParked, queuedPrompts.length])
// Queue-edit cleanup: on session swap the scope effect already stashed the
// edit snapshot; only restore into the composer when still on the same scope.
@ -353,6 +371,7 @@ export function useComposerQueue({
exitQueuedEdit,
queueCurrentDraft,
queueEdit,
queueParked,
queuedPrompts,
sendQueuedNow,
stepQueuedEdit

View file

@ -13,7 +13,7 @@ import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } from '@/store/composer-input-history'
import { POPOUT_WIDTH_REM } from '@/store/composer-popout'
import { removeQueuedPrompt } from '@/store/composer-queue'
import { parkQueuedPrompts, removeQueuedPrompt, unparkQueuedPrompts } from '@/store/composer-queue'
import { toggleReview } from '@/store/review'
import { $gatewayState } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
@ -189,6 +189,7 @@ export function ChatBar({
exitQueuedEdit,
queueCurrentDraft,
queueEdit,
queueParked,
queuedPrompts,
sendQueuedNow,
stepQueuedEdit
@ -209,6 +210,20 @@ export function ChatBar({
const statusStackVisible = queuedPrompts.length > 0 || statusPresent
// Halt vs. reach-the-queue: every interrupt lands on onCancel, but only the
// gestures that MEAN "stop working" (Stop button, Esc) go through this
// wrapper, which parks the queue first — an explicit halt must not roll
// straight into the next queued prompt (that read as Stop not working; the
// queued text also seemed to vanish, since the collapsed panel row was its
// only trace). Interrupts that exist to advance the queue (send-now-while-
// busy) call the raw onCancel and keep draining on settle. Parked entries
// stay in the panel until resumed, sent, edited, or deleted.
const haltRun = useCallback(() => {
parkQueuedPrompts(activeQueueSessionKeyRef.current)
return onCancel()
}, [activeQueueSessionKeyRef, onCancel])
const { compactPill, stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut })
const hasComposerPayload = hasText || attachments.length > 0
const canSubmit = busy || hasComposerPayload
@ -235,7 +250,9 @@ export function ChatBar({
focusInput,
inputDisabled,
loadIntoComposer,
onCancel,
// The submit engine's only cancel call is the Stop-button branch (busy +
// empty composer) — an explicit halt, so it parks the queue.
onCancel: haltRun,
onSteer,
onSubmit,
queueCurrentDraft,
@ -621,11 +638,11 @@ export function ChatBar({
// Otherwise Esc interrupts the running turn (Stop-button parity) — unless
// the turn is parked waiting on the user, where Esc must not discard the
// pending prompt.
// pending prompt. An explicit halt, so it parks the queue too.
if (busy && !awaitingInput) {
event.preventDefault()
triggerHaptic('cancel')
void Promise.resolve(onCancel())
void Promise.resolve(haltRun())
}
}
}
@ -662,7 +679,8 @@ export function ChatBar({
useComposerBranch({ clearDraft, cwd, draftRef })
// Global Esc-to-cancel when the chat (not the composer input) has focus.
useComposerEscCancel({ awaitingInput, busy, onCancel, target: scope.target })
// Same explicit-halt semantics as the Stop button: park the queue.
useComposerEscCancel({ awaitingInput, busy, onCancel: haltRun, target: scope.target })
const {
conversation,
@ -734,7 +752,7 @@ export function ChatBar({
autoCapitalize="off"
autoCorrect="off"
className={cn(
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) cursor-text overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
'min-h-[1.625rem] min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) cursor-text overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
'**:data-ref-text:cursor-default',
stacked && 'pl-3',
@ -894,7 +912,17 @@ export function ChatBar({
}
}}
onEdit={beginQueuedEdit}
onResume={() => {
unparkQueuedPrompts(activeQueueSessionKey)
// Idle → kick the head immediately; busy → the settle drain
// takes over now that the park is lifted.
if (!busy) {
void drainNextQueued()
}
}}
onSendNow={id => void sendQueuedNow(id)}
parked={queueParked}
/>
) : null
}

View file

@ -14,13 +14,17 @@ interface QueuePanelProps {
entries: QueuedPromptEntry[]
onDelete: (id: string) => void
onEdit: (entry: QueuedPromptEntry) => void
/** Lift a park (explicit Stop/Esc halt) and let the queue flow again. */
onResume: () => void
onSendNow: (id: string) => void
/** True after an explicit halt: entries wait until resumed / sent / edited. */
parked: boolean
}
const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) =>
entry.text.trim() || (entry.attachments.length > 0 ? c.attachmentOnly : c.emptyTurn)
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) {
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onResume, onSendNow, parked }: QueuePanelProps) {
const { t } = useI18n()
const c = t.composer
@ -29,9 +33,36 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
}
return (
// Keyed on the park flag: StatusSection owns its collapse state from
// defaultCollapsed, so remount on park/unpark. A Stop must EXPAND the
// panel — the halted prompts' only presence is here, and leaving them
// behind a collapsed "N queued" pill is how they read as vanished.
<StatusSection
icon={<Codicon className="text-muted-foreground/70" name="layers" size="0.8rem" />}
label={c.queued(entries.length)}
accessory={
parked ? (
<Tip label={c.queueResumeTip}>
<Button
className="text-muted-foreground/75 hover:text-foreground/90"
onClick={onResume}
size="micro"
type="button"
variant="text"
>
{c.queueResume}
</Button>
</Tip>
) : undefined
}
defaultCollapsed={!parked}
icon={
<Codicon
className="text-muted-foreground/70"
name={parked ? 'debug-pause' : 'layers'}
size="0.8rem"
/>
}
key={parked ? 'parked' : 'flowing'}
label={parked ? c.queuedPaused(entries.length) : c.queued(entries.length)}
>
{entries.map(entry => {
const isEditing = editingId === entry.id

View file

@ -360,4 +360,13 @@ export function normalizeComposerEditorDom(editor: HTMLElement) {
editor.removeChild(last)
}
}
// ContentEditable elements with no children can visually collapse to
// near-zero height in some browsers (especially Chromium), causing the
// composer to appear as a tiny dot/pixel. Ensure there's always at least
// one <br> so the element maintains intrinsic height. The CSS min-height
// is a belt; the <br> is suspenders — together they prevent the shrink.
if (editor.childNodes.length === 0) {
editor.appendChild(document.createElement('br'))
}
}

View file

@ -2,7 +2,7 @@ import { type AppendMessage, AssistantRuntimeProvider, type ThreadMessage } from
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import type * as React from 'react'
import { Suspense, useCallback, useMemo } from 'react'
import { Suspense, useCallback, useEffect, useMemo } from 'react'
import { useLocation } from 'react-router-dom'
import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'
@ -20,6 +20,8 @@ import type { ChatMessage } from '@/lib/chat-messages'
import { quickModelOptions, sessionTitle } from '@/lib/chat-runtime'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { cn } from '@/lib/utils'
import { migrateSessionDraft } from '@/store/composer'
import { migrateQueuedPrompts, parkQueuedPrompts } from '@/store/composer-queue'
import { $pinnedSessionIds } from '@/store/layout'
import { $petActive } from '@/store/pet'
import { $petOverlayActive } from '@/store/pet-overlay'
@ -32,6 +34,7 @@ import {
$introSeed,
$resumeExhaustedSessionId,
$sessions,
resolveComposerSessionKey,
sessionMatchesStoredId,
sessionPinId
} from '@/store/session'
@ -276,7 +279,38 @@ export function ChatView({
const messagesEmpty = useStore(view.$messagesEmpty)
const lastVisibleIsUser = useStore(view.$lastVisibleIsUser)
const selectedSessionId = useStore(view.$storedId)
const sessions = useStore($sessions)
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
// Durable composer/queue scope (lineage root) so auto-compression tip rotation
// does not wipe an in-progress draft or orphan /queue entries.
const queueSessionKey = useMemo(
() => resolveComposerSessionKey(selectedSessionId, sessions),
[selectedSessionId, sessions]
)
// When the tip row arrives after compression, migrate any tip-keyed stash onto
// the durable lineage key before the composer remounts onto that key.
useEffect(() => {
if (!selectedSessionId || !queueSessionKey || selectedSessionId === queueSessionKey) {
return
}
migrateSessionDraft(selectedSessionId, queueSessionKey)
migrateQueuedPrompts(selectedSessionId, queueSessionKey)
}, [queueSessionKey, selectedSessionId])
// Transcript-side stops (the streaming message's hover Stop, the runtime's
// cancel) are explicit halts, same as the composer's Stop button: park any
// queued turns so the interrupt doesn't roll straight into the next one.
// ChatBar wraps its own onCancel internally — its send-now-while-busy path
// needs the raw interrupt — so it still receives the unwrapped prop.
const haltRun = useCallback(() => {
parkQueuedPrompts(queueSessionKey || activeSessionId)
return onCancel()
}, [activeSessionId, onCancel, queueSessionKey])
// A tile IS its session — no route involved, never "mismatched".
const routedSessionId = isPrimary ? routeSessionId(location.pathname) : selectedSessionId
const isRoutedSessionView = Boolean(routedSessionId)
@ -437,7 +471,7 @@ export function ChatView({
<ChatRuntimeBoundary
busy={busy}
onCancel={onCancel}
onCancel={haltRun}
onEdit={onEdit}
onReload={onReload}
onThreadMessagesChange={onThreadMessagesChange}
@ -455,7 +489,7 @@ export function ChatView({
intro={showIntro ? { personality: introPersonality, seed: introSeed } : undefined}
loading={threadLoading}
onBranchInNewChat={onBranchInNewChat}
onCancel={onCancel}
onCancel={haltRun}
onDismissError={onDismissError}
onRestoreToMessage={onRestoreToMessage}
sessionId={activeSessionId}
@ -524,7 +558,7 @@ export function ChatView({
onSteer={onSteer}
onSubmit={onSubmit}
onTranscribeAudio={onTranscribeAudio}
queueSessionKey={selectedSessionId}
queueSessionKey={queueSessionKey}
sessionId={activeSessionId}
state={chatBarState}
/>

View file

@ -1,3 +1,4 @@
import { atom } from 'nanostores'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
@ -10,9 +11,20 @@ import { renameSessionPreferringRpc } from './session-actions-menu'
// must route the ACTIVE row through the session.title RPC (runtime id), which
// persists the row on demand, and otherwise fall back to REST.
const renameSession = vi.fn(async () => ({ ok: true, title: 'rest-title' }))
const request = vi.fn(async () => ({ title: 'rpc-title' }) as never)
const activeGateway = vi.fn<() => { request: typeof request } | null>(() => ({ request }))
// Hoisted so the vi.mock factories below (which vitest lifts to the top of the
// module) can reference these before the module body runs. This matters because
// projects.ts subscribes to $gateway at import and nanostores fires the
// subscriber synchronously — that reaches the @/store/gateway mock's
// activeGateway() during the transitive import on line 4, before a plain
// module-level const would be initialized (temporal dead zone).
const { renameSession, request, activeGateway } = vi.hoisted(() => ({
renameSession: vi.fn(async () => ({ ok: true, title: 'rest-title' })),
request: vi.fn(async () => ({ title: 'rpc-title' }) as never),
activeGateway: vi.fn<() => { request: unknown } | null>(() => ({ request: undefined }))
}))
// Wire activeGateway's default return to the shared request mock now that it exists.
activeGateway.mockReturnValue({ request })
vi.mock('@/hermes', () => ({
renameSession: (...args: unknown[]) => renameSession(...(args as [])),
@ -23,6 +35,11 @@ vi.mock('@/hermes', () => ({
}))
vi.mock('@/store/gateway', () => ({
// projects.ts subscribes to $gateway at module load (its repo-scan sync fires
// immediately), pulled in transitively via the session store. Provide a real
// atom plus the hoisted activeGateway so the synchronous subscriber doesn't
// throw on an incomplete mock or hit an uninitialized reference.
$gateway: atom(null),
activeGateway: () => activeGateway()
}))

View file

@ -13,6 +13,7 @@ import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import {
Activity,
AppWindow,
Archive,
BarChart3,
ChevronLeft,
@ -59,6 +60,7 @@ import { openPetGenerate } from '@/store/pet-generate'
import { requestStartWorkSession } from '@/store/projects'
import { runGatewayRestart } from '@/store/system-actions'
import { applyBackendUpdate } from '@/store/updates'
import { canOpenNewWindow, openNewWindow } from '@/store/windows'
import { luminance } from '@/themes/color'
import { type ThemeMode, useTheme } from '@/themes/context'
import { isUserTheme, resolveTheme } from '@/themes/user-themes'
@ -413,6 +415,18 @@ export function CommandPalette() {
label: cc.nav.newChat.title,
run: go(NEW_CHAT_ROUTE)
},
...(canOpenNewWindow()
? [
{
action: 'session.newWindow',
icon: AppWindow,
id: 'nav-new-window',
keywords: ['window', 'instance', 'open', 'new'],
label: t.keybinds.actions['session.newWindow'],
run: () => void openNewWindow()
}
]
: []),
{
action: 'view.showTerminal',
icon: Terminal,

View file

@ -31,6 +31,7 @@ import {
import { SidebarProvider } from '@/components/ui/sidebar'
import { discoverBundledPlugins } from '@/contrib/plugins'
import { Slot } from '@/contrib/react/slot'
import { useContributions } from '@/contrib/react/use-contributions'
import { registry } from '@/contrib/registry'
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
@ -600,6 +601,26 @@ $filePreviewTarget.listen(target => target && revealPreview())
// ---------------------------------------------------------------------------
interface TitlebarSlotProps {
area: 'titleBar.center' | 'titleBar.left' | 'titleBar.right'
className: string
style?: CSSProperties
}
function TitlebarSlot({ area, className, style }: TitlebarSlotProps) {
const items = useContributions(area)
if (items.length === 0) {
return null
}
return (
<div className={className} style={style}>
<Slot area={area} />
</div>
)
}
export function ContribController() {
const sidebarOpen = useStore($sidebarOpen)
@ -641,26 +662,25 @@ export function ContribController() {
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[calc(var(--titlebar-controls-left,14px)+(var(--titlebar-control-size,1.25rem)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,5.5rem)+0.75rem)] [-webkit-app-region:drag]"
/>
<div
<TitlebarSlot
area="titleBar.left"
className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]"
style={{
left: 'max(calc(var(--workspace-left, 0px) + 0.5rem), calc(var(--titlebar-controls-left, 14px) + 2 * var(--titlebar-control-size, 1.25rem) + 1rem))'
}}
>
<Slot area="titleBar.left" />
</div>
<div className="pointer-events-auto absolute left-1/2 top-1/2 z-10 flex w-max -translate-x-1/2 -translate-y-1/2 items-center gap-2 [-webkit-app-region:no-drag]">
<Slot area="titleBar.center" />
</div>
<div
/>
<TitlebarSlot
area="titleBar.center"
className="pointer-events-auto absolute left-1/2 top-1/2 z-10 flex w-max -translate-x-1/2 -translate-y-1/2 items-center gap-2 [-webkit-app-region:no-drag]"
/>
<TitlebarSlot
area="titleBar.right"
className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]"
style={{
right:
'max(calc(var(--workspace-right, 0px) + 0.5rem), calc(var(--titlebar-tools-right, 0.75rem) + 4 * (var(--titlebar-control-size, 1.25rem) + 0.25rem) + 0.5rem))'
}}
>
<Slot area="titleBar.right" />
</div>
/>
</div>
<LayoutTreeRoot />

View file

@ -71,7 +71,9 @@ import { ModelVisibilityOverlay } from '../model-visibility-overlay'
import { PetGenerateOverlay } from '../pet-generate/pet-generate-overlay'
import { FileActionDialogs } from '../right-sidebar/file-actions'
import { RemoteFolderPicker } from '../right-sidebar/files/remote-picker'
import { resetProjectTreeState } from '../right-sidebar/files/use-project-tree'
import { PersistentTerminal } from '../right-sidebar/terminal/persistent'
import { closeAllTerminals } from '../right-sidebar/terminal/terminals'
import { CRON_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE, syncWorkspaceIsPage } from '../routes'
import { SessionPickerOverlay } from '../session-picker-overlay'
import { SessionSwitcher } from '../session-switcher'
@ -173,6 +175,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
openCommandCenterSection,
openStarmap,
profilesOpen,
resetOverlayReturnRoute,
settingsOpen,
starmapOpen,
toggleCommandCenter
@ -632,6 +635,12 @@ export function ContribWiring({ children }: { children: ReactNode }) {
)
useGatewayBoot({
beforeConnectionSwitch: () => {
startFreshSessionDraft({ preserveRoute: true, workspaceTarget: null })
resetOverlayReturnRoute()
resetProjectTreeState()
closeAllTerminals()
},
handleGatewayEvent: handleGatewayEventWithPlugins,
onConnectionReady: c => {
connectionRef.current = c

View file

@ -18,6 +18,7 @@ import { useGatewayBoot } from './use-gateway-boot'
// post-boot reconnect loop.
type Listener = (ev: unknown) => void
let connectionApplied: null | (() => void) = null
// Minimal WebSocket stand-in implementing only what json-rpc-gateway.connect()
// touches: readyState, add/removeEventListener('open'|'error'|'close'), close().
@ -97,7 +98,13 @@ function fakeDesktop() {
})),
onBootProgress: vi.fn(() => () => undefined),
onBackendExit: vi.fn(() => () => undefined),
onConnectionApplied: vi.fn(() => () => undefined),
onConnectionApplied: vi.fn(callback => {
connectionApplied = callback
return () => {
connectionApplied = null
}
}),
onPowerResume: vi.fn(() => () => undefined),
onWindowStateChanged: vi.fn(() => () => undefined),
touchBackend: vi.fn(async () => undefined),
@ -105,8 +112,12 @@ function fakeDesktop() {
}
}
function Harness({ refreshSessions }: { refreshSessions?: () => Promise<void> } = {}) {
function Harness({
beforeConnectionSwitch = () => undefined,
refreshSessions
}: { beforeConnectionSwitch?: () => void; refreshSessions?: () => Promise<void> } = {}) {
useGatewayBoot({
beforeConnectionSwitch,
handleGatewayEvent: () => undefined,
onConnectionReady: () => undefined,
onGatewayReady: () => undefined,
@ -123,6 +134,7 @@ beforeEach(() => {
vi.useFakeTimers()
FakeWebSocket.mode = 'open'
FakeWebSocket.instances = []
connectionApplied = null
;(globalThis as { WebSocket: unknown }).WebSocket = FakeWebSocket
;(window as { hermesDesktop?: unknown }).hermesDesktop = fakeDesktop()
$gatewayState.set('idle')
@ -199,6 +211,18 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>
expect($desktopBoot.get().error).toBeTruthy()
})
it('resets the old machine context before connecting an applied gateway', async () => {
const beforeConnectionSwitch = vi.fn()
render(<Harness beforeConnectionSwitch={beforeConnectionSwitch} />)
await flushAsync()
expect(connectionApplied).not.toBeNull()
act(() => connectionApplied?.())
expect(beforeConnectionSwitch).toHaveBeenCalledTimes(1)
await flushAsync()
expect($gatewayState.get()).toBe('open')
})
it('a remote that drops post-boot keeps looping with NO boot.error (the dead-end CONNECTING combo)', async () => {
render(<Harness />)
await flushAsync()

View file

@ -48,6 +48,7 @@ import type { RpcEvent } from '@/types/hermes'
const RECONNECT_ESCALATE_AFTER = 6
interface GatewayBootOptions {
beforeConnectionSwitch: () => void
handleGatewayEvent: (event: RpcEvent) => void
onConnectionReady: (
connection: Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null
@ -58,6 +59,7 @@ interface GatewayBootOptions {
}
export function useGatewayBoot({
beforeConnectionSwitch,
handleGatewayEvent,
onConnectionReady,
onGatewayReady,
@ -65,6 +67,7 @@ export function useGatewayBoot({
refreshSessions
}: GatewayBootOptions) {
const callbacksRef = useRef({
beforeConnectionSwitch,
handleGatewayEvent,
onConnectionReady,
onGatewayReady,
@ -73,6 +76,7 @@ export function useGatewayBoot({
})
callbacksRef.current = {
beforeConnectionSwitch,
handleGatewayEvent,
onConnectionReady,
onGatewayReady,
@ -155,9 +159,10 @@ export function useGatewayBoot({
// with a short TTL, so the ticket baked into the cached conn.wsUrl is
// dead on every reconnect after the initial boot — reusing it surfaces
// as an opaque "Could not connect to Hermes gateway". resolveGatewayWsUrl
// mints a fresh ticket (or throws a reauth error in OAuth mode rather
// than connecting with a stale one). For local/token gateways the URL
// carries a long-lived token and the re-mint is a cheap no-op.
// mints a fresh ticket rather than connecting with a stale one. An
// explicit auth rejection asks for sign-in; transport failures stay in
// this reconnect loop. For local/token gateways the URL carries a
// long-lived token and the re-mint is a cheap no-op.
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await gateway.connect(wsUrl)
@ -264,6 +269,7 @@ export function useGatewayBoot({
reconnectAttempt = 0
escalated = false
reauthNotified = false
callbacksRef.current.beforeConnectionSwitch()
wipeSessionListsForGatewaySwitch()
try {
@ -454,9 +460,9 @@ export function useGatewayBoot({
publish(conn)
// Mint a fresh WS URL right before connecting. For OAuth gateways the
// ticket is single-use with a short TTL, so the ticket baked into
// conn.wsUrl is stale; resolveGatewayWsUrl() re-mints it and, on
// failure, throws a reauth error rather than connecting with a dead
// ticket (which would surface as an opaque "connection closed").
// conn.wsUrl is stale; resolveGatewayWsUrl() re-mints it rather than
// connecting with a dead ticket. Auth rejection asks for sign-in;
// connectivity failures remain retryable.
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await gateway.connect(wsUrl)

View file

@ -69,9 +69,10 @@ export function useGatewayRequest() {
setConnection(conn)
// Re-mint the WS URL before reconnecting. OAuth tickets are single-use
// and short-lived, so the cached conn.wsUrl ticket is dead here;
// resolveGatewayWsUrl() throws a reauth error in OAuth mode rather than
// connecting with a stale ticket. Stash it so requestGateway can show
// the actionable "sign in again" message.
// resolveGatewayWsUrl() never connects with a stale ticket. An explicit
// auth rejection becomes a reauth error; transport failures remain
// retryable. Stash only the former so requestGateway can show the
// actionable "sign in again" message.
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await existing.connect(wsUrl)

View file

@ -40,7 +40,7 @@ import {
switcherActive,
switcherJustClosed
} from '@/store/session-switcher'
import { openNewSessionInNewWindow } from '@/store/windows'
import { openNewWindow } from '@/store/windows'
import { useTheme } from '@/themes/context'
import { requestComposerFocus, requestVoiceToggle } from '../chat/composer/focus'
@ -145,7 +145,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
},
'session.newTab': () => deps.openNewSessionTab(),
'session.newWindow': () => void openNewSessionInNewWindow(),
'session.newWindow': () => void openNewWindow(),
// ⌃Tab cycles the focused session/main tab strip; only a non-tabbed focus
// falls through to the recent-session switcher.
'session.next': () => void (cycleTreeTabInFocusedZone(1) || stepSession(1)),

View file

@ -3,7 +3,13 @@ import type { MutableRefObject } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $queuedPromptsBySession, enqueueQueuedPrompt, getQueuedPrompts } from '@/store/composer-queue'
import {
$parkedQueueSessions,
$queuedPromptsBySession,
enqueueQueuedPrompt,
getQueuedPrompts,
parkQueuedPrompts
} from '@/store/composer-queue'
import { clearAllSessionStates, publishSessionState } from '@/store/session-states'
import { useBackgroundQueueDrain } from './use-background-queue-drain'
@ -41,6 +47,7 @@ describe('useBackgroundQueueDrain', () => {
vi.restoreAllMocks()
vi.useRealTimers()
$queuedPromptsBySession.set({})
$parkedQueueSessions.set({})
clearAllSessionStates()
})
@ -96,6 +103,25 @@ describe('useBackgroundQueueDrain', () => {
expect(getQueuedPrompts('stored-session-a')).toHaveLength(1)
})
it('does not drain a parked background session, even when idle', async () => {
// A Stop in a tile parks that session's queue; when the user then focuses
// another chat, THIS drainer takes over the tile's queue — it must honor
// the park just like the mounted ChatBar drainer does.
const runtimeMap = { current: new Map([['stored-session-a', 'rt-session-a']]) }
const submitText = vi.fn(async () => true)
enqueueQueuedPrompt('stored-session-a', { text: 'halted by stop', attachments: [] })
parkQueuedPrompts('stored-session-a')
clearAllSessionStates()
render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)
await new Promise(resolve => window.setTimeout(resolve, 0))
expect(submitText).not.toHaveBeenCalled()
expect(getQueuedPrompts('stored-session-a')).toHaveLength(1)
})
it('passes a null runtime id so submitText can resume stale background sessions by stored id', async () => {
const runtimeMap = { current: new Map<string, string>() }
const submitText = vi.fn(async () => true)

View file

@ -4,6 +4,7 @@ import { type MutableRefObject, useCallback, useEffect, useRef, useState } from
import { useI18n } from '@/i18n'
import { resetBrowseState } from '@/store/composer-input-history'
import {
$parkedQueueSessions,
$queuedPromptsBySession,
getQueuedPrompts,
MAX_AUTO_DRAIN_ATTEMPTS,
@ -43,6 +44,7 @@ export function useBackgroundQueueDrain({
}: BackgroundQueueDrainOptions) {
const { t } = useI18n()
const queuedPromptsBySession = useStore($queuedPromptsBySession)
const parkedQueueSessions = useStore($parkedQueueSessions)
const workingSessionIds = useStore($workingSessionIds)
const submitTextRef = useRef(submitText)
const drainingSessionIdsRef = useRef(new Set<string>())
@ -157,7 +159,11 @@ export function useBackgroundQueueDrain({
if (
sessionKey === selectedStoredSessionId ||
drainingSessionIdsRef.current.has(sessionKey) ||
!shouldAutoDrain({ isBusy: working.has(sessionKey), queueLength: entries.length })
!shouldAutoDrain({
isBusy: working.has(sessionKey),
parked: Boolean(parkedQueueSessions[sessionKey]),
queueLength: entries.length
})
) {
continue
}
@ -170,5 +176,13 @@ export function useBackgroundQueueDrain({
drainSessionQueue(sessionKey, entry)
}
}, [drainSessionQueue, enabled, queuedPromptsBySession, retryTick, selectedStoredSessionId, workingSessionIds])
}, [
drainSessionQueue,
enabled,
parkedQueueSessions,
queuedPromptsBySession,
retryTick,
selectedStoredSessionId,
workingSessionIds
])
}

View file

@ -483,7 +483,8 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
flushQueuedDeltas(sessionId)
playCompletionSound()
// Keyed by session so only one window beeps when several are open.
playCompletionSound(sessionId)
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
completeAssistantMessage(sessionId, finalText, payload?.response_previewed)

View file

@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { getSessionMessages, type SessionInfo } from '@/hermes'
import { createClientSessionState } from '@/lib/chat-runtime'
import { clearSessionDraft, stashSessionDraft, takeSessionDraft } from '@/store/composer'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile } from '@/store/profile'
import { $projectScope, $projectTree, ALL_PROJECTS } from '@/store/projects'
import {
@ -89,9 +90,11 @@ function storedSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
}
function Harness({
navigate = vi.fn(),
onReady,
requestGateway
}: {
navigate?: ReturnType<typeof vi.fn>
onReady: (handle: HarnessHandle) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
@ -105,7 +108,7 @@ function Harness({
ensureSessionState: () => ({}) as ClientSessionState,
getRouteToken: () => 'token',
getRoutedStoredSessionId: () => null,
navigate: vi.fn() as never,
navigate: navigate as never,
requestGateway,
resetViewSync: vi.fn(),
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
@ -196,6 +199,89 @@ describe('active stored-session id rotation routing', () => {
expect($activeSessionStoredIdRotation.get()).toBeNull()
})
it('keeps draft on the previous tip when the new tip row is not loaded yet', async () => {
const tipBefore = 'tip-root'
const tipAfter = 'tip-new-unloaded'
const runtimeSessionId = 'runtime-gap'
const activeSessionIdRef: MutableRefObject<string | null> = { current: runtimeSessionId }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: tipBefore }
const navigate = vi.fn()
setSessions([])
stashSessionDraft(tipBefore, 'typed during gap', [])
setSelectedStoredSessionId(tipBefore)
setActiveSessionId(runtimeSessionId)
render(
<StoredIdRotationHarness
activeSessionIdRef={activeSessionIdRef}
getRoutedStoredSessionId={() => tipBefore}
navigate={navigate}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
/>
)
act(() => {
setActiveSessionStoredIdRotation({
nextStoredSessionId: tipAfter,
previousStoredSessionId: tipBefore,
runtimeSessionId
})
})
await waitFor(() => expect($selectedStoredSessionId.get()).toBe(tipAfter))
expect(takeSessionDraft(tipBefore).text).toBe('typed during gap')
expect(takeSessionDraft(tipAfter).text).toBe('')
clearSessionDraft(tipBefore)
clearSessionDraft(tipAfter)
setActiveSessionId(null)
})
it('parks an in-progress composer draft on the lineage root across tip rotation', async () => {
// Desktop draft must stay on the durable composer key (lineage root), not
// move onto the fresh tip — ChatBar scopes drafts via resolveComposerSessionKey.
const tipBefore = '20260720_062637_ad96b3'
const tipAfter = '20260720_071049_a28905'
const runtimeSessionId = 'runtime-desktop-thinking'
const activeSessionIdRef: MutableRefObject<string | null> = { current: runtimeSessionId }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: tipBefore }
const navigate = vi.fn()
const typedWhileThinking = 'follow up I am still typing during thinking'
setSessions([storedSession({ id: tipAfter, message_count: 2, _lineage_root_id: tipBefore })])
stashSessionDraft(tipBefore, typedWhileThinking, [])
setSelectedStoredSessionId(tipBefore)
setActiveSessionId(runtimeSessionId)
render(
<StoredIdRotationHarness
activeSessionIdRef={activeSessionIdRef}
getRoutedStoredSessionId={() => tipBefore}
navigate={navigate}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
/>
)
act(() => {
setActiveSessionStoredIdRotation({
nextStoredSessionId: tipAfter,
previousStoredSessionId: tipBefore,
runtimeSessionId
})
})
await waitFor(() => expect($selectedStoredSessionId.get()).toBe(tipAfter))
// Durable key remains the lineage root — same scope ChatBar will keep using.
expect(takeSessionDraft(tipBefore).text).toBe(typedWhileThinking)
expect(takeSessionDraft(tipAfter).text).toBe('')
clearSessionDraft(tipBefore)
clearSessionDraft(tipAfter)
setActiveSessionId(null)
setSessions([])
})
it('does not overwrite a newer route intent before its resume effect has synchronized selection', async () => {
const activeSessionIdRef: MutableRefObject<string | null> = { current: 'runtime-A' }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: 'stored-A' }
@ -320,6 +406,25 @@ async function createWith(
return createParams
}
describe('startFreshSessionDraft', () => {
afterEach(() => cleanup())
it('can reset machine-bound session state without closing the current overlay route', async () => {
const navigate = vi.fn()
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(<Harness navigate={navigate} onReady={value => (handle = value)} requestGateway={requestGateway} />)
await waitFor(() => expect(handle).not.toBeNull())
act(() => handle!.startFreshSessionDraft({ preserveRoute: true, workspaceTarget: null }))
expect(navigate).not.toHaveBeenCalled()
expect($currentCwd.get()).toBe('')
expect($newChatWorkspaceTarget.get()).toBeNull()
})
})
describe('createBackendSessionForSend profile routing', () => {
afterEach(() => {
cleanup()

View file

@ -8,7 +8,8 @@ import { useI18n } from '@/i18n'
import { type ChatMessage, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { isMissingRpcMethod } from '@/lib/gateway-rpc'
import { setSessionYolo } from '@/lib/yolo-session'
import { clearQueuedPrompts } from '@/store/composer-queue'
import { migrateSessionDraft } from '@/store/composer'
import { clearQueuedPrompts, migrateQueuedPrompts } from '@/store/composer-queue'
import { $pinnedSessionIds } from '@/store/layout'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
@ -25,6 +26,7 @@ import {
$sessions,
$yoloActive,
type NewChatWorkspaceTarget,
resolveComposerSessionKey,
sessionPinId,
setActiveSessionId,
setActiveSessionStoredIdRotation,
@ -175,6 +177,7 @@ async function desktopSessionCreateParams(cwd: string): Promise<Record<string, u
}
interface FreshSessionDraftOptions {
preserveRoute?: boolean
replaceRoute?: boolean
workspaceTarget?: NewChatWorkspaceTarget
}
@ -233,20 +236,42 @@ export function useSessionActions({
return
}
setSelectedStoredSessionId(storedIdRotation.nextStoredSessionId)
selectedStoredSessionIdRef.current = storedIdRotation.nextStoredSessionId
// Park unsent draft/queue on the durable lineage key (not the new tip).
// ChatBar scopes composer state on resolveComposerSessionKey(); migrating
// onto the tip while the composer is still bound to the root can lose newer
// live editor text on a brief remount. If the new tip row is not in
// $sessions yet, resolveComposerSessionKey falls back to the tip id — prefer
// the previous id (usually the lineage root) in that gap.
const previousId = storedIdRotation.previousStoredSessionId
const nextId = storedIdRotation.nextStoredSessionId
const sessions = $sessions.get()
const resolvedNext = resolveComposerSessionKey(nextId, sessions)
const durableKey =
resolvedNext && resolvedNext !== nextId
? resolvedNext
: (resolveComposerSessionKey(previousId, sessions) ?? previousId)
migrateSessionDraft(previousId, durableKey)
migrateSessionDraft(nextId, durableKey)
migrateQueuedPrompts(previousId, durableKey)
migrateQueuedPrompts(nextId, durableKey)
setSelectedStoredSessionId(nextId)
selectedStoredSessionIdRef.current = nextId
// A route overlay/page has no routed session id, but the underlying selected
// chat still needs to follow the continuation. Update that selection in
// place without navigating out of the surface the user deliberately opened.
if (routedStoredSessionId === storedIdRotation.previousStoredSessionId) {
navigate(sessionRoute(storedIdRotation.nextStoredSessionId), { replace: true })
if (routedStoredSessionId === previousId) {
navigate(sessionRoute(nextId), { replace: true })
}
}, [activeSessionIdRef, getRoutedStoredSessionId, navigate, selectedStoredSessionIdRef, storedIdRotation])
const startFreshSessionDraft = useCallback(
(options: boolean | FreshSessionDraftOptions = false) => {
const draftOptions = typeof options === 'boolean' ? { replaceRoute: options } : options
const preserveRoute = draftOptions.preserveRoute ?? false
const replaceRoute = draftOptions.replaceRoute ?? false
const hasWorkspaceTarget =
@ -267,7 +292,11 @@ export function useSessionActions({
// rebind race, so leaving the old id here could revive it on a very fast
// New Chat -> Enter sequence.
onFreshDraftRouteIntent?.()
navigate(NEW_CHAT_ROUTE, { replace: replaceRoute })
if (!preserveRoute) {
navigate(NEW_CHAT_ROUTE, { replace: replaceRoute })
}
setActiveSessionId(null)
activeSessionIdRef.current = null
setSelectedStoredSessionId(null)

View file

@ -32,19 +32,19 @@ export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentat
case 'insufficient_scope':
return {
action: { type: 'step_up' },
message: 'This needs terminal billing enabled. Start a top-up to enable it, then retry.',
title: 'Terminal billing needs approval'
message: 'This needs Remote Spending allowed. Start a top-up to allow it, then retry.',
title: 'Remote Spending needs approval'
}
case 'remote_spending_revoked': {
const who =
refusal.actor === 'admin'
? 'An admin turned off terminal billing for this terminal.'
: 'You turned off terminal billing for this terminal.'
? 'An admin stopped remote spending for this terminal.'
: 'You stopped remote spending for this terminal.'
return {
action: portalAction(refusal.portalUrl),
message: `${who} Reconnect from Settings → Gateway to re-authorize this device.`,
title: 'Terminal billing was turned off'
title: 'Remote spending was stopped'
}
}
@ -60,8 +60,9 @@ export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentat
case 'remote_spending_disabled':
return {
action: portalAction(refusal.portalUrl),
message: 'Terminal billing is off for this account — an admin must enable it on the portal.',
title: 'Terminal billing is off'
message:
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page.",
title: 'Remote spending is off'
}
case 'role_required':

View file

@ -74,7 +74,9 @@ describe('BillingSettings', () => {
expect(screen.getByText('Ultra · $200/mo')).toBeTruthy()
expect(screen.getByText('Visa •••• 3206')).toBeTruthy()
expect(
screen.getByText('Terminal billing is off for this account — an admin must enable it on the portal.')
screen.getByText(
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page."
)
).toBeTruthy()
expect(screen.queryByRole('button', { name: '$100' })).toBeNull()
expect(screen.getByText('Refill $10 when balance falls below $5')).toBeTruthy()
@ -197,10 +199,8 @@ describe('BillingSettings', () => {
})
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByText('Terminal billing needs approval:')).toBeTruthy()
expect(
screen.getByText('This needs terminal billing enabled. Start a top-up to enable it, then retry.')
).toBeTruthy()
expect(await screen.findByText('Remote Spending needs approval:')).toBeTruthy()
expect(screen.getByText('This needs Remote Spending allowed. Start a top-up to allow it, then retry.')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Verify to continue' })).toBeTruthy()
})

View file

@ -94,7 +94,14 @@ function RowValue({ onAction, row }: { onAction?: () => void; row: BillingAccoun
{row.pill && <Pill tone={row.pill.tone}>{row.pill.label}</Pill>}
{row.secondaryPill && <Pill>{row.secondaryPill}</Pill>}
{row.chips?.map(chip => (
<Button disabled={chip.disabled} key={chip.label} size="sm" type="button" variant="outline">
<Button
disabled={chip.disabled}
key={chip.label}
onClick={chip.url ? () => openExternal(chip.url) : undefined}
size="sm"
type="button"
variant="outline"
>
{chip.label}
</Button>
))}

View file

@ -65,7 +65,7 @@ describe('deriveBillingView', () => {
const buyCredits = view.accountRows.find(row => row.id === 'buy_credits')
expect(buyCredits?.description).toBe(
'Terminal billing is off for this account — an admin must enable it on the portal.'
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page."
)
expect(buyCredits?.chips).toBeUndefined()
expect(view.accountRows.find(row => row.id === 'auto_reload')).toMatchObject({
@ -184,6 +184,103 @@ describe('deriveBillingView', () => {
})
})
it('free with catalog: tier chips render inline and open the portal', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
context: 'personal',
current: null,
tiers: [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0',
name: 'Free',
tier_id: 'free',
tier_order: 0
},
{
dollars_per_month_display: '$40',
is_current: false,
is_enabled: true,
monthly_credits: '3000',
name: 'Ultra',
tier_id: 'ultra',
tier_order: 2
},
{
dollars_per_month_display: '$20',
is_current: false,
is_enabled: true,
monthly_credits: '1000',
name: 'Plus',
tier_id: 'plus',
tier_order: 1
}
]
})
)
const subscription = view.accountRows.find(row => row.id === 'subscription')
expect(subscription?.description).toBe('Paid models need a subscription — pick a plan to start it on the portal.')
expect(subscription?.chips).toEqual([
{ disabled: false, label: 'Plus · $20/mo · $1,000 credits/mo', url: `${subscription?.action?.url}&plan=plus` },
{ disabled: false, label: 'Ultra · $40/mo · $3,000 credits/mo', url: `${subscription?.action?.url}&plan=ultra` }
])
})
it('subscriber who can change plans: current tier marked inert, others open the portal', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
context: 'personal',
tiers: [
{
dollars_per_month_display: '$20',
is_current: true,
is_enabled: true,
monthly_credits: '1000',
name: 'Plus',
tier_id: 'plus',
tier_order: 1
},
{
dollars_per_month_display: '$40',
is_current: false,
is_enabled: true,
monthly_credits: '3000',
name: 'Ultra',
tier_id: 'ultra',
tier_order: 2
}
]
})
)
const subscription = view.accountRows.find(row => row.id === 'subscription')
expect(subscription?.chips).toEqual([
{ disabled: true, label: '✓ Plus · $20/mo · $1,000 credits/mo' },
{ disabled: false, label: 'Ultra · $40/mo · $3,000 credits/mo', url: `${subscription?.action?.url}&plan=ultra` }
])
})
it('members and team contexts get no tier chips', () => {
const member = deriveBillingView(
okBilling(todayBillingState),
okSubscription({ ...todaySubscriptionState, can_change_plan: false, context: 'personal' })
)
const team = deriveBillingView(okBilling(todayBillingState), okSubscription(todaySubscriptionState))
expect(member.accountRows.find(row => row.id === 'subscription')?.chips).toBeUndefined()
expect(team.accountRows.find(row => row.id === 'subscription')?.chips).toBeUndefined()
})
it('clamps overdrawn subscription credits to $0 and names the overage', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
@ -254,20 +351,15 @@ describe('deriveBillingView', () => {
})
})
it('renders top-up balance as a full ok bar when credits remain', () => {
it('renders top-up balance as a bare amount — no bar (no denominator exists)', () => {
const view = deriveBillingView(okBilling(postTrainBillingState), okSubscription(postTrainSubscriptionState))
const topup = view.usageRows.find(row => row.id === 'topup_credits')
expect(view.usageRows.find(row => row.id === 'topup_credits')).toMatchObject({
bar: {
state: 'ok',
tone: 'topup',
value: 1
},
value: '$75'
})
expect(topup?.value).toBe('$75')
expect(topup?.bar).toBeUndefined()
})
it('renders zero top-up balance as an empty neutral bar', () => {
it('renders zero top-up balance without a bar too', () => {
const view = deriveBillingView(
okBilling({
...todayBillingState,
@ -281,14 +373,10 @@ describe('deriveBillingView', () => {
undefined
)
expect(view.usageRows.find(row => row.id === 'topup_credits')).toMatchObject({
bar: {
state: 'neutral',
tone: 'topup',
value: 0
},
value: '$0'
})
const topup = view.usageRows.find(row => row.id === 'topup_credits')
expect(topup?.value).toBe('$0')
expect(topup?.bar).toBeUndefined()
})
})
@ -301,4 +389,27 @@ describe('buildManageSubscriptionUrl', () => {
})
).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123')
})
it('appends the tier as a plan query param when provided', () => {
expect(
buildManageSubscriptionUrl(
{
org_id: 'org_123',
portal_url: 'https://portal.nousresearch.com/billing'
},
undefined,
'ultra'
)
).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123&plan=ultra')
})
it('omits the plan param when no tierId is given', () => {
expect(
buildManageSubscriptionUrl(
{ org_id: null, portal_url: 'https://portal.nousresearch.com/billing' },
undefined,
undefined
)
).toBe('https://portal.nousresearch.com/manage-subscription')
})
})

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