mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
commit
93e2998f89
405 changed files with 52409 additions and 3370 deletions
2
.github/actions/detect-changes/action.yml
vendored
2
.github/actions/detect-changes/action.yml
vendored
|
|
@ -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 }}
|
||||
|
||||
|
|
|
|||
59
.github/actions/get-app-token/action.yml
vendored
Normal file
59
.github/actions/get-app-token/action.yml
vendored
Normal 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"
|
||||
147
.github/workflows/ci.yml
vendored
147
.github/workflows/ci.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
18
.github/workflows/contributor-check.yml
vendored
18
.github/workflows/contributor-check.yml
vendored
|
|
@ -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."
|
||||
|
|
|
|||
11
.github/workflows/deploy-site.yml
vendored
11
.github/workflows/deploy-site.yml
vendored
|
|
@ -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
212
.github/workflows/e2e-desktop.yml
vendored
Normal 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
|
||||
12
.github/workflows/history-check.yml
vendored
12
.github/workflows/history-check.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
13
.github/workflows/js-autofix.yml
vendored
13
.github/workflows/js-autofix.yml
vendored
|
|
@ -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
81
.github/workflows/label-rerun.yml
vendored
Normal 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."
|
||||
126
.github/workflows/lint.yml
vendored
126
.github/workflows/lint.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
81
.github/workflows/lockfile-diff.yml
vendored
81
.github/workflows/lockfile-diff.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
139
.github/workflows/osv-scanner.yml
vendored
139
.github/workflows/osv-scanner.yml
vendored
|
|
@ -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
92
.github/workflows/review-labels.yml
vendored
Normal 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
|
||||
10
.github/workflows/skills-index-freshness.yml
vendored
10
.github/workflows/skills-index-freshness.yml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
17
.github/workflows/skills-index.yml
vendored
17
.github/workflows/skills-index.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
177
.github/workflows/supply-chain-audit.yml
vendored
177
.github/workflows/supply-chain-audit.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
11
.github/workflows/upload_to_pypi.yml
vendored
11
.github/workflows/upload_to_pypi.yml
vendored
|
|
@ -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: >-
|
||||
|
|
|
|||
11
.github/workflows/uv-lockfile-check.yml
vendored
11
.github/workflows/uv-lockfile-check.yml
vendored
|
|
@ -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
6
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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, "
|
||||
|
|
|
|||
|
|
@ -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
|
||||
kind→string mapping on the startup path.
|
||||
"""
|
||||
generic = {
|
||||
ErrorKind.NOT_CONFIGURED: (
|
||||
f"Run `hermes secrets {self.name} setup` to finish configuration."
|
||||
),
|
||||
ErrorKind.BINARY_MISSING: (
|
||||
f"Run `hermes secrets {self.name} setup` to install the helper CLI."
|
||||
),
|
||||
ErrorKind.AUTH_FAILED: (
|
||||
f"Credentials rejected — run `hermes secrets {self.name} setup` "
|
||||
"to re-authenticate."
|
||||
),
|
||||
ErrorKind.AUTH_EXPIRED: (
|
||||
f"Credentials expired — run `hermes secrets {self.name} setup` "
|
||||
"to re-authenticate."
|
||||
),
|
||||
ErrorKind.NETWORK: (
|
||||
"Network problem reaching the secrets backend — check "
|
||||
"connectivity and retry."
|
||||
),
|
||||
ErrorKind.TIMEOUT: (
|
||||
f"Backend was slow — raise secrets.{self.name}.timeout_seconds "
|
||||
"if this recurs."
|
||||
),
|
||||
}
|
||||
return generic.get(kind, "") if kind is not None else ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers — use these instead of hand-rolling per backend
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
47
apps/desktop/e2e/boot-failure.spec.ts
Normal file
47
apps/desktop/e2e/boot-failure.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
63
apps/desktop/e2e/boot.spec.ts
Normal file
63
apps/desktop/e2e/boot.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
91
apps/desktop/e2e/chat.spec.ts
Normal file
91
apps/desktop/e2e/chat.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
72
apps/desktop/e2e/fix-electron-tracing.ts
Normal file
72
apps/desktop/e2e/fix-electron-tracing.ts
Normal 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
|
||||
}
|
||||
674
apps/desktop/e2e/fixtures.ts
Normal file
674
apps/desktop/e2e/fixtures.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
88
apps/desktop/e2e/launch-packaged-app.spec.ts
Normal file
88
apps/desktop/e2e/launch-packaged-app.spec.ts
Normal 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 })
|
||||
})
|
||||
87
apps/desktop/e2e/mock-backend-setup.spec.ts
Normal file
87
apps/desktop/e2e/mock-backend-setup.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
203
apps/desktop/e2e/mock-server.ts
Normal file
203
apps/desktop/e2e/mock-server.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
76
apps/desktop/e2e/onboarding.spec.ts
Normal file
76
apps/desktop/e2e/onboarding.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
150
apps/desktop/e2e/visual-snapshot.ts
Normal file
150
apps/desktop/e2e/visual-snapshot.ts
Normal 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`,
|
||||
)
|
||||
}
|
||||
100
apps/desktop/electron/connection-apply.test.ts
Normal file
100
apps/desktop/electron/connection-apply.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
50
apps/desktop/electron/connection-apply.ts
Normal file
50
apps/desktop/electron/connection-apply.ts
Normal 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 }
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
105
apps/desktop/electron/desktop-installation.test.ts
Normal file
105
apps/desktop/electron/desktop-installation.test.ts
Normal 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', ''))
|
||||
})
|
||||
137
apps/desktop/electron/desktop-installation.ts
Normal file
137
apps/desktop/electron/desktop-installation.ts
Normal 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 }
|
||||
37
apps/desktop/electron/event-dedupe.test.ts
Normal file
37
apps/desktop/electron/event-dedupe.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
32
apps/desktop/electron/event-dedupe.ts
Normal file
32
apps/desktop/electron/event-dedupe.ts
Normal 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
|
||||
}
|
||||
}
|
||||
79
apps/desktop/electron/git-repo-scan.test.ts
Normal file
79
apps/desktop/electron/git-repo-scan.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
58
apps/desktop/electron/power-save.test.ts
Normal file
58
apps/desktop/electron/power-save.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
50
apps/desktop/electron/power-save.ts
Normal file
50
apps/desktop/electron/power-save.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
1017
apps/desktop/electron/remote-lifecycle.test.ts
Normal file
1017
apps/desktop/electron/remote-lifecycle.test.ts
Normal file
File diff suppressed because it is too large
Load diff
906
apps/desktop/electron/remote-lifecycle.ts
Normal file
906
apps/desktop/electron/remote-lifecycle.ts
Normal 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
|
||||
}
|
||||
253
apps/desktop/electron/remote-liveness.test.ts
Normal file
253
apps/desktop/electron/remote-liveness.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
182
apps/desktop/electron/remote-liveness.ts
Normal file
182
apps/desktop/electron/remote-liveness.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
192
apps/desktop/electron/ssh-bootstrap-coordinator.test.ts
Normal file
192
apps/desktop/electron/ssh-bootstrap-coordinator.test.ts
Normal 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'])
|
||||
})
|
||||
130
apps/desktop/electron/ssh-bootstrap-coordinator.ts
Normal file
130
apps/desktop/electron/ssh-bootstrap-coordinator.ts
Normal 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 }
|
||||
104
apps/desktop/electron/ssh-config.test.ts
Normal file
104
apps/desktop/electron/ssh-config.test.ts
Normal 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)
|
||||
})
|
||||
175
apps/desktop/electron/ssh-config.ts
Normal file
175
apps/desktop/electron/ssh-config.ts
Normal 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 }
|
||||
866
apps/desktop/electron/ssh-connection.test.ts
Normal file
866
apps/desktop/electron/ssh-connection.test.ts
Normal 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)
|
||||
})
|
||||
921
apps/desktop/electron/ssh-connection.ts
Normal file
921
apps/desktop/electron/ssh-connection.ts
Normal 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
|
||||
}
|
||||
122
apps/desktop/electron/windows-remote-lifecycle.test.ts
Normal file
122
apps/desktop/electron/windows-remote-lifecycle.test.ts
Normal 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/)
|
||||
})
|
||||
455
apps/desktop/electron/windows-remote-lifecycle.ts
Normal file
455
apps/desktop/electron/windows-remote-lifecycle.ts
Normal 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
|
||||
}
|
||||
106
apps/desktop/electron/windows-remote-live.test.ts
Normal file
106
apps/desktop/electron/windows-remote-live.test.ts
Normal 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
|
||||
)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
63
apps/desktop/playwright.config.ts
Normal file
63
apps/desktop/playwright.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
237
apps/desktop/scripts/dev-mock.mjs
Normal file
237
apps/desktop/scripts/dev-mock.mjs
Normal 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)
|
||||
})
|
||||
65
apps/desktop/src/app/chat/close-tab.test.ts
Normal file
65
apps/desktop/src/app/chat/close-tab.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue