diff --git a/.github/actions/detect-changes/action.yml b/.github/actions/detect-changes/action.yml index 7d95ba76c9a..0794b5ed4f8 100644 --- a/.github/actions/detect-changes/action.yml +++ b/.github/actions/detect-changes/action.yml @@ -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 }} diff --git a/.github/actions/get-app-token/action.yml b/.github/actions/get-app-token/action.yml new file mode 100644 index 00000000000..611533f2833 --- /dev/null +++ b/.github/actions/get-app-token/action.yml @@ -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" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index faae3b6f270..9cf15a1a6e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 + # ```` 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: diff --git a/.github/workflows/contributor-check.yml b/.github/workflows/contributor-check.yml index 2c5db6f311d..014e1e2ff93 100644 --- a/.github/workflows/contributor-check.yml +++ b/.github/workflows/contributor-check.yml @@ -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"": "",\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." diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index e06a0842a63..fd09205a055 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -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: | diff --git a/.github/workflows/e2e-desktop.yml b/.github/workflows/e2e-desktop.yml new file mode 100644 index 00000000000..c07a94be8a3 --- /dev/null +++ b/.github/workflows/e2e-desktop.yml @@ -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 diff --git a/.github/workflows/history-check.yml b/.github/workflows/history-check.yml index a48dba8cb8a..668f0f795dd 100644 --- a/.github/workflows/history-check.yml +++ b/.github/workflows/history-check.yml @@ -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" diff --git a/.github/workflows/js-autofix.yml b/.github/workflows/js-autofix.yml index 38494abd0e3..8fb0460bc05 100644 --- a/.github/workflows/js-autofix.yml +++ b/.github/workflows/js-autofix.yml @@ -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 diff --git a/.github/workflows/label-rerun.yml b/.github/workflows/label-rerun.yml new file mode 100644 index 00000000000..fbd8b3b8932 --- /dev/null +++ b/.github/workflows/label-rerun.yml @@ -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." diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 28df38ad3e5..670b6f2a44a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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="" - 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="" - - # 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 diff --git a/.github/workflows/lockfile-diff.yml b/.github/workflows/lockfile-diff.yml index 2dcf66ea7c5..9d8d59f6da7 100644 --- a/.github/workflows/lockfile-diff.yml +++ b/.github/workflows/lockfile-diff.yml @@ -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='' + 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 diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml index e5a983b1bca..37fed82fbbf 100644 --- a/.github/workflows/osv-scanner.yml +++ b/.github/workflows/osv-scanner.yml @@ -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" diff --git a/.github/workflows/review-labels.yml b/.github/workflows/review-labels.yml new file mode 100644 index 00000000000..f0167b48674 --- /dev/null +++ b/.github/workflows/review-labels.yml @@ -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 diff --git a/.github/workflows/skills-index-freshness.yml b/.github/workflows/skills-index-freshness.yml index 5a9bf98a0f4..4931ccaa010 100644 --- a/.github/workflows/skills-index-freshness.yml +++ b/.github/workflows/skills-index-freshness.yml @@ -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: | diff --git a/.github/workflows/skills-index.yml b/.github/workflows/skills-index.yml index 8930a636fc0..ae05c9e7046 100644 --- a/.github/workflows/skills-index.yml +++ b/.github/workflows/skills-index.yml @@ -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 }} diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml index 648a1f7c6a6..1b8a35cb301 100644 --- a/.github/workflows/supply-chain-audit.yml +++ b/.github/workflows/supply-chain-audit.yml @@ -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 \`=floor,=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 - diff --git a/.github/workflows/uv-lockfile-check.yml b/.github/workflows/uv-lockfile-check.yml index 27b072a9941..aff4f0eb8cb 100644 --- a/.github/workflows/uv-lockfile-check.yml +++ b/.github/workflows/uv-lockfile-check.yml @@ -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" diff --git a/.gitignore b/.gitignore index 6f1b3be6d92..c4fb20049ea 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/acp_registry/agent.json b/acp_registry/agent.json index 09319a1d02a..1d3752bf15a 100644 --- a/acp_registry/agent.json +++ b/acp_registry/agent.json @@ -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"] } } diff --git a/agent/agent_init.py b/agent/agent_init.py index 1eae555c599..210743f92ac 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -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": , "from": , "to": }``. - 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 diff --git a/agent/billing_view.py b/agent/billing_view.py index 0e9930fd3ea..a535aee1f14 100644 --- a/agent/billing_view.py +++ b/agent/billing_view.py @@ -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 diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 791c3ddb649..dd7b75796b5 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -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 diff --git a/agent/lsp/client.py b/agent/lsp/client.py index 2aab98c2b76..9207cb75bda 100644 --- a/agent/lsp/client.py +++ b/agent/lsp/client.py @@ -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]]: diff --git a/agent/lsp/manager.py b/agent/lsp/manager.py index aebb4881c96..d3b4244790b 100644 --- a/agent/lsp/manager.py +++ b/agent/lsp/manager.py @@ -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) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 8c56cdf3c6a..93eed273e5d 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -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: diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 25d900341f4..4ae71cbb3a3 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -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, " diff --git a/agent/secret_sources/base.py b/agent/secret_sources/base.py index 882e6b21210..d4ead7d3f26 100644 --- a/agent/secret_sources/base.py +++ b/agent/secret_sources/base.py @@ -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 diff --git a/agent/secret_sources/bitwarden.py b/agent/secret_sources/bitwarden.py index 728f0ccd4e7..04e76b0910a 100644 --- a/agent/secret_sources/bitwarden.py +++ b/agent/secret_sources/bitwarden.py @@ -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) diff --git a/agent/secret_sources/onepassword.py b/agent/secret_sources/onepassword.py index a9ec9b313c6..5c0ddf1ab7e 100644 --- a/agent/secret_sources/onepassword.py +++ b/agent/secret_sources/onepassword.py @@ -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) diff --git a/agent/tool_executor.py b/agent/tool_executor.py index a5a1e54ed85..8957d24439f 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -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": diff --git a/apps/desktop/AGENTS.md b/apps/desktop/AGENTS.md index ca3a3d44d05..6908c2a4234 100644 --- a/apps/desktop/AGENTS.md +++ b/apps/desktop/AGENTS.md @@ -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." diff --git a/apps/desktop/e2e/boot-failure.spec.ts b/apps/desktop/e2e/boot-failure.spec.ts new file mode 100644 index 00000000000..a94373ce84a --- /dev/null +++ b/apps/desktop/e2e/boot-failure.spec.ts @@ -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 }) + }) +}) diff --git a/apps/desktop/e2e/boot.spec.ts b/apps/desktop/e2e/boot.spec.ts new file mode 100644 index 00000000000..0fd73d84511 --- /dev/null +++ b/apps/desktop/e2e/boot.spec.ts @@ -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 }) + }) +}) diff --git a/apps/desktop/e2e/chat.spec.ts b/apps/desktop/e2e/chat.spec.ts new file mode 100644 index 00000000000..0d12003df81 --- /dev/null +++ b/apps/desktop/e2e/chat.spec.ts @@ -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 }) + }) +}) diff --git a/apps/desktop/e2e/fix-electron-tracing.ts b/apps/desktop/e2e/fix-electron-tracing.ts new file mode 100644 index 00000000000..1c2a9825dad --- /dev/null +++ b/apps/desktop/e2e/fix-electron-tracing.ts @@ -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() +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 +} diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts new file mode 100644 index 00000000000..942e89dbe5b --- /dev/null +++ b/apps/desktop/e2e/fixtures.ts @@ -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): Record { + const clean: Record = {} + + 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 = {}): Record { + 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, +): 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 +} + +/** + * 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 { + // 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 +} + +/** + * 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 { + 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 +} + +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 { + 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 +} + +/** + * 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 { + 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).HERMES_DESKTOP_DEV_SERVER + delete (env as Record).HERMES_DESKTOP_HERMES + delete (env as Record).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 { + 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 { + // 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 { + 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 }, + ) +} diff --git a/apps/desktop/e2e/launch-packaged-app.spec.ts b/apps/desktop/e2e/launch-packaged-app.spec.ts new file mode 100644 index 00000000000..a404a01cf55 --- /dev/null +++ b/apps/desktop/e2e/launch-packaged-app.spec.ts @@ -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 }) +}) diff --git a/apps/desktop/e2e/mock-backend-setup.spec.ts b/apps/desktop/e2e/mock-backend-setup.spec.ts new file mode 100644 index 00000000000..1f068f20203 --- /dev/null +++ b/apps/desktop/e2e/mock-backend-setup.spec.ts @@ -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 }) + }) +}) diff --git a/apps/desktop/e2e/mock-server.ts b/apps/desktop/e2e/mock-server.ts new file mode 100644 index 00000000000..680cba30e7a --- /dev/null +++ b/apps/desktop/e2e/mock-server.ts @@ -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 }> { + 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() + } + }) + }), + }) + }) + }) +} diff --git a/apps/desktop/e2e/onboarding.spec.ts b/apps/desktop/e2e/onboarding.spec.ts new file mode 100644 index 00000000000..952178a3e06 --- /dev/null +++ b/apps/desktop/e2e/onboarding.spec.ts @@ -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 }) + }) +}) diff --git a/apps/desktop/e2e/visual-snapshot.ts b/apps/desktop/e2e/visual-snapshot.ts new file mode 100644 index 00000000000..df1bdc9d7e4 --- /dev/null +++ b/apps/desktop/e2e/visual-snapshot.ts @@ -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: + * -actual.png, -expected.png, -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 { + 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 { + 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`, + ) +} diff --git a/apps/desktop/electron/connection-apply.test.ts b/apps/desktop/electron/connection-apply.test.ts new file mode 100644 index 00000000000..ccf697a9284 --- /dev/null +++ b/apps/desktop/electron/connection-apply.test.ts @@ -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(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() + }) +}) diff --git a/apps/desktop/electron/connection-apply.ts b/apps/desktop/electron/connection-apply.ts new file mode 100644 index 00000000000..5764b9091e7 --- /dev/null +++ b/apps/desktop/electron/connection-apply.ts @@ -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 } diff --git a/apps/desktop/electron/connection-config.test.ts b/apps/desktop/electron/connection-config.test.ts index 425e63b15f2..4361678cb8f 100644 --- a/apps/desktop/electron/connection-config.test.ts +++ b/apps/desktop/electron/connection-config.test.ts @@ -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), diff --git a/apps/desktop/electron/connection-config.ts b/apps/desktop/electron/connection-config.ts index 569f9cc0726..f7b1ac2e0d4 100644 --- a/apps/desktop/electron/connection-config.ts +++ b/apps/desktop/electron/connection-config.ts @@ -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) { + 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 } diff --git a/apps/desktop/electron/desktop-installation.test.ts b/apps/desktop/electron/desktop-installation.test.ts new file mode 100644 index 00000000000..c8e8c78a288 --- /dev/null +++ b/apps/desktop/electron/desktop-installation.test.ts @@ -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', '')) +}) diff --git a/apps/desktop/electron/desktop-installation.ts b/apps/desktop/electron/desktop-installation.ts new file mode 100644 index 00000000000..2a067ca96df --- /dev/null +++ b/apps/desktop/electron/desktop-installation.ts @@ -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 } diff --git a/apps/desktop/electron/event-dedupe.test.ts b/apps/desktop/electron/event-dedupe.test.ts new file mode 100644 index 00000000000..3c0f905587e --- /dev/null +++ b/apps/desktop/electron/event-dedupe.test.ts @@ -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) + } +}) diff --git a/apps/desktop/electron/event-dedupe.ts b/apps/desktop/electron/event-dedupe.ts new file mode 100644 index 00000000000..a2d12a8f2cb --- /dev/null +++ b/apps/desktop/electron/event-dedupe.ts @@ -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() + + 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 + } +} diff --git a/apps/desktop/electron/git-repo-scan.test.ts b/apps/desktop/electron/git-repo-scan.test.ts new file mode 100644 index 00000000000..1ad00357637 --- /dev/null +++ b/apps/desktop/electron/git-repo-scan.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/electron/git-repo-scan.ts b/apps/desktop/electron/git-repo-scan.ts index 36ac189e66e..ce5b60368c8 100644 --- a/apps/desktop/electron/git-repo-scan.ts +++ b/apps/desktop/electron/git-repo-scan.ts @@ -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(items: T[], limit: number, fn: (item: T) => Promise): Promise { let cursor = 0 - async function worker() { + async function worker(): Promise { 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() + + function isExcluded(candidate: string): boolean { + return exclusions.some(excluded => repoScanPathIsWithin(candidate, excluded.value, pathOptions)) + } + + async function walk(dir: string, depth: number): Promise { + 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 } diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index e0dd2c4fb54..5ea4c9e16cb 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -20,6 +20,7 @@ import { nativeTheme, Notification, powerMonitor, + powerSaveBlocker, protocol, safeStorage, screen, @@ -38,6 +39,7 @@ import { waitForDashboardPortAnnouncement } from './backend-ready' import { shouldLatchBackendStartFailure } from './backend-start-failure' import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } from './bootstrap-platform' import { runBootstrap } from './bootstrap-runner' +import { applyConnectionChange, resolveTerminalConnection } from './connection-apply' import { authModeFromStatus, buildGatewayWsUrl, @@ -46,16 +48,25 @@ import { cookiesHaveLiveSession, cookiesHavePrivySession, cookiesHaveSession, + gatewayTicketFailure, + gatewayWsUrlIpcResult, + hostLabelFromBaseUrl, + localProfileEntry, modeIsRemoteLike, normalizeRemoteBaseUrl, + normalizeSshConfig, normAuthMode, pathWithGlobalRemoteProfile, + profileHasRemoteConnection, profileRemoteOverride, + profileSshOverride, resolveAuthMode, resolveTestWsUrl, + savedProfileSsh, tokenPreview } from './connection-config' import { adoptServedDashboardToken } from './dashboard-token' +import { loadOrCreateInstallationId, sshOwnershipId } from './desktop-installation' import { buildPosixCleanupScript, buildWindowsCleanupScript, @@ -66,6 +77,7 @@ import { uninstallArgsForMode } from './desktop-uninstall' import { installEmbedReferer } from './embed-referer' +import { createEventDeduper } from './event-dedupe' import { readDirForIpc } from './fs-read-dir' import { probeGatewayWebSocket } from './gateway-ws-probe' import { scanGitRepos } from './git-repo-scan' @@ -105,15 +117,28 @@ import { import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' import { ensureMainWindow } from './main-window-lifecycle' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' +import { createKeepAwake } from './power-save' import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' +import * as remoteLifecycle from './remote-lifecycle' +import { RemoteLivenessTracker, RemoteRevalidationCoordinator, revalidateRemoteConnection } from './remote-liveness' import { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry, + instanceWindowBounds, SESSION_WINDOW_MIN_HEIGHT, SESSION_WINDOW_MIN_WIDTH } from './session-windows' import { ensureSpawnHelperExecutable } from './spawn-helper-perms' +import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator' +import { collectSshConfigHosts, parseSshGOutput } from './ssh-config' +import { + buildInteractiveSshArgs, + createSshProbeConnection, + pickLocalPort, + redactSecrets, + SshConnection +} from './ssh-connection' import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width' import { resolveBehindCount, shouldCountCommits } from './update-count' import { readLiveUpdateMarker, writeUpdateMarker } from './update-marker' @@ -144,6 +169,12 @@ import { getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path' +import { + buildWindowsInteractiveCommand, + connectWindowsRemote, + detectRemotePlatform, + helper +} from './windows-remote-lifecycle' import { alreadyHasNoSandbox, buildNoSandboxRelaunchArgs, @@ -500,6 +531,7 @@ const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstr const BOOTSTRAP_MARKER_SCHEMA_VERSION = 1 const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'connection.json') +const DESKTOP_INSTALLATION_PATH = path.join(app.getPath('userData'), 'desktop-installation.json') const DESKTOP_UPDATE_CONFIG_PATH = path.join(app.getPath('userData'), 'updates.json') const DESKTOP_WINDOW_STATE_PATH = path.join(app.getPath('userData'), 'window-state.json') // active-profile.json records which Hermes profile the desktop launches its @@ -542,6 +574,7 @@ const DESKTOP_LOG_BACKUP_COUNT = 3 const DESKTOP_LOG_DISCARD_BYTES = DESKTOP_LOG_MAX_BYTES * 4 const desktopLogBackupPath = n => `${DESKTOP_LOG_PATH}.${n}` const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1' +const BOOT_FAKE_ERROR = process.env.HERMES_DESKTOP_BOOT_FAKE_ERROR || '' const BOOT_FAKE_STEP_MS = (() => { const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10) @@ -930,6 +963,8 @@ function registerMediaProtocol() { let mainWindow = null const backendConnectionState = createBackendConnectionState, any>() +const remoteLiveness = new RemoteLivenessTracker() +const remoteRevalidation = new RemoteRevalidationCoordinator() // True while connection-config:apply soft-rehomes the primary — suppresses the // backend-exit toast so an intentional kill doesn't look like a crash. let softRehomeInProgress = false @@ -4580,18 +4615,36 @@ function closePreviewWatchers() { } } -async function waitForHermes(baseUrl, token) { +async function waitForHermes(baseUrl, token, signal?) { const deadline = Date.now() + 45_000 let lastError = null while (Date.now() < deadline) { + if (signal?.aborted) { + const error: any = new Error('SSH bootstrap was superseded by newer connection settings.') + error.kind = 'superseded' + throw error + } + try { await fetchJson(`${baseUrl}/api/status`, token) return } catch (error) { lastError = error - await new Promise(resolve => setTimeout(resolve, 500)) + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, 500) + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer) + const aborted: any = new Error('SSH bootstrap was superseded by newer connection settings.') + aborted.kind = 'superseded' + reject(aborted) + }, + { once: true } + ) + }) } } @@ -4610,9 +4663,9 @@ function getNativeOverlayWidth() { return computeNativeOverlayWidth({ isWindows: IS_WINDOWS, isWsl: IS_WSL, isMac: IS_MAC }) } -function getWindowState() { +function getWindowState(win = mainWindow) { return { - isFullscreen: Boolean(mainWindow?.isFullScreen?.()), + isFullscreen: Boolean(win?.isFullScreen?.()), nativeOverlayWidth: getNativeOverlayWidth(), windowButtonPosition: getWindowButtonPosition() } @@ -4713,18 +4766,21 @@ function sendOpenUpdatesRequested() { mainWindow.focus() } -function sendWindowStateChanged(nextIsFullscreen?: boolean) { - if (!mainWindow || mainWindow.isDestroyed()) { +// Push titlebar/fullscreen chrome state to a window's renderer. Defaults to the +// primary, but any full chat window (primary or a secondary "instance" peer) +// passes itself so its own fullscreen toggle drives its own traffic-light inset. +function sendWindowStateChanged(nextIsFullscreen?: boolean, target = mainWindow) { + if (!target || target.isDestroyed()) { return } - const { webContents } = mainWindow + const { webContents } = target if (!webContents || webContents.isDestroyed()) { return } - const state = getWindowState() + const state = getWindowState(target) if (typeof nextIsFullscreen === 'boolean') { state.isFullscreen = nextIsFullscreen @@ -4762,6 +4818,11 @@ function buildApplicationMenu() { template.push({ label: 'File', submenu: [ + // No accelerator: ⌘⇧N is a rebindable renderer keybind (session.newWindow); + // a menu accelerator would fight the rebind panel and (on macOS) be + // swallowed before the renderer sees it. Here purely for discoverability. + { click: () => createInstanceWindow(), label: 'New Window' }, + { type: 'separator' }, IS_MAC ? { // NO accelerator: on macOS a registered ⌘W is consumed by the OS @@ -4918,7 +4979,9 @@ function setAndPersistZoomLevel(window, zoomLevel) { // downgrade or JSON read failure still finds a sane value). window.webContents .executeJavaScript( - `try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}` + `try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch { + void 0 + }` ) .catch(error => rememberLog(`[zoom] persist failed: ${error?.message || error}`)) } @@ -6024,16 +6087,39 @@ function sanitizeConnectionProfiles(raw: Record) { continue } + if (entry.mode === 'ssh') { + const ssh = normalizeSshConfig(entry) + + if (ssh) { + if (entry.token && typeof entry.token === 'object') { + ssh.token = entry.token + } + + out[name] = ssh + } + + continue + } + const cleaned: { mode: 'remote' | 'local' | 'cloud' url?: string authMode?: string token?: object org?: string + savedSsh?: object } = { mode: modeIsRemoteLike(entry.mode) ? entry.mode : 'local' } + if (cleaned.mode === 'local') { + const savedSsh = normalizeSshConfig(entry.savedSsh) + + if (savedSsh) { + cleaned.savedSsh = savedSsh + } + } + const url = String(entry.url || '').trim() if (url) { @@ -6091,7 +6177,7 @@ function readDesktopConnectionConfig() { // backward compatibility with configs written before OAuth support. remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token' config = { - mode: modeIsRemoteLike(parsed.mode) ? parsed.mode : 'local', + mode: parsed.mode === 'ssh' ? 'ssh' : modeIsRemoteLike(parsed.mode) ? parsed.mode : 'local', remote, // Per-profile remote overrides: each profile may point at its own // backend (local spawn or its own remote URL). Preserved verbatim so @@ -6158,15 +6244,15 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon const block = key ? scoped || {} : config.remote || {} const envOverride = key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL) + const savedMode = key ? scoped?.mode : config.mode + const ssh = savedMode === 'ssh' ? normalizeSshConfig(block) : null + + const savedSsh = savedMode === 'local' ? (key ? savedProfileSsh(config, key) : normalizeSshConfig(block)) : null const remoteToken = decryptDesktopSecret(block.token) const authMode = normAuthMode(block.authMode) const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '') - // The env override forces a plain remote connection. Otherwise reflect the - // saved mode, preserving 'cloud' (a Hermes Cloud connection — Q6) so the UI - // reopens into the cloud picker; any non-remote-like value collapses to local. - const savedMode = key ? scoped?.mode : config.mode - const mode = envOverride ? 'remote' : modeIsRemoteLike(savedMode) ? savedMode : 'local' + const mode = envOverride ? 'remote' : savedMode === 'ssh' ? 'ssh' : modeIsRemoteLike(savedMode) ? savedMode : 'local' let remoteOauthConnected = false @@ -6194,6 +6280,11 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon cloudOrg: mode === 'cloud' ? String(block.org || '') : '', remoteTokenPreview: tokenPreview(remoteToken), remoteTokenSet: Boolean(remoteToken), + sshHost: (ssh || savedSsh)?.host || '', + sshUser: (ssh || savedSsh)?.user || '', + sshPort: (ssh || savedSsh)?.port || null, + sshKeyPath: (ssh || savedSsh)?.keyPath || '', + sshRemoteHermesPath: (ssh || savedSsh)?.remoteHermesPath || '', // The env override only forces the global/primary connection; a per-profile // scope is never overridden by HERMES_DESKTOP_REMOTE_URL. envOverride @@ -6232,7 +6323,7 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo // 'cloud' and 'remote' both persist a remote-shaped block; 'cloud' is // remembered as its own provenance (Q6) and resolves to remote downstream. // Anything else collapses to local. - const mode = modeIsRemoteLike(input.mode) ? input.mode : 'local' + const mode = input.mode === 'ssh' ? 'ssh' : modeIsRemoteLike(input.mode) ? input.mode : 'local' const remoteLike = modeIsRemoteLike(mode) // The block being edited: a per-profile entry or the global remote block. @@ -6245,7 +6336,8 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo // block. (remote↔local toggles still preserve a real remote URL as before.) const existingMode = key ? existing.profiles?.[key]?.mode : existing.mode const leavingCloud = existingMode === 'cloud' && mode !== 'cloud' - const existingBlock = leavingCloud ? {} : rawExistingBlock + const leavingSsh = rawExistingBlock.mode === 'ssh' && mode !== 'ssh' && mode !== 'local' + const existingBlock = leavingCloud || leavingSsh ? {} : rawExistingBlock const remoteUrl = String(input.remoteUrl ?? existingBlock.url ?? '').trim() // authMode: explicit input wins; otherwise inherit the saved value, default 'token'. const authMode = resolveAuthMode(input.remoteAuthMode, existingBlock.authMode) @@ -6261,6 +6353,22 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo : { encoding: 'plain', value: incomingToken } : existingBlock.token + if (mode === 'ssh') { + const sshBlock = buildSshBlock(input, savedProfileSsh(existing, key) || rawExistingBlock) + + if (key) { + const profiles = { ...(existing.profiles || {}), [key]: sshBlock } + + return { + mode: existing.mode === 'ssh' || modeIsRemoteLike(existing.mode) ? existing.mode : 'local', + remote: existing.remote || {}, + profiles + } + } + + return { mode: 'ssh', remote: sshBlock, profiles: existing.profiles || {} } + } + if (key) { // Per-profile scope: a remote/cloud entry pins this profile to its own // backend; a local entry clears the override so the profile inherits the @@ -6270,11 +6378,17 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo if (remoteLike) { profiles[key] = { mode, ...buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg) } } else { - delete profiles[key] + const localEntry = localProfileEntry(rawExistingBlock) + + if (localEntry) { + profiles[key] = localEntry + } else { + delete profiles[key] + } } return { - mode: modeIsRemoteLike(existing.mode) ? existing.mode : 'local', + mode: existing.mode === 'ssh' || modeIsRemoteLike(existing.mode) ? existing.mode : 'local', remote: existing.remote || {}, profiles } @@ -6282,19 +6396,62 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo const nextRemote = remoteLike ? buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg) - : { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken } + : existingMode === 'ssh' + ? rawExistingBlock + : { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken } // Preserve per-profile overrides when saving the global connection. return { mode, remote: nextRemote, profiles: existing.profiles || {} } } +// Build an SSH connection block from a save payload, preserving an +// already-adopted dashboard token from the existing block (the token is minted +// + reconciled at bootstrap, never user-entered). `mode: 'ssh'` is stamped so +// normalizeSshConfig/profileSshOverride recognize it. +function buildSshBlock(input: any, existingBlock: any = {}) { + // `??` (not `||`) so an explicit '' (user CLEARED the field) wins over the + // saved value; only a truly absent (undefined) field inherits. + const merged = normalizeSshConfig({ + mode: 'ssh', + host: input.sshHost ?? existingBlock.host, + user: input.sshUser ?? existingBlock.user, + port: input.sshPort ?? existingBlock.port, + keyPath: input.sshKeyPath ?? existingBlock.keyPath, + remoteHermesPath: input.sshRemoteHermesPath ?? existingBlock.remoteHermesPath + }) + + if (!merged) { + throw new Error('SSH host is required.') + } + + // Carry forward an already-adopted dashboard token unless the host changed + // (a different host invalidates the old dashboard's token). + if (existingBlock.token && existingBlock.host === merged.host) { + merged.token = existingBlock.token + } + + return merged +} + // Build a remote backend connection descriptor from an already-resolved remote // config. Handles both auth models (OAuth ws-ticket vs static session token) // and is shared by the per-profile, env, and global resolution paths. `token` // is the DECRYPTED static token (or null in OAuth mode). `source` is a label // for diagnostics ('profile' | 'env' | 'settings'). -async function buildRemoteConnection(rawUrl, authMode, token, source) { +async function buildRemoteConnection( + rawUrl, + authMode, + token, + source, + remoteHost?, + remoteKind = 'url', + remoteIdentity? +) { const baseUrl = normalizeRemoteBaseUrl(rawUrl) + // For token/oauth remotes the meaningful host is the real backend URL; for + // SSH remotes the caller passes the entered/resolved host explicitly (the + // baseUrl is a 127.0.0.1 tunnel and would be useless in the pill). + const host = remoteHost || hostLabelFromBaseUrl(baseUrl) if (authMode === 'oauth') { // OAuth gateway: auth comes from the session cookies in the OAuth @@ -6320,13 +6477,11 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) { try { ticket = await mintGatewayWsTicket(baseUrl) } catch (error) { - const err = new Error( - 'Your remote gateway session has expired. ' + 'Open Settings → Gateway and click "Sign in" again.' - ) as any - - err.needsOauthLogin = true - err.cause = error - throw err + throw gatewayTicketFailure( + error, + 'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.', + 'Could not reach the remote Hermes gateway while refreshing its WebSocket ticket. Try reconnecting.' + ) } return { @@ -6334,6 +6489,9 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) { mode: 'remote', source, authMode: 'oauth', + remoteHost: host || undefined, + remoteIdentity, + remoteKind, // No static token in OAuth mode; REST is cookie-authed via the partition. token: null, wsUrl: buildGatewayWsUrlWithTicket(baseUrl, ticket) @@ -6352,11 +6510,288 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) { mode: 'remote', source, authMode: 'token', + remoteHost: host || undefined, + remoteIdentity, + remoteKind, token, wsUrl: buildGatewayWsUrl(baseUrl, token) } } +const sshConnections = new Map() +const desktopInstallationId = loadOrCreateInstallationId(DESKTOP_INSTALLATION_PATH) + +const sshBootstrapCoordinator = createBootstrapCoordinator() + +let sshQuitTeardownDone = false + +function sshScopeKey(profile) { + return connectionScopeKey(profile) || '' +} + +function sshOwnershipKey(profile) { + return sshOwnershipId(desktopInstallationId, sshScopeKey(profile)) +} + +function sshRememberLog(chunk) { + rememberLog(redactSecrets(String(chunk == null ? '' : chunk))) +} + +async function sshProbeReuseProof(baseUrl, token, spawnNonce) { + try { + const proof: any = await fetchJson(`${baseUrl}/api/ssh/ownership`, token) + + return proof?.ok === true && proof.sshOwnerNonce === spawnNonce && proof.protocolVersion === 1 + ? 'authenticated-ok' + : 'authenticated-stale' + } catch (error: any) { + if (/^(401|403|404):/.test(String(error?.message || ''))) { + return 'authenticated-stale' + } + + throw error + } +} + +async function teardownSshConnection(profile) { + const scope = sshScopeKey(profile) + const state = sshConnections.get(scope) + + if (!state) { + return + } + + sshConnections.delete(scope) + + for (const [id, info] of [...terminalSessions.entries()]) { + if (info.sshScope === scope) { + disposeTerminalSession(id) + } + } + + try { + if (state.localPort && state.remotePort) { + await state.ssh.cancelForward(state.localPort, state.remotePort) + } + } catch { + // best effort + } + + try { + await state.ssh.close() + } catch { + // best effort + } +} + +// CRITICAL: this must mirror resolveRemoteBackend's precedence, not just return +// any cached SSH state. A per-profile token/OAuth override wins over a global +// SSH connection — so if the active profile resolves to a NON-SSH backend, the +// terminal must NOT fall through to a global SSH host. +function activeSshTerminalTarget() { + const profile = primaryProfileKey() + const config = readDesktopConnectionConfig() + + if (profileSshOverride(config, profile)) { + const scope = sshScopeKey(profile) + const state = sshConnections.get(scope) + + return state && state.ssh ? { ssh: state.ssh, scope } : 'pending' + } + + if (profileRemoteOverride(config, profile)) { + return null + } + + if (process.env.HERMES_DESKTOP_REMOTE_URL) { + return null + } + + if (config.mode === 'ssh') { + const state = sshConnections.get('') + + return state && state.ssh ? { ssh: state.ssh, scope: '' } : 'pending' + } + + return null +} + +function effectiveSshConfigFingerprint(sshConfig) { + const ssh = + process.platform === 'win32' + ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe') + : 'ssh' + + const args = ['-G'] + + if (sshConfig.port) { + args.push('-p', String(sshConfig.port)) + } + + if (sshConfig.keyPath) { + args.push('-i', sshConfig.keyPath) + } + + args.push('--', sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host) + const output = execFileSync(ssh, args, { encoding: 'utf8', timeout: 10_000, windowsHide: true }) + + return crypto.createHash('sha256').update(output).digest('hex') +} + +async function bootstrapSshConnection(profile, sshConfig, reuseToken, source) { + const scope = sshScopeKey(profile) + const effectiveConfigFingerprint = effectiveSshConfigFingerprint(sshConfig) + const resolvedConfig = { ...sshConfig, effectiveConfigFingerprint } + const fingerprint = sshConfigFingerprint(scope, resolvedConfig) + + return sshBootstrapCoordinator.start(scope, fingerprint, lease => + bootstrapSshConnectionInner(profile, resolvedConfig, reuseToken, source, fingerprint, lease) + ) +} + +async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, source, fingerprint, lease) { + const scope = sshScopeKey(profile) + const hostLabel = sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host + const existing = sshConnections.get(scope) + + if (existing && existing.fingerprint !== fingerprint) { + await teardownSshConnection(profile) + } + + let ssh = sshConnections.get(scope)?.ssh + + if (ssh && !(await ssh.isAlive())) { + try { + await ssh.close() + } catch { + void 0 + } + + ssh = null + sshConnections.delete(scope) + } + + const created = !ssh + + let removeForceCleanup = () => {} + + if (created) { + ssh = new SshConnection( + { host: sshConfig.host, user: sshConfig.user, port: sshConfig.port, keyPath: sshConfig.keyPath }, + { + rememberLog: sshRememberLog, + ownershipId: sshOwnershipKey(profile), + scope, + effectiveConfigFingerprint: sshConfig.effectiveConfigFingerprint + } + ) + removeForceCleanup = lease.onForceCleanup(() => ssh.close()) + await ssh.open() + } + + let result + + try { + const platform = await detectRemotePlatform(ssh, sshConfig.remoteHermesPath || '') + const lifecycle = platform.os === 'Windows' ? connectWindowsRemote : remoteLifecycle.connect + result = await lifecycle({ + ssh, + profile: connectionScopeKey(profile) || '', + remoteHermesPath: sshConfig.remoteHermesPath || '', + ownershipId: sshOwnershipKey(profile), + reuseToken: reuseToken || '', + forward: (localPort, remotePort) => ssh.forward(localPort, remotePort), + cancelForward: (localPort, remotePort) => ssh.cancelForward(localPort, remotePort), + pickLocalPort, + waitForHermes: (baseUrl, token) => waitForHermes(baseUrl, token, lease.signal), + probeReuseProof: sshProbeReuseProof, + adoptServedToken: adoptServedDashboardToken, + rememberLog: sshRememberLog, + signal: lease.signal + }) + } catch (error: any) { + if (created) { + try { + await ssh.close() + } catch { + void 0 + } + } + + const err = new Error(error.message) as any + err.sshError = error.kind || 'unknown' + err.isSshBootstrap = true + throw err + } + + try { + lease.assertCurrent() + } catch (error) { + try { + await ssh.cancelForward(result.localPort, result.remotePort) + await ssh.close() + } catch { + void 0 + } + + throw error + } + + persistSshConnectionToken(profile, source, result.token) + + removeForceCleanup() + sshConnections.set(scope, { + ssh, + fingerprint, + localPort: result.localPort, + remotePort: result.remotePort, + pid: result.pid, + host: sshConfig.host, + hostLabel, + hermesVersion: result.hermesVersion || '', + remotePlatform: result.platform?.os || '', + reused: result.reused + }) + + sshRememberLog( + `[ssh] connection ${result.reused ? 'REUSED' : 'spawned'} dashboard: ` + + `${result.hermesVersion || 'hermes (version unknown)'} at ${result.hermesPath || '?'}` + ) + + const connection = await buildRemoteConnection( + result.baseUrl, + 'token', + result.token, + source, + hostLabel, + 'ssh', + result.ownershipId + ) + + return { ...connection, remoteHermesVersion: result.hermesVersion || '' } +} + +function persistSshConnectionToken(profile, source, token) { + try { + const config = readDesktopConnectionConfig() + const encrypted = encryptDesktopSecret(token) + + if (source === 'profile') { + const key = connectionScopeKey(profile) + + if (key && config.profiles?.[key]?.mode === 'ssh') { + config.profiles[key].token = encrypted + writeDesktopConnectionConfig(config) + } + } else if (config.mode === 'ssh' && config.remote) { + config.remote.token = encrypted + writeDesktopConnectionConfig(config) + } + } catch (error: any) { + sshRememberLog(`[ssh] could not persist served token: ${error.message}`) + } +} + // Resolve the remote backend for a given profile, or null when that profile // should run a LOCAL backend. Precedence: // 1. explicit per-profile remote override (connection.json `profiles[name]`) @@ -6370,12 +6805,27 @@ async function resolveRemoteBackend(profile) { // 1. Per-profile override — "a profile with its own remote host". Wins even // over the env override so an explicitly-configured profile always // reaches its intended backend. + const sshOverride = profileSshOverride(config, profile) + + if (sshOverride) { + const reuseToken = decryptDesktopSecret(config.profiles?.[connectionScopeKey(profile)]?.token) + + return bootstrapSshConnection(profile, sshOverride, reuseToken, 'profile') + } + const override = profileRemoteOverride(config, profile) if (override) { const token = override.authMode === 'oauth' ? null : decryptDesktopSecret(override.token) - return buildRemoteConnection(override.url, override.authMode, token, 'profile') + return buildRemoteConnection( + override.url, + override.authMode, + token, + 'profile', + undefined, + config.profiles?.[connectionScopeKey(profile)]?.mode === 'cloud' ? 'cloud' : 'url' + ) } // 2. Env override (global, token-auth only). @@ -6393,7 +6843,20 @@ async function resolveRemoteBackend(profile) { return buildRemoteConnection(rawEnvUrl, 'token', rawEnvToken, 'env') } - // 3. Global remote (or cloud — cloud resolves to a remote backend, Q6). + // 3. Global remote. + if (config.mode === 'ssh') { + const ssh = normalizeSshConfig({ mode: 'ssh', ...(config.remote || {}) }) + + if (!ssh) { + throw new Error('SSH remote mode is selected but no host is configured.') + } + + const reuseToken = decryptDesktopSecret(config.remote?.token) + + return bootstrapSshConnection(null, ssh, reuseToken, 'settings') + } + + // Cloud resolves through the existing URL/OAuth path. if (!modeIsRemoteLike(config.mode)) { return null } @@ -6401,7 +6864,14 @@ async function resolveRemoteBackend(profile) { const authMode = normAuthMode(config.remote?.authMode) const token = authMode === 'oauth' ? null : decryptDesktopSecret(config.remote?.token) - return buildRemoteConnection(config.remote?.url, authMode, token, 'settings') + return buildRemoteConnection( + config.remote?.url, + authMode, + token, + 'settings', + undefined, + config.mode === 'cloud' ? 'cloud' : 'url' + ) } // A remote profile's sessions live on its remote host's state.db, not on a local @@ -6409,13 +6879,13 @@ async function resolveRemoteBackend(profile) { // not the local-disk fast path. These three helpers drive that (see // interceptSessionReadForRemote). function profileHasRemoteOverride(profile) { - return Boolean(profileRemoteOverride(readDesktopConnectionConfig(), profile)) + return profileHasRemoteConnection(readDesktopConnectionConfig(), profile) } function configuredRemoteProfileNames() { const config = readDesktopConnectionConfig() - return Object.keys(config.profiles || {}).filter(name => profileRemoteOverride(config, name)) + return Object.keys(config.profiles || {}).filter(name => profileHasRemoteConnection(config, name)) } // True when the app is in app-global remote mode (Settings → "All profiles" → @@ -6427,7 +6897,9 @@ function globalRemoteActive() { return true } - return modeIsRemoteLike(readDesktopConnectionConfig().mode) + const mode = readDesktopConnectionConfig().mode + + return modeIsRemoteLike(mode) || mode === 'ssh' } // True when the PRIMARY profile's backend resolves to a remote/cloud host — @@ -6522,6 +6994,90 @@ async function probeRemoteAuthMode(rawUrl) { } async function testDesktopConnectionConfig(input: any = {}) { + if (input.mode === 'ssh') { + const sshConfig = normalizeSshConfig({ + mode: 'ssh', + host: input.sshHost, + user: input.sshUser, + port: input.sshPort, + keyPath: input.sshKeyPath, + remoteHermesPath: input.sshRemoteHermesPath + }) + + if (!sshConfig) { + return { reachable: false, sshError: 'unreachable', error: 'SSH host is required.' } + } + + const ssh = createSshProbeConnection( + { host: sshConfig.host, user: sshConfig.user, port: sshConfig.port, keyPath: sshConfig.keyPath }, + { rememberLog: sshRememberLog } + ) + + try { + // One bounded retry on TIMEOUT only: a cold Windows backend's first + // PowerShell exec can exceed the budget (observed live), and a timeout is + // indeterminate — unlike auth/host-key/unreachable, which are verdicts. + let attempt = 0 + + for (;;) { + try { + await ssh.open() + const platform: any = await detectRemotePlatform(ssh, sshConfig.remoteHermesPath || '') + let hermesPath + let hermesVersion + let supported + + if (platform.os === 'Windows') { + const runtime = platform + hermesPath = runtime.hermesPath + const inspection = await helper(ssh, runtime, 'inspect', [runtime.hermesPath]) + hermesVersion = inspection.version + supported = inspection.supported + } else { + hermesPath = await remoteLifecycle.locateHermes(ssh, sshConfig.remoteHermesPath || '') + hermesVersion = await remoteLifecycle.probeHermesVersion(ssh, hermesPath) + supported = await remoteLifecycle.remoteSupportsSshOwnership(ssh, hermesPath) + } + + if (!supported) { + return { + reachable: false, + sshError: 'update-required', + error: 'Update Hermes on the remote host before connecting with Desktop SSH.' + } + } + + return { + reachable: true, + sshError: null, + error: null, + remotePlatform: `${platform.os}/${platform.arch}`, + remoteHermesPath: hermesPath, + remoteHermesVersion: hermesVersion, + host: sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host + } + } catch (error: any) { + if (error?.kind === 'timeout' && attempt === 0) { + attempt += 1 + sshRememberLog('[ssh] test probe timed out once; retrying') + + continue + } + + throw error + } + } + } catch (error: any) { + return { reachable: false, sshError: error.kind || 'unknown', error: error.message } + } finally { + try { + await ssh.close() + } catch { + void 0 + } + } + } + const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false }) const key = connectionScopeKey(input.profile) // The block under test: a per-profile entry or the global remote. Coerce has @@ -6608,6 +7164,7 @@ function stopBackendChild(child) { // switch / crash recovery), which still resets boot progress + reloads. function resetHermesConnection({ soft = false } = {}) { backendStartFailure = null + remoteLiveness.clear() const hermesProcess = backendConnectionState.invalidate() stopBackendChild(hermesProcess) @@ -6999,6 +7556,16 @@ async function startHermes() { throw backendStartFailure } + // E2E: simulate a boot failure without breaking the real backend. The boot + // progresses a few steps, then fails with the given error message. + if (BOOT_FAKE_ERROR) { + await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8) + const error = new Error(BOOT_FAKE_ERROR) as any + error.isBootstrapFailure = true + bootstrapFailure = error + throw error + } + const existingConnectionPromise = backendConnectionState.getPromise() if (existingConnectionPromise) { @@ -7036,6 +7603,9 @@ async function startHermes() { mode: 'remote', source: remote.source, authMode: remote.authMode || 'token', + remoteHost: remote.remoteHost, + remoteKind: remote.remoteKind, + remoteHermesVersion: remote.remoteHermesVersion, token: remote.token, wsUrl: remote.wsUrl, logs: hermesLog.slice(-80), @@ -7316,11 +7886,7 @@ function focusWindow(win) { win.focus() } -function spawnSecondaryWindow({ - sessionId, - watch, - newSession -}: { sessionId?: string; watch?: boolean; newSession?: boolean } = {}) { +function spawnSecondaryWindow({ sessionId, watch }: { sessionId?: string; watch?: boolean } = {}) { const icon = getAppIconPath() const win = new BrowserWindow({ @@ -7365,8 +7931,7 @@ function spawnSecondaryWindow({ buildSessionWindowUrl(sessionId, { devServer: DEV_SERVER, rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(), - watch, - newSession + watch }) ) @@ -7378,11 +7943,82 @@ function createSessionWindow(sessionId, { watch = false } = {}) { return sessionWindows.openOrFocus(sessionId, () => spawnSecondaryWindow({ sessionId, watch })) } -// Open a fresh compact window on the new-session draft (#/). Not registry-keyed: -// like ⌘N in a browser, every press opens a new window — and a draft window that -// later converts to a real session must not get refocused as if it were blank. -function createNewSessionWindow() { - return spawnSecondaryWindow({ newSession: true }) +// Additional full "instance" windows — peers of the primary that render the +// COMPLETE app (sidebar, routing, its own draft) against the shared backend, so +// a user can run multiple GUI windows at once (⌘⇧N / the "New Window" palette +// command). Unlike the compact session windows they carry no `?win` flag. The +// primary mainWindow stays the notification / deep-link / pet-overlay anchor and +// is NOT tracked here. The set holds a strong reference so an open peer isn't +// garbage-collected, and drops it on close. +const instanceWindows = new Set() + +// Cascade a new instance off whichever window spawned it so it doesn't land +// exactly on top of its source. Falls back to the persisted primary geometry +// when there's no live source window (e.g. all windows closed on macOS). The +// pure cascade math lives in session-windows.ts (instanceWindowBounds). +function nextInstanceBounds() { + const source = BrowserWindow.getFocusedWindow() || mainWindow + const fallback = computeWindowOptions(readWindowState(), screen.getAllDisplays()) + const base = source && !source.isDestroyed() ? source.getBounds() : null + + return instanceWindowBounds(base, fallback) +} + +// Open a new full-chrome instance window. Mirrors createWindow()'s window +// options (shared chatWindowWebPreferences keeps backgroundThrottling:false so a +// streamed answer never stalls in the background) but is a peer, not the +// primary: it never overwrites the mainWindow global, doesn't start the backend +// (the renderer's getConnection() joins the already-running one), and loads the +// plain renderer URL so the full app renders. +function createInstanceWindow() { + const icon = getAppIconPath() + + const win = new BrowserWindow({ + ...nextInstanceBounds(), + minWidth: WINDOW_MIN_WIDTH, + minHeight: WINDOW_MIN_HEIGHT, + title: 'Hermes', + titleBarStyle: 'hidden', + titleBarOverlay: getTitleBarOverlayOptions(), + trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined, + vibrancy: IS_MAC ? 'sidebar' : undefined, + opacity: windowOpacity(), + icon, + show: false, + backgroundColor: getWindowBackgroundColor(), + webPreferences: chatWindowWebPreferences(PRELOAD_PATH) + }) + + instanceWindows.add(win) + + if (IS_MAC) { + win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION) + } + + win.once('ready-to-show', () => { + if (!win.isDestroyed()) { + win.show() + } + }) + + // Per-window fullscreen chrome: send this window its own titlebar inset so its + // traffic lights hide/show independently of the primary. + win.on('enter-full-screen', () => sendWindowStateChanged(true, win)) + win.on('leave-full-screen', () => sendWindowStateChanged(false, win)) + + wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat')) + + win.on('closed', () => { + instanceWindows.delete(win) + }) + + if (DEV_SERVER) { + win.loadURL(DEV_SERVER) + } else { + win.loadURL(pathToFileURL(resolveRendererIndex()).toString()) + } + + return win } // The pet overlay: a single transparent, frameless, always-on-top window that @@ -7570,7 +8206,9 @@ function createWindow() { if (!nativeThemeListenerInstalled) { nativeThemeListenerInstalled = true nativeTheme.on('updated', () => { - applyTitleBarOverlay(mainWindow) + for (const win of BrowserWindow.getAllWindows()) { + applyTitleBarOverlay(win) + } }) } } @@ -7608,6 +8246,14 @@ function createWindow() { } }) + // Under Playright testing, instantly show the window. + // `ready-to-show` doesn't fire in some testing envs. + if (process.env.TEST_WORKER_INDEX !== undefined) { + if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) { + mainWindow.show() + } + } + mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true)) mainWindow.on('enter-full-screen', () => sendWindowStateChanged(true)) mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false)) @@ -7758,42 +8404,43 @@ ipcMain.handle('hermes:connection:revalidate', async () => { return { ok: true, rebuilt: false } } - let conn = null + // Main and every session pop-out have their own renderer reconnect loop but + // share this primary connection. Coalesce simultaneous requests so one outage + // produces one failure observation rather than exhausting the whole streak. + return remoteRevalidation.run(connectionPromise, async () => { + const result = await revalidateRemoteConnection({ + connectionPromise, + currentConnectionPromise: () => backendConnectionState.getPromise(), + log: rememberLog, + probe: fetchPublicJson, + resetConnection: resetHermesConnection, + tracker: remoteLiveness + }) - try { - conn = await connectionPromise - } catch { - // The cached boot already rejected (its own catch clears the promise); - // nothing to revalidate — the next getConnection() builds fresh. - return { ok: true, rebuilt: false } - } + // A rebuilt SSH connection must also tear down its tunnel/master before the + // renderer re-dials (which only happens after this handler resolves), so the + // fresh bootstrap can't reattach to a dying transport. + if (result.rebuilt) { + const conn = await connectionPromise.catch(() => null) - if (!conn || conn.mode !== 'remote' || !conn.baseUrl) { - return { ok: true, rebuilt: false } - } + if (conn?.remoteKind === 'ssh') { + const profile = primaryProfileKey() + await sshBootstrapCoordinator.cancelAndWait(sshScopeKey(profile)) + await teardownSshConnection(profile) + } + } - const base = conn.baseUrl.replace(/\/+$/, '') - - try { - await fetchPublicJson(`${base}/api/status`, { timeoutMs: 2_500 }) - - return { ok: true, rebuilt: false } - } catch { - // Unreachable remote: drop the stale cache so the renderer's next reconnect - // tick rebuilds a fresh, reachable descriptor. resetHermesConnection only - // clears the connection promise for a remote (no child to SIGTERM). - rememberLog('Cached remote Hermes backend failed liveness probe; dropping stale connection.') - resetHermesConnection() - - return { ok: true, rebuilt: true } - } + return result + }) }) ipcMain.handle('hermes:backend:touch', async (_event, profile) => { touchPoolBackend(profile) return { ok: true } }) -ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile)) +ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => { + return gatewayWsUrlIpcResult(() => freshGatewayWsUrl(profile)) +}) ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => { if (typeof sessionId !== 'string' || !sessionId.trim()) { return { ok: false, error: 'invalid-session-id' } @@ -7803,8 +8450,8 @@ ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => { return { ok: true } }) -ipcMain.handle('hermes:window:openNewSession', async () => { - createNewSessionWindow() +ipcMain.handle('hermes:window:openInstance', async () => { + createInstanceWindow() return { ok: true } }) @@ -8014,6 +8661,50 @@ ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState()) ipcMain.handle('hermes:connection-config:get', async (_event, profile) => sanitizeDesktopConnectionConfig(readDesktopConnectionConfig(), profile) ) +ipcMain.handle('hermes:ssh-config:hosts', async () => ({ hosts: collectSshConfigHosts() })) +ipcMain.handle('hermes:ssh-config:resolve', async (_event, host) => { + const value = String(host || '').trim() + + if (!value) { + throw new Error('SSH host is required.') + } + + const ssh = + process.platform === 'win32' + ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe') + : 'ssh' + + return new Promise((resolve, reject) => { + const child = spawn(ssh, ['-G', '--', value], hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'pipe'] })) + let stdout = '' + let stderr = '' + + const timer = setTimeout(() => { + child.kill() + reject(new Error('SSH config resolution timed out.')) + }, 10_000) + + child.stdout.on('data', chunk => { + stdout += String(chunk) + }) + child.stderr.on('data', chunk => { + stderr += String(chunk) + }) + child.once('error', error => { + clearTimeout(timer) + reject(error) + }) + child.once('close', code => { + clearTimeout(timer) + + if (code !== 0) { + reject(new Error(stderr.trim() || 'Could not resolve SSH host.')) + } else { + resolve(parseSshGOutput(stdout)) + } + }) + }) +}) ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload)) ipcMain.handle('hermes:connection-config:probe', async (_event, rawUrl) => probeRemoteAuthMode(rawUrl)) ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => { @@ -8075,19 +8766,17 @@ ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => { writeDesktopConnectionConfig(config) const key = connectionScopeKey(payload?.profile) + const scope = key || '' - if (key && key !== primaryProfileKey()) { - // Editing a NON-primary profile's connection: don't disturb the window's - // primary backend. Drop the profile's pooled backend so the next switch - // re-resolves against the new remote/local target. - stopPoolBackend(key) - } else { - // Global / primary connection: soft re-home. Tear down the window backend - // without resetting boot UI or reloading — the shell stays, the renderer - // wipes session lists (skeletons) and re-dials on hermes:connection:applied. - await teardownPrimaryBackendAndWait({ soft: true }) - sendConnectionApplied() - } + await applyConnectionChange({ + cancelAndWait: value => sshBootstrapCoordinator.cancelAndWait(value), + isPrimary: !key || key === primaryProfileKey(), + scope, + sendApplied: sendConnectionApplied, + stopPool: stopPoolBackend, + teardownPrimary: () => teardownPrimaryBackendAndWait({ soft: true }), + teardownSsh: value => teardownSshConnection(value || null) + }) return sanitizeDesktopConnectionConfig(config, payload?.profile) }) @@ -8418,11 +9107,28 @@ ipcMain.handle('hermes:api', async (_event, request) => { }) }) +// One deduper per cross-window cue — the choke point every window shares. Main +// handles IPC serially, so the first window to claim a key wins with no race. +const isDuplicateNotification = createEventDeduper() +const claimedAmbientCue = createEventDeduper() + +// A window asks "do I own this ambient cue (turn-end sound / spoken reply)?". +// The first caller within the window gets true; peers get false and stay quiet. +ipcMain.handle('hermes:ambient:claim', (_event, key) => !claimedAmbientCue(String(key ?? ''))) + ipcMain.handle('hermes:notify', (_event, payload) => { if (!Notification.isSupported()) { return false } + // Multiple full windows each run their own renderer throttle, so the same + // kind+session can arrive here twice. Collapse it at this single choke point. + // Return true (not false): a notification for the event IS being shown by the + // first caller, so the settings "send test" success probe stays honest. + if (isDuplicateNotification(`${payload?.kind ?? ''}:${payload?.sessionId ?? ''}`)) { + return true + } + // Action buttons render only on signed macOS builds; elsewhere they're dropped // and the body click still works. const actions = Array.isArray(payload?.actions) ? payload.actions : [] @@ -8592,7 +9298,13 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => { background: payload.background, foreground: payload.foreground } - applyTitleBarOverlay(mainWindow) + + // Repaint the native (Windows/Linux) titlebar overlay on every open chat + // window, not just the primary — instance peers and session windows share the + // one app theme. applyTitleBarOverlay no-ops on the frameless pet overlay. + for (const win of BrowserWindow.getAllWindows()) { + applyTitleBarOverlay(win) + } }) // Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH). @@ -8624,6 +9336,33 @@ ipcMain.on('hermes:translucency', (_event, payload) => { } }) +// Keep-awake: hold the machine awake for long/overnight runs. Main owns the one +// blocker and its persisted state so a cold launch restores it (applied on +// ready — powerSaveBlocker needs the app ready). The renderer toggles it from +// Settings → Advanced over IPC. See store/keep-awake. +const KEEP_AWAKE_CONFIG_PATH = path.join(app.getPath('userData'), 'keep-awake.json') +const keepAwake = createKeepAwake(powerSaveBlocker) + +function readPersistedKeepAwake() { + try { + return JSON.parse(fs.readFileSync(KEEP_AWAKE_CONFIG_PATH, 'utf8')).on === true + } catch { + return false + } +} + +ipcMain.on('hermes:keep-awake', (_event, on) => { + const enabled = Boolean(on) + keepAwake.set(enabled) + + try { + fs.mkdirSync(path.dirname(KEEP_AWAKE_CONFIG_PATH), { recursive: true }) + fs.writeFileSync(KEEP_AWAKE_CONFIG_PATH, JSON.stringify({ on: enabled }, null, 2), 'utf8') + } catch (error) { + rememberLog(`[keep-awake] write failed: ${error.message}`) + } +}) + ipcMain.handle('hermes:openExternal', (_event, url) => { if (!openExternalUrl(url)) { throw new Error('Invalid external URL') @@ -9115,15 +9854,30 @@ ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80) const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24) - const ptyProcess = nodePty.spawn(command, args, { - cols, - cwd, - env: terminalShellEnv(), - name: 'xterm-256color', - rows - }) + const sshTarget = await resolveTerminalConnection(activeSshTerminalTarget, () => ensureBackend(primaryProfileKey())) + const remote = Boolean(sshTarget) + const remoteState = remote ? sshConnections.get(sshTarget.scope) : null - terminalSessions.set(id, { pty: ptyProcess, webContentsId: event.sender.id }) + const remoteCommand = + remoteState?.remotePlatform === 'Windows' + ? buildWindowsInteractiveCommand(String(payload?.cwd || '').trim()) + : undefined + + const ptyProcess = remote + ? nodePty.spawn( + process.platform === 'win32' + ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe') + : 'ssh', + buildInteractiveSshArgs(sshTarget.ssh, String(payload?.cwd || '').trim(), undefined, remoteCommand), + { cols, cwd: app.getPath('home'), env: terminalShellEnv(), name: 'xterm-256color', rows } + ) + : nodePty.spawn(command, args, { cols, cwd, env: terminalShellEnv(), name: 'xterm-256color', rows }) + + terminalSessions.set(id, { + pty: ptyProcess, + webContentsId: event.sender.id, + ...(remote ? { sshScope: sshTarget.scope, remoteCwd: String(payload?.cwd || '') } : {}) + }) const send = (suffix, payload) => { if (event.sender.isDestroyed()) { @@ -9140,7 +9894,7 @@ ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { }) event.sender.once('destroyed', () => disposeTerminalSession(id)) - return { cwd, id, shell: name } + return { cwd: remote ? null : cwd, id, shell: remote ? 'ssh' : name } }) ipcMain.handle('hermes:terminal:write', (_event, id, data) => { @@ -9176,7 +9930,7 @@ ipcMain.handle('hermes:terminal:cwd', async (_event, id) => { return null } - return readProcessCwd(sessionInfo.pty.pid) + return sessionInfo.sshScope !== undefined ? null : readProcessCwd(sessionInfo.pty.pid) }) ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || ''))) @@ -9624,6 +10378,7 @@ app.whenReady().then(() => { ensureWslWindowsFonts() configureSpellChecker() registerPowerResumeListeners() + keepAwake.set(readPersistedKeepAwake()) createWindow() // Win/Linux cold start: the launching hermes:// URL is in our own argv. @@ -9668,7 +10423,24 @@ function configureSpellChecker() { } } -app.on('before-quit', () => { +app.on('before-quit', event => { + if ((sshConnections.size > 0 || sshBootstrapCoordinator.promises().length > 0) && !sshQuitTeardownDone) { + event.preventDefault() + sshBootstrapCoordinator.cancelAll() + const scopes = [...sshConnections.keys()] + + const pending = Promise.allSettled([ + ...scopes.map(scope => teardownSshConnection(scope || null)), + ...sshBootstrapCoordinator.promises() + ]) + + void Promise.race([pending, new Promise(resolve => setTimeout(resolve, 4_000))]).then(async () => { + await sshBootstrapCoordinator.forceCleanupAll() + sshQuitTeardownDone = true + app.quit() + }) + } + // Clean quit mid-boot should not trip next-launch --no-sandbox (#38216). // FATAL GPU aborts skip before-quit, leaving the `booting` marker in place. // Keyed on sticky (not active): a manual --no-sandbox run still records a diff --git a/apps/desktop/electron/power-save.test.ts b/apps/desktop/electron/power-save.test.ts new file mode 100644 index 00000000000..97725e0ed01 --- /dev/null +++ b/apps/desktop/electron/power-save.test.ts @@ -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() + + 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') + }) +}) diff --git a/apps/desktop/electron/power-save.ts b/apps/desktop/electron/power-save.ts new file mode 100644 index 00000000000..0939de99d80 --- /dev/null +++ b/apps/desktop/electron/power-save.ts @@ -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() + } + } +} diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 952fdfdc8da..7652a7688dd 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -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), diff --git a/apps/desktop/electron/remote-lifecycle.test.ts b/apps/desktop/electron/remote-lifecycle.test.ts new file mode 100644 index 00000000000..b421b0ef0c3 --- /dev/null +++ b/apps/desktop/electron/remote-lifecycle.test.ts @@ -0,0 +1,1017 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { + buildSpawnCommand, + cleanupStale, + connect, + expandRemotePath, + fingerprintToken, + isForwardBindCollision, + locateHermes, + LOCKFILE_SCHEMA_VERSION, + lockfilePath, + openForward, + ownershipDirectory, + pidIsOurDashboard, + probeRemotePlatform, + PROTOCOL_VERSION, + readLockfile, + READY_RE, + remotePidAlive, + remoteSupportsSshOwnership, + scrapeReadyPort, + spawnLogPath, + spawnRemoteDashboard, + validateRemotePath, + writeLockfile +} from './remote-lifecycle' + +const OWNERSHIP_ID = '0123456789abcdef0123456789abcdef' +const SPAWN_NONCE = '0123456789abcdef' + +function ownedLock(over: any = {}) { + return { + schemaVersion: LOCKFILE_SCHEMA_VERSION, + protocolVersion: PROTOCOL_VERSION, + ownershipId: OWNERSHIP_ID, + spawnNonce: SPAWN_NONCE, + pid: 333, + port: 40000, + profile: '', + hermesPath: '~/.local/bin/hermes', + hermesHome: '~/.hermes', + logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), + tokenFingerprint: fingerprintToken('stored-token'), + startedAt: '2026-07-14T00:00:00.000Z', + ...over + } +} + +// A fake SshConnection whose exec() is matched against an ordered list of +// [regex|fn, response|fn] rules. First match wins; unmatched commands return ''. +function fakeSsh(rules: any[] = []) { + const calls: string[] = [] + + return { + calls, + async exec(cmd) { + calls.push(cmd) + + for (const [matcher, resp] of rules) { + const hit = typeof matcher === 'function' ? matcher(cmd) : matcher.test(cmd) + + if (hit) { + const out = typeof resp === 'function' ? resp(cmd) : resp + + if (out instanceof Error) { + throw out + } + + return out + } + } + + return '' + } + } +} + +test('locateHermes prefers the explicit profile path when executable', async () => { + const ssh = fakeSsh([[/\[ -x .*\/opt\/hermes/, 'OK']]) + assert.equal(await locateHermes(ssh, '/opt/hermes'), '/opt/hermes') +}) + +test('locateHermes throws (no silent fallback) when an EXPLICIT path is not executable', async () => { + // command -v WOULD find a different install, but an explicit path must not + // silently fall back to it — that is the "connected to the wrong hermes" bug. + const ssh = fakeSsh([ + [/command -v hermes/, '/home/u/.local/bin/hermes\n'], + [/\[ -x .*\.local\/bin\/hermes/, 'OK'] + ]) + + await assert.rejects( + () => locateHermes(ssh, '/bad/path/hermes'), + (err: any) => { + assert.equal(err.kind, 'hermes-not-found') + assert.match(err.message, /\/bad\/path\/hermes/) + + return true + } + ) +}) + +test('locateHermes falls back to the login-shell command -v probe', async () => { + const ssh = fakeSsh([ + [/command -v hermes/, '/home/u/.local/bin/hermes\n'], + [/\[ -x .*\.local\/bin\/hermes/, 'OK'] + ]) + + assert.equal(await locateHermes(ssh, ''), '/home/u/.local/bin/hermes') +}) + +test('locateHermes canonicalizes an installer wrapper to its executable target', async () => { + const ssh = fakeSsh([ + [/command -v hermes/, '/home/u/.local/bin/hermes\n'], + [/\[ -x .*\.local\/bin\/hermes/, 'OK'], + [/python3 -c/, '/home/u/.hermes/hermes-agent/venv/bin/hermes\n'] + ]) + + assert.equal(await locateHermes(ssh, ''), '/home/u/.hermes/hermes-agent/venv/bin/hermes') +}) + +test('locateHermes falls back to ~/.local/bin/hermes when the login-shell probe misses', async () => { + // ~/.local/bin is the non-root installer's command location (scripts/install.sh). + const ssh = fakeSsh([ + [/command -v hermes/, ''], + [/\[ -x .*\.local\/bin\/hermes/, 'OK'] + ]) + + assert.equal(await locateHermes(ssh, ''), '~/.local/bin/hermes') +}) + +test('locateHermes tries the conventional venv path last', async () => { + const ssh = fakeSsh([[/\[ -x .*venv\/bin\/hermes/, 'OK']]) + assert.equal(await locateHermes(ssh, ''), '~/.hermes/hermes-agent/venv/bin/hermes') +}) + +test('locateHermes throws a hermes-not-found error with an install hint', async () => { + const ssh = fakeSsh([]) // nothing is executable + await assert.rejects( + () => locateHermes(ssh, ''), + (err: any) => { + assert.equal(err.kind, 'hermes-not-found') + assert.match(err.message, /install/i) + + return true + } + ) +}) + +test('locateHermes uses a login shell for the command -v probe', async () => { + const ssh = fakeSsh([ + [/command -v hermes/, '/x/hermes'], + [/\[ -x/, 'OK'] + ]) + + await locateHermes(ssh, '') + assert.ok( + ssh.calls.some(c => /bash -lc/.test(c)), + 'must probe in a login shell (PATH pitfall)' + ) +}) + +test('probeRemotePlatform accepts Linux and macOS', async () => { + assert.deepEqual(await probeRemotePlatform(fakeSsh([[/uname/, 'Linux\nx86_64']])), { + os: 'Linux', + arch: 'x86_64' + }) + assert.deepEqual(await probeRemotePlatform(fakeSsh([[/uname/, 'Darwin\narm64']])), { + os: 'Darwin', + arch: 'arm64' + }) +}) + +test('probeRemotePlatform rejects unsupported remote platforms', async () => { + await assert.rejects( + () => probeRemotePlatform(fakeSsh([[/uname/, 'MINGW64_NT\nx86_64']])), + (err: any) => { + assert.equal(err.kind, 'unsupported-platform') + + return true + } + ) +}) + +test('ownership paths are isolated by ownership ID and spawn nonce', () => { + assert.equal(ownershipDirectory(OWNERSHIP_ID), `~/.hermes/desktop-ssh/${OWNERSHIP_ID}`) + assert.equal(lockfilePath(OWNERSHIP_ID), `~/.hermes/desktop-ssh/${OWNERSHIP_ID}/backend.lock.json`) + assert.equal(spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), `~/.hermes/desktop-ssh/${OWNERSHIP_ID}/${SPAWN_NONCE}.log`) +}) + +test('readLockfile returns null for missing, empty, malformed, or wrong-schema', async () => { + assert.equal(await readLockfile(fakeSsh([[/cat/, '']]), OWNERSHIP_ID), null) + assert.equal(await readLockfile(fakeSsh([[/cat/, 'not json']]), OWNERSHIP_ID), null) + assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify({ schemaVersion: 999 })]]), OWNERSHIP_ID), null) + const good = ownedLock({ pid: 1, port: 2 }) + assert.deepEqual(await readLockfile(fakeSsh([[/cat/, JSON.stringify(good)]]), OWNERSHIP_ID), good) +}) + +test('writeLockfile mkdir -ps and stamps the schema version', async () => { + const ssh = fakeSsh([]) + await writeLockfile(ssh, OWNERSHIP_ID, ownedLock({ pid: 7, port: 9 })) + const cmd = ssh.calls.join('\n') + assert.match(cmd, /mkdir -p/) + assert.match(cmd, new RegExp(`"schemaVersion":${LOCKFILE_SCHEMA_VERSION}`)) +}) + +test('remotePidAlive maps kill -0 ALIVE/DEAD', async () => { + assert.equal(await remotePidAlive(fakeSsh([[/kill -0/, 'ALIVE']]), 123), true) + assert.equal(await remotePidAlive(fakeSsh([[/kill -0/, 'DEAD']]), 123), false) + assert.equal(await remotePidAlive(fakeSsh([]), null), false) +}) + +test('metadata and process proof transport failures remain indeterminate', async () => { + const failure = new Error('connection reset') + await assert.rejects( + () => readLockfile(fakeSsh([[/cat/, failure]]), OWNERSHIP_ID), + (error: any) => error.kind === 'transient-transport-error' + ) + await assert.rejects( + () => remotePidAlive(fakeSsh([[/kill -0/, failure]]), 123), + (error: any) => error.kind === 'transient-transport-error' + ) + await assert.rejects( + () => pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, failure]]), 5, SPAWN_NONCE, '/x/hermes'), + (error: any) => error.kind === 'transient-transport-error' + ) +}) + +test('pidIsOurDashboard requires the exact serve ownership nonce', async () => { + const ours = `/x/hermes serve --isolated --ssh-owner-nonce ${SPAWN_NONCE}` + assert.equal(await pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, 'OWNED\n']]), 5, SPAWN_NONCE, '/x/hermes'), true) + assert.equal( + await pidIsOurDashboard( + fakeSsh([[/print\("OWNED"/, command => (command.includes('fedcba9876543210') ? 'FOREIGN\n' : 'OWNED\n')]]), + 5, + 'fedcba9876543210', + '/x/hermes' + ), + false + ) + assert.equal(await pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, 'FOREIGN\n']]), 5, SPAWN_NONCE, '/x/hermes'), false) +}) + +test('cleanupStale kills ONLY a provably-ours pid, always drops the lockfile', async () => { + const notOurs = fakeSsh([[/print\("OWNED"/, 'FOREIGN\n']]) + await cleanupStale(notOurs, OWNERSHIP_ID, { + pid: 5, + spawnNonce: SPAWN_NONCE, + hermesPath: '/x/hermes', + logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) + }) + assert.ok(!notOurs.calls.some(c => /kill 5\b/.test(c)), 'must not kill a pid that is not our dashboard') + assert.ok(notOurs.calls.some(c => /rm -f/.test(c))) + + const ours = fakeSsh([[/print\("OWNED"/, 'OWNED\n']]) + await cleanupStale(ours, OWNERSHIP_ID, { + pid: 9, + spawnNonce: SPAWN_NONCE, + hermesPath: '/x/hermes', + logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) + }) + assert.ok(ours.calls.some(c => /kill 9\b/.test(c))) + assert.ok(ours.calls.some(c => /rm -f/.test(c))) +}) + +test('buildSpawnCommand is headless serve, detached, token not in argv', () => { + const cmd = buildSpawnCommand('/x/hermes', 'work', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) }) + assert.match(cmd, /serve --isolated/) + assert.match(cmd, /--host 127\.0\.0\.1 --port 0/) + assert.doesNotMatch(cmd, /--skip-build|--no-open/) + assert.doesNotMatch(cmd, /\bdashboard\b/) + assert.match(cmd, /--profile/) + assert.match(cmd, /work/) + assert.match(cmd, /setsid/) + assert.match(cmd, /<\/dev\/null/) + assert.match(cmd, /echo \$!/) + assert.ok(!cmd.includes('tok_secret_value'), 'token must not appear in spawn command') + assert.ok(!cmd.includes('HERMES_DASHBOARD_SESSION_TOKEN'), 'token env var must not appear') +}) + +test('buildSpawnCommand always uses serve (legacy dashboard path removed)', () => { + const cmd = buildSpawnCommand('/x/hermes', 'work', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) }) + assert.match(cmd, /serve --isolated/) + assert.match(cmd, /--host 127\.0\.0\.1 --port 0/) + assert.doesNotMatch(cmd, /dashboard/) + assert.doesNotMatch(cmd, /--skip-build/) + assert.match(cmd, /setsid/) +}) + +test('spawnRemoteDashboard returns exact ownership artifacts', async () => { + const ssh = fakeSsh([ + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/printf '%s\\n'/, ''], + [/setsid|nohup/, '4242\n'] + ]) + + const { pid, spawnNonce, logPath } = await spawnRemoteDashboard(ssh, { + hermesPath: '/x/hermes', + profile: '', + token: 'tk', + ownershipId: OWNERSHIP_ID + }) + + assert.equal(pid, 4242) + assert.match(spawnNonce, /^[0-9a-f]{16}$/) + assert.equal(logPath, spawnLogPath(OWNERSHIP_ID, spawnNonce)) +}) + +test('spawnRemoteDashboard always spawns serve (legacy dashboard path removed)', async () => { + const ssh = fakeSsh([ + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/printf '%s\\n'/, ''], + [/setsid|nohup/, '4242\n'] + ]) + + await spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tk', ownershipId: OWNERSHIP_ID }) + const spawn = ssh.calls.find(c => /setsid|nohup/.test(c)) + assert.match(spawn, /serve --isolated/) + assert.doesNotMatch(spawn, /\bdashboard\b/) +}) + +test('READY_RE accepts both serve and dashboard sentinels', () => { + assert.equal(READY_RE.exec('HERMES_BACKEND_READY port=4321')?.[1], '4321') + assert.equal(READY_RE.exec('HERMES_DASHBOARD_READY port=8765')?.[1], '8765') +}) + +test('spawnRemoteDashboard rejects when no pid is returned', async () => { + const ssh = fakeSsh([ + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/printf '%s\\n'/, ''], + [/setsid|nohup/, 'not-a-pid'] + ]) + + await assert.rejects( + () => spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 't', ownershipId: OWNERSHIP_ID }), + (err: any) => { + assert.equal(err.kind, 'spawn-failed') + + return true + } + ) +}) + +test('scrapeReadyPort reads only the named spawn log', async () => { + const logPath = spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) + const ssh = fakeSsh([[/cat/, 'some noise\nHERMES_DASHBOARD_READY port=51234\n']]) + const port = await scrapeReadyPort(ssh, logPath, { timeoutMs: 1000 }) + assert.equal(port, 51234) + assert.ok(ssh.calls.every(call => !call.includes('desktop-ssh.log'))) +}) + +test('scrapeReadyPort times out and reports a dead spawn', async () => { + // never emits a READY line + const ssh = fakeSsh([[/cat .*\.log/, 'still starting...']]) + await assert.rejects( + () => scrapeReadyPort(ssh, spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), { timeoutMs: 60 }), + (err: any) => { + assert.equal(err.kind, 'ready-timeout') + + return true + } + ) + // dead process before announcement → spawn-failed + await assert.rejects( + () => + scrapeReadyPort(fakeSsh([[/cat/, '']]), spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), { + timeoutMs: 1000, + isAlive: async () => false + }), + (err: any) => { + assert.equal(err.kind, 'spawn-failed') + + return true + } + ) +}) + +function connectDeps(ssh, over: any = {}) { + return { + ssh, + ownershipId: OWNERSHIP_ID, + profile: '', + forward: async () => {}, + cancelForward: async () => {}, + pickLocalPort: async () => 50001, + waitForHermes: async () => {}, + probeReuseProof: async () => 'authenticated-ok', + adoptServedToken: async (_baseUrl, spawn) => spawn || 'served-token', + rememberLog: () => {}, + readyTimeoutMs: 2000, + ...over + } +} + +test('connect() spawns fresh when there is no lockfile, adopts the served token', async () => { + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, ''], // no lockfile + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], // token file write + [/printf '%s\\n'/, ''], + [/setsid/, '777\n'], + [/kill -0 777/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=51999\n'] + ]) + + const result = await connect(connectDeps(ssh, { adoptServedToken: async () => 'the-served-token' })) + assert.equal(result.reused, false) + assert.equal(result.remotePort, 51999) + assert.equal(result.localPort, 50001) + assert.equal(result.pid, 777) + assert.equal(result.token, 'the-served-token') + assert.equal(result.baseUrl, 'http://127.0.0.1:50001') + assert.equal(result.tokenFingerprint, fingerprintToken('the-served-token')) +}) + +test('connect() reuses a healthy dashboard when fingerprint + probe pass', async () => { + const reuseToken = 'stored-token' + const lock = ownedLock({ tokenFingerprint: fingerprintToken(reuseToken) }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0/, 'ALIVE'], + [/print\("OWNED"/, 'OWNED\n'] + ]) + + const result = await connect(connectDeps(ssh, { reuseToken, adoptServedToken: async (_b, t) => t })) + assert.equal(result.reused, true) + assert.equal(result.pid, 333) + assert.equal(result.remotePort, 40000) + // never spawned + assert.ok(!ssh.calls.some(c => /setsid/.test(c)), 'reuse path must not spawn a new dashboard') +}) + +test('connect() respawns when the lockfile hermesPath differs from the resolved path', async () => { + const reuseToken = 'stored-token' + const lock = ownedLock({ hermesPath: '/old/stale/hermes', tokenFingerprint: fingerprintToken(reuseToken) }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0/, 'ALIVE'], + [/print\("OWNED"/, 'FOREIGN\n'], + [/--version/, 'Hermes Agent v0.18.2\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '890\n'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=52050\n'] + ]) + + const result = await connect( + connectDeps(ssh, { reuseToken, remoteHermesPath: '/new/hermes', adoptServedToken: async () => 'fresh' }) + ) + + assert.equal(result.reused, false, 'must respawn, not reuse the old-path dashboard') + assert.ok( + ssh.calls.some(c => /setsid/.test(c)), + 'a fresh dashboard must be spawned' + ) +}) + +test('connect() respawns when the lockfile protocolVersion is incompatible', async () => { + const reuseToken = 'stored-token' + + const lock = { + schemaVersion: LOCKFILE_SCHEMA_VERSION, + protocolVersion: PROTOCOL_VERSION + 99, + pid: 333, + port: 40000, + tokenFingerprint: fingerprintToken(reuseToken) + } + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0 333/, 'ALIVE'], + [/print\("OWNED"/, 'FOREIGN\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '901\n'], + [/kill -0 901/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=44100\n'] + ]) + + const result = await connect(connectDeps(ssh, { reuseToken, adoptServedToken: async () => 'fresh' })) + assert.equal(result.reused, false, 'incompatible protocol must force a fresh spawn, not a reattach') + assert.equal(result.pid, 901) +}) + +test('connect() fresh spawn writes hermesHome + protocolVersion into the lockfile', async () => { + const writes: string[] = [] + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, ''], // no lockfile + [/HERMES_HOME/, '/home/alice/.hermes\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/printf '%s\\n'/, ''], + [/setsid/, '700\n'], + [/kill -0 700/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=45500\n'], + [ + /printf '%s' '/, + c => { + writes.push(c) + + return '' + } + ] + ]) + + await connect(connectDeps(ssh, { adoptServedToken: async () => 'fresh' })) + const lockWrite = writes.find(c => c.includes('schemaVersion')) || '' + assert.match(lockWrite, new RegExp(`"protocolVersion":${PROTOCOL_VERSION}`)) + assert.match(lockWrite, /"hermesHome":"\/home\/alice\/\.hermes"/) +}) + +test('connect() respawns when the lockfile pid is dead (killed dashboard)', async () => { + const lock = ownedLock({ tokenFingerprint: fingerprintToken('t') }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0 333/, 'DEAD'], + [/print\("OWNED"/, 'FOREIGN\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '888\n'], + [/kill -0 888/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=42000\n'] + ]) + + const result = await connect(connectDeps(ssh, { reuseToken: 't', adoptServedToken: async () => 'fresh' })) + assert.equal(result.reused, false) + assert.equal(result.pid, 888) + assert.equal(result.remotePort, 42000) + assert.ok( + !ssh.calls.some(command => command.includes('pid=333') && command.includes('print("OWNED"')), + 'a dead pid has no process identity to verify' + ) +}) + +test('connect() respawns when the dashboard is wedged (alive pid, probe fails)', async () => { + const reuseToken = 'stored' + + const lock = { + schemaVersion: LOCKFILE_SCHEMA_VERSION, + protocolVersion: PROTOCOL_VERSION, + pid: 333, + port: 40000, + tokenFingerprint: fingerprintToken(reuseToken) + } + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0/, 'ALIVE'], + [/print\("OWNED"/, 'FOREIGN\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '999\n'], + [/kill -0 999/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=43000\n'] + ]) + + const result = await connect( + connectDeps(ssh, { + reuseToken, + probeReuseProof: async () => 'authenticated-stale', + adoptServedToken: async () => 'fresh' + }) + ) + + assert.equal(result.reused, false) + assert.equal(result.pid, 999) + assert.equal(result.remotePort, 43000) +}) + +test('connect() aborts on an unsupported remote platform before doing anything else', async () => { + const ssh = fakeSsh([[/uname/, 'SunOS\nsun4v']]) + await assert.rejects( + () => connect(connectDeps(ssh)), + (err: any) => { + assert.equal(err.kind, 'unsupported-platform') + + return true + } + ) + assert.ok(!ssh.calls.some(c => /setsid/.test(c))) +}) + +test('openForward retries bind collisions only', async () => { + const ports = [41001, 41002] + const calls: number[] = [] + + const localPort = await openForward( + { + pickLocalPort: async () => ports.shift(), + forward: async port => { + calls.push(port) + + if (calls.length === 1) { + throw new Error('bind: Address already in use') + } + } + }, + 9119 + ) + + assert.equal(localPort, 41002) + assert.deepEqual(calls, [41001, 41002]) + assert.equal(isForwardBindCollision(new Error('Permission denied')), false) +}) + +test('connect() preserves an owned backend when a reuse transport throws', async () => { + const reuseToken = 'stored-token' + const lock = ownedLock({ tokenFingerprint: fingerprintToken(reuseToken) }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0/, 'ALIVE'], + [/print\("OWNED"/, 'OWNED\n'] + ]) + + await assert.rejects( + () => + connect( + connectDeps(ssh, { + reuseToken, + forward: async () => { + throw new Error('network reset') + } + }) + ), + /network reset/ + ) + assert.ok(!ssh.calls.some(cmd => /kill 333\b/.test(cmd))) +}) + +test('validateRemotePath accepts absolute POSIX paths', () => { + assert.doesNotThrow(() => validateRemotePath('/usr/bin/hermes')) + assert.doesNotThrow(() => validateRemotePath('/home/user/.hermes/hermes-agent/venv/bin/hermes')) +}) + +test('validateRemotePath accepts ~/ prefix paths', () => { + assert.doesNotThrow(() => validateRemotePath('~/bin/hermes')) + assert.doesNotThrow(() => validateRemotePath('~/.hermes/logs/desktop-ssh.log')) + assert.doesNotThrow(() => validateRemotePath('~')) +}) + +test('validateRemotePath accepts paths with spaces and quotes', () => { + assert.doesNotThrow(() => validateRemotePath('/home/user/my project/hermes')) + assert.doesNotThrow(() => validateRemotePath("~/path with 'quotes'/file")) + assert.doesNotThrow(() => validateRemotePath('/path with "double quotes"/file')) +}) + +test('validateRemotePath rejects relative paths', () => { + assert.throws(() => validateRemotePath('hermes'), /absolute|relative/i) + assert.throws(() => validateRemotePath('./bin/hermes'), /absolute|relative/i) + assert.throws(() => validateRemotePath('../etc/passwd'), /absolute|relative/i) +}) + +test('validateRemotePath rejects NUL and newline', () => { + assert.throws(() => validateRemotePath('/usr/bin/hermes\x00'), /unsafe/i) + assert.throws(() => validateRemotePath('/usr/bin/hermes\n'), /unsafe/i) + assert.throws(() => validateRemotePath('/usr/bin/hermes\r'), /unsafe/i) +}) + +test('validateRemotePath preserves shell metacharacters as path data', () => { + for (const p of ['/usr/$(whoami)/hermes', '/usr/`id`/hermes', '/usr/a;b|c&df']) { + assert.doesNotThrow(() => validateRemotePath(p)) + assert.match(expandRemotePath(p), /^'/) + } +}) + +test('expandRemotePath expands ~/ to "$HOME"/', () => { + const result = expandRemotePath('~/.hermes/logs/desktop-ssh.log') + assert.match(result, /\$HOME/) + assert.ok(!result.includes('eval'), 'must not use eval') + assert.ok(!result.includes('echo'), 'must not use echo for expansion') +}) + +test('expandRemotePath returns quoted absolute paths unchanged', () => { + const result = expandRemotePath('/usr/local/bin/hermes') + assert.ok(result.includes('/usr/local/bin/hermes')) + assert.ok(!result.includes('eval')) +}) + +test('expandRemotePath preserves spaces as data', () => { + const result = expandRemotePath('/home/user/my project/hermes') + assert.ok(result.includes('my project'), 'spaces must be preserved, not split') +}) + +test('buildSpawnCommand does not embed the token in the command string', () => { + const cmd = buildSpawnCommand('/x/hermes', 'work', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) }) + assert.ok(!cmd.includes('super_secret_token_value'), 'token must not appear in the spawn command') + assert.ok(!cmd.includes('HERMES_DASHBOARD_SESSION_TOKEN'), 'env var name must not appear') +}) + +test('buildSpawnCommand includes --ssh-session-token-file when tokenFilePath is provided', () => { + const cmd = buildSpawnCommand('/x/hermes', 'work', { + tokenFilePath: `~/.hermes/desktop-ssh/${OWNERSHIP_ID}/${SPAWN_NONCE}.token`, + logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), + spawnNonce: SPAWN_NONCE + }) + + assert.match(cmd, /--ssh-session-token-file/) + assert.match(cmd, /\.hermes\/desktop-ssh\//) +}) + +test('buildSpawnCommand always uses serve, never dashboard', () => { + const cmd = buildSpawnCommand('/x/hermes', '', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) }) + assert.match(cmd, /serve --isolated/) + assert.doesNotMatch(cmd, /\bdashboard\b/) + assert.doesNotMatch(cmd, /--skip-build/) + assert.doesNotMatch(cmd, /--no-open/) +}) + +test('spawnRemoteDashboard removes a token file when upload reporting fails', async () => { + const failure = new Error('channel closed') + + const ssh = fakeSsh([ + [/grep -q ssh-session-token-file/, 'YES\n'], + [command => /python3 -c/.test(command) && !/rm -f/.test(command), failure], + [/rm -f/, ''] + ]) + + await assert.rejects( + () => spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tok', ownershipId: OWNERSHIP_ID }), + /channel closed/ + ) + assert.ok(ssh.calls.some(command => /rm -f .*\.token/.test(command))) +}) + +test('spawnRemoteDashboard streams the token over stdin, not argv/env', async () => { + const stdinCalls: string[] = [] + const calls: string[] = [] + + const ssh = { + calls, + async exec(cmd, opts?) { + calls.push(cmd) + + if (opts?.stdinData) { + stdinCalls.push(opts.stdinData) + } + + if (/grep -q ssh-session-token-file/.test(cmd)) { + return 'YES\n' + } + + if (/python3 -c/.test(cmd)) { + return '' + } + + if (/setsid|nohup/.test(cmd)) { + return '4242\n' + } + + if (/printf '%s\\n'/.test(cmd)) { + return '' + } + + return '' + } + } + + const { pid } = await spawnRemoteDashboard(ssh as any, { + hermesPath: '/x/hermes', + profile: '', + token: 'secret_token_val', + ownershipId: OWNERSHIP_ID + }) + + assert.equal(pid, 4242) + assert.ok(stdinCalls.length > 0, 'token must be sent via stdin') + assert.ok( + stdinCalls.some(d => d === 'secret_token_val'), + 'stdin must contain the token' + ) + + for (const cmd of calls) { + assert.ok(!cmd.includes('secret_token_val'), `token leaked into command: ${cmd}`) + } +}) + +test('spawnRemoteDashboard upload uses exclusive-create and O_NOFOLLOW', async () => { + const calls: string[] = [] + + const ssh = { + calls, + async exec(cmd, opts?) { + calls.push(cmd) + + if (/grep -q ssh-session-token-file/.test(cmd)) { + return 'YES\n' + } + + if (/python3 -c/.test(cmd)) { + return '' + } + + if (/setsid|nohup/.test(cmd)) { + return '4242\n' + } + + if (/printf '%s\\n'/.test(cmd)) { + return '' + } + + return '' + } + } + + await spawnRemoteDashboard(ssh as any, { + hermesPath: '/x/hermes', + profile: '', + token: 'tk', + ownershipId: OWNERSHIP_ID + }) + const uploadCmd = calls.find(c => /python3 -c/.test(c)) + assert.ok(uploadCmd, 'must use python3 -c for token upload') + assert.match(uploadCmd, /O_EXCL/, 'upload must use O_EXCL to reject existing files') + assert.match(uploadCmd, /O_NOFOLLOW/, 'upload must use O_NOFOLLOW to reject symlinks') + assert.match(uploadCmd, /O_WRONLY/, 'upload must open write-only') + assert.match(uploadCmd, /dir_fd=dd/, 'upload must create relative to the opened parent directory') + assert.match(uploadCmd, /os\.fstat\(dd\)/, 'upload must validate the opened parent directory') + assert.ok(!uploadCmd.includes('tk'), 'token must not appear in the upload command') +}) + +test('readLockfile rejects lock with non-integer pid', async () => { + const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 'not-a-number', port: 8080 } + assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID), null) +}) + +test('readLockfile rejects lock with pid <= 0', async () => { + const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: -1, port: 8080 } + assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID), null) +}) + +test('readLockfile rejects lock with port out of range', async () => { + const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 100, port: 99999 } + assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID), null) + const lock2 = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 100, port: 0 } + assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock2)]]), OWNERSHIP_ID), null) +}) + +test('readLockfile accepts a complete owned lock', async () => { + const lock = ownedLock({ pid: 42, port: 51234 }) + const result = await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID) + assert.deepEqual(result, lock) +}) + +test('connect() reuse path does not write a token file', async () => { + const reuseToken = 'stored-token' + const lock = ownedLock({ tokenFingerprint: fingerprintToken(reuseToken) }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0/, 'ALIVE'], + [/print\("OWNED"/, 'OWNED\n'] + ]) + + const result = await connect(connectDeps(ssh, { reuseToken, adoptServedToken: async (_b, t) => t })) + assert.equal(result.reused, true) + assert.ok(!ssh.calls.some(c => /sys\.stdin\.buffer\.read/.test(c)), 'reuse must not upload a token file') +}) + +test('spawnRemoteDashboard fails with update-required when remote lacks --ssh-session-token-file', async () => { + const ssh = fakeSsh([[/--ssh-session-token-file/, 'NO\n']]) + + await assert.rejects( + () => spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tk', ownershipId: OWNERSHIP_ID }), + (err: any) => { + assert.match(err.message, /update|upgrade/i) + assert.equal(err.kind, 'update-required') + + return true + } + ) +}) + +test('readLockfile rejects a log path outside the exact ownership and spawn path', async () => { + const lock = ownedLock({ logPath: '~/.hermes/desktop-ssh/other.log' }) + const ssh = fakeSsh([[/cat .*lock\.json/, JSON.stringify(lock)]]) + assert.equal(await readLockfile(ssh, OWNERSHIP_ID), null) +}) + +test('cleanupStale never deletes a lock-supplied unexpected log path', async () => { + const ssh = fakeSsh([[/print\("OWNED"/, 'OWNED\n']]) + await cleanupStale(ssh, OWNERSHIP_ID, ownedLock({ logPath: '~/.hermes/unrelated.log' })) + assert.ok(!ssh.calls.some(command => command.includes('unrelated.log'))) +}) + +test('pidIsOurDashboard requires an exact nonce option value', async () => { + const prefix = `/x/hermes serve --isolated --ssh-owner-nonce ${SPAWN_NONCE}ff` + const suffix = `/x/hermes serve --isolated --ssh-owner-nonce xx${SPAWN_NONCE}` + assert.equal(await pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, 'FOREIGN\n']]), 5, SPAWN_NONCE, '/x/hermes'), false) + assert.equal(await pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, 'FOREIGN\n']]), 5, SPAWN_NONCE, '/x/hermes'), false) +}) + +test('connect removes the token file when a fresh backend fails after returning a pid', async () => { + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, ''], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '999\n'], + [/kill -0 999/, 'DEAD'] + ]) + + await assert.rejects(() => connect(connectDeps(ssh)), /exited before announcing/i) + assert.ok(ssh.calls.some(command => /rm -f .*\.token/.test(command))) +}) + +test('connect preserves an exact-owned backend when reuse proof transport fails', async () => { + const reuseToken = 'stored-token' + const lock = ownedLock({ tokenFingerprint: fingerprintToken(reuseToken) }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0/, 'ALIVE'], + [/print\("OWNED"/, 'OWNED\n'] + ]) + + await assert.rejects( + () => + connect( + connectDeps(ssh, { + reuseToken, + probeReuseProof: async () => { + throw new Error('connection reset') + } + }) + ), + (error: any) => error.kind === 'transient-transport-error' + ) + assert.ok(!ssh.calls.some(command => /kill 333\b/.test(command))) + assert.ok(!ssh.calls.some(command => /rm -f .*backend\.lock\.json/.test(command))) +}) + +test('connect replaces an exact-owned backend only after authenticated stale proof', async () => { + const reuseToken = 'stored-token' + const lock = ownedLock({ tokenFingerprint: fingerprintToken(reuseToken) }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0 333/, 'ALIVE'], + [/print\("OWNED"/, 'OWNED\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '999\n'], + [/kill -0 999/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=43000\n'] + ]) + + const result = await connect( + connectDeps(ssh, { + reuseToken, + probeReuseProof: async (_baseUrl, token, nonce) => { + assert.equal(token, reuseToken) + assert.equal(nonce, SPAWN_NONCE) + + return 'authenticated-stale' + }, + adoptServedToken: async () => 'fresh' + }) + ) + + assert.equal(result.reused, false) + assert.ok(ssh.calls.some(command => /kill 333\b/.test(command))) +}) + +test('remote SSH ownership capability requires both secure bootstrap flags', async () => { + let helpProbe = '' + + const supported = fakeSsh([ + [ + /serve --help/, + command => { + helpProbe = command + + return 'YES\n' + } + ] + ]) + + assert.equal(await remoteSupportsSshOwnership(supported, '/x/hermes'), true) + assert.match(helpProbe, /ssh-session-token-file/) + assert.match(helpProbe, /ssh-owner-nonce/) + + const unsupported = fakeSsh([[/serve --help/, 'NO\n']]) + assert.equal(await remoteSupportsSshOwnership(unsupported, '/x/hermes'), false) +}) diff --git a/apps/desktop/electron/remote-lifecycle.ts b/apps/desktop/electron/remote-lifecycle.ts new file mode 100644 index 00000000000..705c80000b9 --- /dev/null +++ b/apps/desktop/electron/remote-lifecycle.ts @@ -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=` 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 ` --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 > ${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 +} diff --git a/apps/desktop/electron/remote-liveness.test.ts b/apps/desktop/electron/remote-liveness.test.ts new file mode 100644 index 00000000000..6c52e11f69f --- /dev/null +++ b/apps/desktop/electron/remote-liveness.test.ts @@ -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(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 = {}) { + const connection = { baseUrl: 'https://gateway.example.com/', mode: 'remote' } + const connectionPromise = Promise.resolve(connection) + const current = { promise: connectionPromise as null | Promise } + 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() + }) +}) diff --git a/apps/desktop/electron/remote-liveness.ts b/apps/desktop/electron/remote-liveness.ts new file mode 100644 index 00000000000..eedc16682e7 --- /dev/null +++ b/apps/desktop/electron/remote-liveness.ts @@ -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 { + connectionPromise: Promise + currentConnectionPromise: () => null | Promise + log: (message: string) => void + probe: (url: string, options: { timeoutMs: number }) => Promise + 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>() + + run(connection: object, task: () => Promise): Promise { + const existing = this.#inflightByConnection.get(connection) as Promise | 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() + 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({ + connectionPromise, + currentConnectionPromise, + log, + probe, + resetConnection, + tracker +}: RevalidateRemoteConnectionOptions): Promise { + 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 } + } +} diff --git a/apps/desktop/electron/session-windows.test.ts b/apps/desktop/electron/session-windows.test.ts index fcfca868073..5167593bfbf 100644 --- a/apps/desktop/electron/session-windows.test.ts +++ b/apps/desktop/electron/session-windows.test.ts @@ -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', () => { diff --git a/apps/desktop/electron/session-windows.ts b/apps/desktop/electron/session-windows.ts index af55608b0f4..48597ee9e0e 100644 --- a/apps/desktop/electron/session-windows.ts +++ b/apps/desktop/electron/session-windows.ts @@ -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 } diff --git a/apps/desktop/electron/ssh-bootstrap-coordinator.test.ts b/apps/desktop/electron/ssh-bootstrap-coordinator.test.ts new file mode 100644 index 00000000000..cf5c24e9727 --- /dev/null +++ b/apps/desktop/electron/ssh-bootstrap-coordinator.test.ts @@ -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']) +}) diff --git a/apps/desktop/electron/ssh-bootstrap-coordinator.ts b/apps/desktop/electron/ssh-bootstrap-coordinator.ts new file mode 100644 index 00000000000..827e9a4e7a1 --- /dev/null +++ b/apps/desktop/electron/ssh-bootstrap-coordinator.ts @@ -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() + const pending = new Map() + const generations = new Map() + const drains = new Map>() + + 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(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 } diff --git a/apps/desktop/electron/ssh-config.test.ts b/apps/desktop/electron/ssh-config.test.ts new file mode 100644 index 00000000000..01eb8991055 --- /dev/null +++ b/apps/desktop/electron/ssh-config.test.ts @@ -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) +}) diff --git a/apps/desktop/electron/ssh-config.ts b/apps/desktop/electron/ssh-config.ts new file mode 100644 index 00000000000..e22a6a9d83a --- /dev/null +++ b/apps/desktop/electron/ssh-config.ts @@ -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 } diff --git a/apps/desktop/electron/ssh-connection.test.ts b/apps/desktop/electron/ssh-connection.test.ts new file mode 100644 index 00000000000..a3d6d731bd5 --- /dev/null +++ b/apps/desktop/electron/ssh-connection.test.ts @@ -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=/) + // 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=/) + assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?ticket=onetimeticket'), /\?ticket=/) + assert.match(redactSecrets('GET /x?a=1&token=zzz HTTP'), /&token=/) + 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 /) + assert.ok(!redactSecrets('Authorization: Bearer tok_9999').includes('tok_9999')) + assert.match(redactSecrets('X-Hermes-Session-Token: hdr_888'), /X-Hermes-Session-Token: ?/) + 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 `.<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 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(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(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(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) +}) diff --git a/apps/desktop/electron/ssh-connection.ts b/apps/desktop/electron/ssh-connection.ts new file mode 100644 index 00000000000..9b535c69c5b --- /dev/null +++ b/apps/desktop/electron/ssh-connection.ts @@ -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'], + [/(X-Hermes-Session-Token["']?\s*[:=]\s*["']?)([^\s"'&]+)/gi, '$1'], + [/(Authorization["']?\s*:\s*Bearer\s+)(\S+)/gi, '$1'], + [/([?&](?:token|ticket)=)([^\s&"']+)/gi, '$1'] +] + +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 `.<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((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 + + 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((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 +} diff --git a/apps/desktop/electron/windows-remote-lifecycle.test.ts b/apps/desktop/electron/windows-remote-lifecycle.test.ts new file mode 100644 index 00000000000..56cfb4d45d2 --- /dev/null +++ b/apps/desktop/electron/windows-remote-lifecycle.test.ts @@ -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/) +}) diff --git a/apps/desktop/electron/windows-remote-lifecycle.ts b/apps/desktop/electron/windows-remote-lifecycle.ts new file mode 100644 index 00000000000..ff03d6a8c79 --- /dev/null +++ b/apps/desktop/electron/windows-remote-lifecycle.ts @@ -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 +} diff --git a/apps/desktop/electron/windows-remote-live.test.ts b/apps/desktop/electron/windows-remote-live.test.ts new file mode 100644 index 00000000000..8a499f31ec6 --- /dev/null +++ b/apps/desktop/electron/windows-remote-live.test.ts @@ -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 +) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1cde03bf64d..743269c1047 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts new file mode 100644 index 00000000000..dc06c59fe78 --- /dev/null +++ b/apps/desktop/playwright.config.ts @@ -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, + }, + }, +}) diff --git a/apps/desktop/scripts/dev-mock.mjs b/apps/desktop/scripts/dev-mock.mjs new file mode 100644 index 00000000000..7b523d88b3c --- /dev/null +++ b/apps/desktop/scripts/dev-mock.mjs @@ -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) +}) diff --git a/apps/desktop/src/app/chat/close-tab.test.ts b/apps/desktop/src/app/chat/close-tab.test.ts new file mode 100644 index 00000000000..95847fd925c --- /dev/null +++ b/apps/desktop/src/app/chat/close-tab.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/src/app/chat/close-tab.ts b/apps/desktop/src/app/chat/close-tab.ts index 100e1436585..d5a5a0bcae1 100644 --- a/apps/desktop/src/app/chat/close-tab.ts +++ b/apps/desktop/src/app/chat/close-tab.ts @@ -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() diff --git a/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts index c3268bc9cbd..949b8f1020c 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts @@ -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 diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.test.tsx new file mode 100644 index 00000000000..ad0f62e4a5a --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.test.tsx @@ -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(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') + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts index 9d54e17bd95..4e813f548ae 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts @@ -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(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 diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index ce0e43d7d1c..b996a8eb7ab 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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 } diff --git a/apps/desktop/src/app/chat/composer/queue-panel.tsx b/apps/desktop/src/app/chat/composer/queue-panel.tsx index 8f38fb89303..3d55f8f4a57 100644 --- a/apps/desktop/src/app/chat/composer/queue-panel.tsx +++ b/apps/desktop/src/app/chat/composer/queue-panel.tsx @@ -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. } - label={c.queued(entries.length)} + accessory={ + parked ? ( + + + + ) : undefined + } + defaultCollapsed={!parked} + icon={ + + } + key={parked ? 'parked' : 'flowing'} + label={parked ? c.queuedPaused(entries.length) : c.queued(entries.length)} > {entries.map(entry => { const isEditing = editingId === entry.id diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index 2587202c96a..71491b87496 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -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
so the element maintains intrinsic height. The CSS min-height + // is a belt; the
is suspenders — together they prevent the shrink. + if (editor.childNodes.length === 0) { + editor.appendChild(document.createElement('br')) + } } diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index b47f0dfa990..900e80ead27 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -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({ diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.ts b/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.ts index 8bdc509a021..259cae81cc0 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.ts +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.ts @@ -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() })) diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 05a4d804cb8..e0a669bb300 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -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, diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index 5edd193dbca..26155923e1f 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -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 ( +
+ +
+ ) +} + 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]" /> -
- -
-
- -
-
+ + - -
+ /> diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 40e65ad0e83..47beed69bcb 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -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 diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx index 4e48eb8d0bf..74828222551 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx @@ -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 } = {}) { +function Harness({ + beforeConnectionSwitch = () => undefined, + refreshSessions +}: { beforeConnectionSwitch?: () => void; refreshSessions?: () => Promise } = {}) { 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() + 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() await flushAsync() diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index d6084181327..ee47db73642 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -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['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) diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts index d6c9ab0e029..72bd08c98b5 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts @@ -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) diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 34a6bfa1e1c..4df1edb202c 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -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)), diff --git a/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx b/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx index 9f5589b816f..dcc16593c18 100644 --- a/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx @@ -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() + + 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() } const submitText = vi.fn(async () => true) diff --git a/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts b/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts index 8d98df14931..6f54453c71c 100644 --- a/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts +++ b/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts @@ -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()) @@ -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 + ]) } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 09ba6bdb52a..9d2ded2e5f6 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -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) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index a06e642d6e0..f2409dd060a 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -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 { } function Harness({ + navigate = vi.fn(), onReady, requestGateway }: { + navigate?: ReturnType onReady: (handle: HarnessHandle) => void requestGateway: (method: string, params?: Record) => Promise }) { @@ -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()), @@ -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 = { current: runtimeSessionId } + const selectedStoredSessionIdRef: MutableRefObject = { current: tipBefore } + const navigate = vi.fn() + + setSessions([]) + stashSessionDraft(tipBefore, 'typed during gap', []) + setSelectedStoredSessionId(tipBefore) + setActiveSessionId(runtimeSessionId) + + render( + 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 = { current: runtimeSessionId } + const selectedStoredSessionIdRef: MutableRefObject = { 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( + 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 = { current: 'runtime-A' } const selectedStoredSessionIdRef: MutableRefObject = { 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( (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() diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 6934d00208c..545cc3cda2b 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -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 { 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) diff --git a/apps/desktop/src/app/settings/billing/errors.ts b/apps/desktop/src/app/settings/billing/errors.ts index f368a9e0abc..0357bf42442 100644 --- a/apps/desktop/src/app/settings/billing/errors.ts +++ b/apps/desktop/src/app/settings/billing/errors.ts @@ -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': diff --git a/apps/desktop/src/app/settings/billing/index.test.tsx b/apps/desktop/src/app/settings/billing/index.test.tsx index 27b702d99d3..29b8f95e82a 100644 --- a/apps/desktop/src/app/settings/billing/index.test.tsx +++ b/apps/desktop/src/app/settings/billing/index.test.tsx @@ -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() }) diff --git a/apps/desktop/src/app/settings/billing/index.tsx b/apps/desktop/src/app/settings/billing/index.tsx index 94ffda3b7c6..a5640475754 100644 --- a/apps/desktop/src/app/settings/billing/index.tsx +++ b/apps/desktop/src/app/settings/billing/index.tsx @@ -94,7 +94,14 @@ function RowValue({ onAction, row }: { onAction?: () => void; row: BillingAccoun {row.pill && {row.pill.label}} {row.secondaryPill && {row.secondaryPill}} {row.chips?.map(chip => ( - ))} diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts index cd7c74507a9..90bb728990d 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts @@ -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') + }) }) diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.ts b/apps/desktop/src/app/settings/billing/use-billing-state.ts index 33ee75e77fb..0e9f1a23988 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts @@ -41,6 +41,8 @@ export interface BillingRowActionView { export interface BillingChipView { disabled: boolean label: string + /** When set, clicking the chip opens this URL externally. */ + url?: string } export interface BillingAccountRowView { @@ -160,7 +162,8 @@ export function deriveBillingView( export function buildManageSubscriptionUrl( subscription?: null | Pick, - fallbackPortalUrl?: null | string + fallbackPortalUrl?: null | string, + tierId?: string ): string { const portalUrls = [subscription?.portal_url, fallbackPortalUrl].filter( (url): url is string => typeof url === 'string' && url.length > 0 @@ -174,6 +177,10 @@ export function buildManageSubscriptionUrl( url.searchParams.set('org_id', subscription.org_id) } + if (tierId) { + url.searchParams.set('plan', tierId) + } + return url.toString() } catch { // Try the next candidate; malformed portal URLs should not break settings. @@ -272,24 +279,64 @@ function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView } } +/** + * Tier catalog as chips for accounts that can change plans; the current plan is + * inert, every other opens the portal where the change/start happens, deep-linked + * to that tier via `?plan=`. + */ +function subscriptionTierChips( + subscription: null | SubscriptionStateResponse, + fallbackPortalUrl?: null | string +): BillingChipView[] | undefined { + // Teams have no personal subscription to sell into. + if (!subscription?.can_change_plan || subscription.context === 'team') { + return undefined + } + + const tiers = (subscription.tiers ?? []) + .filter(tier => tier.is_enabled && tier.tier_order > 0) + .sort((a, b) => a.tier_order - b.tier_order) + + if (tiers.length === 0) { + return undefined + } + + return tiers.map(tier => { + // Monthly credits are dollars; NAS sends a bare decimal string. + const credits = Number((tier.monthly_credits ?? '').replace(/,/g, '')) + const suffix = Number.isFinite(credits) && credits > 0 ? ` · $${credits.toLocaleString('en-US')} credits/mo` : '' + const label = `${tier.name} · ${tier.dollars_per_month_display}/mo${suffix}` + + return tier.is_current + ? { disabled: true, label: `✓ ${label}` } + : { disabled: false, label, url: buildManageSubscriptionUrl(subscription, fallbackPortalUrl, tier.tier_id) } + }) +} + function subscriptionRow( billing: BillingStateResponse, subscription: null | SubscriptionStateResponse, subscriptionResult?: BillingResult ): BillingAccountRowView { - const manageUrl = buildManageSubscriptionUrl(subscription, subscription?.portal_url ?? billing.portal_url) + const fallbackPortalUrl = subscription?.portal_url ?? billing.portal_url + const manageUrl = buildManageSubscriptionUrl(subscription, fallbackPortalUrl) const current = subscription?.current const fallbackPlan = billing.usage?.plan_name ?? EMPTY_BILLING_VALUE const value = current?.tier_name ?? fallbackPlan const renewal = formatBillingDate(current?.cycle_ends_at ?? billing.usage?.renews_at) const unavailable = subscriptionResult && !subscriptionResult.ok + const chips = subscriptionTierChips(subscription, fallbackPortalUrl) return { action: { label: 'Adjust plan ↗', url: manageUrl }, caption: unavailable ? 'Subscription details are unavailable; opening the portal is still available.' : `Renews ${renewal}`, - description: 'Review your plan and change it from the billing portal.', + chips, + description: + !current && chips + ? 'Paid models need a subscription — pick a plan to start it on the portal.' + : 'Review your plan and change it from the billing portal.', id: 'subscription', secondaryPill: 'opens portal', title: 'Subscription', @@ -421,18 +468,10 @@ function deriveUsageRows( }) const topupValue = topupCreditsValue(billing, usage) - const topupRemaining = topupCreditsAmount(billing, usage) + // No bar: top-ups have no denominator (the wire carries only the current + // balance, and the pool is open-ended), so a fill fraction would be fiction. rows.push({ - bar: - topupRemaining != null - ? { - label: 'Top-up credits remaining', - state: topupRemaining > 0 ? 'ok' : 'neutral', - tone: 'topup', - value: topupRemaining > 0 ? 1 : 0 - } - : undefined, caption: 'Does not expire', id: 'topup_credits', title: 'Top-up credits', @@ -458,7 +497,7 @@ function deriveUsageRows( value: clamp01(usedFraction) } : undefined, - caption: cap.is_default_ceiling ? 'Default ceiling' : 'Monthly terminal billing spend', + caption: cap.is_default_ceiling ? 'Default ceiling' : 'Monthly remote spending', id: 'monthly_cap', title: 'Monthly spend cap', value @@ -495,14 +534,6 @@ function topupCreditsValue(billing: BillingStateResponse, usage?: UsageModelData ) } -function topupCreditsAmount(billing: BillingStateResponse, usage?: UsageModelData): null | number { - return ( - parseAmount(usage?.topup_bar?.remaining_display) ?? - parseAmount(usage?.topup_remaining_display) ?? - parseAmount(billing.balance_usd) ?? - parseAmount(billing.balance_display) - ) -} function buyCreditsDisabledReason(billing: BillingStateResponse): null | string { if (!billing.is_admin) { diff --git a/apps/desktop/src/app/settings/billing/use-step-up.ts b/apps/desktop/src/app/settings/billing/use-step-up.ts index 7658dc91756..2966f602a41 100644 --- a/apps/desktop/src/app/settings/billing/use-step-up.ts +++ b/apps/desktop/src/app/settings/billing/use-step-up.ts @@ -121,7 +121,7 @@ export function useStepUpFlow() { if (!result.data.granted) { setMessage({ kind: 'error', - text: 'Verification finished without granting billing management access.', + text: 'Verification finished without allowing Remote Spending for this terminal.', title: 'Verification was not approved' }) @@ -134,7 +134,7 @@ export function useStepUpFlow() { ]) setMessage({ kind: 'success', - text: 'Billing management access was verified.', + text: 'Remote Spending is allowed for this terminal.', title: 'Verification complete' }) }, [api, gateway, queryClient, unsubscribe]) diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index 2674fa19103..1d8a2de0127 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -1,3 +1,4 @@ +import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import type { ChangeEvent } from 'react' import { useEffect, useMemo, useRef, useState } from 'react' @@ -6,7 +7,9 @@ import { useSearchParams } from 'react-router-dom' import { Button } from '@/components/ui/button' import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes' import { useI18n } from '@/i18n' +import { $keepAwake, setKeepAwake } from '@/store/keep-awake' import { notify, notifyError } from '@/store/notifications' +import { repoDiscoveryPolicyFromConfig, repoDiscoveryPolicySignature, scanAndRecordRepos } from '@/store/projects' import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes' import { setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config-record' @@ -18,7 +21,7 @@ import { enumOptionsFor, getNested, isExternalMemoryProvider, sectionFieldEntrie import { MemoryConnect } from './memory/connect' import { ProviderConfigPanel } from './memory/provider-config-panel' import { ModelSettings, ModelSettingsSkeleton } from './model-settings' -import { EmptyState, LoadingState, SettingsContent } from './primitives' +import { EmptyState, LoadingState, SettingsContent, ToggleRow } from './primitives' // On the Voice page, only surface the sub-fields of the *selected* TTS/STT // provider — otherwise every provider's options render at once (the "totally @@ -53,6 +56,7 @@ export function ConfigSettings({ }) { const { t } = useI18n() const c = t.settings.config + const keepAwake = useStore($keepAwake) // The editable draft is local (debounced autosave watches it), but it's seeded // from — and saved back through — the shared config cache, so edits are visible // in the MCP/model surfaces and reopening the page doesn't reload-flash. @@ -73,6 +77,7 @@ export function ConfigSettings({ const [elevenLabsVoiceOptions, setElevenLabsVoiceOptions] = useState(null) const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState>({}) const saveVersionRef = useRef(0) + const savedDiscoverySignatureRef = useRef(undefined) const [saveVersion, setSaveVersion] = useState(0) // Seed the local draft once, the first time the shared record lands. @@ -82,6 +87,7 @@ export function ConfigSettings({ useEffect(() => { if (loadedConfig && !configSeeded.current) { configSeeded.current = true + savedDiscoverySignatureRef.current = repoDiscoveryPolicySignature(repoDiscoveryPolicyFromConfig(loadedConfig)) setConfig(loadedConfig) } }, [loadedConfig]) @@ -92,6 +98,7 @@ export function ConfigSettings({ // the pending debounced autosave is cancelled by its effect cleanup. useOnProfileSwitch(() => { configSeeded.current = false + savedDiscoverySignatureRef.current = undefined setConfig(null) saveVersionRef.current = 0 setSaveVersion(0) @@ -129,12 +136,24 @@ export function ConfigSettings({ const t = window.setTimeout(() => { void (async () => { try { - await saveHermesConfig(config) + const result = await saveHermesConfig(config) + + if (!result.ok) { + throw new Error(c.autosaveFailed) + } + // Mirror the saved record into the shared cache so MCP/model surfaces // reflect the edit without their own refetch. setHermesConfigCache(config) if (saveVersionRef.current === v) { + const discoverySignature = repoDiscoveryPolicySignature(repoDiscoveryPolicyFromConfig(config)) + + if (savedDiscoverySignatureRef.current !== discoverySignature) { + savedDiscoverySignatureRef.current = discoverySignature + await scanAndRecordRepos(true) + } + onConfigSaved?.() } } catch (err) { @@ -269,6 +288,11 @@ export function ConfigSettings({ )} + {/* Device-local desktop pref (not config.yaml) — lives here since keeping + the machine awake is a power-user knob. */} + {activeSectionId === 'advanced' && ( + + )} {visibleFields.length === 0 ? ( ) : ( diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 596e71259b1..055f72ac19a 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -390,6 +390,11 @@ export const FIELD_LABELS: Record = defineFieldCopy({ personality: 'Personality', showReasoning: 'Reasoning Blocks' }, + desktop: { + repoScanEnabled: 'Automatic Repository Discovery', + repoScanRoots: 'Repository Discovery Roots', + repoScanExcludePaths: 'Excluded Repository Paths' + }, agent: { maxTurns: 'Max Agent Steps', imageInputMode: 'Image Attachments', @@ -551,6 +556,11 @@ export const FIELD_DESCRIPTIONS: Record = defineFieldCopy({ personality: 'Default assistant style for new sessions.', showReasoning: 'Show reasoning sections when the backend provides them.' }, + desktop: { + repoScanEnabled: 'Scan local folders for Git repositories to show in Projects.', + repoScanRoots: 'Folders to scan. Leave empty to scan your home directory.', + repoScanExcludePaths: 'Folders and their descendants to skip during repository discovery.' + }, timezone: 'Used when Hermes needs local time context. Blank uses the system timezone.', agent: { imageInputMode: 'Controls how image attachments are sent to the model.', @@ -645,6 +655,9 @@ export const SECTIONS: DesktopConfigSection[] = [ icon: Monitor, keys: [ 'terminal.cwd', + 'desktop.repo_scan_enabled', + 'desktop.repo_scan_roots', + 'desktop.repo_scan_exclude_paths', 'code_execution.mode', 'terminal.persistent_shell', 'terminal.env_passthrough', diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index ca7ce2384f9..008bd44d2ea 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -3,11 +3,24 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Tip } from '@/components/ui/tooltip' import type { DesktopAuthProvider, DesktopCloudAgent, DesktopCloudOrg, DesktopConnectionProbeResult } from '@/global' import { useI18n } from '@/i18n' import { ExternalLink } from '@/lib/external-link' -import { AlertCircle, Check, Cloud, FileText, Globe, HelpCircle, Loader2, LogIn, Monitor, RefreshCw } from '@/lib/icons' +import { + AlertCircle, + Check, + Cloud, + FileText, + Globe, + HelpCircle, + Loader2, + LogIn, + Monitor, + RefreshCw, + Terminal +} from '@/lib/icons' import { selectableCardClass } from '@/lib/selectable-card' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' @@ -15,8 +28,9 @@ import { $profiles, refreshActiveProfile } from '@/store/profile' import { CONTROL_TEXT } from './constants' import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives' +import { enrichSelectedSshHost, selectSshHost } from './ssh-host-selection' -type Mode = 'local' | 'remote' | 'cloud' +type Mode = 'local' | 'remote' | 'cloud' | 'ssh' type AuthMode = 'oauth' | 'token' type ProbeStatus = 'idle' | 'probing' | 'done' | 'error' // Hermes Cloud discovery lifecycle for the cloud-mode panel. @@ -31,8 +45,15 @@ interface GatewaySettingsState { remoteTokenSet: boolean remoteUrl: string cloudOrg: string + sshHost: string + sshUser: string + sshPort: number | null + sshKeyPath: string + sshRemoteHermesPath: string } +const SSH_HOST_CUSTOM = '__custom__' + const EMPTY_STATE: GatewaySettingsState = { envOverride: false, mode: 'local', @@ -41,7 +62,12 @@ const EMPTY_STATE: GatewaySettingsState = { remoteTokenPreview: null, remoteTokenSet: false, remoteUrl: '', - cloudOrg: '' + cloudOrg: '', + sshHost: '', + sshUser: '', + sshPort: null, + sshKeyPath: '', + sshRemoteHermesPath: '' } export function savedCloudConnectionUrl(config: Pick): string { @@ -128,6 +154,14 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { const [state, setState] = useState(EMPTY_STATE) const [remoteToken, setRemoteToken] = useState('') const [lastTest, setLastTest] = useState(null) + const [sshHostSuggestions, setSshHostSuggestions] = useState([]) + const [sshCustomHost, setSshCustomHost] = useState(false) + const sshResolveSeq = useRef(0) + const sshTestSeq = useRef(0) + const saveSeq = useRef(0) + const signingSeq = useRef(0) + const cloudConnectSeq = useRef(0) + const contextSeq = useRef(0) const [connectedCloudUrl, setConnectedCloudUrl] = useState('') const acceptSavedConfig = (config: GatewaySettingsState) => { @@ -337,6 +371,48 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { // per-profile scopes are the named, non-default profiles. const namedProfiles = useMemo(() => profiles.filter(profile => profile.name !== 'default'), [profiles]) + useEffect(() => { + // One-directional: a saved host that isn't in the suggestions must render + // the free-text input (rehydration). Never force custom OFF here — that + // instantly snapped the just-clicked-Custom (empty-host) input back to the + // dropdown, making a raw-IP host impossible to type. The way back to the + // dropdown is the input's onBlur (empty host + suggestions). + if (state.sshHost && !sshHostSuggestions.includes(state.sshHost)) { + setSshCustomHost(true) + } + }, [state.sshHost, sshHostSuggestions]) + + useEffect(() => { + if (state.mode !== 'ssh' || !window.hermesDesktop?.sshConfigHosts) { + return + } + + let cancelled = false + void window.hermesDesktop + .sshConfigHosts() + .then(result => { + if (!cancelled) { + setSshHostSuggestions(result.hosts) + } + }) + .catch(() => { + if (!cancelled) { + setSshHostSuggestions([]) + } + }) + + return () => void (cancelled = true) + }, [state.mode]) + + useEffect(() => { + contextSeq.current += 1 + sshTestSeq.current += 1 + saveSeq.current += 1 + signingSeq.current += 1 + cloudConnectSeq.current += 1 + setLastTest(null) + }, [scope, state.mode, state.sshHost, state.sshUser, state.sshPort, state.sshKeyPath, state.sshRemoteHermesPath]) + const oauthConnected = state.remoteOauthConnected const canUseRemote = useMemo(() => { @@ -356,10 +432,17 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { profile: scope ?? undefined, remoteAuthMode: authMode, remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined, - remoteUrl: trimmedUrl + remoteUrl: trimmedUrl, + sshHost: state.sshHost.trim(), + sshUser: state.sshUser.trim() || undefined, + sshPort: state.sshPort, + sshKeyPath: state.sshKeyPath.trim() || undefined, + sshRemoteHermesPath: state.sshRemoteHermesPath.trim() }) const save = async (apply: boolean) => { + const seq = ++saveSeq.current + if (state.mode === 'remote' && !canUseRemote) { notify({ kind: 'warning', @@ -377,6 +460,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { ? await window.hermesDesktop.applyConnectionConfig(payload()) : await window.hermesDesktop.saveConnectionConfig(payload()) + if (seq !== saveSeq.current) { + return + } + acceptSavedConfig(next) setRemoteToken('') notify({ @@ -385,9 +472,35 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { message: apply ? g.restartingMessage : g.savedMessage }) } catch (err) { - notifyError(err, apply ? g.applyFailed : g.saveFailed) + if (seq !== saveSeq.current) { + return + } + + const sshError = err && typeof err === 'object' && 'sshError' in err ? String(err.sshError) : '' + + const errors = { + 'auth-failed': g.sshErrAuth, + 'hermes-not-found': g.sshErrNotInstalled, + 'host-key-changed': g.sshErrHostKey, + timeout: g.sshErrTimeout, + unreachable: g.sshErrUnreachable, + 'unsupported-platform': g.sshErrPlatform, + 'update-required': g.sshErrUpdateRequired + } + + if (state.mode === 'ssh' && sshError) { + notify({ + kind: 'error', + title: apply ? g.applyFailed : g.saveFailed, + message: (errors as Record)[sshError] || g.sshErrUnknown + }) + } else { + notifyError(err, apply ? g.applyFailed : g.saveFailed) + } } finally { - setSaving(false) + if (seq === saveSeq.current) { + setSaving(false) + } } } @@ -395,6 +508,8 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { // the URL the login window needs), then open the gateway login window and // refresh the connection status from the saved config once it completes. const signIn = async () => { + const seq = ++signingSeq.current + if (!trimmedUrl) { notify({ kind: 'warning', title: g.incompleteTitle, message: g.enterUrlFirst }) @@ -413,10 +528,18 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { remoteUrl: trimmedUrl }) + if (seq !== signingSeq.current) { + return + } + acceptSavedConfig(saved) const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl) + if (seq !== signingSeq.current) { + return + } + if (result.connected) { const refreshed = await window.hermesDesktop.getConnectionConfig(scope) acceptSavedConfig(refreshed) @@ -429,24 +552,38 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { }) } } catch (err) { - notifyError(err, g.signInFailed) + if (seq === signingSeq.current) { + notifyError(err, g.signInFailed) + } } finally { - setSigningIn(false) + if (seq === signingSeq.current) { + setSigningIn(false) + } } } const signOut = async () => { + const seq = ++signingSeq.current setSigningIn(true) try { await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined) const refreshed = await window.hermesDesktop.getConnectionConfig(scope) + + if (seq !== signingSeq.current) { + return + } + acceptSavedConfig(refreshed) notify({ kind: 'success', title: g.signedOutTitle, message: g.signedOutMessage }) } catch (err) { - notifyError(err, g.signOutFailed) + if (seq === signingSeq.current) { + notifyError(err, g.signOutFailed) + } } finally { - setSigningIn(false) + if (seq === signingSeq.current) { + setSigningIn(false) + } } } @@ -458,6 +595,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { // needsOrgSelection we surface the org list and show a picker instead. const discoverCloud = async (org?: string) => { const desktop = window.hermesDesktop + const seq = contextSeq.current if (!desktop?.cloud) { return @@ -468,6 +606,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { try { const result = await desktop.cloud.discover(org) + if (seq !== contextSeq.current) { + return + } + if ('needsOrgSelection' in result && result.needsOrgSelection) { // Multi-org user with no org chosen yet: show the picker. Don't clear a // previously-chosen org list on a refresh. @@ -495,6 +637,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { setCloudDiscover('done') } catch (err) { + if (seq !== contextSeq.current) { + return + } + setCloudAgents([]) setCloudDiscover('error') @@ -579,6 +725,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { const cloudSignIn = async () => { const desktop = window.hermesDesktop + const seq = ++signingSeq.current if (!desktop?.cloud) { return @@ -588,20 +735,30 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { try { const result = await desktop.cloud.login() + + if (seq !== signingSeq.current) { + return + } + setCloudSignedIn(result.signedIn) if (result.signedIn) { await discoverCloud() } } catch (err) { - notifyError(err, g.cloudSignInFailed) + if (seq === signingSeq.current) { + notifyError(err, g.cloudSignInFailed) + } } finally { - setCloudSigningIn(false) + if (seq === signingSeq.current) { + setCloudSigningIn(false) + } } } const cloudSignOut = async () => { const desktop = window.hermesDesktop + const seq = ++signingSeq.current if (!desktop?.cloud) { return @@ -611,6 +768,11 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { try { await desktop.cloud.logout() + + if (seq !== signingSeq.current) { + return + } + setCloudSignedIn(false) setCloudAgents([]) setCloudOrgs([]) @@ -618,9 +780,13 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { setCloudDiscover('idle') notify({ kind: 'success', title: g.cloudSignedOutTitle, message: g.cloudSignedOutMessage }) } catch (err) { - notifyError(err, g.signOutFailed) + if (seq === signingSeq.current) { + notifyError(err, g.signOutFailed) + } } finally { - setCloudSigningIn(false) + if (seq === signingSeq.current) { + setCloudSigningIn(false) + } } } @@ -628,6 +794,8 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { // prompt — the shared portal session auto-approves), then persist a cloud-mode // connection pointed at its dashboardUrl and apply it (soft-reconnects in place). const connectCloudAgent = async (agent: DesktopCloudAgent) => { + const seq = contextSeq.current + if (!agent.dashboardUrl) { return } @@ -643,6 +811,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { try { const result = await desktop.cloud.agentSignIn(agent.dashboardUrl) + if (seq !== contextSeq.current) { + return + } + if (!result.connected) { notify({ kind: 'warning', @@ -665,20 +837,113 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { cloudOrg: cloudOrgRef.current ?? undefined }) + if (seq !== contextSeq.current) { + return + } + acceptSavedConfig(next) notify({ kind: 'success', title: g.cloudConnectedTitle, message: g.cloudConnectedTo(agent.name) }) } catch (err) { + if (seq !== contextSeq.current) { + return + } + if (err && typeof err === 'object' && 'needsCloudLogin' in err) { setCloudSignedIn(false) } notifyError(err, g.cloudConnectFailed) } finally { - setCloudConnectingId(null) + if (seq === contextSeq.current) { + setCloudConnectingId(null) + } + } + } + + const resolveSshHost = async (host: string) => { + if (!host || !window.hermesDesktop?.sshResolveHost) { + return + } + + const seq = ++sshResolveSeq.current + + try { + const resolved = await window.hermesDesktop.sshResolveHost(host) + + if (seq !== sshResolveSeq.current) { + return + } + + setState(current => enrichSelectedSshHost(current, host, resolved)) + } catch { + return + } + } + + const selectHost = (value: string) => { + if (value === SSH_HOST_CUSTOM) { + setSshCustomHost(true) + setState(current => selectSshHost(current, '')) + + return + } + + setSshCustomHost(false) + setState(current => selectSshHost(current, value)) + void resolveSshHost(value) + } + + const testSsh = async () => { + const seq = ++sshTestSeq.current + + if (!state.sshHost.trim()) { + notify({ kind: 'warning', title: g.incompleteTitle, message: g.sshIncompleteHost }) + + return + } + + setTesting(true) + setLastTest(null) + + try { + const result = await window.hermesDesktop.testConnectionConfig(payload()) + + if (seq !== sshTestSeq.current) { + return + } + + if (!result.reachable) { + const errors = { + 'auth-failed': g.sshErrAuth, + 'hermes-not-found': g.sshErrNotInstalled, + 'host-key-changed': g.sshErrHostKey, + timeout: g.sshErrTimeout, + unreachable: g.sshErrUnreachable, + 'unsupported-platform': g.sshErrPlatform, + 'update-required': g.sshErrUpdateRequired, + unknown: g.sshErrUnknown + } + + throw new Error(errors[result.sshError || 'unknown'] || result.error || g.sshErrUnknown) + } + + const message = g.sshReachable(result.host || state.sshHost, result.remotePlatform || '?') + setLastTest(message) + notify({ kind: 'success', title: g.reachableTitle, message }) + } catch (err) { + if (seq === sshTestSeq.current) { + notifyError(err, g.testFailed) + } + } finally { + if (seq === sshTestSeq.current) { + setTesting(false) + } } } const testRemote = async () => { + const seq = ++sshTestSeq.current + if (!canUseRemote) { notify({ kind: 'warning', @@ -701,13 +966,21 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { remoteUrl: trimmedUrl }) - const message = g.connectedTo(result.baseUrl, result.version ?? undefined) + if (seq !== sshTestSeq.current) { + return + } + + const message = g.connectedTo(result.baseUrl || trimmedUrl, result.version ?? undefined) setLastTest(message) notify({ kind: 'success', title: g.reachableTitle, message }) } catch (err) { - notifyError(err, g.testFailed) + if (seq === sshTestSeq.current) { + notifyError(err, g.testFailed) + } } finally { - setTesting(false) + if (seq === sshTestSeq.current) { + setTesting(false) + } } } @@ -770,7 +1043,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
{g.modeTitle}
-
+
setState(current => ({ ...current, mode: 'remote' }))} title={g.remoteTitle} /> + setState(current => ({ ...current, mode: 'ssh' }))} + title={g.sshTitle} + />
@@ -1033,6 +1315,109 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { ) : null} + {state.mode === 'ssh' && !state.envOverride ? ( +
+ {sshHostSuggestions.length > 0 && !sshCustomHost ? ( + + + + + + {sshHostSuggestions.map(host => ( + + {host} + + ))} + {g.sshHostCustom} + + + } + description={g.sshHostPickDesc} + title={g.sshHostPickTitle} + /> + ) : ( + { + // Empty host on blur with suggestions available = the user backed + // out of Custom; return to the dropdown. + if (!state.sshHost.trim() && sshHostSuggestions.length > 0) { + setSshCustomHost(false) + + return + } + + void resolveSshHost(state.sshHost) + }} + onChange={event => setState(current => selectSshHost(current, event.target.value))} + value={state.sshHost} + /> + } + description={g.sshHostDesc} + title={g.sshHostTitle} + /> + )} + setState(current => ({ ...current, sshUser: event.target.value }))} + placeholder={g.sshUserPlaceholder} + value={state.sshUser} + /> + } + description={g.sshUserDesc} + title={g.sshUserTitle} + /> + + setState(current => ({ ...current, sshPort: event.target.value ? Number(event.target.value) : null })) + } + placeholder="22" + value={state.sshPort ?? ''} + /> + } + description={g.sshPortDesc} + title={g.sshPortTitle} + /> + setState(current => ({ ...current, sshKeyPath: event.target.value }))} + value={state.sshKeyPath} + /> + } + description={g.sshKeyDesc} + title={g.sshKeyTitle} + /> + setState(current => ({ ...current, sshRemoteHermesPath: event.target.value }))} + placeholder={g.sshHermesPathPlaceholder} + value={state.sshRemoteHermesPath} + /> + } + description={g.sshHermesPathDesc} + title={g.sshHermesPathTitle} + /> +
+ ) : null} + {lastTest ?
{lastTest}
: null} {/* Test/Save apply to local + remote. Cloud connects via the agent picker @@ -1051,6 +1436,17 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { {testing ? : null} {g.testRemote} + ) : state.mode === 'ssh' ? ( + ) : null} {embedded ? null : (