Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0f6c1fab0 | ||
|
|
ef7749d4c2 |
@@ -7,7 +7,7 @@ description: >-
|
||||
|
||||
inputs:
|
||||
github-token:
|
||||
description: Token for the GitHub API (gh CLI). Pass steps.app-token.outputs.token from the calling workflow.
|
||||
description: Token for the GitHub API (gh CLI). Pass secrets.AUTOFIX_BOT_PAT from the calling workflow.
|
||||
required: false
|
||||
default: ${{ github.token }}
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
name: Get GitHub App Token
|
||||
description: >-
|
||||
Mint a short-lived (1-hour) installation access token from the repo's
|
||||
GitHub App, replacing the long-lived AUTOFIX_BOT_PAT. App tokens get
|
||||
5,000 req/hr per installation (vs 1,000 for the default GITHUB_TOKEN)
|
||||
and are scoped to the App's installation permissions, not a user account.
|
||||
|
||||
Falls back to the built-in GITHUB_TOKEN when APP_CLIENT_ID is not set —
|
||||
this happens on fork PRs where repo secrets are unavailable. The fallback
|
||||
ensures classification, timings, and review comments still work on
|
||||
forks (with the lower GITHUB_TOKEN rate limit).
|
||||
|
||||
Composite actions cannot access the secrets context directly, so the
|
||||
calling workflow must pass secrets.APP_CLIENT_ID and secrets.APP_PRIVATE_KEY
|
||||
as inputs. When both are empty (fork PRs), the fallback fires.
|
||||
|
||||
inputs:
|
||||
client-id:
|
||||
description: GitHub App Client ID. Pass secrets.APP_CLIENT_ID from the calling workflow.
|
||||
required: false
|
||||
default: ''
|
||||
private-key:
|
||||
description: GitHub App private key PEM. Pass secrets.APP_PRIVATE_KEY from the calling workflow.
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
outputs:
|
||||
token:
|
||||
description: A GitHub App installation access token (1-hour TTL), or GITHUB_TOKEN on forks.
|
||||
value: ${{ steps.app-token.outputs.token || steps.fallback.outputs.token }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Check if App credentials exist
|
||||
id: check
|
||||
shell: bash
|
||||
env:
|
||||
CLIENT_ID: ${{ inputs.client-id }}
|
||||
run: |
|
||||
if [ -n "$CLIENT_ID" ]; then
|
||||
echo "has_app=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_app=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create GitHub App token
|
||||
id: app-token
|
||||
if: steps.check.outputs.has_app == 'true'
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ inputs.client-id }}
|
||||
private-key: ${{ inputs.private-key }}
|
||||
|
||||
- name: Fall back to GITHUB_TOKEN
|
||||
id: fallback
|
||||
if: steps.check.outputs.has_app != 'true'
|
||||
shell: bash
|
||||
run: echo "token=${{ github.token }}" >> "$GITHUB_OUTPUT"
|
||||
@@ -1,56 +0,0 @@
|
||||
name: Hermes Plugin Validate
|
||||
description: >-
|
||||
Validate a Hermes Agent plugin (plugin.yaml manifest schema AND
|
||||
declared-vs-actually-registered capabilities) using
|
||||
`hermes plugins validate`. Drop this into your plugin repo's CI:
|
||||
|
||||
- uses: actions/checkout@<sha>
|
||||
- uses: NousResearch/hermes-agent/.github/actions/plugin-validate@main
|
||||
with:
|
||||
path: .
|
||||
|
||||
The caller's job owns checkout; this action installs Python + hermes-agent
|
||||
(git install — a supported CI-context install route) and runs the
|
||||
validator against your plugin directory.
|
||||
|
||||
inputs:
|
||||
path:
|
||||
description: Path to the plugin directory (containing plugin.yaml).
|
||||
default: "."
|
||||
hermes-ref:
|
||||
description: hermes-agent git ref (branch/tag/sha) to install and validate with.
|
||||
default: "main"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install hermes-agent
|
||||
shell: bash
|
||||
env:
|
||||
_HERMES_REF: ${{ inputs.hermes-ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# CI-context install from git; the ref lets plugin authors validate
|
||||
# against a pinned hermes release instead of main.
|
||||
pip install "git+https://github.com/NousResearch/hermes-agent@${_HERMES_REF}"
|
||||
|
||||
- name: Validate plugin
|
||||
shell: bash
|
||||
env:
|
||||
_PLUGIN_PATH: ${{ inputs.path }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# `hermes plugins validate` checks the plugin.yaml manifest schema
|
||||
# and loads the plugin in a scratch subprocess to verify that the
|
||||
# capabilities it DECLARES match what it actually registers.
|
||||
if hermes plugins validate "$_PLUGIN_PATH"; then
|
||||
echo "✅ PASS: plugin at '$_PLUGIN_PATH' validated cleanly"
|
||||
else
|
||||
echo "❌ FAIL: plugin at '$_PLUGIN_PATH' failed validation (see output above)"
|
||||
exit 1
|
||||
fi
|
||||
+12
-129
@@ -17,7 +17,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # needed by lint (PR comment) + supply-chain review_status
|
||||
pull-requests: write # needed by lint (PR comment) + supply-chain (PR comment)
|
||||
actions: read # needed by osv-scanner (SARIF upload)
|
||||
security-events: write # needed by osv-scanner (SARIF upload)
|
||||
packages: write # needed by docker build
|
||||
@@ -49,19 +49,13 @@ jobs:
|
||||
event_name: ${{ github.event_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
- name: Detect affected areas
|
||||
id: classify
|
||||
uses: ./.github/actions/detect-changes
|
||||
with:
|
||||
# The get-app-token composite action falls back to GITHUB_TOKEN
|
||||
# on fork PRs where APP_ID is unavailable.
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
# Forks get no repo secrets (AUTOFIX_BOT_PAT is empty); fall back to
|
||||
# the built-in read-only token so classification still works there.
|
||||
github-token: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Lane-gated sub-workflows. Each runs in parallel after detect finishes.
|
||||
@@ -79,10 +73,11 @@ jobs:
|
||||
lint:
|
||||
name: Python lints
|
||||
needs: detect
|
||||
if: needs.detect.outputs.python == 'true'
|
||||
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.ci_review == 'true'
|
||||
uses: ./.github/workflows/lint.yml
|
||||
with:
|
||||
event_name: ${{ needs.detect.outputs.event_name }}
|
||||
ci_review: ${{ needs.detect.outputs.ci_review == 'true' }}
|
||||
secrets: inherit
|
||||
|
||||
js-tests:
|
||||
@@ -149,22 +144,13 @@ jobs:
|
||||
supply-chain:
|
||||
name: Supply-chain scan
|
||||
needs: detect
|
||||
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true')
|
||||
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true' || needs.detect.outputs.mcp_catalog == 'true')
|
||||
uses: ./.github/workflows/supply-chain-audit.yml
|
||||
with:
|
||||
event_name: ${{ needs.detect.outputs.event_name }}
|
||||
scan: ${{ needs.detect.outputs.scan == 'true' }}
|
||||
deps: ${{ needs.detect.outputs.deps == 'true' }}
|
||||
|
||||
review-labels:
|
||||
name: Review label gate
|
||||
needs: [detect, supply-chain]
|
||||
if: always() && needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.ci_review == 'true' || needs.detect.outputs.mcp_catalog == 'true' || needs.supply-chain.outputs.critical_findings == 'true')
|
||||
uses: ./.github/workflows/review-labels.yml
|
||||
with:
|
||||
ci_review: ${{ needs.detect.outputs.ci_review == 'true' }}
|
||||
mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }}
|
||||
supply_chain: ${{ needs.supply-chain.outputs.critical_findings == 'true' }}
|
||||
secrets: inherit
|
||||
|
||||
osv-scanner:
|
||||
@@ -172,95 +158,16 @@ jobs:
|
||||
uses: ./.github/workflows/osv-scanner.yml
|
||||
secrets: inherit
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Live-updating PR review comment.
|
||||
#
|
||||
# A single ``comment-live`` job polls the GitHub Actions API every 15s
|
||||
# for job statuses in this run, re-assembles the review comment from
|
||||
# whatever results are available, and upserts it via the
|
||||
# ``<!-- hermes-ci-review-bot -->`` marker.
|
||||
#
|
||||
# The poller exits when all non-infra jobs are completed (or on
|
||||
# timeout). ci-timings' review_status is picked up automatically when
|
||||
# its artifact becomes available — the poller downloads and merges it.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
comment-live:
|
||||
name: CI review comment (live)
|
||||
needs: [detect, review-labels, lockfile-diff, supply-chain, osv-scanner, uv-lockfile, history-check, contributor-check]
|
||||
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork != true
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Run live comment poller
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
# Commit info for the review comment header.
|
||||
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
COMMIT_MESSAGE: ${{ github.event.pull_request.head.commit.message }}
|
||||
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/pull/${{ github.event.pull_request.number }}/commits/${{ github.event.pull_request.head.sha }}
|
||||
# Structured review statuses from workflow_call jobs.
|
||||
# Each job outputs a JSON array of {source, results: [...]} objects
|
||||
# that the assembler renders directly — no hardcoded job-name
|
||||
# matching. We merge all available outputs into one array.
|
||||
REVIEW_STATUSES: ${{ toJSON(needs.*.outputs.review_status) }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
|
||||
# REVIEW_STATUSES is a JSON array of strings (some may be empty
|
||||
# when a job was skipped). Parse each string and merge into one
|
||||
# flat array for the assembler.
|
||||
python3 - <<'PYEOF'
|
||||
import json, os, sys
|
||||
|
||||
raw = os.environ.get("REVIEW_STATUSES", "")
|
||||
merged = []
|
||||
if raw:
|
||||
try:
|
||||
arr = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
arr = []
|
||||
for item in arr:
|
||||
if not item:
|
||||
continue
|
||||
try:
|
||||
statuses = json.loads(item)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if isinstance(statuses, list):
|
||||
merged.extend(statuses)
|
||||
|
||||
# Write merged array to a temp file the poller reads.
|
||||
with open("/tmp/review_statuses.json", "w") as f:
|
||||
json.dump(merged, f)
|
||||
print(f"Merged {len(merged)} review status entries")
|
||||
PYEOF
|
||||
|
||||
python3 scripts/ci/live_comment.py \
|
||||
--interval 15 \
|
||||
--timeout 2100 \
|
||||
--review-statuses-file /tmp/review_statuses.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Gate: runs after everything. ``if: always()`` ensures it reports a
|
||||
# status even when some deps were skipped. Only actual ``failure``
|
||||
# results cause it to fail; ``skipped`` is treated as success.
|
||||
#
|
||||
# Branch protection should require ONLY this check.
|
||||
#
|
||||
# Outputs ``needs-json`` — a compact ``{job_name: result}`` dict — so
|
||||
# the live comment poller can list failed jobs in the PR comment.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
all-checks-pass:
|
||||
name: All required checks pass
|
||||
needs:
|
||||
- detect
|
||||
- tests
|
||||
- lint
|
||||
- js-tests
|
||||
@@ -272,30 +179,20 @@ jobs:
|
||||
- lockfile-diff
|
||||
- docker-lint
|
||||
- supply-chain
|
||||
- review-labels
|
||||
- osv-scanner
|
||||
# comment-live is a polling job — it doesn't block the gate.
|
||||
# we don't require docker to pass rn because it's so slow lol
|
||||
# - docker
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
needs-json: ${{ steps.evaluate.outputs.needs-json }}
|
||||
steps:
|
||||
- name: Evaluate job results
|
||||
id: evaluate
|
||||
env:
|
||||
NEEDS: ${{ toJSON(needs) }}
|
||||
run: |
|
||||
echo "$NEEDS" | python3 -c "
|
||||
import json, sys
|
||||
needs = json.load(sys.stdin)
|
||||
# Emit compact {job_name: result} for the comment assembler.
|
||||
compact = {name: info['result'] for name, info in needs.items()}
|
||||
print(f'needs-json={json.dumps(compact)}')
|
||||
with open('$GITHUB_OUTPUT', 'a') as f:
|
||||
f.write(f'needs-json={json.dumps(compact)}\n')
|
||||
failed = [name for name, info in needs.items() if info['result'] == 'failure']
|
||||
for name, info in sorted(needs.items()):
|
||||
result = info['result']
|
||||
@@ -312,9 +209,6 @@ jobs:
|
||||
# cache them on main (as a baseline), and on PRs generate an HTML diff
|
||||
# report with a gantt chart + per-step breakdown. The report is uploaded
|
||||
# as an artifact and a markdown summary is written to $GITHUB_STEP_SUMMARY.
|
||||
#
|
||||
# The live comment poller picks up ci-timings' completion automatically —
|
||||
# it reads review-status.json from the artifact when the job finishes.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
ci-timings:
|
||||
name: CI timing report
|
||||
@@ -326,13 +220,6 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Restore baseline cache (PR only)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -349,28 +236,24 @@ jobs:
|
||||
# Forks get no repo secrets (AUTOFIX_BOT_PAT is empty); fall back to
|
||||
# the built-in read-only token so the timings API read still works
|
||||
# there instead of hard-failing this advisory job on every fork PR.
|
||||
# The get-app-token composite action falls back to GITHUB_TOKEN
|
||||
# on fork PRs where APP_ID is unavailable.
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
|
||||
run: |
|
||||
python3 scripts/ci/timings_report.py \
|
||||
--baseline ci-timings-baseline.json \
|
||||
--output ci-timings-report.html \
|
||||
--json-out ci-timings.json \
|
||||
--summary-out ci-timings-summary.md \
|
||||
--review-status-out review-status.json
|
||||
--summary-out ci-timings-summary.md
|
||||
|
||||
- name: Upload HTML report + review status
|
||||
- name: Upload HTML report
|
||||
# Advisory report — artifact-service blips must not fail the job.
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
id: ci-timings-artifact
|
||||
with:
|
||||
name: ci-timings-report
|
||||
path: |
|
||||
ci-timings-report.html
|
||||
review-status.json
|
||||
path: ci-timings-report.html
|
||||
retention-days: 14
|
||||
archive: false
|
||||
|
||||
- name: Output summary
|
||||
env:
|
||||
|
||||
@@ -2,10 +2,6 @@ name: Contributor Attribution Check
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
review_status:
|
||||
description: "JSON array of review status objects"
|
||||
value: ${{ jobs.check-attribution.outputs.review_status }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -14,15 +10,12 @@ jobs:
|
||||
check-attribution:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
review_status: ${{ steps.check-emails.outputs.review_status }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for git log
|
||||
|
||||
- name: Check for unmapped contributor emails
|
||||
id: check-emails
|
||||
run: |
|
||||
# Get the merge base between this PR and main
|
||||
MERGE_BASE=$(git merge-base origin/main HEAD)
|
||||
@@ -32,7 +25,6 @@ jobs:
|
||||
|
||||
if [ -z "$NEW_EMAILS" ]; then
|
||||
echo "No new commits to check."
|
||||
echo "review_status=[]" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -75,16 +67,6 @@ jobs:
|
||||
echo ""
|
||||
echo "To find the GitHub username for an email:"
|
||||
echo " gh api 'search/users?q=EMAIL+in:email' --jq '.items[0].login'"
|
||||
|
||||
# Emit review_status for unmapped emails
|
||||
DETAIL=$(echo -e "$MISSING" | sed '/^$/d; s/^ //')
|
||||
HOW_TO_FIX=$'Add mappings to scripts/release.py AUTHOR_MAP:\n```\n"<email>": "<github-username>",\n```\nTo find the GitHub username for an email:\n```\ngh api \'search/users?q=EMAIL+in:email\' --jq \'.items[0].login\'\n```\n'
|
||||
REVIEW_STATUS=$(jq -nc \
|
||||
--arg detail "$DETAIL" \
|
||||
--arg how_to_fix "$HOW_TO_FIX" \
|
||||
'[{"source":"contributor attribution","results":[{"kind":"action_required","title":"Unmapped contributor email(s)","summary":"New contributor email(s) are not in AUTHOR_MAP.","detail":$detail,"how_to_fix":$how_to_fix}]}]')
|
||||
echo "review_status=$REVIEW_STATUS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
exit 1
|
||||
else
|
||||
echo "✅ All contributor emails are mapped."
|
||||
|
||||
@@ -56,13 +56,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
@@ -80,8 +73,8 @@ jobs:
|
||||
|
||||
- name: Prepare skills index (unified multi-source catalog)
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
SKILLS_INDEX_RUN_ID: ${{ github.event.inputs.skills_index_run_id || '' }}
|
||||
REBUILD_SKILLS_INDEX: ${{ github.event.inputs.rebuild_skills_index || 'false' }}
|
||||
run: |
|
||||
@@ -157,9 +150,6 @@ jobs:
|
||||
- name: Extract skill metadata for dashboard
|
||||
run: python3 website/scripts/extract-skills.py
|
||||
|
||||
- name: Extract plugin catalog for the Plugins page
|
||||
run: python3 website/scripts/extract-plugins.py
|
||||
|
||||
- name: Regenerate per-skill docs pages + catalogs
|
||||
run: python3 website/scripts/generate-skill-docs.py
|
||||
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
name: E2E Windows Desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ethie/e2e]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: e2e-windows-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# this is separated so we don't have node.js and stuff polluting the system
|
||||
build-installer:
|
||||
if: false # NOTE: build-installer is disabled for now, since we don't ship updates to the installer. when we do, re-enable it.
|
||||
name: Build Hermes-Setup.exe
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: checkout cache inputs
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: |
|
||||
package-lock.json
|
||||
apps/bootstrap-installer
|
||||
sparse-checkout-cone-mode: true
|
||||
|
||||
# The cache key is the exact installer build fingerprint. A hit means
|
||||
# this package-lock + bootstrap-installer source combo was already built,
|
||||
# so we can skip the entire Node/Rust/toolchain dance and just upload it.
|
||||
- name: Restore installer build cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
id: installer-cache
|
||||
with:
|
||||
path: Hermes-Setup.exe
|
||||
key: hermes-installer-built-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'apps/bootstrap-installer/**', '!apps/bootstrap-installer/src-tauri/target/**') }}
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.installer-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust
|
||||
if: steps.installer-cache.outputs.cache-hit != 'true'
|
||||
uses: dtolnay/rust-toolchain@1.96.0 # stable
|
||||
- name: checkout full tree on cache miss
|
||||
if: steps.installer-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install npm dependencies
|
||||
if: steps.installer-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Build dev installer for this branch
|
||||
if: steps.installer-cache.outputs.cache-hit != 'true'
|
||||
run: npm run tauri:build
|
||||
working-directory: apps/bootstrap-installer
|
||||
timeout-minutes: 10
|
||||
|
||||
# Only runs on cache miss. Pick the exe the Tauri build produced and
|
||||
# normalize its name so downstream jobs always know what to download.
|
||||
- name: Normalize installer artifact name
|
||||
if: steps.installer-cache.outputs.cache-hit != 'true'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$candidates = @(
|
||||
'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes.exe',
|
||||
'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes_0.0.1_x64.exe',
|
||||
'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes_0.0.1_x64-setup.exe',
|
||||
'apps/bootstrap-installer/src-tauri/target/release/Hermes.exe'
|
||||
)
|
||||
$installer = $null
|
||||
foreach ($c in $candidates) {
|
||||
if (Test-Path $c) {
|
||||
$installer = Resolve-Path $c
|
||||
break
|
||||
}
|
||||
}
|
||||
if (-not $installer) {
|
||||
$installer = Get-ChildItem -Path 'apps/bootstrap-installer/src-tauri/target/release' `
|
||||
-Recurse -Filter '*.exe' | Where-Object { $_.Name -notlike '*setup*' -or $true } | Select-Object -First 1 -ExpandProperty FullName
|
||||
}
|
||||
if (-not $installer) {
|
||||
throw 'Could not find built Hermes-Setup.exe'
|
||||
}
|
||||
Copy-Item -Path $installer -Destination 'Hermes-Setup.exe' -Force
|
||||
Write-Host "Normalized installer: Hermes-Setup.exe (from $installer)"
|
||||
|
||||
e2e:
|
||||
name: Install this commit as latest main
|
||||
if: false # NOTE: this job isn't used yet.
|
||||
# needs: build-installer
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
env:
|
||||
# Isolated install directory so the real install flow doesn't touch the
|
||||
# runner's user profile. Kept under the workspace for easy cleanup.
|
||||
HERMES_HOME: ${{ github.workspace }}\.e2e-hermes-home
|
||||
INSTALL_DIR: ${{ github.workspace }}\.e2e-hermes-home\hermes-agent
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: source
|
||||
|
||||
- name: Restore installer from build cache
|
||||
if: false # build-installer is since we don't update the installer right now. instead, we download the latest installer from the website
|
||||
id: installer-cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: Hermes-Setup.exe
|
||||
key: hermes-installer-built-cache-${{ runner.os }}-${{ hashFiles('source/package-lock.json', 'source/apps/bootstrap-installer/**', '!source/apps/bootstrap-installer/src-tauri/target/**') }}
|
||||
|
||||
- name: Download latest production installer
|
||||
id: download-installer
|
||||
shell: pwsh
|
||||
run: |
|
||||
Invoke-WebRequest https://hermes-assets.nousresearch.com/Hermes-Setup.exe -OutFile Hermes-Setup.exe
|
||||
|
||||
- name: Restore cached test tools
|
||||
id: test-tools-cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: test-bins
|
||||
key: test-bins-${{ runner.os }}-v1
|
||||
|
||||
- name: Install AutoHotkey v2 and ffmpeg
|
||||
if: steps.test-tools-cache.outputs.cache-hit != 'true'
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Install fresh when the cache missed.
|
||||
|
||||
New-Item -ItemType Directory -Path test-bins\autohotkey, test-bins\ffmpeg -Force | Out-Null
|
||||
|
||||
# AutoHotkey: copy its whole v2 directory so helper exes/dlls come along.
|
||||
winget install -e --id AutoHotkey.AutoHotkey --silent --accept-source-agreements --accept-package-agreements --disable-interactivity
|
||||
$ahkDir = "$env:ProgramW6432\AutoHotkey\v2"
|
||||
if (-not (Test-Path $ahkDir)) {
|
||||
throw "AutoHotkey install directory not found: $ahkDir"
|
||||
}
|
||||
Copy-Item -Path "$ahkDir\*" -Destination test-bins\autohotkey -Recurse -Force
|
||||
|
||||
# ffmpeg : just install into dir
|
||||
winget install -e --id Gyan.FFmpeg --silent --accept-source-agreements --accept-package-agreements --disable-interactivity --location ffmpeg_dir
|
||||
Copy-Item -Path "ffmpeg_dir\*\*" -Destination test-bins\ffmpeg -Recurse -Force
|
||||
|
||||
- name: Add test-bins to PATH
|
||||
shell: pwsh
|
||||
run: |
|
||||
ls "$PWD\test-bins\ffmpeg"
|
||||
Add-Content -Path $env:GITHUB_PATH -Value "$PWD\test-bins\autohotkey"
|
||||
Add-Content -Path $env:GITHUB_PATH -Value "$PWD\test-bins\ffmpeg\bin"
|
||||
|
||||
# ── Prepare an isolated HERMES_HOME and copy checked-out repo ──────
|
||||
# actions/checkout already has the right commit; just mirror it into the
|
||||
# isolated home so the installer doesn't need to reach GitHub.
|
||||
- name: Move checked-out workspace into isolated HERMES_HOME
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Path $env:INSTALL_DIR -Force
|
||||
Get-ChildItem -Path ${{ github.workspace }}\source -Force | Move-Item -Destination $env:INSTALL_DIR -Force
|
||||
Write-Host "Isolated install dir ready: $env:INSTALL_DIR"
|
||||
|
||||
# ── Run the headed installer + AHK helper ───────────────────────
|
||||
- name: Launch Hermes-Setup.exe and install it
|
||||
shell: pwsh
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
HERMES_SETUP_DEV_REPO_ROOT: ${{ env.INSTALL_DIR }}
|
||||
run: |
|
||||
$installer = "Hermes-Setup.exe"
|
||||
|
||||
# ── Start screen recording (live stdin pipe) ──────────────────
|
||||
# ffmpeg must be started, fed, and stopped from the SAME step: the
|
||||
# graceful-stop signal is the character 'q' written to ffmpeg's live
|
||||
# stdin. A separate teardown step can't do this because the process
|
||||
# that owns the writable stdin pipe dies when this step ends.
|
||||
#
|
||||
# Start-Process / -RedirectStandardInput <file> does NOT work: it
|
||||
# hands ffmpeg a file handle opened once at EOF, so appending 'q' to
|
||||
# the file on disk never reaches the running process. We need a real
|
||||
# writable pipe, which only System.Diagnostics.Process exposes.
|
||||
#
|
||||
# -pix_fmt yuv420p keeps
|
||||
# the output broadly playable.
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = "ffmpeg"
|
||||
$psi.Arguments = "-y -f gdigrab -framerate 15 -i desktop " +
|
||||
"-hide_banner -loglevel error " +
|
||||
"-c:v libx264 -preset ultrafast -pix_fmt yuv420p recording.mkv"
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.UseShellExecute = $false
|
||||
$ffmpeg = [System.Diagnostics.Process]::Start($psi)
|
||||
$ffmpeg.Id | Out-File ffmpeg.pid
|
||||
# Note: stderr is intentionally left attached to the console so it is
|
||||
# captured in the step log. Do NOT redirect a stream we don't drain --
|
||||
# ffmpeg's progress output would fill the pipe buffer and block.
|
||||
Write-Host "ffmpeg recording started (pid $($ffmpeg.Id ))"
|
||||
|
||||
$e2eDir = "$env:RUNNER_TEMP\e2e-windows"
|
||||
|
||||
Copy-Item -Path "$env:INSTALL_DIR\e2e\windows" -Destination $e2eDir -Force -Recurse
|
||||
|
||||
$installerSuccess = $false
|
||||
try {
|
||||
# Launch the real installer
|
||||
$proc = Start-Process -FilePath $installer -PassThru -NoNewWindow
|
||||
$proc.Id | Out-File installer.pid
|
||||
|
||||
$ahkProc = Start-Process -FilePath ".\test-bins\autohotkey\AutoHotkey64.exe" `
|
||||
-ArgumentList "$e2eDir\install-hermes-desktop.ahk", "$PWD\ahk.log" -PassThru -NoNewWindow
|
||||
|
||||
# Wait for AHK helper to finish, and tail logs.
|
||||
|
||||
$logReader = $null
|
||||
$logStream = $null
|
||||
$logPath = Join-Path $env:HERMES_HOME "logs\bootstrap-installer.log"
|
||||
# can take a long time for installer!
|
||||
$deadline = (Get-Date).AddSeconds(60 * 8)
|
||||
|
||||
try {
|
||||
while ((Get-Date) -lt $deadline -and -not $ahkProc.HasExited) {
|
||||
if (-not $logReader) {
|
||||
if (Test-Path $logPath) {
|
||||
Write-Host "Found bootstrap-installer.log; tailing..."
|
||||
# FileShare.ReadWrite is required: the installer almost
|
||||
# certainly still has the file open for writing, and a
|
||||
# plain Get-Content/File.Open would throw or lock it out.
|
||||
$logStream = [System.IO.File]::Open(
|
||||
$logPath, 'Open', 'Read', 'ReadWrite')
|
||||
$logReader = New-Object System.IO.StreamReader($logStream)
|
||||
}
|
||||
} else {
|
||||
$line = $logReader.ReadLine()
|
||||
while ($null -ne $line) {
|
||||
Write-Host "[bootstrap] $line"
|
||||
$line = $logReader.ReadLine()
|
||||
}
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
|
||||
# Drain anything written in the final tick before exit/timeout.
|
||||
if ($logReader) {
|
||||
$line = $logReader.ReadLine()
|
||||
while ($null -ne $line) {
|
||||
Write-Host "[bootstrap] $line"
|
||||
$line = $logReader.ReadLine()
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($logReader) { $logReader.Dispose() }
|
||||
if ($logStream) { $logStream.Dispose() }
|
||||
}
|
||||
|
||||
if (-not $ahkProc.HasExited) {
|
||||
Write-Host "AHK helper is still running; stopping it"
|
||||
Stop-Process -Id $ahkProc.Id -Force -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
Write-Host "autohotkey helper exited"
|
||||
}
|
||||
}
|
||||
|
||||
finally {
|
||||
# Gracefully stop ffmpeg by writing 'q' to its LIVE stdin pipe, so
|
||||
# the container header/index are finalized and the mkv is playable.
|
||||
# This runs in the same step that owns the pipe, even on failure.
|
||||
if ($ffmpeg -and -not $ffmpeg.HasExited) {
|
||||
Write-Host "Stopping ffmpeg gracefully (q on stdin)"
|
||||
try {
|
||||
$ffmpeg.StandardInput.Write("q")
|
||||
$ffmpeg.StandardInput.Close()
|
||||
} catch {
|
||||
Write-Host "Failed to write q to ffmpeg stdin: $_"
|
||||
}
|
||||
if (-not $ffmpeg.WaitForExit(15000)) {
|
||||
Write-Host "ffmpeg did not exit after 15s; killing"
|
||||
$ffmpeg.Kill()
|
||||
}
|
||||
}
|
||||
Write-Host "ffmpeg stopped"
|
||||
|
||||
# Installer should have exited
|
||||
if ($proc.HasExited) {
|
||||
# TODO check exit code once we add exit code in installer
|
||||
$installerSuccess = $true
|
||||
} else {
|
||||
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
||||
throw "Installer is still running. Install did not succeed."
|
||||
}
|
||||
if (-not $installerSuccess) {
|
||||
throw "Installer did not exit after autohotkey script finished. Check installer logs!"
|
||||
}
|
||||
}
|
||||
|
||||
# ── Run Playwright against the installed binary ─────────────────
|
||||
# (placeholder: will be enabled once installer completes successfully.)
|
||||
- name: Launch installed app and run e2e
|
||||
if: false
|
||||
working-directory: source/apps/desktop
|
||||
run: npm exec playwright test e2e/ --reporter=list
|
||||
env:
|
||||
# Point the e2e spec at the desktop binary that the installer built.
|
||||
HERMES_E2E_INSTALL_ROOT: ${{ env.HERMES_HOME }}\hermes-agent
|
||||
|
||||
# ── Teardown & artifacts ────────────────────────────────────────
|
||||
# ffmpeg is normally started AND gracefully stopped inside the launch
|
||||
# step (so 'q' reaches its live stdin pipe). This step is only a
|
||||
# safety net: if the launch step timed out or crashed before its
|
||||
# finally block ran, force-kill any orphaned ffmpeg so the runner can
|
||||
# release recording.mkv for upload. The mkv container survives a hard
|
||||
# kill (only the trailing seek index is lost), so the artifact is still
|
||||
# usable for coordinate discovery even on this fallback path.
|
||||
- name: Stop orphaned screen recording (safety net)
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ffmpegpid = Get-Content ffmpeg.pid -ErrorAction SilentlyContinue
|
||||
if ($ffmpegpid) {
|
||||
$proc = Get-Process -Id $ffmpegpid -ErrorAction SilentlyContinue
|
||||
if ($proc) {
|
||||
Write-Host "Orphaned ffmpeg (pid $ffmpegpid) still running; force-stopping"
|
||||
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 2
|
||||
} else {
|
||||
Write-Host "ffmpeg already exited cleanly; nothing to do"
|
||||
}
|
||||
}
|
||||
|
||||
- name: Burn debug overlay into recording
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
$logPath = Join-Path $PWD 'ahk.log'
|
||||
$x = $y = $w = $h = $null
|
||||
if (Test-Path $logPath) {
|
||||
$line = Get-Content $logPath -Raw | Select-String -Pattern 'Window found at x=(\d+) y=(\d+) w=(\d+) h=(\d+)' -AllMatches | Select-Object -Last 1
|
||||
if ($line) {
|
||||
$x = [int]$line.Matches[0].Groups[1].Value
|
||||
$y = [int]$line.Matches[0].Groups[2].Value
|
||||
$w = [int]$line.Matches[0].Groups[3].Value
|
||||
$h = [int]$line.Matches[0].Groups[4].Value
|
||||
Write-Host "Parsed window rect: x=$x y=$y w=$w h=$h"
|
||||
} else {
|
||||
Write-Host "no window rect found in ahk.log; only timestamp will be burned"
|
||||
}
|
||||
} else {
|
||||
Write-Host "ahk.log not found; only timestamp will be burned"
|
||||
}
|
||||
|
||||
# Build the timestamp overlay
|
||||
$vf = "drawtext=text='%{pts\:hms}':fontfile='C\:\\Windows\\Fonts\\arial.ttf':fontsize=20:fontcolor=white:box=1:boxcolor=black@0.5:x=8:y=8"
|
||||
|
||||
if ($x -ne $null -and $y -ne $null -and $w -ne $null -and $h -ne $null) {
|
||||
# Window border + 16px grid only over the window + axis labels
|
||||
$grid = "drawbox=x=$x`:y=$y`:w=$w`:hf=$h`:color=red@0.9:t=2,split=2[box][win];[win]crop=$w`:$h`:$x`:$y`:$y"
|
||||
}
|
||||
|
||||
Write-Host "Overlay filter: $vf"
|
||||
|
||||
ffmpeg -y -i recording.mkv -vf "$vf" -c:v libx264 -preset veryfast -pix_fmt yuv420p recording-overlay.mkv
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "ffmpeg overlay failed"
|
||||
}
|
||||
|
||||
Move-Item -Path recording-overlay.mkv -Destination recording.mkv -Force
|
||||
Write-Host "Overlayed recording saved as recording.mkv"
|
||||
|
||||
- name: Upload screen recording
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
id: upload-recording
|
||||
if: always()
|
||||
with:
|
||||
name: screen-recording-${{ github.sha }}
|
||||
path: recording.mkv
|
||||
retention-days: 1
|
||||
archive: false
|
||||
overwrite: true
|
||||
|
||||
- name: Bootstrap Installer log
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
Get-Content "$env:HERMES_HOME\logs\bootstrap-installer.log"
|
||||
|
||||
- name: Autohotkey log
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
Get-Content ahk.log
|
||||
@@ -15,10 +15,6 @@ name: History Check
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
review_status:
|
||||
description: "JSON array of review_status objects for the synthesizer."
|
||||
value: ${{ jobs.check-common-ancestor.outputs.review_status }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -27,23 +23,18 @@ jobs:
|
||||
check-common-ancestor:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
review_status: ${{ steps.merge-base-check.outputs.review_status }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # full history both sides for merge-base
|
||||
|
||||
- id: merge-base-check
|
||||
name: Reject PRs with no common ancestor on main
|
||||
- name: Reject PRs with no common ancestor on main
|
||||
run: |
|
||||
# `git merge-base` exits non-zero AND prints nothing when the two
|
||||
# commits share no ancestor. We check both conditions explicitly
|
||||
# so the failure message is clear regardless of which signal fires
|
||||
# first.
|
||||
if ! BASE=$(git merge-base origin/main HEAD 2>/dev/null) || [ -z "$BASE" ]; then
|
||||
STATUS='[{"source":"unrelated histories","results":[{"kind":"action_required","title":"Unrelated histories","summary":"This PR has no common ancestor with main.","detail":"","how_to_fix":"Rebase your changes onto current main:\n```\ngit fetch origin main\ngit checkout -b fix-branch origin/main\n# re-apply your changes (cherry-pick, copy files, etc.)\ngit push -f origin fix-branch\n```\n"}]}]'
|
||||
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
|
||||
echo ""
|
||||
echo "::error::This PR has no common ancestor with main."
|
||||
echo ""
|
||||
@@ -65,4 +56,3 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::Common ancestor with main: $BASE"
|
||||
echo "review_status=[]" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -7,7 +7,7 @@ name: auto-fix lint issues & formatting
|
||||
# auto-corrected on merge so PRs aren't blocked by them. The PR-time eslint
|
||||
# check in typecheck.yml fails only when un-fixable errors remain.
|
||||
#
|
||||
# NOTE: App token pushes DO trigger further workflow runs (unlike
|
||||
# NOTE: AUTOFIX_BOT_PAT pushes DO trigger further workflow runs (unlike
|
||||
# secrets.GITHUB_TOKEN). The concurrency group (ts-autofix-${{ github.ref }})
|
||||
# with cancel-in-progress: true prevents an infinite loop — a re-triggered
|
||||
# run cancels the in-flight one, and since the second run finds no new fixes
|
||||
@@ -128,13 +128,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Download patch
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
@@ -177,7 +170,7 @@ jobs:
|
||||
|
||||
- name: Create/update PR and enable auto-merge
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
BOT_BRANCH: bot/js-autofix
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -200,7 +193,7 @@ jobs:
|
||||
|
||||
- name: Wait for merge, auto-close on failure or stale
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
START_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
name: Label rerun
|
||||
|
||||
# When the ``ci-reviewed`` label is added to a PR, rerun all failed jobs in
|
||||
# the latest CI run. This re-evaluates ``review-labels`` (which now sees the
|
||||
# label) and GitHub automatically reruns dependent jobs (``comment-live``,
|
||||
# ``all-checks-pass``) — so the review comment gets updated too.
|
||||
#
|
||||
# If the CI run is still in progress when the label is added, we wait for it
|
||||
# to finish before rerunning (``gh run rerun`` only works on completed runs).
|
||||
# The wait can be long (20+ min for a full CI run), but it's better than
|
||||
# silently failing and leaving the reviewer stuck.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: label-rerun-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
rerun-review-labels:
|
||||
name: Rerun review-labels job
|
||||
if: github.event.label.name == 'ci-reviewed'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- name: Wait for CI run to finish, then rerun failed jobs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
|
||||
# Find the latest CI run for this PR's head SHA.
|
||||
RUN_ID=$(gh run list \
|
||||
--repo "$REPO" \
|
||||
--commit "$HEAD_SHA" \
|
||||
--workflow ci.yml \
|
||||
--limit 1 \
|
||||
--json databaseId,status \
|
||||
--jq '.[0] | "\(.databaseId) \(.status)"' 2>/dev/null || true)
|
||||
|
||||
if [ -z "$RUN_ID" ]; then
|
||||
echo "No CI run found for this PR — nothing to rerun."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Split "RUN_ID STATUS" into two vars.
|
||||
RUN_ID="${RUN_ID%% *}"
|
||||
STATUS="${RUN_ID##* }"
|
||||
|
||||
echo "Latest CI run: $RUN_ID (status: $STATUS)"
|
||||
|
||||
# If the run is still in progress, wait for it to finish.
|
||||
# gh run rerun only works on completed runs — if we try while it's
|
||||
# running, GitHub rejects with "cannot be rerun; This workflow is
|
||||
# already running".
|
||||
if [ "$STATUS" != "completed" ]; then
|
||||
echo "Run is $STATUS — waiting for completion (this may take a while)..."
|
||||
# gh run watch --exit-status exits non-zero if the run fails,
|
||||
# which is expected (the label gate fails). Don't let that kill
|
||||
# the workflow — we WANT to rerun failed jobs.
|
||||
timeout 2100 gh run watch "$RUN_ID" --repo "$REPO" --interval 15 || true
|
||||
|
||||
# Verify it's actually completed now.
|
||||
STATUS=$(gh run view "$RUN_ID" --repo "$REPO" --json status --jq '.status' 2>/dev/null || echo "unknown")
|
||||
if [ "$STATUS" != "completed" ]; then
|
||||
echo "Run is still $STATUS after wait — giving up."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Run completed. Rerunning all failed jobs..."
|
||||
gh run rerun "$RUN_ID" --repo "$REPO" --failed || true
|
||||
echo "Done. GitHub will rerun review-labels and all dependent jobs."
|
||||
+120
-6
@@ -2,14 +2,11 @@ name: Lint (ruff + ty)
|
||||
|
||||
# Two things here:
|
||||
# 1. Advisory diff — ruff + ty diagnostics as a diff vs the target branch.
|
||||
# Writes a Markdown summary to the run page. Exit zero always.
|
||||
# Posts a Markdown summary and a PR comment. Exit zero always.
|
||||
# 2. Blocking ``ruff check .`` — enforces the explicit rules in
|
||||
# ``[tool.ruff.lint.select]`` (currently PLW1514). Failure blocks merge.
|
||||
# Separate job so the advisory diff still runs even when enforcement
|
||||
# fails.
|
||||
#
|
||||
# CI-sensitive file review was previously here as a ``ci-review`` job but
|
||||
# has moved to ``review-labels.yml`` so it can be rerun independently.
|
||||
# Separate job so the advisory diff still runs and posts even when
|
||||
# enforcement fails.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -18,9 +15,14 @@ on:
|
||||
description: The event name from the calling orchestrator (pull_request or push).
|
||||
type: string
|
||||
required: true
|
||||
ci_review:
|
||||
description: Whether CI-sensitive files (eslint config, workflows, actions) changed and require a review label.
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # needed to post/update PR comments
|
||||
|
||||
concurrency:
|
||||
group: lint-${{ github.ref }}
|
||||
@@ -160,3 +162,115 @@ jobs:
|
||||
|
||||
- name: Run footgun checker
|
||||
run: python scripts/check-windows-footguns.py --all
|
||||
|
||||
ci-review:
|
||||
# Require explicit maintainer review when CI-sensitive files change:
|
||||
# eslint config, workflow YAMLs, or composite actions. These files
|
||||
# influence what code the js-autofix job executes and pushes to
|
||||
# main, so a malicious PR could inject arbitrary code via a custom eslint
|
||||
# rule's `fix` function. The label gate ensures a human reviews before
|
||||
# merge. Mirrors the mcp-catalog-reviewed pattern in supply-chain-audit.yml.
|
||||
name: CI-sensitive file review
|
||||
if: inputs.event_name == 'pull_request' && inputs.ci_review
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Require ci-reviewed label
|
||||
id: label-check
|
||||
env:
|
||||
# Read-only label lookup. Use the built-in GITHUB_TOKEN (present and
|
||||
# read-only on forks) so the gate works on fork PRs; fall back to it
|
||||
# when AUTOFIX_BOT_PAT is empty. `|| true` degrades an API blip to
|
||||
# "label absent" rather than hard-failing the step.
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true)
|
||||
if echo "$LABELS" | grep -Fxq 'ci-reviewed'; then
|
||||
echo "reviewed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "ci-reviewed label present."
|
||||
exit 0
|
||||
fi
|
||||
echo "reviewed=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# On failure: find the bot's previous comment and edit it, or create
|
||||
# a new one if none exists. Using an HTML comment marker so we can
|
||||
# locate it reliably across runs without parsing the body text.
|
||||
# Skipped on fork PRs — GITHUB_TOKEN is read-only there, so the API
|
||||
# call would fail. The label gate still holds via the step below.
|
||||
- name: Post or update review warning
|
||||
if: steps.label-check.outputs.reviewed != 'true' && github.event.pull_request.head.repo.fork != true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
MARKER="<!-- ci-review-bot -->"
|
||||
BODY="${MARKER}
|
||||
## ⚠️ CI-sensitive file review required
|
||||
|
||||
This PR changes CI-sensitive files (eslint config, workflow YAMLs,
|
||||
or composite actions). These files influence what code the
|
||||
js-autofix job executes and pushes to main.
|
||||
|
||||
A maintainer should verify:
|
||||
- no new eslint rules with custom \`fix\` functions that write outside linted paths,
|
||||
- no workflow changes that widen permissions or remove guards,
|
||||
- no composite action changes that alter what gets executed.
|
||||
|
||||
After review, add the \`ci-reviewed\` label and re-run this check."
|
||||
|
||||
# Find an existing comment with our marker.
|
||||
COMMENT_ID=$(gh api \
|
||||
"repos/${{ github.repository }}/issues/${PR}/comments" \
|
||||
--paginate --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \
|
||||
| head -1 || true)
|
||||
|
||||
if [ -n "$COMMENT_ID" ]; then
|
||||
gh api --method PATCH \
|
||||
"repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \
|
||||
-f body="$BODY"
|
||||
else
|
||||
gh pr comment "$PR" --body "$BODY"
|
||||
fi
|
||||
|
||||
# Fail the job when the label is missing — always runs (including
|
||||
# fork PRs) so the security gate holds even when the comment step
|
||||
# was skipped above.
|
||||
- name: Fail on missing label
|
||||
if: steps.label-check.outputs.reviewed != 'true'
|
||||
run: |
|
||||
echo "::error::CI-sensitive changes require the ci-reviewed label."
|
||||
exit 1
|
||||
|
||||
# On success: if a previous warning comment exists, edit it to show
|
||||
# the review passed so the PR doesn't have a stale ⚠️ sitting around.
|
||||
# Skipped on fork PRs — no comment was ever posted to update.
|
||||
- name: Update previous warning to passed
|
||||
if: steps.label-check.outputs.reviewed == 'true' && github.event.pull_request.head.repo.fork != true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
MARKER="<!-- ci-review-bot -->"
|
||||
|
||||
# Find an existing comment with our marker.
|
||||
COMMENT_ID=$(gh api \
|
||||
"repos/${{ github.repository }}/issues/${PR}/comments" \
|
||||
--paginate --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \
|
||||
| head -1 || true)
|
||||
|
||||
if [ -n "$COMMENT_ID" ]; then
|
||||
BODY="${MARKER}
|
||||
## ✅ CI-sensitive file review passed
|
||||
|
||||
The \`ci-reviewed\` label is present on this PR."
|
||||
|
||||
gh api --method PATCH \
|
||||
"repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \
|
||||
-f body="$BODY"
|
||||
fi
|
||||
|
||||
@@ -7,25 +7,22 @@ name: Lockfile diff
|
||||
# the ``packages`` map at the merge base and at HEAD and set-diffs the
|
||||
# {install path: version} maps instead.
|
||||
#
|
||||
# The semantic diff is exposed as a workflow_call output ``review_status``
|
||||
# (a JSON array in the unified status format) and an artifact
|
||||
# (``lockfile-diff`` containing the markdown fragment) for the step
|
||||
# summary.
|
||||
# The comment is upserted: the script embeds a hidden HTML marker and the
|
||||
# workflow PATCHes the existing comment when one is found, so a PR gets
|
||||
# exactly one lockfile-diff comment that tracks the latest push instead
|
||||
# of a stack of stale ones. When a later push reverts all lockfile
|
||||
# changes, the comment is updated to say so (deleting it would be more
|
||||
# surprising than telling the reviewer it's resolved).
|
||||
#
|
||||
# Never blocking — this is review signal, not enforcement.
|
||||
# Never blocking — this is review signal, not enforcement. Exit is 0 even
|
||||
# when commenting fails (fork PRs get a read-only GITHUB_TOKEN).
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
changed:
|
||||
description: Whether package-lock.json changed relative to the target branch.
|
||||
value: ${{ jobs.diff.outputs.changed }}
|
||||
review_status:
|
||||
description: JSON array of review status objects for the unified PR comment.
|
||||
value: ${{ jobs.diff.outputs.review_status }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # post/update the diff comment
|
||||
|
||||
concurrency:
|
||||
group: lockfile-diff-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -36,9 +33,6 @@ jobs:
|
||||
name: package-lock.json semantic diff
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
changed: ${{ steps.diff.outputs.changed }}
|
||||
review_status: ${{ steps.emit-status.outputs.review_status }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -60,36 +54,45 @@ jobs:
|
||||
--output /tmp/lockfile-diff.md
|
||||
if [ -s /tmp/lockfile-diff.md ]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "## package-lock.json semantic diff"
|
||||
echo ""
|
||||
cat /tmp/lockfile-diff.md
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
cat /tmp/lockfile-diff.md >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
: > /tmp/lockfile-diff.md
|
||||
fi
|
||||
|
||||
- name: Emit review_status
|
||||
id: emit-status
|
||||
- name: Post or update PR comment
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
CHANGED: ${{ steps.diff.outputs.changed }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CHANGED="${{ steps.diff.outputs.changed }}"
|
||||
STATUS="[]"
|
||||
MARKER='<!-- hermes-lockfile-diff -->'
|
||||
|
||||
if [ "$CHANGED" = "true" ]; then
|
||||
CONTENT=$(cat /tmp/lockfile-diff.md | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
|
||||
STATUS="[{\"source\":\"lockfile-diff\",\"results\":[{\"kind\":\"action_required\",\"title\":\"package-lock.json\",\"summary\":\"Locked npm dependency versions changed.\",\"detail\":${CONTENT},\"how_to_fix\":\"Add the \`ci-reviewed\` label after verifying the version changes are expected.\"}]}"
|
||||
else
|
||||
STATUS="[]"
|
||||
# Find our previous comment (paginated — busy PRs exceed one page).
|
||||
EXISTING=$(gh api --paginate "repos/${REPO}/issues/${PR}/comments" \
|
||||
--jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" \
|
||||
| head -1 || true)
|
||||
|
||||
if [ "$CHANGED" != "true" ]; then
|
||||
if [ -n "$EXISTING" ]; then
|
||||
# A previous push changed the lockfile but the latest one
|
||||
# doesn't — update the comment rather than leave stale info.
|
||||
printf '%s\n✅ package-lock.json changes from an earlier push have been reverted — locked versions now match the target branch.\n' "$MARKER" > /tmp/lockfile-diff.md
|
||||
else
|
||||
echo "No lockfile changes and no existing comment — nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload diff artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: lockfile-diff
|
||||
path: /tmp/lockfile-diff.md
|
||||
retention-days: 1
|
||||
overwrite: true
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "Updating existing comment ${EXISTING}"
|
||||
gh api --method PATCH "repos/${REPO}/issues/comments/${EXISTING}" \
|
||||
-F body=@/tmp/lockfile-diff.md > /dev/null \
|
||||
|| echo "::warning::Could not update PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)"
|
||||
else
|
||||
echo "Creating new comment"
|
||||
gh api "repos/${REPO}/issues/${PR}/comments" \
|
||||
-F body=@/tmp/lockfile-diff.md > /dev/null \
|
||||
|| echo "::warning::Could not post PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)"
|
||||
fi
|
||||
|
||||
@@ -14,14 +14,14 @@ name: OSV-Scanner
|
||||
# code patterns in PR diffs) by covering the orthogonal "currently-pinned
|
||||
# dep became known-vulnerable" case.
|
||||
#
|
||||
# Uses Google's officially-recommended reusable workflow, pinned by SHA.
|
||||
# Steps below are inlined from Google's officially-recommended reusable
|
||||
# workflow (google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml),
|
||||
# rather than called via `uses:` so we can set a `timeout-minutes` in the
|
||||
# degenerate case where this job hangs.
|
||||
|
||||
# Findings land in the repo's Security tab (Code Scanning > OSV-Scanner).
|
||||
# fail-on-vuln is disabled so the job does not block merges on pre-existing
|
||||
# vulnerabilities in pinned deps that we may need to patch deliberately.
|
||||
#
|
||||
# The reusable workflow can't emit custom outputs, so a wrapper job
|
||||
# downloads the SARIF result and summarizes the vulnerability count into
|
||||
# a review_status for the unified PR comment.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -40,85 +40,62 @@ permissions:
|
||||
jobs:
|
||||
scan:
|
||||
name: Scan lockfiles
|
||||
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
|
||||
with:
|
||||
# Scan explicit lockfiles rather than recursing, so we only look at
|
||||
# the three sources of truth and skip vendored / test / worktree dirs.
|
||||
scan-args: |-
|
||||
--lockfile=uv.lock
|
||||
--lockfile=package-lock.json
|
||||
--lockfile=website/package-lock.json
|
||||
fail-on-vuln: false
|
||||
|
||||
emit-status:
|
||||
name: Emit review status
|
||||
runs-on: ubuntu-latest
|
||||
needs: scan
|
||||
if: always()
|
||||
outputs:
|
||||
review_status: ${{ steps.emit.outputs.review_status }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Download SARIF result
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
name: osv-results
|
||||
path: /tmp/osv-results
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Run scanner'
|
||||
uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
|
||||
with:
|
||||
# Scan explicit lockfiles rather than recursing, so we only look at
|
||||
# the three sources of truth and skip vendored / test / worktree dirs.
|
||||
scan-args: |-
|
||||
--output=results.json
|
||||
--format=json
|
||||
--lockfile=uv.lock
|
||||
--lockfile=package-lock.json
|
||||
--lockfile=website/package-lock.json
|
||||
continue-on-error: true
|
||||
|
||||
- name: Emit review_status
|
||||
id: emit
|
||||
- name: 'Run osv-scanner-reporter'
|
||||
uses: google/osv-scanner-action/osv-reporter-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
|
||||
with:
|
||||
scan-args: |-
|
||||
--output=results.sarif
|
||||
--new=results.json
|
||||
--gh-annotations=false
|
||||
--fail-on-vuln=false
|
||||
|
||||
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||
# format to the repository Actions tab.
|
||||
- name: 'Upload artifact'
|
||||
id: 'upload_artifact'
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: OSV Scanner SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: 'Upload to code-scanning'
|
||||
if: ${{ !cancelled() }}
|
||||
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
- name: 'Print Code Scanning URL'
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
STATUS="[]"
|
||||
echo "View the OSV-Scanner results in the 'Security' tab, using the following link:"
|
||||
echo "${{ github.server_url }}/${{ github.repository }}/security/code-scanning?query=is%3Aopen+branch%3A${GITHUB_REF_NAME}+tool%3Aosv-scanner"
|
||||
env:
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
|
||||
if [ -f /tmp/osv-results/osv-results.sarif ]; then
|
||||
# Count vulnerabilities from the SARIF file
|
||||
VULN_COUNT=$(python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
with open('/tmp/osv-results/osv-results.sarif') as f:
|
||||
data = json.load(f)
|
||||
count = 0
|
||||
vulns = []
|
||||
for run in data.get('runs', []):
|
||||
for result in run.get('results', []):
|
||||
count += 1
|
||||
rule_id = result.get('ruleId', 'unknown')
|
||||
message = result.get('message', {}).get('text', '')
|
||||
loc = result.get('locations', [{}])[0].get('physicalLocation', {}).get('artifactLocation', {}).get('uri', '')
|
||||
vulns.append(f'- {rule_id} in {loc}: {message}')
|
||||
print(count)
|
||||
if vulns:
|
||||
print('\n'.join(vulns[:20]), file=sys.stderr)
|
||||
except Exception:
|
||||
print(0)
|
||||
")
|
||||
|
||||
VULN_DETAIL=""
|
||||
if [ "$VULN_COUNT" -gt 0 ] 2>/dev/null; then
|
||||
VULN_PLURAL=$([ "$VULN_COUNT" -eq 1 ] && echo "y" || echo "ies")
|
||||
VULN_DETAIL=$(python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
with open('/tmp/osv-results/osv-results.sarif') as f:
|
||||
data = json.load(f)
|
||||
vulns = []
|
||||
for run in data.get('runs', []):
|
||||
for result in run.get('results', []):
|
||||
rule_id = result.get('ruleId', 'unknown')
|
||||
loc = result.get('locations', [{}])[0].get('physicalLocation', {}).get('artifactLocation', {}).get('uri', '')
|
||||
vulns.append(f'- {rule_id} in {loc}')
|
||||
print(json.dumps('\n'.join(vulns[:20])))
|
||||
except Exception:
|
||||
print(json.dumps(''))
|
||||
")
|
||||
STATUS="[{\"source\":\"osv scan\",\"results\":[{\"kind\":\"warning\",\"title\":\"OSV vulnerability scan\",\"summary\":\"${VULN_COUNT} known vulnerabilit${VULN_PLURAL} found in pinned dependencies.\",\"detail\":${VULN_DETAIL},\"how_to_fix\":\"Review the findings in the [Security tab](../../security/code-scanning). Update the affected dependencies if a patched version is available.\"}]}]"
|
||||
else
|
||||
STATUS="[]"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
|
||||
- name: 'Error troubleshooter'
|
||||
if: ${{ always() && steps.upload_artifact.outcome == 'failure' }}
|
||||
run: |
|
||||
echo "::error::Artifact upload failed. This is most likely caused by a error during scanning earlier in the workflow."
|
||||
exit 1
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
name: Plugin Catalog CI
|
||||
|
||||
# Admission gate for plugin-catalog entries. Fires ONLY on PRs touching
|
||||
# plugin-catalog/** so it can never go red on unrelated PRs.
|
||||
#
|
||||
# Two gates:
|
||||
# structural — cheap schema check, no hermes install needed
|
||||
# pinned-source-validate — supply-chain gate: the pinned sha MUST be
|
||||
# reachable in the entry's repo, and the plugin
|
||||
# at that exact commit must pass
|
||||
# `hermes plugins validate`.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "plugin-catalog/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
structural:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install PyYAML
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: pip install pyyaml==6.0.2
|
||||
|
||||
- name: Validate catalog files (structural)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Validating the whole directory is simpler than diffing and keeps
|
||||
# the invariant that EVERYTHING in plugin-catalog/ stays valid.
|
||||
python3 scripts/validate_plugin_catalog.py plugin-catalog/
|
||||
|
||||
pinned-source-validate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # need the merge-base to diff changed catalog files
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Find changed catalog entries
|
||||
id: changed
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MERGE_BASE=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
|
||||
# Added + modified entry files only; deletions and removed.yaml
|
||||
# have nothing to clone.
|
||||
CHANGED=$(git diff --name-only --diff-filter=AM "$MERGE_BASE"...HEAD \
|
||||
-- 'plugin-catalog/*.yaml' 'plugin-catalog/*.yml' \
|
||||
| grep -v '/removed\.yaml$' || true)
|
||||
echo "Changed catalog entries:"
|
||||
echo "${CHANGED:-<none>}"
|
||||
{
|
||||
echo 'files<<__EOF__'
|
||||
echo "$CHANGED"
|
||||
echo '__EOF__'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install hermes-agent from the PR's own checkout
|
||||
if: steps.changed.outputs.files != ''
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: pip install -e .
|
||||
|
||||
- name: Clone each entry at its pinned sha and validate
|
||||
if: steps.changed.outputs.files != ''
|
||||
env:
|
||||
CHANGED_FILES: ${{ steps.changed.outputs.files }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
FAILED=0
|
||||
while IFS= read -r entry; do
|
||||
[ -z "$entry" ] && continue
|
||||
echo "::group::validate $entry"
|
||||
|
||||
# Parse repo / sha / subdir from the entry yaml.
|
||||
eval "$(python3 - "$entry" <<'PYEOF'
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh) or {}
|
||||
print(f"REPO={shlex.quote(str(data.get('repo', '')))}")
|
||||
print(f"SHA={shlex.quote(str(data.get('sha', '')))}")
|
||||
print(f"SUBDIR={shlex.quote(str(data.get('subdir', '') or ''))}")
|
||||
PYEOF
|
||||
)"
|
||||
echo "repo=$REPO sha=$SHA subdir=$SUBDIR"
|
||||
|
||||
CLONE_DIR=$(mktemp -d)
|
||||
# Full clone (no --depth 1): the pinned sha may not be the branch tip.
|
||||
if ! git clone "$REPO" "$CLONE_DIR"; then
|
||||
echo "::error file=$entry::clone failed for $REPO"
|
||||
FAILED=1; echo "::endgroup::"; continue
|
||||
fi
|
||||
|
||||
# SUPPLY-CHAIN GATE: the pinned sha must be reachable in the repo.
|
||||
if ! git -C "$CLONE_DIR" checkout --detach "$SHA"; then
|
||||
echo "::error file=$entry::pinned sha $SHA is not reachable in $REPO"
|
||||
FAILED=1; echo "::endgroup::"; continue
|
||||
fi
|
||||
|
||||
PLUGIN_DIR="$CLONE_DIR${SUBDIR:+/$SUBDIR}"
|
||||
if [ ! -f "$PLUGIN_DIR/plugin.yaml" ]; then
|
||||
echo "::error file=$entry::no plugin.yaml at subdir '$SUBDIR' of $REPO@$SHA"
|
||||
FAILED=1; echo "::endgroup::"; continue
|
||||
fi
|
||||
|
||||
# Manifest schema + declared-vs-registered capability check.
|
||||
if hermes plugins validate "$PLUGIN_DIR"; then
|
||||
echo "✅ PASS: $entry"
|
||||
else
|
||||
echo "::error file=$entry::hermes plugins validate failed"
|
||||
FAILED=1
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done <<< "$CHANGED_FILES"
|
||||
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo "❌ FAIL: one or more catalog entries failed pinned-source validation"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ PASS: all changed catalog entries validated at their pinned shas"
|
||||
@@ -1,98 +0,0 @@
|
||||
name: Review labels
|
||||
|
||||
# Require explicit maintainer review when CI-sensitive files or the MCP
|
||||
# catalog change. Previously this was split across two jobs in two
|
||||
# workflows: ``ci-review`` in lint.yml (gated on ``ci_review``) and
|
||||
# ``mcp-catalog-review`` in supply-chain-audit.yml (gated on
|
||||
# ``mcp_catalog``). Both checked for their own label.
|
||||
#
|
||||
# Now consolidated: a single ``ci-reviewed`` label covers both. The
|
||||
# comment sections tell the reviewer exactly what to verify per area,
|
||||
# so one label is enough — the human reads the comment, not the label
|
||||
# name.
|
||||
#
|
||||
# Outputs:
|
||||
# ci_reviewed — "true" / "false" / "" (empty when neither lane ran)
|
||||
# review_status — JSON array of status objects consumed by the review
|
||||
# comment assembler. See scripts/ci/emit_review_status.py.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ci_review:
|
||||
description: Whether CI-sensitive files (eslint config, workflows, actions) changed.
|
||||
type: boolean
|
||||
default: false
|
||||
mcp_catalog:
|
||||
description: Whether the MCP catalog / installer changed.
|
||||
type: boolean
|
||||
default: false
|
||||
supply_chain:
|
||||
description: Whether the critical supply-chain scan found a risk requiring review.
|
||||
type: boolean
|
||||
default: false
|
||||
outputs:
|
||||
ci_reviewed:
|
||||
description: Whether the ci-reviewed label is present. Empty when neither input was true.
|
||||
value: ${{ jobs.check.outputs.ci_reviewed }}
|
||||
review_status:
|
||||
description: JSON array of status objects for the review comment assembler.
|
||||
value: ${{ jobs.check.outputs.review_status }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read # read PR labels
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Review label gate
|
||||
if: inputs.ci_review || inputs.mcp_catalog || inputs.supply_chain
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
outputs:
|
||||
ci_reviewed: ${{ steps.label-check.outputs.ci_reviewed }}
|
||||
review_status: ${{ steps.build-status.outputs.review_status }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check ci-reviewed label
|
||||
id: label-check
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
LABELS=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name' || true)
|
||||
|
||||
if echo "$LABELS" | grep -Fxq 'ci-reviewed'; then
|
||||
echo "ci-reviewed label present."
|
||||
echo "ci_reviewed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ci-reviewed label missing."
|
||||
echo "ci_reviewed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build review_status JSON
|
||||
id: build-status
|
||||
env:
|
||||
CI_REVIEW: ${{ inputs.ci_review }}
|
||||
MCP_CATALOG: ${{ inputs.mcp_catalog }}
|
||||
SUPPLY_CHAIN: ${{ inputs.supply_chain }}
|
||||
LABEL_PRESENT: ${{ steps.label-check.outputs.ci_reviewed }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=()
|
||||
if [ "$CI_REVIEW" = "true" ]; then args+=(--ci-review); fi
|
||||
if [ "$MCP_CATALOG" = "true" ]; then args+=(--mcp-catalog); fi
|
||||
if [ "$SUPPLY_CHAIN" = "true" ]; then args+=(--supply-chain); fi
|
||||
if [ "$LABEL_PRESENT" = "true" ]; then args+=(--label-present); fi
|
||||
|
||||
python3 scripts/ci/emit_review_status.py "${args[@]}" --output "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Fail on missing label
|
||||
if: steps.label-check.outputs.ci_reviewed != 'true'
|
||||
run: |
|
||||
echo "::error::CI-sensitive changes require the ci-reviewed label. Add the label and re-run this check."
|
||||
exit 1
|
||||
@@ -108,18 +108,10 @@ jobs:
|
||||
echo "Summary: ${{ steps.probe.outputs.summary }}"
|
||||
fi
|
||||
|
||||
- name: Get GitHub App token
|
||||
if: steps.probe.outputs.status != 'ok'
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Open issue on degraded / failed probe
|
||||
if: steps.probe.outputs.status != 'ok'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
STATUS: ${{ steps.probe.outputs.status }}
|
||||
DETAIL: ${{ steps.probe.outputs.detail }}
|
||||
run: |
|
||||
|
||||
@@ -24,13 +24,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
@@ -42,7 +35,7 @@ jobs:
|
||||
|
||||
- name: Build skills index
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
run: python scripts/build_skills_index.py
|
||||
|
||||
- name: Upload index artifact
|
||||
@@ -61,13 +54,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
- name: Trigger Deploy Site workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
run: gh workflow run deploy-site.yml --repo ${{ github.repository }} -f skills_index_run_id=${{ github.run_id }}
|
||||
|
||||
@@ -10,18 +10,9 @@ name: Supply Chain Audit
|
||||
# advisory-only workflow instead.
|
||||
#
|
||||
# Path-gating is handled centrally by the ``ci.yml`` orchestrator's
|
||||
# ``detect`` job. The orchestrator passes ``scan`` / ``deps`` booleans as
|
||||
# inputs; this workflow's jobs gate on those inputs instead of re-computing
|
||||
# the diff. MCP catalog review was previously here but has moved to
|
||||
# ``review-labels.yml`` so it can be rerun independently.
|
||||
#
|
||||
# Outputs:
|
||||
# review_status — JSON array of status objects consumed by the review
|
||||
# comment assembler (scripts/ci/assemble_review_comment.py).
|
||||
# critical_findings — "true" when the narrow critical-pattern scan found
|
||||
# something. The review-label gate consumes this and
|
||||
# owns the action-required result, so adding
|
||||
# ``ci-reviewed`` can heal the run on rerun.
|
||||
# ``detect`` job. The orchestrator passes ``scan`` / ``deps`` /
|
||||
# ``mcp_catalog`` booleans as inputs; this workflow's jobs gate on those
|
||||
# inputs instead of re-computing the diff.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -38,13 +29,10 @@ on:
|
||||
description: Whether pyproject.toml changed.
|
||||
type: boolean
|
||||
required: true
|
||||
outputs:
|
||||
review_status:
|
||||
description: JSON array of review status objects for the review comment assembler.
|
||||
value: ${{ jobs.aggregate.outputs.review_status }}
|
||||
critical_findings:
|
||||
description: Whether the critical-pattern scan found a risk requiring maintainer review.
|
||||
value: ${{ jobs.aggregate.outputs.critical_findings }}
|
||||
mcp_catalog:
|
||||
description: Whether the MCP catalog / installer changed.
|
||||
type: boolean
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
@@ -56,26 +44,16 @@ jobs:
|
||||
if: inputs.scan
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
review_status: ${{ steps.emit-status.outputs.review_status }}
|
||||
critical_findings: ${{ steps.scan.outputs.found }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Scan diff for critical patterns
|
||||
id: scan
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -83,7 +61,7 @@ jobs:
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
|
||||
# Added lines only, excluding lockfiles.
|
||||
# Three-point diff (base...head) diffs from the merge base to HEAD,
|
||||
# Three-dot diff (base...head) diffs from the merge base to HEAD,
|
||||
# so only changes introduced by this PR are included — not changes
|
||||
# that landed on main after the PR branched off.
|
||||
DIFF=$(git diff "$BASE"..."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true)
|
||||
@@ -161,32 +139,33 @@ jobs:
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Emit review_status
|
||||
id: emit-status
|
||||
if: always()
|
||||
- name: Post critical finding comment
|
||||
if: steps.scan.outputs.found == 'true'
|
||||
env:
|
||||
FOUND: ${{ steps.scan.outputs.found }}
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
run: |
|
||||
python3 - <<'PYEOF'
|
||||
import json, os
|
||||
BODY="## 🚨 CRITICAL Supply Chain Risk Detected
|
||||
|
||||
# The review-label gate renders and blocks critical findings. Keep
|
||||
# this scan a fact-finder so adding ci-reviewed can rerun the gate
|
||||
# without requiring the scanner itself to fail again.
|
||||
status = []
|
||||
This PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging.
|
||||
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
|
||||
f.write(f"review_status={json.dumps(status)}\n")
|
||||
PYEOF
|
||||
$(cat /tmp/findings.md)
|
||||
|
||||
---
|
||||
*Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting.*"
|
||||
|
||||
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)"
|
||||
|
||||
- name: Fail on critical findings
|
||||
if: steps.scan.outputs.found == 'true'
|
||||
run: |
|
||||
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
|
||||
exit 1
|
||||
|
||||
dep-bounds:
|
||||
name: Check PyPI dependency upper bounds
|
||||
if: inputs.deps
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
review_status: ${{ steps.emit-status.outputs.review_status }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -209,7 +188,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Match PyPI dep specs that have >= and no < ceiling.
|
||||
# Match PyPI dep specs that have >= but no < ceiling.
|
||||
# Pattern: "package>=version" without a following ",<" bound.
|
||||
# Excludes git+ URLs (which use commit SHAs) and comments.
|
||||
UNBOUNDED=$(echo "$ADDED" | grep -oE '"[a-zA-Z0-9_-]+(\[[^\]]*\])?>=[ 0-9.]+"' | grep -v ',<' || true)
|
||||
@@ -221,36 +200,26 @@ jobs:
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Emit review_status
|
||||
id: emit-status
|
||||
if: always()
|
||||
- name: Post unbounded dep warning
|
||||
if: steps.bounds.outputs.found == 'true'
|
||||
env:
|
||||
FOUND: ${{ steps.bounds.outputs.found }}
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
run: |
|
||||
python3 - <<'PYEOF'
|
||||
import json, os
|
||||
BODY="## ⚠️ Unbounded PyPI Dependency Detected
|
||||
|
||||
found = os.environ.get("FOUND", "") == "true"
|
||||
This PR adds PyPI dependencies without a \`<next_major\` upper bound. Per our [supply chain policy](../blob/main/CONTRIBUTING.md#dependency-pinning-policy-supply-chain-hardening), all PyPI deps must be pinned as \`>=floor,<next_major\`.
|
||||
|
||||
if found:
|
||||
with open("/tmp/unbounded.txt", encoding="utf-8") as f:
|
||||
detail = f.read()
|
||||
status = [{
|
||||
"source": "supply chain",
|
||||
"results": [{
|
||||
"kind": "action_required",
|
||||
"title": "Unbounded PyPI dependencies",
|
||||
"summary": "This PR adds PyPI dependencies without upper bounds.",
|
||||
"detail": detail,
|
||||
"how_to_fix": 'Add a `<next_major` upper bound, e.g. `"package>=1.2.0,<2"`. See CONTRIBUTING.md dependency pinning policy.'
|
||||
}]
|
||||
}]
|
||||
else:
|
||||
status = []
|
||||
**Unbounded specs found:**
|
||||
\`\`\`
|
||||
$(cat /tmp/unbounded.txt)
|
||||
\`\`\`
|
||||
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
|
||||
f.write(f"review_status={json.dumps(status)}\n")
|
||||
PYEOF
|
||||
**Fix:** Add an upper bound, e.g. \`"package>=1.2.0,<2"\`
|
||||
|
||||
---
|
||||
*See PR #2810 and CONTRIBUTING.md for the full policy rationale.*"
|
||||
|
||||
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
|
||||
|
||||
- name: Fail on unbounded deps
|
||||
if: steps.bounds.outputs.found == 'true'
|
||||
@@ -258,39 +227,45 @@ jobs:
|
||||
echo "::error::PyPI dependencies without upper bounds detected. Add <next_major ceiling per CONTRIBUTING.md policy."
|
||||
exit 1
|
||||
|
||||
aggregate:
|
||||
name: Aggregate review statuses
|
||||
needs: [scan, dep-bounds]
|
||||
if: always()
|
||||
mcp-catalog-review:
|
||||
name: MCP catalog security review
|
||||
if: inputs.mcp_catalog
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
review_status: ${{ steps.merge.outputs.review_status }}
|
||||
critical_findings: ${{ steps.merge.outputs.critical_findings }}
|
||||
steps:
|
||||
- name: Merge review statuses
|
||||
id: merge
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Require explicit MCP catalog review label
|
||||
env:
|
||||
SCAN_STATUS: ${{ needs.scan.outputs.review_status }}
|
||||
DEP_STATUS: ${{ needs.dep-bounds.outputs.review_status }}
|
||||
CRITICAL_FINDINGS: ${{ needs.scan.outputs.critical_findings }}
|
||||
# Read-only label lookup. Use the built-in GITHUB_TOKEN (present and
|
||||
# read-only on forks) so the gate works on fork PRs; fall back to it
|
||||
# when AUTOFIX_BOT_PAT is empty. `|| true` degrades an API blip to
|
||||
# "label absent" rather than hard-failing the step.
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
|
||||
run: |
|
||||
python3 - <<'PYEOF'
|
||||
import json, os
|
||||
set -euo pipefail
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true)
|
||||
if echo "$LABELS" | grep -Fxq 'mcp-catalog-reviewed'; then
|
||||
echo "MCP catalog review label present."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
merged = []
|
||||
for key in ("SCAN_STATUS", "DEP_STATUS"):
|
||||
raw = os.environ.get(key, "")
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if isinstance(data, list):
|
||||
merged.extend(data)
|
||||
BODY="## ⚠️ MCP catalog security review required
|
||||
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
|
||||
f.write(f"review_status={json.dumps(merged)}\n")
|
||||
f.write("critical_findings=" + os.environ.get("CRITICAL_FINDINGS", "false") + "\n")
|
||||
PYEOF
|
||||
This PR changes the bundled MCP catalog or MCP catalog installer code. MCP entries can define local commands that users later install into \`mcp_servers\`, so this needs explicit maintainer review before merge.
|
||||
|
||||
A maintainer should verify:
|
||||
- any new/changed \`optional-mcps/**/manifest.yaml\` command and args are expected,
|
||||
- stdio transports do not use shell+egress/exfiltration payloads,
|
||||
- git install refs are pinned and bootstrap commands are minimal,
|
||||
- requested env vars/secrets match the upstream MCP's documented needs.
|
||||
|
||||
After review, add the \`mcp-catalog-reviewed\` label and re-run this check."
|
||||
|
||||
gh pr comment "$PR" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
|
||||
echo "::error::MCP catalog changes require the mcp-catalog-reviewed label."
|
||||
exit 1
|
||||
|
||||
@@ -143,16 +143,9 @@ jobs:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Wait for GitHub Release to exist
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
# release.py creates the GitHub Release after pushing the tag,
|
||||
# but this workflow starts from the tag push — wait for it.
|
||||
run: |
|
||||
@@ -178,7 +171,7 @@ jobs:
|
||||
- name: Attach signed artifacts to GitHub Release
|
||||
if: env.skip_sign != 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
# release.py already created the GitHub Release — just upload
|
||||
# the Sigstore signatures alongside the existing assets.
|
||||
run: >-
|
||||
|
||||
@@ -45,10 +45,6 @@ name: uv.lock check
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
review_status:
|
||||
description: "JSON review status for the review-status aggregator"
|
||||
value: ${{ jobs.check.outputs.review_status }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -62,8 +58,6 @@ jobs:
|
||||
name: uv lock --check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
review_status: ${{ steps.verify.outputs.review_status }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -79,7 +73,6 @@ jobs:
|
||||
# of this file) — failures often mean "your branch is behind main,
|
||||
# rebase and regenerate uv.lock."
|
||||
- name: Verify uv.lock is up-to-date
|
||||
id: verify
|
||||
run: |
|
||||
# uv lock --check re-resolves against PyPI (network). Retry so a
|
||||
# registry blip doesn't read as "lockfile stale". A genuinely stale
|
||||
@@ -124,9 +117,5 @@ jobs:
|
||||
on `main` post-merge.
|
||||
EOF
|
||||
echo "::error title=uv.lock out of sync::Run \`uv lock\` locally and commit the result. If on a PR, sync with main first."
|
||||
review_status='[{"source":"uv.lock check","results":[{"kind":"action_required","title":"uv.lock out of sync","summary":"uv.lock is out of sync with pyproject.toml.","how_to_fix":"Run `uv lock` locally and commit the result. If on a PR, sync with main first:\n```\ngit fetch origin main\ngit rebase origin/main\nuv lock\ngit add uv.lock\ngit commit -m \"chore: refresh uv.lock\"\n```\n"}]}]'
|
||||
echo "review_status=${review_status}" >> "$GITHUB_OUTPUT"
|
||||
exit 1
|
||||
fi
|
||||
review_status='[]'
|
||||
echo "review_status=${review_status}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
+1
-11
@@ -44,10 +44,7 @@ run_datagen_sonnet.sh
|
||||
source-data/*
|
||||
run_datagen_megascience_glm4-6.sh
|
||||
data/*
|
||||
# No trailing slash: also matches node_modules SYMLINKS (worktrees often
|
||||
# symlink node_modules to the main checkout; the dir-only pattern let one
|
||||
# slip into a commit and break `npm ci` on CI with ENOTDIR).
|
||||
node_modules
|
||||
node_modules/
|
||||
browser-use/
|
||||
agent-browser/
|
||||
# Private keys
|
||||
@@ -75,8 +72,6 @@ environments/benchmarks/evals/
|
||||
|
||||
# Web UI build output
|
||||
hermes_cli/web_dist/
|
||||
# Cross-process web UI build lock (flock target, always empty)
|
||||
.web_ui_build.lock
|
||||
apps/desktop/build/
|
||||
apps/desktop/dist/
|
||||
|
||||
@@ -113,10 +108,6 @@ website/static/api/skills-index.json
|
||||
# every build).
|
||||
website/static/api/skills.json
|
||||
website/static/api/skills-meta.json
|
||||
# plugins.json + plugins-meta.json are build artifacts emitted by
|
||||
# website/scripts/extract-plugins.py during prebuild (Plugin Catalog page).
|
||||
website/static/api/plugins.json
|
||||
website/static/api/plugins-meta.json
|
||||
# automation-blueprints-index.json is a build artifact emitted by
|
||||
# website/scripts/extract-automation-blueprints.py during prebuild.
|
||||
website/static/api/automation-blueprints-index.json
|
||||
@@ -173,4 +164,3 @@ apps/desktop/demo/
|
||||
# PR body is the archive. See the hermes-agent-dev skill's
|
||||
# pr-infographic-workflow reference (storage rule + lapse #8 / #COMMIT-1).
|
||||
infographic/
|
||||
native/fts5_cjk/*.so
|
||||
|
||||
@@ -998,8 +998,7 @@ Two shapes:
|
||||
Roles:
|
||||
|
||||
- `role="leaf"` (default) — focused worker. Cannot call `delegate_task`,
|
||||
`clarify`, `memory`, `send_message`, `cronjob`. Retains `execute_code`
|
||||
(programmatic tool calling).
|
||||
`clarify`, `memory`, `send_message`, `execute_code`.
|
||||
- `role="orchestrator"` — retains `delegate_task` so it can spawn its
|
||||
own workers. Gated by `delegation.orchestrator_enabled` (default true)
|
||||
and bounded by `delegation.max_spawn_depth` (default 2).
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
graft skills
|
||||
graft optional-skills
|
||||
graft optional-mcps
|
||||
graft hermes_cli/web_dist
|
||||
graft locales
|
||||
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
|
||||
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs
|
||||
|
||||
@@ -456,7 +456,7 @@ class HermesACPAgent(acp.Agent):
|
||||
"tools": "List available tools",
|
||||
"context": "Show conversation context info",
|
||||
"reset": "Clear conversation history",
|
||||
"compress": "Compress conversation context",
|
||||
"compact": "Compress conversation context",
|
||||
"steer": "Inject guidance into the currently running agent turn",
|
||||
"queue": "Queue a prompt to run after the current turn finishes",
|
||||
"version": "Show Hermes version",
|
||||
@@ -485,7 +485,7 @@ class HermesACPAgent(acp.Agent):
|
||||
"description": "Clear conversation history",
|
||||
},
|
||||
{
|
||||
"name": "compress",
|
||||
"name": "compact",
|
||||
"description": "Compress conversation context",
|
||||
},
|
||||
{
|
||||
@@ -1756,7 +1756,7 @@ class HermesACPAgent(acp.Agent):
|
||||
"tools": self._cmd_tools,
|
||||
"context": self._cmd_context,
|
||||
"reset": self._cmd_reset,
|
||||
"compress": self._cmd_compress,
|
||||
"compact": self._cmd_compact,
|
||||
"steer": self._cmd_steer,
|
||||
"queue": self._cmd_queue,
|
||||
"version": self._cmd_version,
|
||||
@@ -1898,7 +1898,7 @@ class HermesACPAgent(acp.Agent):
|
||||
lines.append(
|
||||
f"Compression: due now (threshold ~{threshold_tokens:,}"
|
||||
+ (f", {threshold_pct:.0f}%" if threshold_pct else "")
|
||||
+ "). Run /compress."
|
||||
+ "). Run /compact."
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
@@ -1913,7 +1913,7 @@ class HermesACPAgent(acp.Agent):
|
||||
if getattr(agent, "compression_enabled", True) is False:
|
||||
lines.append("Compression is disabled for this agent.")
|
||||
else:
|
||||
lines.append("Tip: run /compress to compress manually before the threshold.")
|
||||
lines.append("Tip: run /compact to compress manually before the threshold.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -1933,7 +1933,7 @@ class HermesACPAgent(acp.Agent):
|
||||
return "Conversation history cleared. Agent session state reset failed; see logs."
|
||||
return "Conversation history cleared."
|
||||
|
||||
def _cmd_compress(self, args: str, state: SessionState) -> str:
|
||||
def _cmd_compact(self, args: str, state: SessionState) -> str:
|
||||
if not state.history:
|
||||
return "Nothing to compress — conversation is empty."
|
||||
try:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "hermes-agent",
|
||||
"name": "Hermes Agent",
|
||||
"version": "0.19.0",
|
||||
"version": "0.18.2",
|
||||
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
|
||||
"repository": "https://github.com/NousResearch/hermes-agent",
|
||||
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
|
||||
@@ -9,7 +9,7 @@
|
||||
"license": "MIT",
|
||||
"distribution": {
|
||||
"uvx": {
|
||||
"package": "hermes-agent[acp]==0.19.0",
|
||||
"package": "hermes-agent[acp]==0.18.2",
|
||||
"args": ["hermes-acp"]
|
||||
}
|
||||
}
|
||||
|
||||
+15
-388
@@ -28,7 +28,7 @@ import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from urllib.parse import parse_qs, urlparse, urlunparse
|
||||
from urllib.parse import urlparse, parse_qs, urlunparse
|
||||
|
||||
from agent.context_compressor import ContextCompressor
|
||||
from agent.iteration_budget import IterationBudget
|
||||
@@ -48,7 +48,6 @@ from agent.tool_guardrails import (
|
||||
ToolGuardrailDecision,
|
||||
)
|
||||
from hermes_cli.config import cfg_get
|
||||
from hermes_cli.route_identity import normalize_route_base_url
|
||||
from hermes_cli.timeouts import get_provider_request_timeout
|
||||
from hermes_constants import get_hermes_home
|
||||
from utils import base_url_host_matches, is_truthy_value
|
||||
@@ -69,151 +68,18 @@ def _ra():
|
||||
return run_agent
|
||||
|
||||
|
||||
def _normalize_route_base_url(base_url: Any) -> str:
|
||||
"""Canonicalize an endpoint URL for model-route identity comparisons."""
|
||||
return normalize_route_base_url(base_url)
|
||||
|
||||
|
||||
def _provider_default_routes(provider: str) -> set[str]:
|
||||
"""Return known exact default routes for a canonical provider id."""
|
||||
routes: set[str] = set()
|
||||
try:
|
||||
from hermes_cli.providers import HERMES_OVERLAYS, get_provider
|
||||
|
||||
overlay = HERMES_OVERLAYS.get(provider)
|
||||
provider_def = get_provider(provider)
|
||||
for value in (
|
||||
getattr(overlay, "base_url_override", ""),
|
||||
getattr(provider_def, "base_url", ""),
|
||||
):
|
||||
route = _normalize_route_base_url(value)
|
||||
if route:
|
||||
routes.add(route)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from providers import get_provider_profile
|
||||
|
||||
profile = get_provider_profile(provider)
|
||||
route = _normalize_route_base_url(
|
||||
getattr(profile, "base_url", "")
|
||||
)
|
||||
if route:
|
||||
routes.add(route)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
from hermes_cli.models import normalize_provider as normalize_model_provider
|
||||
from hermes_cli.providers import normalize_provider as normalize_registry_provider
|
||||
|
||||
for provider_id, config in PROVIDER_REGISTRY.items():
|
||||
canonical_id = normalize_registry_provider(
|
||||
normalize_model_provider(provider_id)
|
||||
)
|
||||
if canonical_id != provider:
|
||||
continue
|
||||
route = _normalize_route_base_url(
|
||||
getattr(config, "inference_base_url", "")
|
||||
)
|
||||
if route:
|
||||
routes.add(route)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if provider == "gemini":
|
||||
routes.update(
|
||||
f"{route.rstrip('/')}/openai"
|
||||
for route in list(routes)
|
||||
)
|
||||
return routes
|
||||
|
||||
|
||||
def _context_route_mismatch(
|
||||
configured_base_url: Any,
|
||||
active_base_url: Any,
|
||||
configured_provider: Any,
|
||||
active_provider: Any,
|
||||
*,
|
||||
already_normalized: bool = False,
|
||||
) -> bool:
|
||||
"""Return whether a context pin's configured route differs from runtime."""
|
||||
if already_normalized:
|
||||
configured_route = str(configured_base_url or "")
|
||||
active_route = str(active_base_url or "")
|
||||
else:
|
||||
configured_route = _normalize_route_base_url(configured_base_url)
|
||||
active_route = _normalize_route_base_url(active_base_url)
|
||||
if configured_route:
|
||||
return configured_route != active_route
|
||||
|
||||
configured_provider = str(configured_provider or "").strip()
|
||||
active_provider = str(active_provider or "").strip()
|
||||
if not configured_provider:
|
||||
return False
|
||||
try:
|
||||
from hermes_cli.models import normalize_provider as normalize_model_provider
|
||||
|
||||
configured_provider = normalize_model_provider(configured_provider)
|
||||
active_provider = normalize_model_provider(active_provider)
|
||||
except Exception:
|
||||
configured_provider = configured_provider.lower()
|
||||
active_provider = active_provider.lower()
|
||||
try:
|
||||
from hermes_cli.providers import normalize_provider as normalize_registry_provider
|
||||
|
||||
configured_provider = normalize_registry_provider(configured_provider)
|
||||
active_provider = normalize_registry_provider(active_provider)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if active_route:
|
||||
configured_routes = _provider_default_routes(configured_provider)
|
||||
return not configured_routes or active_route not in configured_routes
|
||||
return bool(
|
||||
configured_provider
|
||||
and active_provider
|
||||
and configured_provider != active_provider
|
||||
)
|
||||
|
||||
|
||||
def _normalize_custom_provider_name(value: Any) -> str:
|
||||
"""Mirror runtime normalization for a requested custom-provider identity."""
|
||||
return str(value or "").strip().lower().replace(" ", "-")
|
||||
|
||||
|
||||
def _custom_provider_runtime_ids(value: Any) -> set[str]:
|
||||
"""Return raw/menu identities that runtime accepts for a configured name."""
|
||||
normalized = _normalize_custom_provider_name(value)
|
||||
if not normalized:
|
||||
return set()
|
||||
return {normalized, f"custom:{normalized}"}
|
||||
|
||||
|
||||
def _build_codex_gpt5_autoraise_notice(
|
||||
autoraise: Dict[str, Any], context_length: Optional[int] = None
|
||||
) -> str:
|
||||
def _build_codex_gpt5_autoraise_notice(autoraise: Dict[str, Any]) -> str:
|
||||
"""Build the one-time notice shown when Codex gpt-5.x raises compaction.
|
||||
|
||||
``autoraise`` is ``{"model": <slug>, "from": <old_ratio>, "to": <new_ratio>}``.
|
||||
``context_length`` is the live-resolved window from the context compressor
|
||||
(Codex's /models catalog is authoritative and can change server-side, e.g.
|
||||
the gpt-5.6 family's 272K → 372K → 272K shifts in July 2026), so the banner
|
||||
reports what this session actually got rather than a hardcoded cap. The
|
||||
same text is printed inline for CLI users and replayed via
|
||||
The same text is printed inline for CLI users and replayed via
|
||||
``status_callback`` for gateway users, so it must be self-contained and
|
||||
include the exact opt-back-out command.
|
||||
"""
|
||||
model = str(autoraise.get("model") or "gpt-5.4/5.5").strip().lower().rsplit("/", 1)[-1]
|
||||
if isinstance(context_length, int) and context_length > 0:
|
||||
cap = f"{round(context_length / 1000)}K"
|
||||
else:
|
||||
# Static fallback when the resolved window isn't available:
|
||||
# gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5/5.6
|
||||
# family is capped at 272K by the Codex OAuth backend.
|
||||
cap = "128K" if model.startswith("gpt-5.3-codex-spark") else "272K"
|
||||
# gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5/5.6 family
|
||||
# is capped at 272K by the Codex OAuth backend.
|
||||
cap = "128K" if model.startswith("gpt-5.3-codex-spark") else "272K"
|
||||
from_pct = int(round(autoraise["from"] * 100))
|
||||
to_pct = int(round(autoraise["to"] * 100))
|
||||
return (
|
||||
@@ -1561,14 +1427,7 @@ def init_agent(
|
||||
agent._memory_nudge_interval = 10
|
||||
agent._turns_since_memory = 0
|
||||
agent._iters_since_skill = 0
|
||||
# A flush/background agent may pass skip_memory=True to avoid spinning up an
|
||||
# external memory *provider*, but if the caller also explicitly enables the
|
||||
# "memory" toolset it still needs the built-in file-backed store — otherwise
|
||||
# the memory tool dispatches with store=None and every call fails (#65429).
|
||||
# So the built-in store is created unless memory is globally disabled, while
|
||||
# the external-provider block below stays gated on skip_memory.
|
||||
_memory_toolset_requested = "memory" in (agent.enabled_toolsets or [])
|
||||
if not skip_memory or _memory_toolset_requested:
|
||||
if not skip_memory:
|
||||
try:
|
||||
mem_config = _agent_cfg.get("memory", {})
|
||||
agent._memory_enabled = mem_config.get("memory_enabled", False)
|
||||
@@ -1788,34 +1647,6 @@ def init_agent(
|
||||
compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"}
|
||||
compression_target_ratio = float(_compression_cfg.get("target_ratio", 0.20))
|
||||
compression_protect_last = int(_compression_cfg.get("protect_last_n", 20))
|
||||
# Cap on compression retry rounds before a turn gives up with "max
|
||||
# compression attempts reached" (compression.max_attempts). Hardcoding 3
|
||||
# strands sessions that legitimately need more rounds — e.g. a restart
|
||||
# history reload whose incompressible tool schemas keep the request
|
||||
# estimate above the threshold even though the messages compress fine
|
||||
# (the #62605 failure class). Default 3 preserves current behavior, so
|
||||
# an unset key is behavior-neutral; validated >= 1, hard-capped at 10,
|
||||
# and any non-int-like value falls back to 3. Booleans are rejected
|
||||
# (bool subclasses int, so int(True) would silently become 1) and
|
||||
# fractional floats are rejected rather than truncated — "4.7 attempts"
|
||||
# is a config mistake, not a request for 4.
|
||||
_raw_max_attempts = _compression_cfg.get("max_attempts", 3)
|
||||
if isinstance(_raw_max_attempts, bool):
|
||||
compression_max_attempts = 3
|
||||
elif isinstance(_raw_max_attempts, int):
|
||||
compression_max_attempts = _raw_max_attempts
|
||||
elif isinstance(_raw_max_attempts, float):
|
||||
compression_max_attempts = (
|
||||
int(_raw_max_attempts) if _raw_max_attempts.is_integer() else 3
|
||||
)
|
||||
else:
|
||||
try:
|
||||
compression_max_attempts = int(str(_raw_max_attempts).strip())
|
||||
except (TypeError, ValueError):
|
||||
compression_max_attempts = 3
|
||||
if compression_max_attempts < 1:
|
||||
compression_max_attempts = 3
|
||||
compression_max_attempts = min(compression_max_attempts, 10)
|
||||
# protect_first_n is the number of non-system messages to protect at
|
||||
# the head, in addition to the system prompt (which is always
|
||||
# implicitly protected by the compressor). Floor at 0 — a value of
|
||||
@@ -1828,29 +1659,6 @@ def init_agent(
|
||||
compression_abort_on_summary_failure = str(
|
||||
_compression_cfg.get("abort_on_summary_failure", False)
|
||||
).lower() in {"true", "1", "yes"}
|
||||
# Per-model threshold overrides: keys are substring-matched against the
|
||||
# model name (longest match wins). Empty dict = use the global threshold
|
||||
# for all models (backward compatible).
|
||||
_raw_model_thresholds = _compression_cfg.get("model_thresholds", {})
|
||||
if isinstance(_raw_model_thresholds, dict):
|
||||
compression_model_thresholds = {
|
||||
str(k): float(v) for k, v in _raw_model_thresholds.items()
|
||||
if isinstance(v, (int, float)) and not isinstance(v, bool)
|
||||
}
|
||||
else:
|
||||
compression_model_thresholds = {}
|
||||
# Absolute token cap: when set, compression triggers at the lower of
|
||||
# the ratio-based threshold and this absolute count. Clamped to the
|
||||
# model's context length at apply-time so a cap above the window is
|
||||
# a no-op (ratio-based threshold wins).
|
||||
compression_threshold_tokens = _compression_cfg.get("threshold_tokens")
|
||||
if compression_threshold_tokens is not None:
|
||||
try:
|
||||
compression_threshold_tokens = int(compression_threshold_tokens)
|
||||
if compression_threshold_tokens <= 0:
|
||||
compression_threshold_tokens = None
|
||||
except (TypeError, ValueError):
|
||||
compression_threshold_tokens = None
|
||||
# In-place compaction: when True, compress_context() rewrites the message
|
||||
# list + rebuilds the system prompt WITHOUT rotating the session id (no
|
||||
# parent_session_id chain, no `name #N` renumber). See #38763 and
|
||||
@@ -1939,9 +1747,8 @@ def init_agent(
|
||||
)
|
||||
_config_context_length = None
|
||||
|
||||
# Resolve custom_providers once before route-scoping a global context pin:
|
||||
# a named custom provider may keep its base URL only in this list rather
|
||||
# than repeating it under ``model``.
|
||||
# Resolve custom_providers list once for reuse below (startup
|
||||
# context-length override and plugin context-engine init).
|
||||
try:
|
||||
from hermes_cli.config import get_compatible_custom_providers
|
||||
_custom_providers = get_compatible_custom_providers(_agent_cfg)
|
||||
@@ -1950,163 +1757,6 @@ def init_agent(
|
||||
if not isinstance(_custom_providers, list):
|
||||
_custom_providers = []
|
||||
|
||||
# ``model.context_length`` describes the configured default model. A
|
||||
# process launched directly with ``--model`` / ``-m`` has already replaced
|
||||
# ``agent.model`` before this initializer loads config, so carrying the
|
||||
# default model's explicit window into that different runtime is stale. The
|
||||
# live switch/fallback paths already clear this override; keep direct-start
|
||||
# overrides consistent with them and let provider metadata resolve the
|
||||
# active model's window instead.
|
||||
if _config_context_length is not None and isinstance(_model_cfg, dict):
|
||||
_configured_default_model = str(_model_cfg.get("default") or "").strip()
|
||||
_configured_default_runtime_model = _configured_default_model
|
||||
_active_runtime_model = agent.model
|
||||
if _configured_default_model:
|
||||
try:
|
||||
from hermes_cli.model_normalize import normalize_model_for_provider
|
||||
|
||||
_configured_default_runtime_model = normalize_model_for_provider(
|
||||
_configured_default_model, agent.provider
|
||||
)
|
||||
_active_runtime_model = normalize_model_for_provider(
|
||||
agent.model, agent.provider
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
_configured_provider = str(_model_cfg.get("provider") or "").strip()
|
||||
_configured_base_url = _normalize_route_base_url(
|
||||
_model_cfg.get("base_url")
|
||||
)
|
||||
_configured_provider_norm = _normalize_custom_provider_name(
|
||||
_configured_provider
|
||||
)
|
||||
_custom_provider_candidate = bool(_configured_provider_norm)
|
||||
_runtime_first_provider_ids = {
|
||||
"auto",
|
||||
"moa",
|
||||
"vertex",
|
||||
"google-vertex",
|
||||
"vertex-ai",
|
||||
"gcp-vertex",
|
||||
"vertexai",
|
||||
}
|
||||
if _configured_provider_norm in _runtime_first_provider_ids:
|
||||
_custom_provider_candidate = False
|
||||
elif (
|
||||
_custom_provider_candidate
|
||||
and _configured_provider_norm != "custom"
|
||||
and not _configured_provider_norm.startswith("custom:")
|
||||
):
|
||||
try:
|
||||
from hermes_cli.auth import resolve_provider as resolve_auth_provider
|
||||
|
||||
_resolved_auth_provider = resolve_auth_provider(
|
||||
_configured_provider_norm
|
||||
)
|
||||
_custom_provider_candidate = (
|
||||
str(_resolved_auth_provider or "").strip().lower()
|
||||
!= _configured_provider_norm
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if not _configured_base_url and _custom_provider_candidate:
|
||||
_configured_custom_provider = _normalize_custom_provider_name(
|
||||
_configured_provider
|
||||
)
|
||||
_user_providers = _agent_cfg.get("providers")
|
||||
_disabled_custom_provider_ids: set[str] = set()
|
||||
if isinstance(_user_providers, dict):
|
||||
from hermes_cli.config import is_provider_enabled
|
||||
|
||||
for _provider_key, _provider_entry in _user_providers.items():
|
||||
if not isinstance(_provider_entry, dict):
|
||||
continue
|
||||
_entry_name = str(
|
||||
_provider_entry.get("name") or ""
|
||||
).strip()
|
||||
_entry_provider_ids = _custom_provider_runtime_ids(
|
||||
_provider_key
|
||||
) | _custom_provider_runtime_ids(_entry_name)
|
||||
if not is_provider_enabled(_provider_entry):
|
||||
_disabled_custom_provider_ids.update(
|
||||
provider_id
|
||||
for provider_id in _entry_provider_ids
|
||||
if provider_id
|
||||
)
|
||||
continue
|
||||
if _configured_custom_provider not in _entry_provider_ids:
|
||||
continue
|
||||
_configured_base_url = _normalize_route_base_url(
|
||||
_provider_entry.get("api")
|
||||
or _provider_entry.get("url")
|
||||
or _provider_entry.get("base_url")
|
||||
)
|
||||
if _configured_base_url:
|
||||
break
|
||||
if not _configured_base_url:
|
||||
for _provider_entry in _custom_providers:
|
||||
if not isinstance(_provider_entry, dict):
|
||||
continue
|
||||
_entry_name = str(
|
||||
_provider_entry.get("name") or ""
|
||||
).strip()
|
||||
_entry_provider_key = str(
|
||||
_provider_entry.get("provider_key") or ""
|
||||
).strip().lower()
|
||||
_entry_provider_ids = _custom_provider_runtime_ids(
|
||||
_entry_name
|
||||
) | _custom_provider_runtime_ids(_entry_provider_key)
|
||||
if (
|
||||
_entry_provider_key
|
||||
and _custom_provider_runtime_ids(_entry_provider_key)
|
||||
& _disabled_custom_provider_ids
|
||||
):
|
||||
continue
|
||||
if _configured_custom_provider not in _entry_provider_ids:
|
||||
continue
|
||||
_configured_base_url = _normalize_route_base_url(
|
||||
_provider_entry.get("base_url")
|
||||
)
|
||||
if _configured_base_url:
|
||||
break
|
||||
_active_route_url = str(agent.base_url or "")
|
||||
_requested_route_url = str(base_url or "")
|
||||
if "?" in _requested_route_url.split("#", 1)[0]:
|
||||
try:
|
||||
_requested_parts = urlparse(_requested_route_url)
|
||||
_requested_without_query = urlunparse(
|
||||
_requested_parts._replace(query="")
|
||||
)
|
||||
if _normalize_route_base_url(
|
||||
_requested_without_query
|
||||
) == _normalize_route_base_url(_active_route_url):
|
||||
_active_route_url = _requested_route_url
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
_active_base_url = _normalize_route_base_url(_active_route_url)
|
||||
_route_mismatch = _context_route_mismatch(
|
||||
_configured_base_url,
|
||||
_active_base_url,
|
||||
_configured_provider,
|
||||
agent.provider,
|
||||
already_normalized=True,
|
||||
)
|
||||
_model_mismatch = bool(
|
||||
_configured_default_runtime_model
|
||||
and _configured_default_runtime_model != _active_runtime_model
|
||||
)
|
||||
if _model_mismatch or _route_mismatch:
|
||||
_ra().logger.debug(
|
||||
"Ignoring model.context_length=%s for startup runtime %s at %s "
|
||||
"(configured default is %s at %s)",
|
||||
_config_context_length,
|
||||
agent.model,
|
||||
_active_base_url or agent.provider,
|
||||
_configured_default_model,
|
||||
_configured_base_url or _model_cfg.get("provider"),
|
||||
)
|
||||
_config_context_length = None
|
||||
|
||||
# Store for reuse by _check_compression_model_feasibility (auxiliary
|
||||
# compression model context-length detection needs the same list).
|
||||
agent._custom_providers = _custom_providers
|
||||
@@ -2129,11 +1779,11 @@ def init_agent(
|
||||
# Surface a clear warning if the user set a context_length but it
|
||||
# wasn't a valid positive int — the helper silently skips those.
|
||||
if _config_context_length is None:
|
||||
_target = _normalize_route_base_url(agent.base_url)
|
||||
_target = agent.base_url.rstrip("/") if agent.base_url else ""
|
||||
for _cp_entry in _custom_providers:
|
||||
if not isinstance(_cp_entry, dict):
|
||||
continue
|
||||
_cp_url = _normalize_route_base_url(_cp_entry.get("base_url"))
|
||||
_cp_url = (_cp_entry.get("base_url") or "").rstrip("/")
|
||||
if _target and _cp_url == _target:
|
||||
_cp_models = _cp_entry.get("models", {})
|
||||
if isinstance(_cp_models, dict):
|
||||
@@ -2246,16 +1896,6 @@ def init_agent(
|
||||
provider=agent.provider,
|
||||
custom_providers=_custom_providers,
|
||||
)
|
||||
# Per-model threshold overrides are part of the explicit
|
||||
# context-engine contract: assign them BEFORE the initial
|
||||
# update_model() call so the first resolution (which derives
|
||||
# threshold_percent/threshold_tokens for the initial model) already
|
||||
# sees the overrides. Assigning after update_model() left the initial
|
||||
# model on the engine's global threshold until the first /model
|
||||
# switch. Engines that override update_model() own their own policy
|
||||
# and may ignore the attribute.
|
||||
if compression_model_thresholds:
|
||||
agent.context_compressor.model_thresholds = compression_model_thresholds
|
||||
agent.context_compressor.update_model(
|
||||
model=agent.model,
|
||||
context_length=_plugin_ctx_len,
|
||||
@@ -2282,8 +1922,6 @@ def init_agent(
|
||||
api_mode=agent.api_mode,
|
||||
abort_on_summary_failure=compression_abort_on_summary_failure,
|
||||
max_tokens=agent.max_tokens,
|
||||
model_thresholds=compression_model_thresholds,
|
||||
threshold_tokens_cap=compression_threshold_tokens,
|
||||
)
|
||||
_bind_session_state = getattr(agent.context_compressor, "bind_session_state", None)
|
||||
if callable(_bind_session_state):
|
||||
@@ -2294,7 +1932,6 @@ def init_agent(
|
||||
agent.compression_enabled = compression_enabled
|
||||
agent.compression_in_place = compression_in_place
|
||||
agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction
|
||||
agent.max_compression_attempts = compression_max_attempts
|
||||
|
||||
# Reject models whose context window is below the minimum required
|
||||
# for reliable tool-calling workflows (64K tokens).
|
||||
@@ -2479,7 +2116,7 @@ def init_agent(
|
||||
# autoraised model) updates the marker state and re-notifies once. The
|
||||
# config display gate (compression.codex_gpt55_autoraise_notice) still
|
||||
# suppresses the banner entirely without disabling the threshold autoraise.
|
||||
_autoraise = getattr(agent, "_compression_threshold_autoraised", None) or {}
|
||||
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
|
||||
_show_autoraise_notice = (
|
||||
bool(_autoraise)
|
||||
and compression_enabled
|
||||
@@ -2495,21 +2132,14 @@ def init_agent(
|
||||
_active_threshold_pct = getattr(
|
||||
agent.context_compressor, "threshold_percent", compression_threshold
|
||||
)
|
||||
_cap_note = ""
|
||||
_cap = getattr(agent.context_compressor, "threshold_tokens_cap", None)
|
||||
if _cap and _cap > 0:
|
||||
_cap_note = f" (capped at {_cap:,} tokens)"
|
||||
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(_active_threshold_pct*100)}% = {agent.context_compressor.threshold_tokens:,}{_cap_note})")
|
||||
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(_active_threshold_pct*100)}% = {agent.context_compressor.threshold_tokens:,})")
|
||||
else:
|
||||
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (auto-compression disabled)")
|
||||
# Notice with the exact opt-back-out command. Printed inline at startup
|
||||
# for CLI users; gateway users get the same text replayed via
|
||||
# _compression_warning on turn 1 (set below).
|
||||
if _show_autoraise_notice:
|
||||
print(_build_codex_gpt5_autoraise_notice(
|
||||
_autoraise,
|
||||
context_length=getattr(agent.context_compressor, "context_length", None),
|
||||
))
|
||||
print(_build_codex_gpt5_autoraise_notice(_autoraise))
|
||||
|
||||
# Check immediately so CLI users see the warning at startup.
|
||||
# Gateway status_callback is not yet wired, so any warning is stored
|
||||
@@ -2519,10 +2149,7 @@ def init_agent(
|
||||
# above only reaches the CLI, so stash the same text here to be replayed
|
||||
# through status_callback on the first turn (Telegram/Discord/Slack/etc.).
|
||||
if _show_autoraise_notice:
|
||||
agent._compression_warning = _build_codex_gpt5_autoraise_notice(
|
||||
_autoraise,
|
||||
context_length=getattr(agent.context_compressor, "context_length", None),
|
||||
)
|
||||
agent._compression_warning = _build_codex_gpt5_autoraise_notice(_autoraise)
|
||||
|
||||
# Mark shown so repeated inits in this profile (e.g. every gateway message)
|
||||
# stay silent. Recorded once, whether the notice went to the CLI print or
|
||||
|
||||
@@ -457,8 +457,7 @@ def _is_kimi_coding_endpoint(base_url: str | None) -> bool:
|
||||
|
||||
# Model-name prefixes that identify the Kimi / Moonshot family. Covers
|
||||
# - official slugs: ``kimi-k2.5``, ``kimi_thinking``, ``moonshot-v1-8k``
|
||||
# - common release lines: ``k1.5-...``, ``k2-thinking``, ``k25-...``, ``k2.5-...``,
|
||||
# and the bare Coding Plan slug ``k3`` (plus ``k3.x``/``k3-...`` variants)
|
||||
# - common release lines: ``k1.5-...``, ``k2-thinking``, ``k25-...``, ``k2.5-...``
|
||||
# Matched case-insensitively against the post-``normalize_model_name`` form,
|
||||
# so a caller's ``provider/vendor/model`` slug is handled the same as a
|
||||
# bare name.
|
||||
@@ -468,14 +467,8 @@ _KIMI_FAMILY_MODEL_PREFIXES = (
|
||||
"k1.", "k1-",
|
||||
"k2.", "k2-",
|
||||
"k25", "k2.5",
|
||||
"k3.", "k3-",
|
||||
)
|
||||
|
||||
# Bare release slugs with no separator suffix (Kimi Coding Plan serves K3
|
||||
# as the exact slug ``k3``). Kept exact-match so unrelated model names that
|
||||
# merely start with the same characters don't get misclassified.
|
||||
_KIMI_FAMILY_EXACT_SLUGS = frozenset({"k3"})
|
||||
|
||||
|
||||
def _model_name_is_kimi_family(model: str | None) -> bool:
|
||||
if not isinstance(model, str):
|
||||
@@ -486,8 +479,6 @@ def _model_name_is_kimi_family(model: str | None) -> bool:
|
||||
# Strip vendor prefix (e.g. ``moonshotai/kimi-k2.5`` → ``kimi-k2.5``)
|
||||
if "/" in m:
|
||||
m = m.rsplit("/", 1)[-1]
|
||||
if m in _KIMI_FAMILY_EXACT_SLUGS:
|
||||
return True
|
||||
return m.startswith(_KIMI_FAMILY_MODEL_PREFIXES)
|
||||
|
||||
|
||||
@@ -2412,24 +2403,6 @@ def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None:
|
||||
]
|
||||
|
||||
|
||||
def _ensure_leading_user_turn(result: List[Dict[str, Any]]) -> None:
|
||||
"""Anthropic requires messages[0] to have role=user.
|
||||
|
||||
After a second context compaction on the auto path the summary can be
|
||||
emitted as role=assistant with nothing in front of it (the system prompt
|
||||
lives outside messages[] or is extracted into the separate ``system``
|
||||
param), so messages[0] ends up assistant and the Messages API rejects
|
||||
the request with HTTP 400 — often masked by a misleading
|
||||
"tool_use ids were found without tool_result blocks" error (#52160).
|
||||
|
||||
Mirror the Bedrock Converse adapter, which unconditionally prepends a
|
||||
minimal user turn when the first message is not user
|
||||
(convert_messages_to_converse).
|
||||
"""
|
||||
if result and result[0].get("role") != "user":
|
||||
result.insert(0, {"role": "user", "content": [{"type": "text", "text": " "}]})
|
||||
|
||||
|
||||
def convert_messages_to_anthropic(
|
||||
messages: List[Dict],
|
||||
base_url: str | None = None,
|
||||
@@ -2488,7 +2461,6 @@ def convert_messages_to_anthropic(
|
||||
|
||||
_strip_orphaned_tool_blocks(result)
|
||||
result = _merge_consecutive_roles(result)
|
||||
_ensure_leading_user_turn(result)
|
||||
_manage_thinking_signatures(result, base_url, model)
|
||||
_evict_old_screenshots(result)
|
||||
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
"""System-battery read-out for the CLI/TUI status bar.
|
||||
|
||||
Reads the host battery through ``psutil`` (already a Hermes dependency) and
|
||||
exposes a compact, colour-coded label. Everything degrades to "unavailable"
|
||||
when there is no battery (desktops, servers, VMs) or when the read fails, so
|
||||
callers can render the result unconditionally and simply show nothing.
|
||||
|
||||
The status bar repaints often (every keystroke and on a ~1s idle refresh), so
|
||||
:func:`read_battery` memoises the last reading for a few seconds instead of
|
||||
hitting ``psutil`` on every frame.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BatteryStatus:
|
||||
"""A single battery reading.
|
||||
|
||||
``available`` is False on machines without a battery (or when the read
|
||||
failed). ``percent`` is clamped to 0-100. ``plugged`` is True when on AC
|
||||
power, False on battery, and None when the platform can't tell.
|
||||
"""
|
||||
|
||||
available: bool
|
||||
percent: Optional[int] = None
|
||||
plugged: Optional[bool] = None
|
||||
|
||||
@property
|
||||
def charging(self) -> bool:
|
||||
return bool(self.plugged)
|
||||
|
||||
|
||||
UNAVAILABLE = BatteryStatus(available=False)
|
||||
|
||||
# Colour buckets, mirroring the status-bar context styles but inverted (a full
|
||||
# battery is "good", an empty one is "critical").
|
||||
CATEGORY_GOOD = "good"
|
||||
CATEGORY_WARN = "warn"
|
||||
CATEGORY_BAD = "bad"
|
||||
CATEGORY_CRITICAL = "critical"
|
||||
CATEGORY_DIM = "dim"
|
||||
|
||||
_CACHE_TTL_SECONDS = 8.0
|
||||
_cache: Optional[tuple[float, BatteryStatus]] = None
|
||||
|
||||
|
||||
def _read_battery_uncached() -> BatteryStatus:
|
||||
try:
|
||||
import psutil
|
||||
except Exception:
|
||||
return UNAVAILABLE
|
||||
|
||||
# ``sensors_battery`` is missing on some platforms/builds of psutil.
|
||||
reader = getattr(psutil, "sensors_battery", None)
|
||||
if reader is None:
|
||||
return UNAVAILABLE
|
||||
|
||||
try:
|
||||
batt = reader()
|
||||
except Exception:
|
||||
return UNAVAILABLE
|
||||
|
||||
if batt is None:
|
||||
return UNAVAILABLE
|
||||
|
||||
percent: Optional[int] = None
|
||||
raw_percent = getattr(batt, "percent", None)
|
||||
if raw_percent is not None:
|
||||
try:
|
||||
percent = max(0, min(100, int(round(float(raw_percent)))))
|
||||
except (TypeError, ValueError):
|
||||
percent = None
|
||||
|
||||
plugged = getattr(batt, "power_plugged", None)
|
||||
if plugged is not None:
|
||||
plugged = bool(plugged)
|
||||
|
||||
return BatteryStatus(available=True, percent=percent, plugged=plugged)
|
||||
|
||||
|
||||
def read_battery(use_cache: bool = True) -> BatteryStatus:
|
||||
"""Return the current battery status (cached for a few seconds)."""
|
||||
global _cache
|
||||
if use_cache and _cache is not None:
|
||||
ts, cached = _cache
|
||||
if time.monotonic() - ts < _CACHE_TTL_SECONDS:
|
||||
return cached
|
||||
|
||||
status = _read_battery_uncached()
|
||||
_cache = (time.monotonic(), status)
|
||||
return status
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Drop the memoised reading (used by tests)."""
|
||||
global _cache
|
||||
_cache = None
|
||||
|
||||
|
||||
def battery_category(status: BatteryStatus) -> str:
|
||||
"""Bucket a reading into a colour category: good/warn/bad/critical/dim."""
|
||||
if not status.available or status.percent is None:
|
||||
return CATEGORY_DIM
|
||||
# On AC power the level isn't a concern — always read as healthy.
|
||||
if status.charging:
|
||||
return CATEGORY_GOOD
|
||||
pct = status.percent
|
||||
if pct <= 10:
|
||||
return CATEGORY_CRITICAL
|
||||
if pct <= 20:
|
||||
return CATEGORY_BAD
|
||||
if pct <= 50:
|
||||
return CATEGORY_WARN
|
||||
return CATEGORY_GOOD
|
||||
|
||||
|
||||
def battery_glyph(status: BatteryStatus) -> str:
|
||||
"""Return the leading glyph: a bolt while charging, else a battery."""
|
||||
return "\u26a1" if status.charging else "\U0001f50b" # ⚡ / 🔋
|
||||
|
||||
|
||||
def format_battery(status: BatteryStatus) -> str:
|
||||
"""Return a compact label like ``🔋 82%`` / ``⚡ 82%`` (empty if N/A)."""
|
||||
if not status.available or status.percent is None:
|
||||
return ""
|
||||
return f"{battery_glyph(status)} {status.percent}%"
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Surface-agnostic core for the Phase 2b Remote Spending screens.
|
||||
"""Surface-agnostic core for the Phase 2b terminal-billing screens.
|
||||
|
||||
One fetch/parse per concern, consumed identically by the CLI handler
|
||||
(``cli.py::_show_billing``), the TUI JSON-RPC methods
|
||||
|
||||
+14
-9
@@ -55,12 +55,13 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from hermes_cli._subprocess_compat import bounded_git_probe
|
||||
from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
|
||||
|
||||
logger = logging.getLogger("hermes.coding_context")
|
||||
|
||||
@@ -688,14 +689,18 @@ def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]:
|
||||
|
||||
|
||||
def _git(cwd: Path, *args: str) -> str:
|
||||
"""``git -C <cwd> <args>`` → stripped stdout, or ``""`` on any failure.
|
||||
|
||||
Uses the shared :func:`bounded_git_probe` so the post-kill cleanup is bounded
|
||||
on Windows — a plain ``subprocess.run(timeout=...)`` here deadlocked the agent
|
||||
turn inside ``build_coding_workspace_block`` when a killed git left a suspended
|
||||
descendant holding the pipe handles (issue #66037).
|
||||
"""
|
||||
return bounded_git_probe(["git", "-C", str(cwd), *args], timeout=_GIT_TIMEOUT)
|
||||
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "-C", str(cwd), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_GIT_TIMEOUT,
|
||||
**_popen_kwargs,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
return out.stdout.strip() if out.returncode == 0 else ""
|
||||
|
||||
|
||||
def _parse_status(porcelain: str) -> tuple[dict[str, str], dict[str, int]]:
|
||||
|
||||
+83
-799
File diff suppressed because it is too large
Load Diff
@@ -260,19 +260,4 @@ class ContextEngine(ABC):
|
||||
(e.g. recalculate DAG budgets, switch summary models).
|
||||
"""
|
||||
self.context_length = context_length
|
||||
# Apply per-model threshold overrides if set (longest substring match).
|
||||
# Falls back to _config_threshold_percent (the raw config value) when
|
||||
# no override matches. Plugin engines that override update_model() can
|
||||
# call resolve_model_threshold() for the same logic.
|
||||
from agent.context_compressor import resolve_model_threshold
|
||||
if not hasattr(self, "_config_threshold_percent"):
|
||||
# Snapshot the pre-override percent ONCE so repeated model
|
||||
# switches fall back to the engine's configured value, not the
|
||||
# previous model's override.
|
||||
self._config_threshold_percent = self.threshold_percent
|
||||
self._base_threshold_percent = resolve_model_threshold(
|
||||
model, getattr(self, "model_thresholds", {}),
|
||||
self._config_threshold_percent,
|
||||
)
|
||||
self.threshold_percent = self._base_threshold_percent
|
||||
self.threshold_tokens = int(context_length * self.threshold_percent)
|
||||
|
||||
@@ -30,12 +30,9 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
import threading
|
||||
from datetime import datetime
|
||||
@@ -175,43 +172,6 @@ def _session_was_rotated_by_compression(session_db: Any, session_id: str) -> boo
|
||||
)
|
||||
|
||||
|
||||
def _emit_compression_attempt_telemetry(
|
||||
agent: Any,
|
||||
*,
|
||||
started_at: float,
|
||||
commit_status: str,
|
||||
split_status: str,
|
||||
failure_class: str | None = None,
|
||||
) -> None:
|
||||
"""Emit one content-free JSON log line for a compression attempt."""
|
||||
try:
|
||||
telemetry = getattr(agent.context_compressor, "_last_compression_telemetry", None)
|
||||
if not isinstance(telemetry, dict):
|
||||
telemetry = {}
|
||||
payload = dict(telemetry)
|
||||
payload.setdefault("event", "compression_attempt")
|
||||
payload.setdefault("attempt_id", getattr(agent, "_compression_attempt_id", "") or uuid.uuid4().hex)
|
||||
payload.setdefault("session_id", getattr(agent, "session_id", "") or "")
|
||||
payload["total_duration_ms"] = int((time.monotonic() - started_at) * 1000)
|
||||
payload["commit_status"] = commit_status
|
||||
payload["split_status"] = split_status
|
||||
if failure_class:
|
||||
payload["failure_class"] = failure_class
|
||||
payload.setdefault("chunking", False)
|
||||
payload.setdefault("chunk_count", 0)
|
||||
payload["fallback_used"] = bool(
|
||||
payload.get("fallback_used")
|
||||
or getattr(agent.context_compressor, "_last_summary_fallback_used", False)
|
||||
or getattr(agent.context_compressor, "_last_aux_model_failure_model", None)
|
||||
)
|
||||
logger.info(
|
||||
"context compression attempt telemetry: %s",
|
||||
json.dumps(payload, sort_keys=True, separators=(",", ":")),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("failed to emit compression attempt telemetry: %s", exc)
|
||||
|
||||
|
||||
def _compression_lock_holder(agent: Any) -> str:
|
||||
"""Build a unique holder id for the lock: pid:tid:agent-instance:uuid.
|
||||
|
||||
@@ -271,51 +231,6 @@ def _supported_compression_kwargs(
|
||||
return {name: value for name, value in candidates.items() if name in parameters}
|
||||
|
||||
|
||||
class _CompressionActivityHeartbeat:
|
||||
"""Refresh the agent inactivity tracker while compression blocks in an aux call."""
|
||||
|
||||
def __init__(self, agent: Any, interval_seconds: float | None = None) -> None:
|
||||
self._agent = agent
|
||||
if interval_seconds is None:
|
||||
interval_seconds = getattr(agent, "_compression_activity_heartbeat_interval", 60.0)
|
||||
try:
|
||||
interval_seconds = float(interval_seconds or 60.0)
|
||||
except (TypeError, ValueError):
|
||||
interval_seconds = 60.0
|
||||
if not math.isfinite(interval_seconds):
|
||||
interval_seconds = 60.0
|
||||
self._interval_seconds = max(0.1, interval_seconds)
|
||||
self._stop = threading.Event()
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name="compression-activity-heartbeat",
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
def start(self) -> "_CompressionActivityHeartbeat":
|
||||
self._touch("context compression started")
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def stop(self, desc: str = "context compression completed") -> None:
|
||||
self._stop.set()
|
||||
if self._thread.is_alive() and threading.current_thread() is not self._thread:
|
||||
self._thread.join(timeout=1.0)
|
||||
self._touch(desc)
|
||||
|
||||
def _touch(self, desc: str) -> None:
|
||||
try:
|
||||
touch = getattr(self._agent, "_touch_activity", None)
|
||||
if callable(touch):
|
||||
touch(desc)
|
||||
except Exception:
|
||||
logger.debug("compression activity heartbeat touch failed", exc_info=True)
|
||||
|
||||
def _run(self) -> None:
|
||||
while not self._stop.wait(self._interval_seconds):
|
||||
self._touch("context compression in progress")
|
||||
|
||||
|
||||
class _CompressionLockLeaseRefresher:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -521,19 +436,6 @@ def check_compression_model_feasibility(agent: Any) -> None:
|
||||
old_threshold = threshold
|
||||
new_threshold = aux_context
|
||||
agent.context_compressor.threshold_tokens = new_threshold
|
||||
# ``tail_token_budget`` is derived from the trigger threshold, not
|
||||
# directly from the model window. Keep it in lockstep with this
|
||||
# just-in-time correction exactly as ContextCompressor.update_model()
|
||||
# does. Leaving the old budget behind can make the tail's 1.5x soft
|
||||
# ceiling wider than the lowered trigger, so compression preserves
|
||||
# nearly the entire request and repeatedly re-fires.
|
||||
summary_target_ratio = getattr(
|
||||
agent.context_compressor, "summary_target_ratio", None
|
||||
)
|
||||
if isinstance(summary_target_ratio, (int, float)):
|
||||
agent.context_compressor.tail_token_budget = int(
|
||||
new_threshold * summary_target_ratio
|
||||
)
|
||||
# Keep threshold_percent in sync so future main-model
|
||||
# context_length changes (update_model) re-derive from a
|
||||
# sensible number rather than the original too-high value.
|
||||
@@ -543,29 +445,6 @@ def check_compression_model_feasibility(agent: Any) -> None:
|
||||
new_threshold / main_ctx
|
||||
)
|
||||
safe_pct = int((aux_context / main_ctx) * 100) if main_ctx else 50
|
||||
# The "lower the threshold" suggestion must survive the built-in
|
||||
# trigger recomputation (#67422): _effective_threshold_percent()
|
||||
# raises sub-75% values back up for main windows under 512K, and
|
||||
# _compute_threshold_tokens() further applies the output-token
|
||||
# reservation, the 64K floor, and the degenerate-window guard.
|
||||
# Recommending a value those would override is silently ignored
|
||||
# and this warning would reappear every session — so mirror the
|
||||
# compressor's own math and only offer the option when the
|
||||
# recomputed trigger actually fits the auxiliary model's context.
|
||||
# External engines own compaction policy (#44439); the built-in
|
||||
# floor doesn't apply to them, so keep the plain suggestion.
|
||||
from agent.context_compressor import ContextCompressor as _CC
|
||||
|
||||
recomputed_threshold = None
|
||||
if main_ctx and isinstance(agent.context_compressor, _CC):
|
||||
recomputed_threshold = _CC._compute_threshold_tokens(
|
||||
main_ctx,
|
||||
_CC._effective_threshold_percent(main_ctx, safe_pct / 100),
|
||||
getattr(agent.context_compressor, "max_tokens", None),
|
||||
)
|
||||
threshold_suggestion_viable = (
|
||||
recomputed_threshold is None or recomputed_threshold <= aux_context
|
||||
)
|
||||
# Build human-readable "model (provider)" labels for both
|
||||
# the main model and the compression model so users can
|
||||
# tell at a glance which provider each side is actually
|
||||
@@ -599,32 +478,15 @@ def check_compression_model_feasibility(agent: Any) -> None:
|
||||
f"{old_threshold:,} tokens. "
|
||||
f"Auto-lowered this session's threshold to "
|
||||
f"{new_threshold:,} tokens so compression can run.\n"
|
||||
f" To make this permanent, edit config.yaml — either:\n"
|
||||
f" 1. Use a larger compression model:\n"
|
||||
f" auxiliary:\n"
|
||||
f" compression:\n"
|
||||
f" model: <model-with-{old_threshold:,}+-context>\n"
|
||||
f" 2. Lower the compression threshold:\n"
|
||||
f" compression:\n"
|
||||
f" threshold: 0.{safe_pct:02d}"
|
||||
)
|
||||
if threshold_suggestion_viable:
|
||||
msg += (
|
||||
f" To make this permanent, edit config.yaml — either:\n"
|
||||
f" 1. Use a larger compression model:\n"
|
||||
f" auxiliary:\n"
|
||||
f" compression:\n"
|
||||
f" model: <model-with-{old_threshold:,}+-context>\n"
|
||||
f" 2. Lower the compression threshold:\n"
|
||||
f" compression:\n"
|
||||
f" threshold: 0.{safe_pct:02d}"
|
||||
)
|
||||
else:
|
||||
msg += (
|
||||
f" To make this permanent, use a larger compression "
|
||||
f"model in config.yaml:\n"
|
||||
f" auxiliary:\n"
|
||||
f" compression:\n"
|
||||
f" model: <model-with-{old_threshold:,}+-context>\n"
|
||||
f" (Lowering compression.threshold cannot help here — "
|
||||
f"with {_main_label}'s {main_ctx:,}-token window, "
|
||||
f"Hermes's small-context floor and output reservation "
|
||||
f"would recompute the trigger to "
|
||||
f"{recomputed_threshold:,} tokens, still above the "
|
||||
f"compression model's {aux_context:,}.)"
|
||||
)
|
||||
agent._compression_warning = msg
|
||||
agent._emit_status(msg)
|
||||
logger.warning(
|
||||
@@ -738,7 +600,7 @@ def _is_real_user_message(message: Any) -> bool:
|
||||
return False
|
||||
from agent.context_compressor import ContextCompressor
|
||||
|
||||
return not ContextCompressor._is_synthetic_compression_user_turn(message)
|
||||
return not ContextCompressor._is_context_summary_content(text)
|
||||
|
||||
|
||||
def _merge_anchor_into_user_message(target: dict, anchor: dict) -> None:
|
||||
@@ -815,10 +677,7 @@ def _ensure_compressed_has_user_turn(original_messages: list, compressed: list)
|
||||
"""Preserve human intent, not merely a synthetic user-role placeholder."""
|
||||
if any(_is_real_user_message(message) for message in compressed):
|
||||
return
|
||||
from agent.context_compressor import (
|
||||
COMPRESSION_CONTINUATION_USER_CONTENT,
|
||||
_fresh_compaction_message_copy,
|
||||
)
|
||||
from agent.context_compressor import _fresh_compaction_message_copy
|
||||
|
||||
for message in reversed(original_messages):
|
||||
if _is_real_user_message(message):
|
||||
@@ -829,77 +688,13 @@ def _ensure_compressed_has_user_turn(original_messages: list, compressed: list)
|
||||
return
|
||||
compressed.append({
|
||||
"role": "user",
|
||||
"content": COMPRESSION_CONTINUATION_USER_CONTENT,
|
||||
"content": (
|
||||
"Continue from the compressed conversation context above. "
|
||||
"This marker exists because no human user turn was available."
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
_PENDING_CONTEXT_ENGINE_NOTIFICATION = (
|
||||
"_pending_context_engine_compression_notification"
|
||||
)
|
||||
|
||||
|
||||
def _notify_context_engine_compression_complete(
|
||||
agent: Any,
|
||||
*,
|
||||
new_session_id: str,
|
||||
old_session_id: str,
|
||||
) -> bool:
|
||||
"""Notify the active context engine after a durable compression commit."""
|
||||
callback = getattr(agent.context_compressor, "on_session_start", None)
|
||||
if not callable(callback):
|
||||
return False
|
||||
try:
|
||||
callback(
|
||||
new_session_id,
|
||||
boundary_reason="compression",
|
||||
old_session_id=old_session_id,
|
||||
platform=getattr(agent, "platform", None) or "cli",
|
||||
conversation_id=getattr(agent, "_gateway_session_key", None),
|
||||
)
|
||||
except Exception:
|
||||
# Context-engine hooks are observers. A callback failure must not undo
|
||||
# history that the core or an outer host transaction already committed.
|
||||
logger.debug(
|
||||
"context engine on_session_start (compression) failed",
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _queue_context_engine_compression_notification(
|
||||
agent: Any,
|
||||
*,
|
||||
new_session_id: str,
|
||||
old_session_id: str,
|
||||
) -> None:
|
||||
"""Stage exactly one existing hook call for an outer host transaction."""
|
||||
if callable(getattr(agent, _PENDING_CONTEXT_ENGINE_NOTIFICATION, None)):
|
||||
raise RuntimeError("a compression notification is already pending")
|
||||
|
||||
def _notify() -> bool:
|
||||
return _notify_context_engine_compression_complete(
|
||||
agent,
|
||||
new_session_id=new_session_id,
|
||||
old_session_id=old_session_id,
|
||||
)
|
||||
|
||||
setattr(agent, _PENDING_CONTEXT_ENGINE_NOTIFICATION, _notify)
|
||||
|
||||
|
||||
def finalize_context_engine_compression_notification(
|
||||
agent: Any,
|
||||
*,
|
||||
committed: bool,
|
||||
) -> bool:
|
||||
"""Emit or discard a deferred notification; repeated calls are no-ops."""
|
||||
pending = getattr(agent, _PENDING_CONTEXT_ENGINE_NOTIFICATION, None)
|
||||
setattr(agent, _PENDING_CONTEXT_ENGINE_NOTIFICATION, None)
|
||||
if not committed or not callable(pending):
|
||||
return False
|
||||
return bool(pending())
|
||||
|
||||
|
||||
def compress_context(
|
||||
agent: Any,
|
||||
messages: list,
|
||||
@@ -909,7 +704,6 @@ def compress_context(
|
||||
task_id: str = "default",
|
||||
focus_topic: Optional[str] = None,
|
||||
force: bool = False,
|
||||
defer_context_engine_notification: bool = False,
|
||||
) -> Tuple[list, str]:
|
||||
"""Compress conversation context and split the session in SQLite.
|
||||
|
||||
@@ -927,8 +721,6 @@ def compress_context(
|
||||
by the manual ``/compress`` slash command so users can retry
|
||||
immediately after an auto-compress abort. Auto-compress
|
||||
callers use the default ``False``.
|
||||
defer_context_engine_notification: Delay the existing context-engine
|
||||
hook until a manual host commits its outer history transaction.
|
||||
|
||||
Returns:
|
||||
``(compressed_messages, new_system_prompt)`` tuple. When
|
||||
@@ -937,25 +729,6 @@ def compress_context(
|
||||
prompt — the session is NOT rotated. Callers should detect the
|
||||
no-op via ``len(returned) == len(input)`` and stop the retry loop.
|
||||
"""
|
||||
if (
|
||||
defer_context_engine_notification
|
||||
and callable(getattr(agent, _PENDING_CONTEXT_ENGINE_NOTIFICATION, None))
|
||||
):
|
||||
raise RuntimeError("a compression notification is already pending")
|
||||
|
||||
_attempt_started_at = time.monotonic()
|
||||
_attempt_id = uuid.uuid4().hex
|
||||
_trigger_source = "manual" if force else "auto"
|
||||
try:
|
||||
agent._compression_attempt_id = _attempt_id
|
||||
setattr(agent.context_compressor, "_compression_telemetry_seed", {
|
||||
"attempt_id": _attempt_id,
|
||||
"session_id": agent.session_id or "",
|
||||
"trigger_source": _trigger_source,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Codex app-server sessions: the codex agent owns the real thread context;
|
||||
# Hermes' summarizer would only rewrite a local mirror without shrinking
|
||||
# the actual thread (#36801). Route compaction to the app server's own
|
||||
@@ -1162,18 +935,6 @@ def compress_context(
|
||||
_existing_sp = getattr(agent, "_cached_system_prompt", None)
|
||||
if not _existing_sp:
|
||||
_existing_sp = agent._build_system_prompt(system_message)
|
||||
try:
|
||||
if hasattr(agent.context_compressor, "_begin_compression_telemetry"):
|
||||
agent.context_compressor._begin_compression_telemetry(current_tokens=approx_tokens)
|
||||
except Exception:
|
||||
pass
|
||||
_emit_compression_attempt_telemetry(
|
||||
agent,
|
||||
started_at=_attempt_started_at,
|
||||
commit_status="aborted",
|
||||
split_status="aborted",
|
||||
failure_class="lock_contended",
|
||||
)
|
||||
return messages, _existing_sp
|
||||
_lock_released = False
|
||||
|
||||
@@ -1246,7 +1007,6 @@ def compress_context(
|
||||
existing_prompt = agent._build_system_prompt(system_message)
|
||||
return messages, existing_prompt
|
||||
|
||||
_activity_heartbeat: Optional[_CompressionActivityHeartbeat] = None
|
||||
try:
|
||||
if _lock_holder is not None:
|
||||
_lock_refresher = _CompressionLockLeaseRefresher(
|
||||
@@ -1297,27 +1057,13 @@ def compress_context(
|
||||
)
|
||||
|
||||
messages_before_compression = copy.deepcopy(messages)
|
||||
_activity_heartbeat = _CompressionActivityHeartbeat(agent).start()
|
||||
compressed = compress_fn(messages, **compress_kwargs)
|
||||
except BaseException as _compress_exc:
|
||||
except BaseException:
|
||||
# ANY exception after lock acquisition — memory hook, capability
|
||||
# inspection, engine lookup, or compress() — must release the lock so
|
||||
# the session isn't permanently blocked from future compression.
|
||||
if _activity_heartbeat is not None:
|
||||
_activity_heartbeat.stop("context compression failed")
|
||||
_activity_heartbeat = None
|
||||
_release_lock()
|
||||
_emit_compression_attempt_telemetry(
|
||||
agent,
|
||||
started_at=_attempt_started_at,
|
||||
commit_status="aborted",
|
||||
split_status="aborted",
|
||||
failure_class=f"exception:{type(_compress_exc).__name__}",
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
if _activity_heartbeat is not None:
|
||||
_activity_heartbeat.stop("context compression completed")
|
||||
|
||||
try:
|
||||
# Capture boundary quality before session-rotation callbacks run. Built-in
|
||||
@@ -1349,16 +1095,6 @@ def compress_context(
|
||||
_existing_sp = getattr(agent, "_cached_system_prompt", None)
|
||||
if not _existing_sp:
|
||||
_existing_sp = agent._build_system_prompt(system_message)
|
||||
_emit_compression_attempt_telemetry(
|
||||
agent,
|
||||
started_at=_attempt_started_at,
|
||||
commit_status="aborted",
|
||||
split_status="aborted",
|
||||
failure_class=(
|
||||
getattr(agent.context_compressor, "_last_summary_error", None)
|
||||
and "summary_generation_aborted"
|
||||
),
|
||||
)
|
||||
return messages, _existing_sp
|
||||
finally:
|
||||
_release_lock()
|
||||
@@ -1377,13 +1113,6 @@ def compress_context(
|
||||
_existing_sp = getattr(agent, "_cached_system_prompt", None)
|
||||
if not _existing_sp:
|
||||
_existing_sp = agent._build_system_prompt(system_message)
|
||||
_emit_compression_attempt_telemetry(
|
||||
agent,
|
||||
started_at=_attempt_started_at,
|
||||
commit_status="aborted",
|
||||
split_status="aborted",
|
||||
failure_class="no_progress",
|
||||
)
|
||||
_release_lock()
|
||||
return messages, _existing_sp
|
||||
|
||||
@@ -1464,10 +1193,7 @@ def compress_context(
|
||||
new_system_prompt = agent._build_system_prompt(system_message)
|
||||
agent._cached_system_prompt = new_system_prompt
|
||||
|
||||
_session_commit_succeeded = False
|
||||
split_status = "not_applicable"
|
||||
if agent._session_db:
|
||||
split_status = "pending"
|
||||
try:
|
||||
# Trigger memory extraction on the current session before the
|
||||
# transcript is rewritten (runs in BOTH modes — the logical
|
||||
@@ -1495,7 +1221,6 @@ def compress_context(
|
||||
# WITHOUT destroying history, unlike a hard replace_messages).
|
||||
# See #38763.
|
||||
agent._session_db.archive_and_compact(agent.session_id, compressed)
|
||||
split_status = "in_place_committed"
|
||||
# Reset the flush identity set so the next turn's appends are
|
||||
# diffed against the COMPACTED transcript: the compacted dicts
|
||||
# are passed as conversation_history next turn and skipped by
|
||||
@@ -1511,29 +1236,8 @@ def compress_context(
|
||||
# Flush any un-persisted current-turn messages to the OLD
|
||||
# session before ending it, so they survive in the preserved
|
||||
# parent transcript (#47202). (In-place skips this — see above.)
|
||||
#
|
||||
# Pass the already-durable prefix as conversation_history so
|
||||
# the flush skips it by identity (#68196). Preflight
|
||||
# compression runs BEFORE the normal turn flush has stamped
|
||||
# the cold-resumed history dicts with _DB_PERSISTED_MARKER, so
|
||||
# without a boundary _flush_messages_to_session_db treats every
|
||||
# restored row as new and re-appends the whole transcript to
|
||||
# the parent. turn_context anchors _persist_user_message_idx at
|
||||
# the current-turn user message before preflight runs, so
|
||||
# messages[:idx] is exactly the persisted prefix; only the
|
||||
# current turn's new messages get written.
|
||||
current_idx = getattr(agent, "_persist_user_message_idx", None)
|
||||
persisted_history = (
|
||||
messages[:current_idx]
|
||||
if isinstance(current_idx, int)
|
||||
and 0 <= current_idx <= len(messages)
|
||||
else None
|
||||
)
|
||||
try:
|
||||
agent._flush_messages_to_session_db(
|
||||
messages,
|
||||
conversation_history=persisted_history,
|
||||
)
|
||||
agent._flush_messages_to_session_db(messages)
|
||||
except Exception:
|
||||
pass # best-effort — don't block compression on a flush error
|
||||
# Propagate title to the new session with auto-numbering
|
||||
@@ -1612,7 +1316,6 @@ def compress_context(
|
||||
agent._session_db_created = True
|
||||
raise
|
||||
agent._session_db_created = True
|
||||
split_status = "rotated_committed"
|
||||
# Carry a persistent /goal onto the continuation session.
|
||||
# Compression mints a fresh child id; load_goal does a flat
|
||||
# per-session lookup with no parent walk, so without this an
|
||||
@@ -1649,9 +1352,7 @@ def compress_context(
|
||||
for message in compressed
|
||||
if isinstance(message, dict)
|
||||
}
|
||||
_session_commit_succeeded = True
|
||||
except Exception as e:
|
||||
split_status = "aborted" if locals().get("old_session_id") is None and not in_place else "failed_not_indexed"
|
||||
# If the rotation rolled back to the parent (orphan-avoidance
|
||||
# above), agent.session_id is the still-indexed parent and
|
||||
# old_session_id was cleared — so this is recovery, not an
|
||||
@@ -1671,9 +1372,6 @@ def compress_context(
|
||||
# id on rotation, the (unchanged) current id in-place.
|
||||
_old_sid = locals().get("old_session_id")
|
||||
_is_boundary = bool(_old_sid) or in_place
|
||||
_context_engine_boundary_committed = _session_commit_succeeded and (
|
||||
bool(_old_sid) or compacted_in_place
|
||||
)
|
||||
_boundary_parent = _old_sid or agent.session_id or ""
|
||||
|
||||
# Notify the context engine that a compaction boundary occurred. Plugin
|
||||
@@ -1682,19 +1380,17 @@ def compress_context(
|
||||
# re-initializing fresh. See hermes-lcm#68. Built-in ContextCompressor
|
||||
# ignores kwargs. Fires in BOTH modes: rotation passes old→new ids; in-place
|
||||
# passes the SAME id (the boundary is real even though the id didn't move).
|
||||
if _context_engine_boundary_committed:
|
||||
if defer_context_engine_notification:
|
||||
_queue_context_engine_compression_notification(
|
||||
agent,
|
||||
new_session_id=agent.session_id or "",
|
||||
old_session_id=_boundary_parent,
|
||||
)
|
||||
else:
|
||||
_notify_context_engine_compression_complete(
|
||||
agent,
|
||||
new_session_id=agent.session_id or "",
|
||||
try:
|
||||
if _is_boundary and hasattr(agent.context_compressor, "on_session_start"):
|
||||
agent.context_compressor.on_session_start(
|
||||
agent.session_id or "",
|
||||
boundary_reason="compression",
|
||||
old_session_id=_boundary_parent,
|
||||
platform=getattr(agent, "platform", None) or "cli",
|
||||
conversation_id=getattr(agent, "_gateway_session_key", None),
|
||||
)
|
||||
except Exception as _ce_err:
|
||||
logger.debug("context engine on_session_start (compression): %s", _ce_err)
|
||||
|
||||
# Notify memory providers of the compaction boundary so provider-cached
|
||||
# per-session state (Hindsight's _document_id, accumulated turn buffers,
|
||||
@@ -1794,18 +1490,6 @@ def compress_context(
|
||||
agent.session_id or "none", _pre_msg_count, len(compressed),
|
||||
f"{_compressed_est:,}",
|
||||
)
|
||||
_commit_status = "committed" if split_status in {"not_applicable", "in_place_committed", "rotated_committed"} else "aborted"
|
||||
_emit_compression_attempt_telemetry(
|
||||
agent,
|
||||
started_at=_attempt_started_at,
|
||||
commit_status=_commit_status,
|
||||
split_status=split_status,
|
||||
failure_class=(
|
||||
"session_split_failed"
|
||||
if split_status in {"failed_not_indexed", "aborted"}
|
||||
else None
|
||||
),
|
||||
)
|
||||
return compressed, new_system_prompt
|
||||
finally:
|
||||
# Release the lock on the OLD session_id only AFTER rotation completed
|
||||
@@ -1876,20 +1560,7 @@ def _compress_context_via_codex_app_server(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_activity_heartbeat: Optional[_CompressionActivityHeartbeat] = None
|
||||
try:
|
||||
_activity_heartbeat = _CompressionActivityHeartbeat(agent).start()
|
||||
result = codex_session.compact_thread()
|
||||
except BaseException:
|
||||
if _activity_heartbeat is not None:
|
||||
_activity_heartbeat.stop("context compression failed")
|
||||
raise
|
||||
|
||||
if getattr(result, "interrupted", False) or getattr(result, "error", None):
|
||||
_activity_heartbeat.stop("context compression failed")
|
||||
else:
|
||||
_activity_heartbeat.stop("context compression completed")
|
||||
|
||||
result = codex_session.compact_thread()
|
||||
if getattr(result, "should_retire", False):
|
||||
try:
|
||||
codex_session.close()
|
||||
|
||||
@@ -33,7 +33,6 @@ from agent.display import KawaiiSpinner
|
||||
from agent.error_classifier import FailoverReason, classify_api_error
|
||||
from agent.iteration_budget import IterationBudget
|
||||
from agent.turn_context import (
|
||||
_compression_warrants_another_preflight_pass,
|
||||
build_turn_context,
|
||||
compose_user_api_content,
|
||||
reanchor_current_turn_user_idx,
|
||||
@@ -685,15 +684,6 @@ def run_conversation(
|
||||
truncated_tool_call_retries = 0
|
||||
truncated_response_parts: List[str] = []
|
||||
compression_attempts = 0
|
||||
# One resolved per-turn compression attempt cap, shared by every site that
|
||||
# consumes ``compression_attempts``: the pre-API pressure gate, the
|
||||
# overflow/413 retry handlers, and the post-tool compaction gate.
|
||||
# Config-driven via compression.max_attempts (parsed + validated in
|
||||
# agent_init); default 3 preserves the prior hardcoded behavior for
|
||||
# objects without the attribute (older pickles / minimal stubs).
|
||||
max_compression_attempts = getattr(agent, "max_compression_attempts", 3)
|
||||
_last_preflight_pressure: Optional[int] = None
|
||||
_preflight_compression_blocked = _ctx.preflight_compression_blocked
|
||||
_turn_exit_reason = "unknown" # Diagnostic: why the loop ended
|
||||
# Last composed answer intentionally held back by a verification gate. If
|
||||
# that continuation consumes the remaining budget, this is the best
|
||||
@@ -706,10 +696,6 @@ def run_conversation(
|
||||
# reused as the final response — not merely because any interim was
|
||||
# streamed. (#65919 review: response-loss blocker)
|
||||
_pending_verification_response_previewed = False
|
||||
# If pre-API compression fires after MoA advisors have produced guidance,
|
||||
# retain that ephemeral output and rebase it onto the compacted transcript
|
||||
# on the next loop iteration. This prevents a second advisor fan-out.
|
||||
pending_moa_prepared_request = None
|
||||
|
||||
# Per-turn tally of consecutive successful credential-pool token refreshes,
|
||||
# keyed by (provider, pool-entry-id). A persistent upstream 401 lets
|
||||
@@ -1093,29 +1079,6 @@ def run_conversation(
|
||||
# the OpenAI SDK. Sanitizing here prevents the 3-retry cycle.
|
||||
_sanitize_messages_surrogates(api_messages)
|
||||
|
||||
# Build a persistent-MoA request before measuring compression pressure.
|
||||
# MoA reference output is injected into the aggregator prompt, but it
|
||||
# is deliberately ephemeral and therefore absent from ``messages``.
|
||||
# Preparing here makes the pre-API guard measure the exact prompt the
|
||||
# aggregator will receive; ``create()`` consumes this private prepared
|
||||
# request later without running the advisors a second time.
|
||||
_moa_prepared_request = None
|
||||
if agent.provider == "moa":
|
||||
_moa_completions = getattr(getattr(agent.client, "chat", None), "completions", None)
|
||||
if pending_moa_prepared_request is not None:
|
||||
_rebase_moa_request = getattr(_moa_completions, "rebase_prepared_request", None)
|
||||
if callable(_rebase_moa_request):
|
||||
_moa_prepared_request = _rebase_moa_request(
|
||||
pending_moa_prepared_request, api_messages
|
||||
)
|
||||
pending_moa_prepared_request = None
|
||||
if _moa_prepared_request is None:
|
||||
_prepare_moa_request = getattr(_moa_completions, "prepare", None)
|
||||
if callable(_prepare_moa_request):
|
||||
_moa_prepared_request = _prepare_moa_request(api_messages)
|
||||
if _moa_prepared_request is not None:
|
||||
api_messages = _moa_prepared_request["messages"]
|
||||
|
||||
# One image-stripped message estimate feeds both figures. Was: a
|
||||
# str(msg) char walk (re-serialized base64 every call) + a second
|
||||
# messages walk inside estimate_request_tokens_rough. Tools added
|
||||
@@ -1162,37 +1125,6 @@ def run_conversation(
|
||||
# LLM cooldown + anti-thrash guards (#11529). compression_attempts is a
|
||||
# hard per-turn backstop shared with the overflow error handlers.
|
||||
_compressor = agent.context_compressor
|
||||
_preflight_threshold = int(
|
||||
getattr(_compressor, "threshold_tokens", 0) or 0
|
||||
)
|
||||
# A previous mid-turn preflight pass deliberately continued the loop so
|
||||
# API-only context and all sanitization could be rebuilt. Compare that
|
||||
# fully assembled request with the fully assembled request that caused
|
||||
# the pass. Raw ``messages`` are not equivalent here: they omit
|
||||
# api_content/plugin injections, prefills, MoA context, and ephemeral
|
||||
# system text.
|
||||
_previous_preflight_pressure = _last_preflight_pressure
|
||||
_last_preflight_pressure = None
|
||||
if (
|
||||
_previous_preflight_pressure is not None
|
||||
and request_pressure_tokens >= _preflight_threshold
|
||||
and not _compression_warrants_another_preflight_pass(
|
||||
_previous_preflight_pressure,
|
||||
request_pressure_tokens,
|
||||
_preflight_threshold,
|
||||
)
|
||||
):
|
||||
# Stop proactive retries for this turn without consuming the
|
||||
# shared overflow-recovery budget. If the provider proves the
|
||||
# request truly does not fit, its error handler may still compact
|
||||
# with that stronger signal.
|
||||
_preflight_compression_blocked = True
|
||||
logger.warning(
|
||||
"Pre-API compression made insufficient progress: ~%s -> "
|
||||
"~%s request tokens; skipping additional preflight passes",
|
||||
f"{_previous_preflight_pressure:,}",
|
||||
f"{request_pressure_tokens:,}",
|
||||
)
|
||||
_defer_preflight = getattr(
|
||||
_compressor, "should_defer_preflight_to_real_usage", lambda _t: False
|
||||
)
|
||||
@@ -1202,30 +1134,25 @@ def run_conversation(
|
||||
if (
|
||||
agent.compression_enabled
|
||||
and len(messages) > 1
|
||||
and compression_attempts < max_compression_attempts
|
||||
and not _preflight_compression_blocked
|
||||
and compression_attempts < 3
|
||||
and not _defer_preflight(request_pressure_tokens)
|
||||
and not _compression_cooldown
|
||||
and _compressor.should_compress(request_pressure_tokens)
|
||||
):
|
||||
if _moa_prepared_request is not None:
|
||||
pending_moa_prepared_request = _moa_prepared_request
|
||||
compression_attempts += 1
|
||||
logger.info(
|
||||
"Pre-API compression: ~%s request tokens >= %s threshold "
|
||||
"(context=%s, attempt=%s/%s)",
|
||||
"(context=%s, attempt=%s/3)",
|
||||
f"{request_pressure_tokens:,}",
|
||||
f"{int(getattr(_compressor, 'threshold_tokens', 0) or 0):,}",
|
||||
f"{int(getattr(_compressor, 'context_length', 0) or 0):,}"
|
||||
if getattr(_compressor, "context_length", 0) else "unknown",
|
||||
compression_attempts,
|
||||
max_compression_attempts,
|
||||
)
|
||||
agent._emit_status(
|
||||
f"📦 Pre-API compression: ~{request_pressure_tokens:,} tokens "
|
||||
f"near the context/output limit. Compacting before the next model call."
|
||||
)
|
||||
_last_preflight_pressure = request_pressure_tokens
|
||||
messages, active_system_prompt = agent._compress_context(
|
||||
messages,
|
||||
system_message,
|
||||
@@ -1289,6 +1216,7 @@ def run_conversation(
|
||||
retry_count = 0
|
||||
max_retries = agent._api_max_retries
|
||||
_retry = TurnRetryState()
|
||||
max_compression_attempts = 3
|
||||
|
||||
finish_reason = "stop"
|
||||
response = None # Guard against UnboundLocalError if all retries fail
|
||||
@@ -1457,12 +1385,6 @@ def run_conversation(
|
||||
if env_var_enabled("HERMES_DUMP_REQUESTS"):
|
||||
agent._dump_api_request_debug(api_kwargs, reason="preflight")
|
||||
|
||||
# This object is private to the in-process MoA facade. Add it
|
||||
# only after middleware, hooks, and debug dumps so none of them
|
||||
# attempts to serialize it as part of the provider payload.
|
||||
if _moa_prepared_request is not None and agent.provider == "moa":
|
||||
api_kwargs["_moa_prepared_request"] = _moa_prepared_request
|
||||
|
||||
# Always prefer the streaming path — even without stream
|
||||
# consumers. Streaming gives us fine-grained health
|
||||
# checking (90s stale-stream detection, 60s read timeout)
|
||||
@@ -5217,12 +5139,7 @@ def run_conversation(
|
||||
messages, tools=agent.tools or None
|
||||
)
|
||||
|
||||
if (
|
||||
agent.compression_enabled
|
||||
and compression_attempts < max_compression_attempts
|
||||
and _compressor.should_compress(_real_tokens)
|
||||
):
|
||||
compression_attempts += 1
|
||||
if agent.compression_enabled and _compressor.should_compress(_real_tokens):
|
||||
agent._safe_print(" ⟳ compacting context…")
|
||||
messages, active_system_prompt = agent._compress_context(
|
||||
messages, system_message,
|
||||
|
||||
@@ -2309,10 +2309,9 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
def _get_env_prefer_dotenv(key: str) -> str:
|
||||
env_file = load_env()
|
||||
raw = env_file.get(key, "").strip()
|
||||
scoped_value = (_get_secret(key, "") or "").strip()
|
||||
env_val = os.environ.get(key, "").strip()
|
||||
# If .env contains an unresolved op:// reference, prefer the
|
||||
# already-resolved value supplied by the active secret scope (or by
|
||||
# os.environ in legacy single-profile mode), set by
|
||||
# already-resolved value from os.environ (set by
|
||||
# load_hermes_dotenv() -> apply_onepassword_secrets()). The raw
|
||||
# "op://Vault/Item/field" string would otherwise win and every
|
||||
# provider auth attempt would receive a URL instead of a key. This
|
||||
@@ -2320,9 +2319,9 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
# references straight into .env rather than the secrets.onepassword
|
||||
# config block. For every non-op:// value the original
|
||||
# .env-takes-precedence behaviour is preserved unchanged.
|
||||
if raw.startswith("op://") and scoped_value:
|
||||
return scoped_value
|
||||
return raw or scoped_value
|
||||
if raw.startswith("op://") and env_val:
|
||||
return env_val
|
||||
return raw or _get_secret(key, "") or env_val
|
||||
|
||||
# Honour user suppression — `hermes auth remove <provider> <N>` for an
|
||||
# env-seeded credential marks the env:<VAR> source as suppressed so it
|
||||
|
||||
@@ -46,9 +46,6 @@ def build_write_denied_paths(home: str) -> set[str]:
|
||||
# Top-level Anthropic PKCE credential store remains sensitive even
|
||||
# when a profile is active; default/non-profile sessions still read it.
|
||||
str(hermes_root / ".anthropic_oauth.json"),
|
||||
# Bitwarden Secrets Manager encrypted disk cache.
|
||||
str(hermes_home / "cache" / "bws_cache.enc.json"),
|
||||
str(hermes_root / "cache" / "bws_cache.enc.json"),
|
||||
os.path.join(home, ".netrc"),
|
||||
os.path.join(home, ".pgpass"),
|
||||
os.path.join(home, ".npmrc"),
|
||||
|
||||
+71
-140
@@ -18,15 +18,9 @@ into it via :func:`agent.lsp.manager.LSPService.touch_file`.
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- All per-document state lives in one :class:`_DocState` keyed by
|
||||
absolute path. Freshness is tracked with **document versions**,
|
||||
not timestamps: every didChange bumps ``version``, and each stored
|
||||
push/pull result is tagged with the version it describes. A
|
||||
result is fresh iff its tag >= the version being waited on, so a
|
||||
didChange implicitly invalidates everything older — no clearing,
|
||||
no clock comparisons, no race windows. This is what prevents
|
||||
"ghost diagnostics": a slow server's leftovers from the previous
|
||||
edit can never masquerade as a verdict on the current content.
|
||||
- Push diagnostics are stored per-URI in :attr:`_push_diagnostics` from
|
||||
``textDocument/publishDiagnostics`` notifications. Pull diagnostics
|
||||
go in :attr:`_pull_diagnostics`. The merged view dedupes by content.
|
||||
|
||||
- Whole-document sync. Even when the server advertises incremental
|
||||
sync, we send a single ``contentChanges`` entry replacing the
|
||||
@@ -51,7 +45,6 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
|
||||
from urllib.parse import quote, unquote
|
||||
@@ -131,40 +124,6 @@ def _end_position(text: str) -> Dict[str, int]:
|
||||
return {"line": last_line, "character": last_col}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DocState:
|
||||
"""Everything the client tracks for one open document.
|
||||
|
||||
``version`` is the LSP document version we last sent (didOpen=0,
|
||||
each didChange +1). It doubles as the freshness token: stored
|
||||
push/pull results are tagged with the version they describe
|
||||
(``push_version`` / ``pull_version``), and a result is *fresh*
|
||||
iff its tag has caught up to ``version``. Bumping the version on
|
||||
didChange therefore invalidates all older results implicitly —
|
||||
no store-clearing, no timestamps.
|
||||
|
||||
``push_version``/``pull_version`` start at -1 = "no data yet".
|
||||
Servers that echo a document version in publishDiagnostics get
|
||||
exact tagging; those that don't are credited with the current
|
||||
version at receipt time (a push observed after we sent the
|
||||
change describes the changed content or newer).
|
||||
"""
|
||||
|
||||
version: int = 0
|
||||
text: str = ""
|
||||
push: List[Dict[str, Any]] = field(default_factory=list)
|
||||
pull: List[Dict[str, Any]] = field(default_factory=list)
|
||||
push_version: int = -1
|
||||
pull_version: int = -1
|
||||
seed_seen: bool = False
|
||||
|
||||
def fresh_push(self, version: Optional[int] = None) -> bool:
|
||||
return self.push_version >= (self.version if version is None else version)
|
||||
|
||||
def fresh_pull(self, version: Optional[int] = None) -> bool:
|
||||
return self.pull_version >= (self.version if version is None else version)
|
||||
|
||||
|
||||
class LSPClient:
|
||||
"""Async LSP client tied to one server process and one workspace root.
|
||||
|
||||
@@ -227,10 +186,18 @@ class LSPClient:
|
||||
# is silently dropped by default.
|
||||
}
|
||||
|
||||
# Per-document state (version, text, diagnostic stores, and
|
||||
# their freshness tags), keyed by absolute file path (NOT URI).
|
||||
# See _DocState for the version-based freshness model.
|
||||
self._docs: Dict[str, _DocState] = {}
|
||||
# Tracked file state — required for didChange version bumps.
|
||||
self._files: Dict[str, Dict[str, Any]] = {}
|
||||
# Diagnostic stores, keyed by file path (NOT URI).
|
||||
self._push_diagnostics: Dict[str, List[Dict[str, Any]]] = {}
|
||||
self._pull_diagnostics: Dict[str, List[Dict[str, Any]]] = {}
|
||||
# Per-path "last published" time so wait-for-fresh logic works.
|
||||
self._published: Dict[str, float] = {}
|
||||
# Per-path version of the latest push (matches our didChange
|
||||
# version when the server respects it).
|
||||
self._published_version: Dict[str, int] = {}
|
||||
# First-push seen flag, for typescript-style seed-on-first-push.
|
||||
self._first_push_seen: Set[str] = set()
|
||||
# Capability registrations — only diagnostic ones are tracked.
|
||||
self._diagnostic_registrations: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
@@ -680,25 +647,25 @@ class LSPClient:
|
||||
if not isinstance(diagnostics, list):
|
||||
diagnostics = []
|
||||
version = params.get("version")
|
||||
loop_time = asyncio.get_event_loop().time()
|
||||
|
||||
doc = self._docs.setdefault(path, _DocState(version=-1))
|
||||
if self._seed_first_push and not doc.seed_seen:
|
||||
# First push: seed the store WITHOUT a freshness tag. It
|
||||
# arrives before the user-triggered didChange could've
|
||||
# produced fresh diagnostics, so it must never satisfy a
|
||||
# waiter — it's baseline data only.
|
||||
doc.seed_seen = True
|
||||
doc.push = diagnostics
|
||||
if self._seed_first_push and path not in self._first_push_seen:
|
||||
# First push: seed without firing the event so a waiter
|
||||
# doesn't resolve on the very first push (which arrives
|
||||
# before the user-triggered didChange could've produced
|
||||
# fresh diagnostics).
|
||||
self._first_push_seen.add(path)
|
||||
self._push_diagnostics[path] = diagnostics
|
||||
self._published[path] = loop_time
|
||||
if isinstance(version, int):
|
||||
self._published_version[path] = version
|
||||
return
|
||||
|
||||
doc.seed_seen = True
|
||||
doc.push = diagnostics
|
||||
# Tag with the echoed document version when the server provides
|
||||
# one; otherwise credit the current version — a push observed
|
||||
# after we sent the change describes the changed content (or
|
||||
# newer). Note doc.version is -1 for never-opened paths
|
||||
# (e.g. relatedDocuments spillover), keeping them unfresh.
|
||||
doc.push_version = version if isinstance(version, int) else doc.version
|
||||
self._push_diagnostics[path] = diagnostics
|
||||
self._published[path] = loop_time
|
||||
if isinstance(version, int):
|
||||
self._published_version[path] = version
|
||||
self._first_push_seen.add(path)
|
||||
# Bump the monotonic push counter and wake every waiter. We
|
||||
# keep the Event sticky-set so any wait already in progress
|
||||
# resolves; waiters re-check their predicate after waking and
|
||||
@@ -727,16 +694,16 @@ class LSPClient:
|
||||
raise LSPProtocolError(f"cannot read {abs_path}: {e}") from e
|
||||
|
||||
uri = file_uri(abs_path)
|
||||
doc = self._docs.get(abs_path)
|
||||
existing = self._files.get(abs_path)
|
||||
|
||||
if doc is not None and doc.version >= 0:
|
||||
if existing is not None:
|
||||
# Re-open: bump version, fire didChangeWatchedFiles + didChange.
|
||||
await self._send_notification(
|
||||
"workspace/didChangeWatchedFiles",
|
||||
{"changes": [{"uri": uri, "type": 2}]}, # 2 = CHANGED
|
||||
)
|
||||
new_version = doc.version + 1
|
||||
old_text = doc.text
|
||||
new_version = existing["version"] + 1
|
||||
old_text = existing["text"]
|
||||
content_changes: List[Dict[str, Any]]
|
||||
if self._sync_kind == 2:
|
||||
content_changes = [
|
||||
@@ -757,11 +724,7 @@ class LSPClient:
|
||||
"contentChanges": content_changes,
|
||||
},
|
||||
)
|
||||
# Bumping the version is the whole invalidation story:
|
||||
# every stored result tagged with an older version is now
|
||||
# stale by definition (see _DocState).
|
||||
doc.version = new_version
|
||||
doc.text = text
|
||||
self._files[abs_path] = {"version": new_version, "text": text}
|
||||
return new_version
|
||||
|
||||
# First open: didChangeWatchedFiles CREATED + didOpen.
|
||||
@@ -769,9 +732,12 @@ class LSPClient:
|
||||
"workspace/didChangeWatchedFiles",
|
||||
{"changes": [{"uri": uri, "type": 1}]}, # 1 = CREATED
|
||||
)
|
||||
# Fresh doc state — anything stashed under this path by a
|
||||
# pre-open push (relatedDocuments spillover etc.) is discarded.
|
||||
self._docs[abs_path] = _DocState(version=0, text=text)
|
||||
# Clear any stale push/pull entries — fresh open should start
|
||||
# from scratch.
|
||||
self._push_diagnostics.pop(abs_path, None)
|
||||
self._pull_diagnostics.pop(abs_path, None)
|
||||
self._published.pop(abs_path, None)
|
||||
self._published_version.pop(abs_path, None)
|
||||
await self._send_notification(
|
||||
"textDocument/didOpen",
|
||||
{
|
||||
@@ -783,6 +749,7 @@ class LSPClient:
|
||||
}
|
||||
},
|
||||
)
|
||||
self._files[abs_path] = {"version": 0, "text": text}
|
||||
return 0
|
||||
|
||||
async def save_file(self, path: str) -> None:
|
||||
@@ -802,19 +769,12 @@ class LSPClient:
|
||||
async def _pull_document_diagnostics(self, path: str) -> None:
|
||||
"""Send ``textDocument/diagnostic`` for one file.
|
||||
|
||||
Stores results into the doc's pull store, tagged with the
|
||||
document version captured at request send time. If a didChange
|
||||
races past the in-flight request, the version bump makes the
|
||||
stored result stale automatically — no explicit invalidation.
|
||||
Silently no-ops on errors (server may not support the pull
|
||||
endpoint).
|
||||
Stores results into :attr:`_pull_diagnostics`. Silently
|
||||
no-ops on errors (server may not support the pull endpoint).
|
||||
"""
|
||||
abs_path = os.path.abspath(path)
|
||||
doc = self._docs.get(abs_path)
|
||||
sent_version = doc.version if doc else -1
|
||||
try:
|
||||
params: Dict[str, Any] = {
|
||||
"textDocument": {"uri": file_uri(abs_path)}
|
||||
"textDocument": {"uri": file_uri(os.path.abspath(path))}
|
||||
}
|
||||
result = await self._send_request_with_retry(
|
||||
"textDocument/diagnostic",
|
||||
@@ -828,9 +788,7 @@ class LSPClient:
|
||||
return
|
||||
items = result.get("items")
|
||||
if isinstance(items, list):
|
||||
doc = self._docs.setdefault(abs_path, _DocState(version=-1))
|
||||
doc.pull = items
|
||||
doc.pull_version = sent_version
|
||||
self._pull_diagnostics[os.path.abspath(path)] = items
|
||||
related = result.get("relatedDocuments")
|
||||
if isinstance(related, dict):
|
||||
for uri, sub in related.items():
|
||||
@@ -838,11 +796,7 @@ class LSPClient:
|
||||
continue
|
||||
sub_items = sub.get("items")
|
||||
if isinstance(sub_items, list):
|
||||
rel = self._docs.setdefault(uri_to_path(uri), _DocState(version=-1))
|
||||
rel.pull = sub_items
|
||||
# Same send-anchored tagging: fresh only if that
|
||||
# doc hasn't changed since the request went out.
|
||||
rel.pull_version = rel.version
|
||||
self._pull_diagnostics[uri_to_path(uri)] = sub_items
|
||||
|
||||
async def wait_for_diagnostics(
|
||||
self,
|
||||
@@ -850,36 +804,22 @@ class LSPClient:
|
||||
version: int,
|
||||
*,
|
||||
mode: str = "document",
|
||||
timeout: Optional[float] = None,
|
||||
) -> bool:
|
||||
) -> None:
|
||||
"""Wait for the server to publish diagnostics for ``path`` at ``version``.
|
||||
|
||||
``mode`` is ``"document"`` (5s budget, document pulls) or
|
||||
``"full"`` (10s budget, also workspace pulls). ``timeout``
|
||||
overrides the mode's default budget when provided — this is
|
||||
how the user's ``lsp.wait_timeout`` config reaches the wait
|
||||
loop (slow servers like tsserver on big projects need more
|
||||
than the 5s default).
|
||||
|
||||
Returns ``True`` when *fresh* diagnostics arrived (a push at
|
||||
or after our didChange, or a pull answered after it) and
|
||||
``False`` on timeout. Callers must treat ``False`` as "no
|
||||
data", NOT as "no errors" — the diagnostic stores may still
|
||||
hold stale entries from the previous edit at that point.
|
||||
Best-effort — never throws if the server doesn't support pull
|
||||
diagnostics; we still get the push side.
|
||||
``"full"`` (10s budget, also workspace pulls). Best-effort —
|
||||
returns silently on timeout. Does NOT throw if the server
|
||||
doesn't support pull diagnostics; we still get the push side.
|
||||
"""
|
||||
if timeout is not None and timeout > 0:
|
||||
budget = timeout
|
||||
else:
|
||||
budget = DIAGNOSTICS_FULL_WAIT if mode == "full" else DIAGNOSTICS_DOCUMENT_WAIT
|
||||
budget = DIAGNOSTICS_FULL_WAIT if mode == "full" else DIAGNOSTICS_DOCUMENT_WAIT
|
||||
deadline = asyncio.get_event_loop().time() + budget
|
||||
abs_path = os.path.abspath(path)
|
||||
|
||||
while True:
|
||||
remaining = deadline - asyncio.get_event_loop().time()
|
||||
if remaining <= 0:
|
||||
return False
|
||||
return
|
||||
|
||||
# Concurrent: document pull + push wait.
|
||||
pull_task = asyncio.create_task(self._pull_document_diagnostics(abs_path))
|
||||
@@ -898,24 +838,26 @@ class LSPClient:
|
||||
pass
|
||||
|
||||
# If we got a fresh push for our version, we're done.
|
||||
doc = self._docs.get(abs_path)
|
||||
if doc and doc.fresh_push(version):
|
||||
return True
|
||||
current_v = self._published_version.get(abs_path)
|
||||
if abs_path in self._published and (
|
||||
current_v is None or current_v >= version
|
||||
):
|
||||
return
|
||||
|
||||
# Pull may have answered for the current version — that's
|
||||
# also success.
|
||||
if doc and doc.fresh_pull(version):
|
||||
return True
|
||||
# Pull may have populated _pull_diagnostics — that's also
|
||||
# success.
|
||||
if abs_path in self._pull_diagnostics:
|
||||
return
|
||||
|
||||
# Loop until budget runs out.
|
||||
|
||||
async def _wait_for_fresh_push(self, path: str, version: int, timeout: float) -> None:
|
||||
"""Wait until a fresh publishDiagnostics arrives for ``path`` at ``version``+."""
|
||||
"""Wait until a publishDiagnostics arrives for ``path`` at ``version``+."""
|
||||
deadline = asyncio.get_event_loop().time() + timeout
|
||||
baseline = self._push_counter
|
||||
while True:
|
||||
doc = self._docs.get(path)
|
||||
if doc and doc.fresh_push(version):
|
||||
current_v = self._published_version.get(path)
|
||||
if path in self._published and (current_v is None or current_v >= version):
|
||||
# Debounce — wait a tick in case more diagnostics arrive
|
||||
# immediately after. TS often emits in pairs. We
|
||||
# snapshot the counter so we wake on a *new* push, not
|
||||
@@ -946,28 +888,17 @@ class LSPClient:
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
def diagnostics_for(self, path: str, *, fresh_only: bool = False) -> List[Dict[str, Any]]:
|
||||
def diagnostics_for(self, path: str) -> List[Dict[str, Any]]:
|
||||
"""Return current merged + deduped diagnostics for one file.
|
||||
|
||||
Diagnostics from push and pull stores are concatenated and
|
||||
deduplicated by ``(severity, code, message, range)`` content
|
||||
key. Empty list if the server hasn't published anything.
|
||||
|
||||
With ``fresh_only=True``, a store only contributes when its
|
||||
version tag has caught up to the document's current version —
|
||||
stale leftovers from the previous edit cycle are excluded.
|
||||
This is what report paths should use: after an edit, "stale
|
||||
errors" and "no errors" must not be conflated.
|
||||
"""
|
||||
doc = self._docs.get(os.path.abspath(path))
|
||||
if doc is None:
|
||||
return []
|
||||
if fresh_only:
|
||||
return _dedupe(
|
||||
doc.push if doc.fresh_push() else [],
|
||||
doc.pull if doc.fresh_pull() else [],
|
||||
)
|
||||
return _dedupe(doc.push, doc.pull)
|
||||
abs_path = os.path.abspath(path)
|
||||
push = self._push_diagnostics.get(abs_path) or []
|
||||
pull = self._pull_diagnostics.get(abs_path) or []
|
||||
return _dedupe(push, pull)
|
||||
|
||||
|
||||
def _dedupe(*lists: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
|
||||
+10
-41
@@ -292,10 +292,7 @@ class LSPService:
|
||||
if not self.enabled_for(file_path):
|
||||
return
|
||||
try:
|
||||
# Outer join budget must exceed the inner wait budget or a
|
||||
# slow-but-alive server gets falsely marked broken.
|
||||
t = max(8.0, self._wait_timeout + 3.0)
|
||||
diags = self._loop.run(self._snapshot_async(file_path), timeout=t)
|
||||
diags = self._loop.run(self._snapshot_async(file_path), timeout=8.0)
|
||||
self._delta_baseline[os.path.abspath(file_path)] = diags or []
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("baseline snapshot failed for %s: %s", file_path, e)
|
||||
@@ -344,7 +341,7 @@ class LSPService:
|
||||
|
||||
try:
|
||||
t = timeout if timeout is not None else self._wait_timeout + 2.0
|
||||
diags = self._loop.run(self._open_and_wait_async(file_path), timeout=t)
|
||||
diags = self._loop.run(self._open_and_wait_async(file_path), timeout=t) or []
|
||||
except asyncio.TimeoutError as e:
|
||||
eventlog.log_timeout(server_id, file_path)
|
||||
logger.debug("LSP diagnostics timeout for %s: %s", file_path, e)
|
||||
@@ -356,17 +353,6 @@ class LSPService:
|
||||
self._mark_broken_for_file(file_path, e)
|
||||
return []
|
||||
|
||||
if diags is None:
|
||||
# The server is alive but never produced diagnostics for the
|
||||
# post-edit content within the wait budget (common for
|
||||
# tsserver on large projects). Report "no data" rather than
|
||||
# whatever stale state is in the stores — surfacing the
|
||||
# previous edit's errors as if they were current is the
|
||||
# ghost-diagnostics bug. The server is NOT marked broken:
|
||||
# slow is not dead, and the next edit may well succeed.
|
||||
eventlog.log_timeout(server_id, file_path, kind="fresh diagnostics")
|
||||
return []
|
||||
|
||||
abs_path = os.path.abspath(file_path)
|
||||
if delta:
|
||||
baseline = self._delta_baseline.get(abs_path) or []
|
||||
@@ -466,43 +452,26 @@ class LSPService:
|
||||
return []
|
||||
try:
|
||||
version = await client.open_file(file_path, language_id=language_id_for(file_path))
|
||||
fresh = await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
|
||||
await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("snapshot open/wait failed: %s", e)
|
||||
return []
|
||||
self._last_used[(client.server_id, client.workspace_root)] = time.time()
|
||||
if not fresh:
|
||||
# No fresh data for the pre-edit content — an empty baseline
|
||||
# is safe: worst case the delta filter removes less, never
|
||||
# more. Never seed the baseline from stale stores.
|
||||
return []
|
||||
return list(client.diagnostics_for(file_path, fresh_only=True))
|
||||
return list(client.diagnostics_for(file_path))
|
||||
|
||||
async def _open_and_wait_async(self, file_path: str) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Open + wait for FRESH diagnostics.
|
||||
|
||||
Returns the fresh diagnostic list, or ``None`` when the server
|
||||
never produced post-change data within the wait budget. The
|
||||
distinction matters: ``[]`` means "server checked the new
|
||||
content, it's clean", ``None`` means "no verdict" — the caller
|
||||
must not substitute stale data for either.
|
||||
"""
|
||||
async def _open_and_wait_async(self, file_path: str) -> List[Dict[str, Any]]:
|
||||
client = await self._get_or_spawn(file_path)
|
||||
if client is None:
|
||||
return None
|
||||
return []
|
||||
try:
|
||||
version = await client.open_file(file_path, language_id=language_id_for(file_path))
|
||||
await client.save_file(file_path)
|
||||
fresh = await client.wait_for_diagnostics(
|
||||
file_path, version, mode=self._wait_mode, timeout=self._wait_timeout
|
||||
)
|
||||
await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("open/wait failed for %s: %s", file_path, e)
|
||||
return None
|
||||
return []
|
||||
self._last_used[(client.server_id, client.workspace_root)] = time.time()
|
||||
if not fresh:
|
||||
return None
|
||||
return list(client.diagnostics_for(file_path, fresh_only=True))
|
||||
return list(client.diagnostics_for(file_path))
|
||||
|
||||
async def _current_diags_async(self, file_path: str) -> List[Dict[str, Any]]:
|
||||
ws, gated = resolve_workspace_for_file(file_path)
|
||||
@@ -513,7 +482,7 @@ class LSPService:
|
||||
client = self._clients.get((srv.server_id, ws))
|
||||
if client is None:
|
||||
return []
|
||||
return list(client.diagnostics_for(file_path, fresh_only=True))
|
||||
return list(client.diagnostics_for(file_path))
|
||||
|
||||
async def _get_or_spawn(self, file_path: str) -> Optional[LSPClient]:
|
||||
srv = find_server_for_file(file_path)
|
||||
|
||||
+63
-117
@@ -905,114 +905,7 @@ class MoAChatCompletions:
|
||||
except Exception as exc: # pragma: no cover - display must never break the turn
|
||||
logger.debug("MoA reference_callback failed for %s: %s", event, exc)
|
||||
|
||||
def prepare(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Run the advisor fan-out and return the exact aggregator request.
|
||||
|
||||
The normal agent loop needs to measure this augmented prompt before its
|
||||
compression gate. ``create()`` also uses this method for direct callers;
|
||||
when the loop supplies the returned private object back to ``create()``,
|
||||
the advisor fan-out is not repeated.
|
||||
"""
|
||||
return self.create(messages=messages, _moa_prepare_only=True)
|
||||
|
||||
def rebase_prepared_request(
|
||||
self, prepared: dict[str, Any], messages: list[dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
"""Apply already-generated advisor guidance to a rebuilt API transcript.
|
||||
|
||||
Context compression changes the persisted transcript but not the
|
||||
ephemeral advisor result. Reusing the guidance avoids a second costly
|
||||
fan-out while keeping the aggregator request aligned with the compacted
|
||||
history.
|
||||
"""
|
||||
guidance = prepared.get("guidance")
|
||||
agg_messages = [dict(message) for message in messages]
|
||||
if guidance:
|
||||
_attach_reference_guidance(agg_messages, str(guidance))
|
||||
return {**prepared, "messages": agg_messages}
|
||||
|
||||
def _call_prepared_aggregator(
|
||||
self, prepared: dict[str, Any], api_kwargs: dict[str, Any]
|
||||
) -> Any:
|
||||
"""Send an already prepared MoA aggregator request exactly once."""
|
||||
agg_messages = prepared["messages"]
|
||||
aggregator = prepared["aggregator"]
|
||||
aggregator_temperature = prepared["aggregator_temperature"]
|
||||
if aggregator.get("provider") == "moa":
|
||||
raise RuntimeError("MoA aggregator cannot be another MoA preset")
|
||||
agg_kwargs = dict(api_kwargs)
|
||||
max_tokens: Any = agg_kwargs.get("max_tokens")
|
||||
tools: Any = agg_kwargs.get("tools")
|
||||
extra_body: Any = agg_kwargs.get("extra_body")
|
||||
# Record the exact aggregator INPUT (incl. the injected reference
|
||||
# context) into the pending trace so a trace captures what the
|
||||
# aggregator actually saw, not a reconstruction.
|
||||
if self._pending_trace is not None:
|
||||
self._pending_trace["aggregator_input_messages"] = agg_messages
|
||||
self._pending_trace["aggregator_label"] = _slot_label(aggregator)
|
||||
# The aggregator is the acting model. Resolve its slot to the provider's
|
||||
# real runtime (base_url/api_key/api_mode) and call it through the same
|
||||
# request-building path any model uses — so per-model wire-format
|
||||
# handling (anthropic_messages, max_completion_tokens, fixed/forbidden
|
||||
# temperature) applies identically to it. MoA imposes no output cap:
|
||||
# max_tokens is passed through from the caller (normally None → omitted
|
||||
# → the model's real maximum). The preset's old hardcoded 4096 default
|
||||
# is gone — it truncated long syntheses.
|
||||
# When the agent's streaming consumer calls us with stream=True, run the
|
||||
# references first (above) and then return the aggregator's RAW token
|
||||
# stream so the acting model's output reaches the user live. The consumer
|
||||
# reassembles chunks + tool_calls, runs stale-stream detection, and falls
|
||||
# back to a non-streaming retry on error. The non-streaming path
|
||||
# (stream=False) is unchanged — no stream/stream_options/timeout are
|
||||
# forwarded, so its behavior is byte-for-byte identical to before.
|
||||
stream = bool(api_kwargs.get("stream"))
|
||||
stream_kwargs: dict[str, Any] = {}
|
||||
if stream:
|
||||
stream_kwargs["stream"] = True
|
||||
stream_kwargs["stream_options"] = (
|
||||
api_kwargs.get("stream_options") or {"include_usage": True}
|
||||
)
|
||||
# Forward the consumer's per-request (stream read) timeout so it
|
||||
# actually governs the aggregator stream, not just call_llm's default.
|
||||
if api_kwargs.get("timeout") is not None:
|
||||
stream_kwargs["timeout"] = api_kwargs["timeout"]
|
||||
_agg_response = call_llm(
|
||||
task="moa_aggregator",
|
||||
messages=agg_messages,
|
||||
temperature=aggregator_temperature,
|
||||
max_tokens=max_tokens,
|
||||
tools=tools,
|
||||
extra_body=extra_body,
|
||||
# Prepared requests must retain the acting aggregator's reasoning
|
||||
# policy exactly as the direct create() path does (#64187).
|
||||
reasoning_config=_aggregator_reasoning_config(aggregator),
|
||||
**stream_kwargs,
|
||||
**_slot_runtime(aggregator),
|
||||
)
|
||||
# Non-streaming path (quiet mode / eval / subagents): the aggregator
|
||||
# output is available inline, so capture it into the pending trace now.
|
||||
# Streaming path: the aggregator's raw token stream is returned to the
|
||||
# consumer live and its acting output lands as the turn's assistant
|
||||
# message; the trace marks it streamed and points there.
|
||||
if self._pending_trace is not None:
|
||||
if stream:
|
||||
self._pending_trace["aggregator_streamed"] = True
|
||||
self._pending_trace["aggregator_output"] = None
|
||||
else:
|
||||
self._pending_trace["aggregator_streamed"] = False
|
||||
try:
|
||||
self._pending_trace["aggregator_output"] = _extract_text(_agg_response)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
self._pending_trace["aggregator_output"] = None
|
||||
return _agg_response
|
||||
|
||||
def create(self, **api_kwargs: Any) -> Any:
|
||||
prepared_request = api_kwargs.pop("_moa_prepared_request", None)
|
||||
if prepared_request is not None:
|
||||
if not isinstance(prepared_request, dict):
|
||||
raise TypeError("_moa_prepared_request must be a dict")
|
||||
return self._call_prepared_aggregator(prepared_request, api_kwargs)
|
||||
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.moa_config import resolve_moa_preset
|
||||
|
||||
@@ -1172,7 +1065,6 @@ class MoAChatCompletions:
|
||||
ref_count=_ref_count,
|
||||
)
|
||||
|
||||
guidance: str | None = None
|
||||
agg_messages = [dict(m) for m in messages]
|
||||
if reference_outputs:
|
||||
joined = "\n\n".join(
|
||||
@@ -1190,15 +1082,69 @@ class MoAChatCompletions:
|
||||
)
|
||||
_attach_reference_guidance(agg_messages, guidance)
|
||||
|
||||
prepared_request = {
|
||||
"messages": agg_messages,
|
||||
"guidance": guidance,
|
||||
"aggregator": aggregator,
|
||||
"aggregator_temperature": aggregator_temperature,
|
||||
}
|
||||
if api_kwargs.pop("_moa_prepare_only", False):
|
||||
return prepared_request
|
||||
return self._call_prepared_aggregator(prepared_request, api_kwargs)
|
||||
if aggregator.get("provider") == "moa":
|
||||
raise RuntimeError("MoA aggregator cannot be another MoA preset")
|
||||
agg_kwargs = dict(api_kwargs)
|
||||
agg_kwargs["messages"] = agg_messages
|
||||
# Record the exact aggregator INPUT (incl. the injected reference
|
||||
# context) into the pending trace so a trace captures what the
|
||||
# aggregator actually saw, not a reconstruction.
|
||||
if self._pending_trace is not None:
|
||||
self._pending_trace["aggregator_input_messages"] = agg_messages
|
||||
self._pending_trace["aggregator_label"] = _slot_label(aggregator)
|
||||
# The aggregator is the acting model. Resolve its slot to the provider's
|
||||
# real runtime (base_url/api_key/api_mode) and call it through the same
|
||||
# request-building path any model uses — so per-model wire-format
|
||||
# handling (anthropic_messages, max_completion_tokens, fixed/forbidden
|
||||
# temperature) applies identically to it. MoA imposes no output cap:
|
||||
# max_tokens is passed through from the caller (normally None → omitted
|
||||
# → the model's real maximum). The preset's old hardcoded 4096 default
|
||||
# is gone — it truncated long syntheses.
|
||||
# When the agent's streaming consumer calls us with stream=True, run the
|
||||
# references first (above) and then return the aggregator's RAW token
|
||||
# stream so the acting model's output reaches the user live. The consumer
|
||||
# reassembles chunks + tool_calls, runs stale-stream detection, and falls
|
||||
# back to a non-streaming retry on error. The non-streaming path
|
||||
# (stream=False) is unchanged — no stream/stream_options/timeout are
|
||||
# forwarded, so its behavior is byte-for-byte identical to before.
|
||||
stream = bool(api_kwargs.get("stream"))
|
||||
stream_kwargs: dict[str, Any] = {}
|
||||
if stream:
|
||||
stream_kwargs["stream"] = True
|
||||
stream_kwargs["stream_options"] = (
|
||||
api_kwargs.get("stream_options") or {"include_usage": True}
|
||||
)
|
||||
# Forward the consumer's per-request (stream read) timeout so it
|
||||
# actually governs the aggregator stream, not just call_llm's default.
|
||||
if api_kwargs.get("timeout") is not None:
|
||||
stream_kwargs["timeout"] = api_kwargs["timeout"]
|
||||
_agg_response = call_llm(
|
||||
task="moa_aggregator",
|
||||
messages=agg_messages,
|
||||
temperature=aggregator_temperature,
|
||||
max_tokens=agg_kwargs.get("max_tokens"),
|
||||
tools=agg_kwargs.get("tools"),
|
||||
extra_body=agg_kwargs.get("extra_body"),
|
||||
reasoning_config=_aggregator_reasoning_config(aggregator),
|
||||
**stream_kwargs,
|
||||
**_slot_runtime(aggregator),
|
||||
)
|
||||
# Non-streaming path (quiet mode / eval / subagents): the aggregator
|
||||
# output is available inline, so capture it into the pending trace now.
|
||||
# Streaming path: the aggregator's raw token stream is returned to the
|
||||
# consumer live and its acting output lands as the turn's assistant
|
||||
# message; the trace marks it streamed and points there.
|
||||
if self._pending_trace is not None:
|
||||
if stream:
|
||||
self._pending_trace["aggregator_streamed"] = True
|
||||
self._pending_trace["aggregator_output"] = None
|
||||
else:
|
||||
self._pending_trace["aggregator_streamed"] = False
|
||||
try:
|
||||
self._pending_trace["aggregator_output"] = _extract_text(_agg_response)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
self._pending_trace["aggregator_output"] = None
|
||||
return _agg_response
|
||||
|
||||
|
||||
class MoAClient:
|
||||
|
||||
+57
-211
@@ -4,8 +4,6 @@ Pure utility functions with no AIAgent dependency. Used by ContextCompressor
|
||||
and run_agent.py for pre-flight context checks.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
@@ -321,12 +319,7 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
"grok-3": 131072, # grok-3, grok-3-mini, grok-3-fast, grok-3-mini-fast
|
||||
"grok-2": 131072, # grok-2, grok-2-1212, grok-2-latest
|
||||
"grok": 131072, # catch-all (grok-beta, unknown grok-*)
|
||||
# Kimi — K3 ships with a 1 Mi context window (1,048,576; verified against
|
||||
# models.dev and OpenRouter live metadata, matching the endpoint-scoped
|
||||
# override in _endpoint_scoped_context_length). Longest-key-first substring
|
||||
# matching ensures "kimi-k3" resolves to 1M while older/unknown Kimi models
|
||||
# still hit the generic 256K fallback.
|
||||
"kimi-k3": 1_048_576,
|
||||
# Kimi
|
||||
"kimi": 262144,
|
||||
# Upstage Solar — api.upstage.ai/v1/models does not return context_length,
|
||||
# so these fallbacks keep token budgeting / compression from probing down
|
||||
@@ -550,13 +543,7 @@ def _is_known_provider_base_url(base_url: str) -> bool:
|
||||
|
||||
|
||||
def _endpoint_scoped_context_length(model: str, base_url: str) -> Optional[int]:
|
||||
"""Return metadata confirmed only for the Kimi Coding endpoint.
|
||||
|
||||
Kimi Coding serves K3 under the bare slug ``k3``, but users may also
|
||||
configure or select the public-facing aliases ``kimi-k3`` and
|
||||
``kimi-k3-cot``. Only canonical ``https://api.kimi.com/coding`` endpoints
|
||||
(legacy Moonshot keys do not serve K3) get the 1 Mi context window.
|
||||
"""
|
||||
"""Return metadata confirmed only for one provider endpoint."""
|
||||
normalized = _normalize_base_url(base_url)
|
||||
try:
|
||||
parsed = urlparse(normalized)
|
||||
@@ -572,7 +559,7 @@ def _endpoint_scoped_context_length(model: str, base_url: str) -> Optional[int]:
|
||||
and parsed.path.rstrip("/") in {"/coding", "/coding/v1"}
|
||||
and not parsed.query
|
||||
and not parsed.fragment
|
||||
and model.strip().lower() in {"k3", "kimi-k3", "kimi-k3-cot"}
|
||||
and model.strip().lower() == "k3"
|
||||
):
|
||||
return 1_048_576
|
||||
return None
|
||||
@@ -583,13 +570,8 @@ def _skip_persistent_context_cache(base_url: str, provider: str) -> bool:
|
||||
|
||||
LM Studio excludes caching because loaded context is transient — the user
|
||||
can reload the model with a different context_length at any time.
|
||||
|
||||
Codex OAuth excludes caching because its context window is account- and
|
||||
entitlement-specific metadata supplied by the authenticated /models
|
||||
endpoint. A fallback value written after a transient probe failure must
|
||||
not prevent a later live probe from observing an updated allocation.
|
||||
"""
|
||||
return (provider or "").strip().lower() in {"lmstudio", "openai-codex"}
|
||||
"""
|
||||
return provider == "lmstudio"
|
||||
|
||||
|
||||
def _maybe_cache_local_context_length(
|
||||
@@ -1925,72 +1907,32 @@ _CODEX_OAUTH_CONTEXT_FALLBACK: Dict[str, int] = {
|
||||
}
|
||||
|
||||
|
||||
_codex_oauth_context_cache: Dict[str, Tuple[Dict[str, int], float]] = {}
|
||||
_codex_oauth_context_cache: Dict[str, int] = {}
|
||||
_codex_oauth_context_cache_time: float = 0.0
|
||||
_CODEX_OAUTH_CONTEXT_CACHE_TTL = 3600 # 1 hour
|
||||
|
||||
|
||||
def _codex_oauth_token_fingerprint(access_token: str) -> str:
|
||||
"""Return a non-secret cache key for a Codex OAuth access token."""
|
||||
return hashlib.sha256(access_token.encode("utf-8")).hexdigest()[:16]
|
||||
def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]:
|
||||
"""Probe the ChatGPT Codex /models endpoint for per-slug context windows.
|
||||
|
||||
Codex OAuth imposes its own context limits that differ from the direct
|
||||
OpenAI API (e.g. gpt-5.5 is 1.05M on the API, 272K on Codex). The
|
||||
`context_window` field in each model entry is the authoritative source.
|
||||
|
||||
def _extract_chatgpt_account_id(access_token: str) -> Optional[str]:
|
||||
"""Extract ``chatgpt_account_id`` from the Codex OAuth JWT.
|
||||
|
||||
The Codex ``/backend-api/codex/models`` endpoint returns the per-account
|
||||
catalog only when the ``ChatGPT-Account-Id`` header is present; without
|
||||
it, the endpoint returns ``{"models":[]}`` (HTTP 200) and the context
|
||||
probe falls back to the hardcoded defaults — which can be stale or
|
||||
wrong for the active account's plan. Mirrors the same extraction done
|
||||
in ``auxiliary_client.py`` for the request path.
|
||||
|
||||
Returns ``None`` on any parse error rather than raising, so a bad
|
||||
token still surfaces as a normal probe failure instead of crashing
|
||||
the metadata resolver.
|
||||
Returns a ``{slug: context_window}`` dict. Empty on failure.
|
||||
"""
|
||||
try:
|
||||
parts = access_token.split(".")
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
|
||||
claims = json.loads(base64.urlsafe_b64decode(payload_b64))
|
||||
if not isinstance(claims, dict):
|
||||
return None
|
||||
acct_id = claims.get("https://api.openai.com/auth", {}).get("chatgpt_account_id")
|
||||
return acct_id if isinstance(acct_id, str) and acct_id else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_codex_oauth_context_lengths_with_source(
|
||||
access_token: str,
|
||||
) -> Tuple[Dict[str, int], bool]:
|
||||
"""Fetch Codex catalogue data and report whether it came from HTTP.
|
||||
|
||||
The in-process cache is scoped by token fingerprint because Codex model
|
||||
availability and context windows can vary by account entitlement. The raw
|
||||
token is never retained in the cache key. The boolean is false for a
|
||||
same-token in-process hit, which must not be treated as a fresh provider
|
||||
confirmation when deciding whether to update persistent state.
|
||||
"""
|
||||
global _codex_oauth_context_cache
|
||||
global _codex_oauth_context_cache, _codex_oauth_context_cache_time
|
||||
now = time.time()
|
||||
cache_key = _codex_oauth_token_fingerprint(access_token)
|
||||
cached = _codex_oauth_context_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
cached_models, cached_at = cached
|
||||
if now - cached_at < _CODEX_OAUTH_CONTEXT_CACHE_TTL:
|
||||
return cached_models, False
|
||||
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
acct_id = _extract_chatgpt_account_id(access_token)
|
||||
if acct_id:
|
||||
headers["ChatGPT-Account-Id"] = acct_id
|
||||
if (
|
||||
_codex_oauth_context_cache
|
||||
and now - _codex_oauth_context_cache_time < _CODEX_OAUTH_CONTEXT_CACHE_TTL
|
||||
):
|
||||
return _codex_oauth_context_cache
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
"https://chatgpt.com/backend-api/codex/models?client_version=1.0.0",
|
||||
headers=headers,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=(5, 10),
|
||||
verify=_resolve_requests_verify(),
|
||||
)
|
||||
@@ -1999,11 +1941,11 @@ def _fetch_codex_oauth_context_lengths_with_source(
|
||||
"Codex /models probe returned HTTP %s; falling back to hardcoded defaults",
|
||||
resp.status_code,
|
||||
)
|
||||
return {}, False
|
||||
return {}
|
||||
data = resp.json()
|
||||
except Exception as exc:
|
||||
logger.debug("Codex /models probe failed: %s", exc)
|
||||
return {}, False
|
||||
return {}
|
||||
|
||||
entries = data.get("models", []) if isinstance(data, dict) else []
|
||||
result: Dict[str, int] = {}
|
||||
@@ -2016,50 +1958,32 @@ def _fetch_codex_oauth_context_lengths_with_source(
|
||||
result[slug.strip()] = ctx
|
||||
|
||||
if result:
|
||||
_codex_oauth_context_cache[cache_key] = (result, now)
|
||||
return result, True
|
||||
|
||||
|
||||
def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]:
|
||||
"""Probe the ChatGPT Codex /models endpoint for per-slug context windows.
|
||||
|
||||
Codex OAuth imposes its own context limits that differ from the direct
|
||||
OpenAI API (e.g. gpt-5.5 is 1.05M on the API, 272K on Codex). The
|
||||
`context_window` field in each model entry is the authoritative source.
|
||||
|
||||
Returns a ``{slug: context_window}`` dict. Empty on failure.
|
||||
"""
|
||||
result, _fresh = _fetch_codex_oauth_context_lengths_with_source(access_token)
|
||||
_codex_oauth_context_cache = result
|
||||
_codex_oauth_context_cache_time = now
|
||||
return result
|
||||
|
||||
|
||||
def _resolve_codex_oauth_context_length_with_source(
|
||||
def _resolve_codex_oauth_context_length(
|
||||
model: str, access_token: str = ""
|
||||
) -> Tuple[Optional[int], str]:
|
||||
) -> Optional[int]:
|
||||
"""Resolve a Codex OAuth model's real context window.
|
||||
|
||||
Prefers a live probe of chatgpt.com/backend-api/codex/models (when we
|
||||
have a bearer token), then falls back to ``_CODEX_OAUTH_CONTEXT_FALLBACK``.
|
||||
|
||||
Returns ``(context_length, source)`` where source is ``"live"`` for a
|
||||
value returned by a fresh authenticated endpoint probe, ``"memory"`` for
|
||||
a same-token in-process catalogue hit, or ``"fallback"`` for the static
|
||||
conservative table. Only ``"live"`` is eligible for persistent writes.
|
||||
"""
|
||||
model_bare = _strip_provider_prefix(model).strip()
|
||||
if not model_bare:
|
||||
return None, ""
|
||||
return None
|
||||
|
||||
if access_token:
|
||||
live, fresh_probe = _fetch_codex_oauth_context_lengths_with_source(access_token)
|
||||
live_source = "live" if fresh_probe else "memory"
|
||||
live = _fetch_codex_oauth_context_lengths(access_token)
|
||||
if model_bare in live:
|
||||
return live[model_bare], live_source
|
||||
return live[model_bare]
|
||||
# Case-insensitive match in case casing drifts
|
||||
model_lower = model_bare.lower()
|
||||
for slug, ctx in live.items():
|
||||
if slug.lower() == model_lower:
|
||||
return ctx, live_source
|
||||
return ctx
|
||||
|
||||
# Fallback: longest-key-first substring match over hardcoded defaults.
|
||||
model_lower = model_bare.lower()
|
||||
@@ -2067,19 +1991,9 @@ def _resolve_codex_oauth_context_length_with_source(
|
||||
_CODEX_OAUTH_CONTEXT_FALLBACK.items(), key=lambda x: len(x[0]), reverse=True
|
||||
):
|
||||
if slug in model_lower:
|
||||
return ctx, "fallback"
|
||||
return ctx
|
||||
|
||||
return None, ""
|
||||
|
||||
|
||||
def _resolve_codex_oauth_context_length(
|
||||
model: str, access_token: str = ""
|
||||
) -> Optional[int]:
|
||||
"""Resolve a Codex OAuth model's context length (compatibility wrapper)."""
|
||||
context_length, _source = _resolve_codex_oauth_context_length_with_source(
|
||||
model, access_token=access_token,
|
||||
)
|
||||
return context_length
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_nous_context_length(
|
||||
@@ -2169,9 +2083,9 @@ def get_model_context_length(
|
||||
Resolution order:
|
||||
0. Explicit config override (model.context_length or custom_providers per-model)
|
||||
0c. Endpoint-scoped metadata for models validated on one multiplexed endpoint
|
||||
1. Persistent cache (previously discovered via probing). Nous URLs,
|
||||
LM Studio, and Codex OAuth bypass the cache here so their provider
|
||||
metadata can be reconciled against the authoritative live source.
|
||||
1. Persistent cache (previously discovered via probing). Nous URLs
|
||||
bypass the cache here so step 5b can always reconcile against
|
||||
the authoritative portal /v1/models response.
|
||||
1b. AWS Bedrock static table (must precede custom-endpoint probe)
|
||||
2. Active endpoint metadata (/models for explicit custom endpoints)
|
||||
3. Local server query (for local endpoints)
|
||||
@@ -2271,13 +2185,24 @@ def get_model_context_length(
|
||||
# LM Studio is excluded — its loaded context length is transient (the
|
||||
# user can reload the model with a different context_length at any time
|
||||
# via /api/v1/models/load), so a stale cached value would mask reloads.
|
||||
# Codex OAuth is excluded because the authenticated /models catalogue is
|
||||
# account-specific and a fallback must never suppress later revalidation.
|
||||
if base_url and not _skip_persistent_context_cache(base_url, provider):
|
||||
cached = get_cached_context_length(model, base_url)
|
||||
if cached is not None:
|
||||
# Invalidate stale Codex OAuth cache entries: pre-PR #14935 builds
|
||||
# resolved gpt-5.x to the direct-API value (e.g. 1.05M) via
|
||||
# models.dev and persisted it. Codex OAuth caps at 272K for every
|
||||
# slug, so any cached Codex entry at or above 400K is a leftover
|
||||
# from the old resolution path. Drop it and fall through to the
|
||||
# live /models probe in step 5 below.
|
||||
if provider == "openai-codex" and cached >= 400_000:
|
||||
logger.info(
|
||||
"Dropping stale Codex cache entry %s@%s -> %s (pre-fix value); "
|
||||
"re-resolving via live /models probe",
|
||||
model, base_url, f"{cached:,}",
|
||||
)
|
||||
_invalidate_cached_context_length(model, base_url)
|
||||
# Invalidate stale 32k cache entries for Kimi-family models.
|
||||
if cached <= 32768 and _model_name_suggests_kimi(model):
|
||||
elif cached <= 32768 and _model_name_suggests_kimi(model):
|
||||
logger.info(
|
||||
"Dropping stale Kimi cache entry %s@%s -> %s (OpenRouter underreport); "
|
||||
"re-resolving via hardcoded defaults",
|
||||
@@ -2516,14 +2441,9 @@ def get_model_context_length(
|
||||
# Codex OAuth enforces lower context limits than the direct OpenAI
|
||||
# API for the same slug (e.g. gpt-5.5 is 1.05M on the API but 272K
|
||||
# on Codex). Authoritative source is Codex's own /models endpoint.
|
||||
codex_ctx, codex_source = _resolve_codex_oauth_context_length_with_source(
|
||||
model, access_token=api_key or "",
|
||||
)
|
||||
codex_ctx = _resolve_codex_oauth_context_length(model, access_token=api_key or "")
|
||||
if codex_ctx:
|
||||
# Only a successful authenticated catalogue response is safe to
|
||||
# persist. The static fallback is deliberately runtime-only so a
|
||||
# transient OAuth/network failure cannot poison future probes.
|
||||
if base_url and codex_source == "live":
|
||||
if base_url:
|
||||
save_context_length(model, base_url, codex_ctx)
|
||||
return codex_ctx
|
||||
if effective_provider == "gmi" and base_url:
|
||||
@@ -2666,61 +2586,16 @@ async def get_model_context_length_async(
|
||||
)
|
||||
|
||||
|
||||
def _is_cjk_token_dense_char(ch: str) -> bool:
|
||||
code = ord(ch)
|
||||
return (
|
||||
0x1100 <= code <= 0x11FF # Hangul Jamo
|
||||
or 0x2E80 <= code <= 0x9FFF # CJK radicals/ideographs
|
||||
or 0xA960 <= code <= 0xA97F # Hangul Jamo Extended-A
|
||||
or 0xAC00 <= code <= 0xD7AF # Hangul Syllables
|
||||
or 0xF900 <= code <= 0xFAFF # CJK compatibility ideographs
|
||||
or 0xFF00 <= code <= 0xFFEF # Fullwidth forms / halfwidth kana
|
||||
)
|
||||
|
||||
|
||||
# Same codepoint ranges as _is_cjk_token_dense_char, as a compiled character
|
||||
# class so dense-char counting runs in C (``len(text) - len(re.sub(...))``)
|
||||
# instead of a per-char Python loop. MUST stay in sync with
|
||||
# _is_cjk_token_dense_char.
|
||||
_CJK_DENSE_RE = re.compile(
|
||||
"[\u1100-\u11ff" # Hangul Jamo
|
||||
"\u2e80-\u9fff" # CJK radicals/ideographs
|
||||
"\ua960-\ua97f" # Hangul Jamo Extended-A
|
||||
"\uac00-\ud7af" # Hangul Syllables
|
||||
"\uf900-\ufaff" # CJK compatibility ideographs
|
||||
"\uff00-\uffef]" # Fullwidth forms / halfwidth kana
|
||||
)
|
||||
|
||||
|
||||
def estimate_tokens_rough(text: str) -> int:
|
||||
"""Rough token estimate for pre-flight checks.
|
||||
"""Rough token estimate (~4 chars/token) for pre-flight checks.
|
||||
|
||||
Uses ceiling division so short texts (1-3 chars) never estimate as
|
||||
0 tokens, which would cause the compressor and pre-flight checks to
|
||||
systematically undercount when many short tool results are present.
|
||||
CJK/Hangul/Kana text is much denser than English under common LLM
|
||||
tokenizers, so count those codepoints as roughly one token each instead
|
||||
of applying the English-centric ~4 chars/token rule.
|
||||
|
||||
Perf: this runs on every message in every preflight/compaction walk,
|
||||
including MB-scale tool outputs, so the common all-ASCII case must stay
|
||||
O(1). ``str.isascii()`` is a flag check on CPython's compact unicode
|
||||
representation (no scan), and the CJK counting itself is a single
|
||||
C-level ``re.findall`` rather than a per-character Python loop.
|
||||
"""
|
||||
if not text:
|
||||
return 0
|
||||
text = str(text)
|
||||
if text.isascii():
|
||||
# O(1) fast path — ASCII text cannot contain token-dense CJK chars.
|
||||
return (len(text) + 3) // 4
|
||||
dense = len(text) - len(_CJK_DENSE_RE.sub("", text))
|
||||
if not dense:
|
||||
# Non-ASCII but no CJK (accents, Cyrillic, emoji, ...): keep the
|
||||
# classic ~4 chars/token rule.
|
||||
return (len(text) + 3) // 4
|
||||
sparse = len(text) - dense
|
||||
return dense + ((sparse + 3) // 4)
|
||||
return (len(text) + 3) // 4
|
||||
|
||||
|
||||
def estimate_messages_tokens_rough(messages: List[Dict[str, Any]]) -> int:
|
||||
@@ -2732,12 +2607,12 @@ def estimate_messages_tokens_rough(messages: List[Dict[str, Any]]) -> int:
|
||||
estimated at ~250K tokens and trigger premature context compression.
|
||||
"""
|
||||
_IMAGE_TOKEN_COST = 1500
|
||||
text_tokens = 0
|
||||
total_chars = 0
|
||||
image_tokens = 0
|
||||
for msg in messages:
|
||||
text_tokens += _estimate_message_tokens_without_images(msg)
|
||||
total_chars += _estimate_message_chars(msg)
|
||||
image_tokens += _count_image_tokens(msg, _IMAGE_TOKEN_COST)
|
||||
return text_tokens + image_tokens
|
||||
return ((total_chars + 3) // 4) + image_tokens
|
||||
|
||||
|
||||
def _count_image_tokens(msg: Dict[str, Any], cost_per_image: int) -> int:
|
||||
@@ -2799,35 +2674,6 @@ def _estimate_message_chars(msg: Dict[str, Any]) -> int:
|
||||
return len(str(shadow))
|
||||
|
||||
|
||||
def _estimate_message_tokens_without_images(msg: Dict[str, Any]) -> int:
|
||||
"""Token estimate for a message shadow with image payloads stripped."""
|
||||
if not isinstance(msg, dict):
|
||||
return estimate_tokens_rough(str(msg))
|
||||
shadow: Dict[str, Any] = {}
|
||||
for k, v in msg.items():
|
||||
if k == "_anthropic_content_blocks":
|
||||
continue
|
||||
if k == "content":
|
||||
if isinstance(v, list):
|
||||
cleaned = []
|
||||
for part in v:
|
||||
if isinstance(part, dict):
|
||||
if part.get("type") in {"image", "image_url", "input_image"}:
|
||||
cleaned.append({"type": part.get("type"), "image": "[stripped]"})
|
||||
else:
|
||||
cleaned.append(part)
|
||||
else:
|
||||
cleaned.append(part)
|
||||
shadow[k] = cleaned
|
||||
elif isinstance(v, dict) and v.get("_multimodal"):
|
||||
shadow[k] = v.get("text_summary", "")
|
||||
else:
|
||||
shadow[k] = v
|
||||
else:
|
||||
shadow[k] = v
|
||||
return estimate_tokens_rough(str(shadow))
|
||||
|
||||
|
||||
def estimate_request_tokens_rough(
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
@@ -2844,7 +2690,7 @@ def estimate_request_tokens_rough(
|
||||
"""
|
||||
total = 0
|
||||
if system_prompt:
|
||||
total += estimate_tokens_rough(system_prompt)
|
||||
total += (len(system_prompt) + 3) // 4
|
||||
if messages:
|
||||
total += estimate_messages_tokens_rough(messages)
|
||||
if tools:
|
||||
|
||||
@@ -15,9 +15,6 @@ and MoonshotAI/kimi-cli#1595:
|
||||
2. When ``anyOf`` is used, ``type`` must be on the ``anyOf`` children, not
|
||||
the parent. Presence of both causes "type should be defined in anyOf
|
||||
items instead of the parent schema".
|
||||
3. Every object schema must carry a ``required`` array, even an empty one.
|
||||
Standard JSON Schema allows omitting it; Moonshot 400s with
|
||||
"required must be an array".
|
||||
|
||||
The ``#/definitions/...`` → ``#/$defs/...`` rewrite for draft-07 refs is
|
||||
handled separately in ``tools/mcp_tool._normalize_mcp_input_schema`` so it
|
||||
@@ -133,32 +130,9 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
|
||||
else:
|
||||
repaired.pop("enum")
|
||||
|
||||
# Rule 4: object schemas must carry a `required` array, even when empty.
|
||||
if repaired.get("type") == "object":
|
||||
repaired = _ensure_required_array(repaired)
|
||||
|
||||
return repaired
|
||||
|
||||
|
||||
def _ensure_required_array(node: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Guarantee an object schema carries a ``required`` array (Moonshot rule).
|
||||
|
||||
Standard JSON Schema lets you omit ``required`` when nothing is required;
|
||||
Moonshot 400s on that ("required must be an array"). Ensure the key is a
|
||||
list. When ``properties`` is known, prune ``required`` entries that don't
|
||||
name a real property — defensive against dangling names, which Moonshot
|
||||
also rejects. Mutates and returns ``node``.
|
||||
"""
|
||||
props = node.get("properties")
|
||||
req = node.get("required")
|
||||
if isinstance(req, list):
|
||||
if isinstance(props, dict):
|
||||
node["required"] = [r for r in req if r in props]
|
||||
else:
|
||||
node["required"] = []
|
||||
return node
|
||||
|
||||
|
||||
def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Infer a reasonable ``type`` if this schema node has none."""
|
||||
node_type = node.get("type")
|
||||
@@ -200,18 +174,17 @@ def sanitize_moonshot_tool_parameters(parameters: Any) -> Dict[str, Any]:
|
||||
applied. Input is not mutated.
|
||||
"""
|
||||
if not isinstance(parameters, dict):
|
||||
return {"type": "object", "properties": {}, "required": []}
|
||||
return {"type": "object", "properties": {}}
|
||||
|
||||
repaired = _repair_schema(copy.deepcopy(parameters), is_schema=True)
|
||||
if not isinstance(repaired, dict):
|
||||
return {"type": "object", "properties": {}, "required": []}
|
||||
return {"type": "object", "properties": {}}
|
||||
|
||||
# Top-level must be an object schema
|
||||
if repaired.get("type") != "object":
|
||||
repaired["type"] = "object"
|
||||
if "properties" not in repaired:
|
||||
repaired["properties"] = {}
|
||||
_ensure_required_array(repaired)
|
||||
|
||||
return repaired
|
||||
|
||||
@@ -259,10 +232,6 @@ def is_moonshot_model(model: str | None) -> bool:
|
||||
tail = bare.rsplit("/", 1)[-1]
|
||||
if tail.startswith("kimi-") or tail == "kimi":
|
||||
return True
|
||||
# Kimi Coding Plan serves K3 under the bare slug ``k3`` (plus dated /
|
||||
# suffixed variants like ``k3.1`` or ``k3-turbo``).
|
||||
if tail == "k3" or tail.startswith(("k3.", "k3-")):
|
||||
return True
|
||||
# Vendor-prefixed forms commonly used on aggregators
|
||||
if "moonshot" in bare or "/kimi" in bare or bare.startswith("kimi"):
|
||||
return True
|
||||
|
||||
+2
-13
@@ -805,19 +805,8 @@ PLATFORM_HINTS = {
|
||||
),
|
||||
"matrix": (
|
||||
"You are in a Matrix room communicating with your user. "
|
||||
"The adapter converts your Markdown to HTML for rich display — bold, "
|
||||
"italic, inline code, fenced code blocks, headings, bullet and "
|
||||
"numbered lists, blockquotes, and links all render.\n\n"
|
||||
"Do NOT use Markdown tables: many popular Matrix clients (Element X, "
|
||||
"Beeper, most mobile apps) do not render HTML tables, so the cells "
|
||||
"collapse into one continuous run of text. Present tabular data as "
|
||||
"labeled '**Label:** value' lines or bullet lists instead.\n\n"
|
||||
"Avoid ||spoiler|| tags, ~~strikethrough~~, and checkboxes "
|
||||
"(- [ ] / - [x]) — they are not converted and appear as literal "
|
||||
"characters.\n\n"
|
||||
"LINKS: prefer [descriptive link text](url) over bare URLs. When "
|
||||
"referencing something with an associated URL (events, sources, "
|
||||
"people), make the name a clickable link.\n\n"
|
||||
"Matrix renders Markdown — bold, italic, code blocks, and links work; "
|
||||
"the adapter converts your Markdown to HTML for rich display. "
|
||||
"You can send media files natively: include MEDIA:/absolute/path/to/file "
|
||||
"in your response. Images (.jpg, .png, .webp) are sent as inline photos, "
|
||||
"audio (.ogg, .mp3) as voice/audio messages, video (.mp4) inline, "
|
||||
|
||||
+5
-35
@@ -127,16 +127,10 @@ def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
|
||||
|
||||
1. Genuinely-global vars (``_is_global_env``) always read ``os.environ`` —
|
||||
they are deployment settings, not profile secrets.
|
||||
2. When a secret scope is installed (multiplexed turn), read from it. Under
|
||||
multiplexing the scope is authoritative — an absent key returns
|
||||
``default`` and we do NOT fall through to ``os.environ``, because in a
|
||||
multiplexer ``os.environ`` may hold another profile's value. When
|
||||
multiplexing is OFF, a scope miss falls through to ``os.environ``:
|
||||
single-profile deployments legitimately provide credentials via the
|
||||
process environment (systemd ``Environment=``, secret-manager wrappers
|
||||
like ``pass-cli run`` / ``op run``, plain shell exports) rather than
|
||||
``<home>/.env``, and the scope — installed unconditionally around e.g.
|
||||
every cron job — must stay a ``.env`` overlay, not a blindfold.
|
||||
2. When a secret scope is installed (multiplexed turn), read from it; an
|
||||
absent key returns ``default``. The scope is authoritative — we do NOT
|
||||
fall through to ``os.environ``, because in a multiplexer ``os.environ``
|
||||
may hold another profile's value.
|
||||
3. No scope installed:
|
||||
- multiplex INACTIVE (default deployment): read ``os.environ`` —
|
||||
identical to the legacy ``os.getenv`` behavior every caller had before.
|
||||
@@ -150,17 +144,6 @@ def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
|
||||
scope = _SECRET_SCOPE.get()
|
||||
if scope is not None:
|
||||
val = scope.get(name)
|
||||
if val is not None:
|
||||
return val
|
||||
if _MULTIPLEX_ACTIVE:
|
||||
return default
|
||||
# Multiplex off: the scope is an overlay over the process environment,
|
||||
# not an isolation boundary — there is no other profile to leak from.
|
||||
# Without this fallthrough, credentials injected only into the process
|
||||
# environment vanish inside any set_secret_scope(...) block (the cron
|
||||
# scheduler installs one around every job), so cron jobs send a
|
||||
# placeholder API key and 401 while interactive turns keep working.
|
||||
val = os.environ.get(name)
|
||||
return val if val is not None else default
|
||||
|
||||
if _MULTIPLEX_ACTIVE:
|
||||
@@ -218,18 +201,5 @@ def build_profile_secret_scope(hermes_home: Path) -> Dict[str, str]:
|
||||
global vars are intentionally NOT copied in — ``get_secret`` reads those
|
||||
from ``os.environ`` directly, so the scope holds only profile secrets.
|
||||
"""
|
||||
home = Path(hermes_home)
|
||||
secrets = load_env_file(home / ".env")
|
||||
return load_env_file(Path(hermes_home) / ".env")
|
||||
|
||||
try:
|
||||
from hermes_cli.env_loader import get_secret_source_values
|
||||
external_secrets = get_secret_source_values(home)
|
||||
except Exception:
|
||||
external_secrets = {}
|
||||
|
||||
for key, value in external_secrets.items():
|
||||
if _is_global_env(key):
|
||||
continue
|
||||
secrets[key] = value
|
||||
|
||||
return secrets
|
||||
|
||||
@@ -190,45 +190,6 @@ class SecretSource(ABC):
|
||||
"""
|
||||
return {}
|
||||
|
||||
def remediation(self, kind: Optional["ErrorKind"], cfg: dict) -> str:
|
||||
"""One-line, actionable next step for a failed fetch.
|
||||
|
||||
Called by the startup status printer (and ``hermes secrets ...
|
||||
status``) right after a fetch error is surfaced, so the user sees
|
||||
*what to run* next to fix it — not just what broke. Sources
|
||||
should override this to point at their own CLI verbs (e.g.
|
||||
``hermes secrets bitwarden token`` for AUTH_FAILED). Return an
|
||||
empty string to suppress the hint.
|
||||
|
||||
Must never raise and must not perform I/O — it's a pure
|
||||
kind→string mapping on the startup path.
|
||||
"""
|
||||
generic = {
|
||||
ErrorKind.NOT_CONFIGURED: (
|
||||
f"Run `hermes secrets {self.name} setup` to finish configuration."
|
||||
),
|
||||
ErrorKind.BINARY_MISSING: (
|
||||
f"Run `hermes secrets {self.name} setup` to install the helper CLI."
|
||||
),
|
||||
ErrorKind.AUTH_FAILED: (
|
||||
f"Credentials rejected — run `hermes secrets {self.name} setup` "
|
||||
"to re-authenticate."
|
||||
),
|
||||
ErrorKind.AUTH_EXPIRED: (
|
||||
f"Credentials expired — run `hermes secrets {self.name} setup` "
|
||||
"to re-authenticate."
|
||||
),
|
||||
ErrorKind.NETWORK: (
|
||||
"Network problem reaching the secrets backend — check "
|
||||
"connectivity and retry."
|
||||
),
|
||||
ErrorKind.TIMEOUT: (
|
||||
f"Backend was slow — raise secrets.{self.name}.timeout_seconds "
|
||||
"if this recurs."
|
||||
),
|
||||
}
|
||||
return generic.get(kind, "") if kind is not None else ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers — use these instead of hand-rolling per backend
|
||||
|
||||
@@ -29,13 +29,11 @@ is easier to lazy-install than a wheels-with-Rust-extension dependency.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
@@ -47,10 +45,6 @@ import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
from agent.secret_sources._cache import (
|
||||
CachedFetch as _CachedFetch,
|
||||
DiskCache,
|
||||
@@ -97,9 +91,6 @@ _CACHE: Dict[_CacheKey, _CachedFetch] = {}
|
||||
# accidentally commit BSM-sourced secrets. The atomic-write/0600/TTL mechanics
|
||||
# live in agent.secret_sources._cache.DiskCache, shared with the other backends.
|
||||
_DISK_CACHE_BASENAME = "bws_cache.json"
|
||||
_ENCRYPTED_CACHE_BASENAME = "bws_cache.enc.json"
|
||||
_ENCRYPTED_CACHE_VERSION = 1
|
||||
_ENCRYPTED_CACHE_INFO = b"hermes-bws-encrypted-cache-v1"
|
||||
|
||||
|
||||
def _cache_key_str(cache_key: _CacheKey) -> str:
|
||||
@@ -122,13 +113,6 @@ def _disk_cache_path(home_path: Optional[Path] = None) -> Path:
|
||||
return _DISK_CACHE.path(home_path)
|
||||
|
||||
|
||||
def _encrypted_disk_cache_path(home_path: Optional[Path] = None) -> Path:
|
||||
"""Return the encrypted disk cache path under hermes_home/cache/."""
|
||||
from agent.secret_sources._cache import resolve_cache_home
|
||||
|
||||
return resolve_cache_home(home_path) / "cache" / _ENCRYPTED_CACHE_BASENAME
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Binary discovery + lazy install
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -364,134 +348,6 @@ def _token_fingerprint(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _b64e(raw: bytes) -> str:
|
||||
return base64.b64encode(raw).decode("ascii")
|
||||
|
||||
|
||||
def _b64d(text: str) -> bytes:
|
||||
return base64.b64decode(text.encode("ascii"), validate=True)
|
||||
|
||||
|
||||
def _derive_encrypted_cache_key(access_token: str, salt: bytes) -> bytes:
|
||||
"""Derive the local cache encryption key from the bootstrap BWS token."""
|
||||
return HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=salt,
|
||||
info=_ENCRYPTED_CACHE_INFO,
|
||||
).derive(access_token.encode("utf-8"))
|
||||
|
||||
|
||||
def _write_encrypted_disk_cache(
|
||||
*,
|
||||
cache_key: _CacheKey,
|
||||
access_token: str,
|
||||
entry: _CachedFetch,
|
||||
home_path: Optional[Path] = None,
|
||||
) -> None:
|
||||
"""Persist an encrypted last-good cache entry atomically.
|
||||
|
||||
Best-effort by design: cache write failure must never block a fresh BWS
|
||||
fetch. The raw BWS access token is not stored; it only derives the AES key.
|
||||
"""
|
||||
path = _encrypted_disk_cache_path(home_path)
|
||||
try:
|
||||
cache_dir = path.parent
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
os.chmod(cache_dir, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
salt = os.urandom(16)
|
||||
nonce = os.urandom(12)
|
||||
serialized_key = _cache_key_str(cache_key)
|
||||
key = _derive_encrypted_cache_key(access_token, salt)
|
||||
plaintext = json.dumps(
|
||||
{"secrets": entry.secrets, "fetched_at": entry.fetched_at},
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
ciphertext = AESGCM(key).encrypt(
|
||||
nonce, plaintext, serialized_key.encode("utf-8")
|
||||
)
|
||||
payload = {
|
||||
"version": _ENCRYPTED_CACHE_VERSION,
|
||||
"key": serialized_key,
|
||||
"salt": _b64e(salt),
|
||||
"nonce": _b64e(nonce),
|
||||
"ciphertext": _b64e(ciphertext),
|
||||
}
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
prefix=".bws_cache_enc_", suffix=".tmp", dir=str(cache_dir)
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f)
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, path)
|
||||
# A successful encrypted write completes migration; remove the
|
||||
# legacy plaintext cache so stale secrets cannot remain on disk.
|
||||
try:
|
||||
_disk_cache_path(home_path).unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
except Exception: # noqa: BLE001 — best-effort cache only
|
||||
return
|
||||
|
||||
|
||||
def _read_encrypted_disk_cache(
|
||||
*,
|
||||
cache_key: _CacheKey,
|
||||
access_token: str,
|
||||
max_age_seconds: float,
|
||||
home_path: Optional[Path] = None,
|
||||
) -> Optional[_CachedFetch]:
|
||||
"""Return a decrypted encrypted-cache entry if it matches and is in-window."""
|
||||
if max_age_seconds <= 0:
|
||||
return None
|
||||
path = _encrypted_disk_cache_path(home_path)
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
serialized_key = _cache_key_str(cache_key)
|
||||
if payload.get("version") != _ENCRYPTED_CACHE_VERSION:
|
||||
return None
|
||||
if payload.get("key") != serialized_key:
|
||||
return None
|
||||
salt = _b64d(str(payload.get("salt", "")))
|
||||
nonce = _b64d(str(payload.get("nonce", "")))
|
||||
ciphertext = _b64d(str(payload.get("ciphertext", "")))
|
||||
key = _derive_encrypted_cache_key(access_token, salt)
|
||||
raw = AESGCM(key).decrypt(
|
||||
nonce, ciphertext, serialized_key.encode("utf-8")
|
||||
)
|
||||
inner = json.loads(raw.decode("utf-8"))
|
||||
if not isinstance(inner, dict):
|
||||
return None
|
||||
secrets = inner.get("secrets")
|
||||
inner_fetched_at = inner.get("fetched_at")
|
||||
if not isinstance(secrets, dict) or not isinstance(inner_fetched_at, (int, float)):
|
||||
return None
|
||||
entry_age = time.time() - float(inner_fetched_at)
|
||||
if entry_age < 0 or entry_age > max_age_seconds:
|
||||
return None
|
||||
typed = {
|
||||
k: v for k, v in secrets.items()
|
||||
if isinstance(k, str) and isinstance(v, str)
|
||||
}
|
||||
return _CachedFetch(secrets=typed, fetched_at=float(inner_fetched_at))
|
||||
except Exception: # noqa: BLE001 — cache miss on parse/decrypt/I/O errors
|
||||
return None
|
||||
|
||||
|
||||
def fetch_bitwarden_secrets(
|
||||
*,
|
||||
access_token: str,
|
||||
@@ -501,8 +357,6 @@ def fetch_bitwarden_secrets(
|
||||
use_cache: bool = True,
|
||||
server_url: str = "",
|
||||
home_path: Optional[Path] = None,
|
||||
encrypted_cache_enabled: bool = False,
|
||||
encrypted_cache_max_stale_seconds: float = 0,
|
||||
) -> Tuple[Dict[str, str], List[str]]:
|
||||
"""Pull the secrets for ``project_id`` from Bitwarden Secrets Manager.
|
||||
|
||||
@@ -514,13 +368,12 @@ def fetch_bitwarden_secrets(
|
||||
(``https://vault.bitwarden.com``, US Cloud). This is plumbed into
|
||||
the subprocess as ``BWS_SERVER_URL``.
|
||||
|
||||
``cache_ttl_seconds`` controls the normal fresh cache. When
|
||||
``encrypted_cache_enabled`` is true, fresh cache entries are written as
|
||||
AES-GCM encrypted JSON instead of plaintext, and a last-good encrypted
|
||||
entry may be used after NETWORK/TIMEOUT failures for up to
|
||||
``encrypted_cache_max_stale_seconds``. This stale fallback is separate
|
||||
from the fresh-cache TTL so operators can set ``cache_ttl_seconds: 0``
|
||||
while still keeping an encrypted break-glass cache for offline startup.
|
||||
Caching is a two-layer LRU: an in-process dict (for hot-reload paths
|
||||
inside one process) and a disk-persisted JSON file under
|
||||
``<hermes_home>/cache/bws_cache.json`` (for back-to-back CLI invocations).
|
||||
Both share the same TTL. Pass ``home_path`` so disk cache lookups find
|
||||
the right directory in tests / non-standard installs; otherwise we fall
|
||||
back to ``$HERMES_HOME`` / ``~/.hermes``.
|
||||
|
||||
Raises :class:`RuntimeError` for fatal conditions (missing binary,
|
||||
auth failure, unparseable output). Callers in the env_loader path
|
||||
@@ -533,20 +386,12 @@ def fetch_bitwarden_secrets(
|
||||
raise RuntimeError("Bitwarden project_id is empty")
|
||||
|
||||
cache_key = (_token_fingerprint(access_token), project_id, server_url or "")
|
||||
if use_cache and cache_ttl_seconds > 0:
|
||||
if use_cache:
|
||||
cached = _CACHE.get(cache_key)
|
||||
if cached and cached.is_fresh(cache_ttl_seconds):
|
||||
return cached.secrets, []
|
||||
# L2: disk cache. ~5ms on cache hit vs ~380ms for `bws secret list`.
|
||||
if encrypted_cache_enabled:
|
||||
disk_cached = _read_encrypted_disk_cache(
|
||||
cache_key=cache_key,
|
||||
access_token=access_token,
|
||||
max_age_seconds=cache_ttl_seconds,
|
||||
home_path=home_path,
|
||||
)
|
||||
else:
|
||||
disk_cached = _DISK_CACHE.read(cache_key, cache_ttl_seconds, home_path)
|
||||
disk_cached = _DISK_CACHE.read(cache_key, cache_ttl_seconds, home_path)
|
||||
if disk_cached is not None:
|
||||
# Promote into in-process cache so subsequent fetches in the
|
||||
# same process skip the disk read too.
|
||||
@@ -562,107 +407,14 @@ def fetch_bitwarden_secrets(
|
||||
"`hermes secrets bitwarden setup`."
|
||||
)
|
||||
|
||||
try:
|
||||
secrets, warnings = _run_bws_list(bws, access_token, project_id, server_url)
|
||||
except RuntimeError as exc:
|
||||
# Live fetch failed. Fall back to a stale disk cache ONLY for
|
||||
# transport-level failures (network down, DNS error, transient BWS
|
||||
# outage / timeout) — never for AUTH_FAILED or a malformed-output
|
||||
# INTERNAL error, where serving old secrets would mask a real
|
||||
# config/credential problem the caller needs to see. Without this
|
||||
# fallback a fleet of bots sharing one BWS project all stop working
|
||||
# on a single network blip.
|
||||
#
|
||||
# Two fallback tiers share the transport-only gate:
|
||||
# * encrypted cache (opt-in) — AES-GCM payload keyed off the
|
||||
# bootstrap token, with its own max_stale_seconds window. When
|
||||
# enabled it is the ONLY fallback consulted: the whole point is
|
||||
# that the at-rest payload is never plaintext, so we don't
|
||||
# quietly serve the plaintext file alongside it.
|
||||
# * plaintext disk cache (default) — the ordinary DiskCache file.
|
||||
# `cache_ttl_seconds <= 0` means the caller opted out of caching
|
||||
# entirely (DiskCache.read/write both short-circuit on it) —
|
||||
# honor that on the fallback path too. `ttl_seconds=inf` on the
|
||||
# read bypasses freshness (we explicitly want a stale hit); the
|
||||
# caller's real TTL gates whether we even attempt the read.
|
||||
kind = _classify_bws_error(str(exc))
|
||||
if use_cache and kind in (ErrorKind.NETWORK, ErrorKind.TIMEOUT):
|
||||
if encrypted_cache_enabled:
|
||||
stale = _read_encrypted_disk_cache(
|
||||
cache_key=cache_key,
|
||||
access_token=access_token,
|
||||
max_age_seconds=encrypted_cache_max_stale_seconds,
|
||||
home_path=home_path,
|
||||
)
|
||||
if stale is not None:
|
||||
age = max(0.0, time.time() - stale.fetched_at)
|
||||
_CACHE[cache_key] = stale
|
||||
return stale.secrets, [
|
||||
f"bws live fetch failed ({exc}); falling back to "
|
||||
f"stale ENCRYPTED disk cache ({int(age)}s old)"
|
||||
]
|
||||
elif cache_ttl_seconds > 0:
|
||||
stale = _DISK_CACHE.read(cache_key, float("inf"), home_path)
|
||||
if stale is not None:
|
||||
age = max(0.0, time.time() - stale.fetched_at)
|
||||
_CACHE[cache_key] = stale
|
||||
return stale.secrets, [
|
||||
f"bws live fetch failed ({exc}); "
|
||||
f"falling back to stale disk cache ({int(age)}s old)"
|
||||
]
|
||||
raise
|
||||
secrets, warnings = _run_bws_list(bws, access_token, project_id, server_url)
|
||||
entry = _CachedFetch(secrets=secrets, fetched_at=time.time())
|
||||
_CACHE[cache_key] = entry
|
||||
if use_cache:
|
||||
if cache_ttl_seconds > 0:
|
||||
_CACHE[cache_key] = entry
|
||||
if encrypted_cache_enabled:
|
||||
# Encryption is the storage policy; max_stale_seconds only controls
|
||||
# whether an outage may consume the last-good entry. Never fall
|
||||
# back to the plaintext cache just because stale fallback is off.
|
||||
_write_encrypted_disk_cache(
|
||||
cache_key=cache_key,
|
||||
access_token=access_token,
|
||||
entry=entry,
|
||||
home_path=home_path,
|
||||
)
|
||||
elif cache_ttl_seconds > 0:
|
||||
_DISK_CACHE.write(cache_key, entry, cache_ttl_seconds, home_path)
|
||||
_DISK_CACHE.write(cache_key, entry, cache_ttl_seconds, home_path)
|
||||
return secrets, warnings
|
||||
|
||||
|
||||
def _summarize_bws_stderr(raw: str) -> str:
|
||||
"""Reduce a bws (Rust color-eyre) error dump to its cause line(s).
|
||||
|
||||
bws failures look like::
|
||||
|
||||
Error:
|
||||
0: Received error message from server: [400 Bad Request] {"error":"invalid_client"}
|
||||
|
||||
Location:
|
||||
crates/bws/src/main.rs:108
|
||||
...
|
||||
|
||||
Everything from ``Location:`` on is diagnostic noise for a Hermes
|
||||
user. Keep the numbered cause lines (joined), drop the rest, and
|
||||
fall back to the stripped raw text when the shape is unrecognized.
|
||||
"""
|
||||
text = raw.replace("\x1b", "").strip()
|
||||
if not text:
|
||||
return text
|
||||
causes: List[str] = []
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith(("Location:", "Backtrace omitted", "Run with ")):
|
||||
break
|
||||
if stripped in ("", "Error:"):
|
||||
continue
|
||||
# Cause lines are numbered "0: ...", "1: ..." — strip the index.
|
||||
stripped = re.sub(r"^\d+:\s*", "", stripped)
|
||||
if stripped:
|
||||
causes.append(stripped)
|
||||
return "; ".join(causes) if causes else text
|
||||
|
||||
|
||||
def _run_bws_list(
|
||||
bws: Path, access_token: str, project_id: str, server_url: str = ""
|
||||
) -> Tuple[Dict[str, str], List[str]]:
|
||||
@@ -696,11 +448,9 @@ def _run_bws_list(
|
||||
raise RuntimeError(f"failed to invoke bws: {exc}") from exc
|
||||
|
||||
if proc.returncode != 0:
|
||||
# bws writes auth/network errors to stderr as a Rust error-report
|
||||
# dump (color-eyre): an "Error:" header, indented cause lines, then
|
||||
# "Location:" / "Backtrace omitted" noise. Strip ANSI and boil it
|
||||
# down to the meaningful cause line(s) before surfacing.
|
||||
err = _summarize_bws_stderr(proc.stderr or proc.stdout or "")
|
||||
# bws writes auth/network errors to stderr in plain English.
|
||||
# Strip ANSI just in case and surface the first 200 chars.
|
||||
err = (proc.stderr or proc.stdout or "").strip().replace("\x1b", "")
|
||||
raise RuntimeError(
|
||||
f"bws exited {proc.returncode}: {err[:200]}"
|
||||
)
|
||||
@@ -752,8 +502,6 @@ def apply_bitwarden_secrets(
|
||||
auto_install: bool = True,
|
||||
server_url: str = "",
|
||||
home_path: Optional[Path] = None,
|
||||
encrypted_cache_enabled: bool = False,
|
||||
encrypted_cache_max_stale_seconds: float = 0,
|
||||
) -> FetchResult:
|
||||
"""Pull secrets from BSM and set them on ``os.environ``.
|
||||
|
||||
@@ -805,8 +553,6 @@ def apply_bitwarden_secrets(
|
||||
cache_ttl_seconds=cache_ttl_seconds,
|
||||
server_url=server_url,
|
||||
home_path=home_path,
|
||||
encrypted_cache_enabled=encrypted_cache_enabled,
|
||||
encrypted_cache_max_stale_seconds=encrypted_cache_max_stale_seconds,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
result.error = str(exc)
|
||||
@@ -876,16 +622,9 @@ class BitwardenSource(SecretSource):
|
||||
},
|
||||
"project_id": {"description": "BSM project UUID", "default": ""},
|
||||
"cache_ttl_seconds": {
|
||||
"description": "Fresh disk+memory cache TTL; 0 disables fresh-cache reuse",
|
||||
"description": "Disk+memory cache TTL; 0 disables",
|
||||
"default": 300,
|
||||
},
|
||||
"encrypted_cache": {
|
||||
"description": "Encrypted last-good cache for network/timeout fallback",
|
||||
"default": {
|
||||
"enabled": False,
|
||||
"max_stale_seconds": 0,
|
||||
},
|
||||
},
|
||||
"override_existing": {
|
||||
"description": "BSM values overwrite .env/shell values",
|
||||
"default": True,
|
||||
@@ -939,14 +678,6 @@ class BitwardenSource(SecretSource):
|
||||
except (TypeError, ValueError):
|
||||
ttl = 300.0
|
||||
|
||||
encrypted_cfg = cfg.get("encrypted_cache")
|
||||
encrypted_cfg = encrypted_cfg if isinstance(encrypted_cfg, dict) else {}
|
||||
encrypted_enabled = bool(encrypted_cfg.get("enabled", False))
|
||||
try:
|
||||
encrypted_max_stale = float(encrypted_cfg.get("max_stale_seconds", 0))
|
||||
except (TypeError, ValueError):
|
||||
encrypted_max_stale = 0.0
|
||||
|
||||
try:
|
||||
secrets, warnings = fetch_bitwarden_secrets(
|
||||
access_token=access_token,
|
||||
@@ -955,36 +686,16 @@ class BitwardenSource(SecretSource):
|
||||
cache_ttl_seconds=ttl,
|
||||
server_url=str(cfg.get("server_url", "") or "").strip(),
|
||||
home_path=home_path,
|
||||
encrypted_cache_enabled=encrypted_enabled,
|
||||
encrypted_cache_max_stale_seconds=encrypted_max_stale,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
result.error = str(exc)
|
||||
result.error_kind = _classify_bws_error(str(exc))
|
||||
if result.error_kind == ErrorKind.AUTH_FAILED:
|
||||
# Translate the raw OAuth reject into what it actually means
|
||||
# for the user before the mechanics.
|
||||
result.error = (
|
||||
"Bitwarden rejected the machine-account access token "
|
||||
f"({access_token_env}) — it was likely revoked, expired, "
|
||||
f"or belongs to another region. ({result.error})"
|
||||
)
|
||||
return result
|
||||
|
||||
result.secrets = secrets
|
||||
result.warnings.extend(warnings)
|
||||
return result
|
||||
|
||||
def remediation(self, kind, cfg: dict) -> str:
|
||||
if kind in (ErrorKind.AUTH_FAILED, ErrorKind.AUTH_EXPIRED):
|
||||
return (
|
||||
"Run `hermes secrets bitwarden token` to paste a fresh access "
|
||||
"token (create one in the Bitwarden web app: Secrets Manager → "
|
||||
"Machine accounts → Access tokens). Wrong region? Re-run "
|
||||
"`hermes secrets bitwarden setup` and pick EU/self-hosted."
|
||||
)
|
||||
return super().remediation(kind, cfg)
|
||||
|
||||
|
||||
def _classify_bws_error(message: str) -> ErrorKind:
|
||||
"""Best-effort mapping of bws failure text onto the shared taxonomy."""
|
||||
@@ -994,13 +705,7 @@ def _classify_bws_error(message: str) -> ErrorKind:
|
||||
if "binary not available" in lowered or "failed to invoke" in lowered:
|
||||
return ErrorKind.BINARY_MISSING
|
||||
if any(tok in lowered for tok in ("unauthorized", "invalid token",
|
||||
"access token", "401", "403",
|
||||
# The BSM identity endpoint rejects a
|
||||
# revoked/expired/deleted machine-account
|
||||
# token with an OAuth-style
|
||||
# `[400 Bad Request] {"error":"invalid_client"}`.
|
||||
"invalid_client", "invalid_grant",
|
||||
"400 bad request")):
|
||||
"access token", "401", "403")):
|
||||
return ErrorKind.AUTH_FAILED
|
||||
if any(tok in lowered for tok in ("network", "connection", "resolve",
|
||||
"download", "dns")):
|
||||
@@ -1013,22 +718,6 @@ def _classify_bws_error(message: str) -> ErrorKind:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def clear_caches(home_path: Optional[Path] = None) -> None:
|
||||
"""Drop in-process AND disk caches (plaintext and encrypted).
|
||||
|
||||
Used after a token rotation (`hermes secrets bitwarden token`) so the
|
||||
next startup fetches fresh with the new credential instead of serving
|
||||
a pull cached under the old token's fingerprint. The encrypted cache
|
||||
is keyed off the old token too, so it must go as well.
|
||||
"""
|
||||
_CACHE.clear()
|
||||
_DISK_CACHE.clear(home_path)
|
||||
try:
|
||||
_encrypted_disk_cache_path(home_path).unlink()
|
||||
except (FileNotFoundError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def _reset_cache_for_tests(home_path: Optional[Path] = None) -> None:
|
||||
"""Clear in-process AND disk caches.
|
||||
|
||||
@@ -1036,4 +725,5 @@ def _reset_cache_for_tests(home_path: Optional[Path] = None) -> None:
|
||||
Without it we fall back to the same default resolution as the cache
|
||||
writer itself.
|
||||
"""
|
||||
clear_caches(home_path)
|
||||
_CACHE.clear()
|
||||
_DISK_CACHE.clear(home_path)
|
||||
|
||||
@@ -1,488 +0,0 @@
|
||||
"""``command`` secret source — resolve secrets via a user-configured helper.
|
||||
|
||||
Ports the security semantics of the desktop app's TypeScript
|
||||
``CommandSecretsProvider`` (hermes-desktop ``src/main/secrets/commandProvider.ts``)
|
||||
to the Python agent. The helper command (e.g. ``keepassxc-cli``,
|
||||
``secret-tool``, or a script that cats a tmpfs env file) comes from
|
||||
``secrets.command`` in ``config.yaml`` — NEVER from ``.env``, which holds
|
||||
only secret values.
|
||||
|
||||
Security model (mirrors the TS provider line-for-line where it matters):
|
||||
|
||||
* The command string is the USER'S OWN configuration (same trust level as
|
||||
the ``.env`` file they control), so it is run via ``/bin/sh -c <command>``.
|
||||
* The requested key is passed to the child ONLY via the ``HERMES_SECRET_KEY``
|
||||
environment variable — it is NEVER interpolated into the shell string, so
|
||||
a hostile key name (e.g. ``"; rm -rf ~``) is inert data, not code.
|
||||
* Hard timeout (default 3s) + output cap (default 1 MiB); any failure
|
||||
(non-zero exit, timeout, spawn failure, oversized output) degrades to
|
||||
"no value" rather than raising.
|
||||
* Failures log ONLY structured fields (exit code / signal / errno) to
|
||||
stderr — never the command string, the helper's stderr, or any secret
|
||||
value. The helper's stderr is captured via a pipe and DISCARDED so its
|
||||
diagnostics (which can carry secret material) never reach our stderr.
|
||||
* The startup/apply path runs the helper exactly ONCE (with an empty
|
||||
``HERMES_SECRET_KEY``) — it is never called per-key in a loop, so a
|
||||
helper that blocks (e.g. on a vault unlock prompt) can't be spawned
|
||||
dozens of times.
|
||||
* PLATFORM: the provider is POSIX-only (needs ``/bin/sh``). On Windows it
|
||||
degrades to an empty result with a warning; Windows users stay on the
|
||||
default ``env`` provider.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import signal as _signal
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
# Reuse the exact result shape the bitwarden source returns so
|
||||
# hermes_cli.env_loader can consume both providers identically.
|
||||
from agent.secret_sources.base import ErrorKind, SecretSource
|
||||
from agent.secret_sources.bitwarden import FetchResult
|
||||
|
||||
__all__ = [
|
||||
"FetchResult",
|
||||
"apply_command_secrets",
|
||||
"get_command_secret",
|
||||
"list_command_secrets",
|
||||
"parse_secret_output",
|
||||
"unquote_dotenv_value",
|
||||
]
|
||||
|
||||
# Hard cap so a hung helper can never wedge startup. Kept deliberately
|
||||
# TIGHT (3s) — a configured helper MUST be fast and NON-INTERACTIVE
|
||||
# (e.g. `keepassxc-cli` against an already-unlocked DB, `secret-tool
|
||||
# lookup`, or `cat`-ing a tmpfs env file), NOT something that prompts
|
||||
# for a touch/PIN.
|
||||
_COMMAND_TIMEOUT_SECONDS = 3.0
|
||||
# Defensive cap on helper output (1 MiB) — a misbehaving command can't OOM us.
|
||||
_MAX_OUTPUT_BYTES = 1024 * 1024
|
||||
|
||||
# A line is treated as a KEY=VALUE pair only when it matches an env-key
|
||||
# shape before the '='. Anchored; `.` does not cross newlines, so a
|
||||
# multi-line blob never matches as a single "env-shaped" value.
|
||||
_ENV_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
|
||||
|
||||
|
||||
def _is_windows() -> bool:
|
||||
return os.name == "nt" or platform.system() == "Windows"
|
||||
|
||||
|
||||
def unquote_dotenv_value(raw: str) -> str:
|
||||
"""Strip a single layer of matching surrounding quotes from a dotenv value.
|
||||
|
||||
Requires length >= 2 so a lone quote (``"``) is left intact rather than
|
||||
collapsing to empty, and ``""``/``''`` correctly yield an empty string.
|
||||
Shared by the single-key parser and the list path so both unquote
|
||||
identically.
|
||||
"""
|
||||
t = raw.strip()
|
||||
if len(t) >= 2 and (
|
||||
(t.startswith('"') and t.endswith('"'))
|
||||
or (t.startswith("'") and t.endswith("'"))
|
||||
):
|
||||
return t[1:-1]
|
||||
return t
|
||||
|
||||
|
||||
def parse_secret_output(stdout: str, wanted_key: str) -> Optional[str]:
|
||||
"""Parse a secret-fetch helper's stdout. Supports BOTH shapes:
|
||||
|
||||
* a bare value (single secret): the whole trimmed stdout is the value.
|
||||
* a dotenv blob (KEY=VALUE lines): parse them and return the entry for
|
||||
``wanted_key``.
|
||||
|
||||
Mirrors the TS ``parseSecretOutput`` exactly, including the cross-key
|
||||
misroute guard and the base64-padding disambiguation.
|
||||
"""
|
||||
text = stdout.replace("\r\n", "\n")
|
||||
lines = text.split("\n")
|
||||
|
||||
# 1. Exact dotenv match wins: scan for a `wanted_key=...` line. This
|
||||
# is deterministic and never returns another key's value.
|
||||
dotenv_lines = [
|
||||
line
|
||||
for line in (raw.strip() for raw in lines)
|
||||
if line and not line.startswith("#") and _ENV_LINE.match(line)
|
||||
]
|
||||
for line in dotenv_lines:
|
||||
m = _ENV_LINE.match(line)
|
||||
assert m is not None # filtered above
|
||||
if m.group(1) == wanted_key:
|
||||
value = unquote_dotenv_value(m.group(2))
|
||||
# Whitespace-only (e.g. a quoted `K=" "` placeholder) is "no
|
||||
# value": it would otherwise flow into an Authorization header
|
||||
# → guaranteed 401.
|
||||
return value if value.strip() != "" else None
|
||||
|
||||
# 2. The output is a multi-key dotenv dump that does NOT contain the
|
||||
# wanted key → None, rather than mis-returning an unrelated line as
|
||||
# a bare value. Only >=2 env-shaped lines count as a dump: a SINGLE
|
||||
# non-matching env-shaped line falls through to the bare-value
|
||||
# branch, because a bare secret can itself match the KEY=VALUE shape
|
||||
# (e.g. base64 with '=' padding, "dGVzdA==") and must not be
|
||||
# misclassified as a dump.
|
||||
if len(dotenv_lines) > 1:
|
||||
return None
|
||||
|
||||
# 3. Otherwise treat the whole output as a single bare value (a per-key
|
||||
# helper that printed just the secret). Trim first so whitespace-only
|
||||
# output (a ' '/'\t' placeholder entry) resolves to None, never a "key".
|
||||
value = text.strip()
|
||||
if value == "":
|
||||
return None
|
||||
|
||||
# SECURITY (S2): a single env-shaped line for a DIFFERENT key must not
|
||||
# be returned as the wanted secret. A sloppy helper (e.g. `head -1
|
||||
# env-file`, or a grep that matched the wrong line) emitting
|
||||
# `OTHER_KEY=realvalue` would otherwise flow — key name, '=' and the
|
||||
# OTHER key's value — into an Authorization header sent to the WANTED
|
||||
# key's endpoint: cross-provider credential leakage, not just a 401.
|
||||
# Disambiguation from a bare base64 secret: base64 padding only ever
|
||||
# produces an env-shaped line whose "value" part is empty or all '='
|
||||
# (`dGVzdA==` → key `dGVzdA`, value `=`), so a non-trivial value part
|
||||
# after a non-matching key means a misrouted dotenv entry → None.
|
||||
env_shaped = _ENV_LINE.match(value)
|
||||
if (
|
||||
env_shaped
|
||||
and env_shaped.group(1) != wanted_key
|
||||
and re.fullmatch(r"=*", env_shaped.group(2).strip()) is None
|
||||
):
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _run_helper(
|
||||
command: str,
|
||||
secret_key: str,
|
||||
timeout_seconds: float,
|
||||
max_output_bytes: int,
|
||||
) -> Optional[str]:
|
||||
"""Run the helper via ``/bin/sh -c`` and return its stdout, or None.
|
||||
|
||||
The key is passed as DATA via ``HERMES_SECRET_KEY`` — never interpolated
|
||||
into the command string. Both stdout and stderr are captured via pipes
|
||||
(never inherited); stderr is discarded. Any failure logs structured
|
||||
fields only and returns None — never raises.
|
||||
"""
|
||||
if _is_windows():
|
||||
print(
|
||||
"[secrets:command] the 'command' provider is POSIX-only "
|
||||
"(needs /bin/sh); resolving no value on Windows",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
env = os.environ.copy()
|
||||
env["HERMES_SECRET_KEY"] = secret_key
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen( # noqa: S602 — command is the user's own config
|
||||
["/bin/sh", "-c", command],
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, # captured and DISCARDED — never inherited
|
||||
start_new_session=True, # so the hard timeout can kill the whole group
|
||||
)
|
||||
except OSError as exc:
|
||||
print(
|
||||
f"[secrets:command] helper failed to spawn; resolving no value: "
|
||||
f"errno={exc.errno}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
stdout_bytes, _stderr_discarded = proc.communicate(timeout=timeout_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Hard timeout: kill the whole process group (a helper script may
|
||||
# have forked children that would otherwise keep the pipe open).
|
||||
# POSIX-only by construction: _run_helper early-returns on Windows
|
||||
# before ever spawning, so this line can't execute there.
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL) # windows-footgun: ok
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
proc.kill()
|
||||
try:
|
||||
proc.communicate(timeout=1.0)
|
||||
except (subprocess.TimeoutExpired, ValueError, OSError):
|
||||
pass
|
||||
print(
|
||||
f"[secrets:command] helper timed out after {timeout_seconds:g}s; "
|
||||
f"resolving no value",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
if proc.returncode != 0:
|
||||
# Structured fields ONLY — never the command string or the helper's
|
||||
# stderr (either can carry secret material).
|
||||
if proc.returncode < 0:
|
||||
try:
|
||||
sig = _signal.Signals(-proc.returncode).name
|
||||
except ValueError:
|
||||
sig = str(-proc.returncode)
|
||||
code, signame = "?", sig
|
||||
else:
|
||||
code, signame = str(proc.returncode), "none"
|
||||
print(
|
||||
f"[secrets:command] helper failed; resolving no value: "
|
||||
f"code={code} signal={signame}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
if len(stdout_bytes) > max_output_bytes:
|
||||
print(
|
||||
f"[secrets:command] helper output exceeded the "
|
||||
f"{max_output_bytes}-byte cap; resolving no value",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
return stdout_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _parse_dotenv_map(stdout: str) -> Dict[str, str]:
|
||||
"""Parse a KEY=VALUE blob into a map (the list/enumerate path).
|
||||
|
||||
Mirrors the TS ``list()``: only env-shaped lines contribute; comments
|
||||
and non-matching lines are skipped. A bare-value helper yields ``{}``
|
||||
— per-key resolution via :func:`get_command_secret` still works.
|
||||
"""
|
||||
out: Dict[str, str] = {}
|
||||
for raw in stdout.replace("\r\n", "\n").split("\n"):
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
m = _ENV_LINE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
out[m.group(1)] = unquote_dotenv_value(m.group(2))
|
||||
return out
|
||||
|
||||
|
||||
def get_command_secret(
|
||||
*,
|
||||
command: str,
|
||||
key: str,
|
||||
timeout_seconds: float = _COMMAND_TIMEOUT_SECONDS,
|
||||
max_output_bytes: int = _MAX_OUTPUT_BYTES,
|
||||
) -> Optional[str]:
|
||||
"""Resolve a single secret by running the helper with the key in
|
||||
``HERMES_SECRET_KEY``. Returns None on any failure — never raises."""
|
||||
command = (command or "").strip()
|
||||
if not command:
|
||||
return None
|
||||
stdout = _run_helper(command, key, timeout_seconds, max_output_bytes)
|
||||
if stdout is None:
|
||||
return None
|
||||
return parse_secret_output(stdout, key)
|
||||
|
||||
|
||||
def list_command_secrets(
|
||||
*,
|
||||
command: str,
|
||||
timeout_seconds: float = _COMMAND_TIMEOUT_SECONDS,
|
||||
max_output_bytes: int = _MAX_OUTPUT_BYTES,
|
||||
) -> Dict[str, str]:
|
||||
"""Enumerate secrets by running the helper ONCE with an empty key.
|
||||
|
||||
Returns the dotenv map ONLY when the helper emits a KEY=VALUE blob;
|
||||
a bare-value helper returns ``{}``. Never raises.
|
||||
"""
|
||||
command = (command or "").strip()
|
||||
if not command:
|
||||
return {}
|
||||
stdout = _run_helper(command, "", timeout_seconds, max_output_bytes)
|
||||
if stdout is None:
|
||||
return {}
|
||||
return _parse_dotenv_map(stdout)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point — called from hermes_cli.env_loader
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def apply_command_secrets(
|
||||
*,
|
||||
command: str,
|
||||
override_existing: bool = False,
|
||||
timeout_seconds: float = _COMMAND_TIMEOUT_SECONDS,
|
||||
max_output_bytes: int = _MAX_OUTPUT_BYTES,
|
||||
home_path: Optional[Path] = None,
|
||||
) -> FetchResult:
|
||||
"""Run the helper once at startup and set its KEY=VALUE output on
|
||||
``os.environ``.
|
||||
|
||||
LEGACY shim retained for API symmetry with ``apply_bitwarden_secrets``;
|
||||
the startup path goes through :class:`CommandSource` + the registry
|
||||
orchestrator instead (which owns precedence and the environ writes).
|
||||
"""
|
||||
result = FetchResult()
|
||||
|
||||
command = (command or "").strip()
|
||||
if not command:
|
||||
result.error = (
|
||||
"secrets.command.enabled is true but secrets.command.command is "
|
||||
"empty. Set the helper command in config.yaml."
|
||||
)
|
||||
return result
|
||||
|
||||
if _is_windows():
|
||||
result.warnings.append(
|
||||
"the 'command' secret source is POSIX-only (needs /bin/sh); "
|
||||
"skipping on Windows"
|
||||
)
|
||||
return result
|
||||
|
||||
# The list/enumerate path: run the helper exactly ONCE with an empty
|
||||
# HERMES_SECRET_KEY and parse its stdout as a dotenv blob.
|
||||
stdout = _run_helper(command, "", timeout_seconds, max_output_bytes)
|
||||
if stdout is None:
|
||||
# _run_helper already logged structured fields to stderr.
|
||||
result.warnings.append(
|
||||
"helper command failed at startup; no secrets applied "
|
||||
"(process env / .env values remain in effect)"
|
||||
)
|
||||
return result
|
||||
|
||||
secrets = _parse_dotenv_map(stdout)
|
||||
result.secrets = secrets
|
||||
if not secrets:
|
||||
result.warnings.append(
|
||||
"helper output was not a KEY=VALUE map; nothing applied at "
|
||||
"startup (a bare-value helper still resolves single keys on demand)"
|
||||
)
|
||||
return result
|
||||
|
||||
for key, value in secrets.items():
|
||||
if value.strip() == "":
|
||||
# Whitespace-only placeholder entries are "no value" — applying
|
||||
# them would flow into an Authorization header → guaranteed 401.
|
||||
result.skipped.append(key)
|
||||
continue
|
||||
if not override_existing and os.environ.get(key):
|
||||
# Process env / .env win — same precedence as bitwarden.
|
||||
result.skipped.append(key)
|
||||
continue
|
||||
os.environ[key] = value
|
||||
result.applied.append(key)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SecretSource adapter — the registry-facing wrapper around this module.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CommandSource(SecretSource):
|
||||
"""User-configured helper command as a registered secret source.
|
||||
|
||||
Composes with the other sources (Bitwarden, 1Password, plugins) through
|
||||
the ``apply_all()`` orchestrator — enable any combination simultaneously;
|
||||
there is deliberately NO single-provider selector. ``fetch()`` only
|
||||
fetches: precedence, ``override_existing`` semantics, conflict warnings,
|
||||
and the ``os.environ`` writes are the orchestrator's job.
|
||||
|
||||
Bulk shape: the helper enumerates a KEY=VALUE blob in one run. Config::
|
||||
|
||||
secrets:
|
||||
command:
|
||||
enabled: true
|
||||
command: "cat /run/user/1000/hermes-secrets.env"
|
||||
# or per-vault CLIs: keepassxc-cli / secret-tool / pass / gpg —
|
||||
# anything fast and NON-interactive.
|
||||
"""
|
||||
|
||||
name = "command"
|
||||
label = "Command helper"
|
||||
shape = "bulk"
|
||||
|
||||
def config_schema(self) -> dict:
|
||||
return {
|
||||
"enabled": {"description": "Master switch", "default": False},
|
||||
"command": {
|
||||
"description": "Helper run via /bin/sh -c; must print a "
|
||||
"KEY=VALUE blob on stdout",
|
||||
"default": "",
|
||||
},
|
||||
"helper_timeout_seconds": {
|
||||
"description": "Hard timeout for one helper run",
|
||||
"default": _COMMAND_TIMEOUT_SECONDS,
|
||||
},
|
||||
"override_existing": {
|
||||
"description": "Helper values overwrite .env/shell values",
|
||||
"default": False,
|
||||
},
|
||||
}
|
||||
|
||||
def fetch(self, cfg: dict, home_path: Path) -> FetchResult:
|
||||
cfg = cfg if isinstance(cfg, dict) else {}
|
||||
result = FetchResult()
|
||||
|
||||
command = str(cfg.get("command") or "").strip()
|
||||
if not command:
|
||||
result.error = (
|
||||
"secrets.command.enabled is true but secrets.command.command "
|
||||
"is empty. Set the helper command in config.yaml."
|
||||
)
|
||||
result.error_kind = ErrorKind.NOT_CONFIGURED
|
||||
return result
|
||||
|
||||
if _is_windows():
|
||||
result.error = (
|
||||
"the 'command' secret source is POSIX-only (needs /bin/sh); "
|
||||
"skipping on Windows"
|
||||
)
|
||||
result.error_kind = ErrorKind.NOT_CONFIGURED
|
||||
return result
|
||||
|
||||
try:
|
||||
timeout = float(cfg.get("helper_timeout_seconds",
|
||||
_COMMAND_TIMEOUT_SECONDS))
|
||||
except (TypeError, ValueError):
|
||||
timeout = _COMMAND_TIMEOUT_SECONDS
|
||||
|
||||
stdout = _run_helper(command, "", timeout, _MAX_OUTPUT_BYTES)
|
||||
if stdout is None:
|
||||
# _run_helper already logged structured fields to stderr.
|
||||
result.error = (
|
||||
"helper command failed (see structured fields above); "
|
||||
"no secrets applied"
|
||||
)
|
||||
result.error_kind = ErrorKind.INTERNAL
|
||||
return result
|
||||
|
||||
secrets = _parse_dotenv_map(stdout)
|
||||
if not secrets:
|
||||
result.warnings.append(
|
||||
"helper output was not a KEY=VALUE map; nothing to apply"
|
||||
)
|
||||
return result
|
||||
|
||||
result.secrets = secrets
|
||||
return result
|
||||
|
||||
def remediation(self, kind, cfg: dict) -> str:
|
||||
if kind == ErrorKind.NOT_CONFIGURED:
|
||||
return (
|
||||
"Set secrets.command.command in config.yaml to a fast, "
|
||||
"non-interactive helper that prints KEY=VALUE lines."
|
||||
)
|
||||
if kind == ErrorKind.INTERNAL:
|
||||
return (
|
||||
"Run the helper manually in a shell to see its real error — "
|
||||
"Hermes discards helper stderr so diagnostics can't leak "
|
||||
"secret material."
|
||||
)
|
||||
return super().remediation(kind, cfg)
|
||||
@@ -98,9 +98,6 @@ _OP_ENV_ALLOWLIST = (
|
||||
"OP_ACCOUNT",
|
||||
"OP_CONNECT_HOST",
|
||||
"OP_CONNECT_TOKEN",
|
||||
# Lets a user skip op's desktop-app integration probe (which can hang with
|
||||
# no timeout on a wedged desktop container) and go straight to token auth.
|
||||
"OP_LOAD_DESKTOP_APP_SETTINGS",
|
||||
)
|
||||
|
||||
|
||||
@@ -175,19 +172,16 @@ def _validate_references(
|
||||
def _auth_fingerprint(token_env: str) -> str:
|
||||
"""SHA-256 prefix over the auth material `op` would use.
|
||||
|
||||
Folds in the service-account token, ``OP_ACCOUNT``, the 1Password Connect
|
||||
``OP_CONNECT_HOST``/``OP_CONNECT_TOKEN``, and *all* ``OP_SESSION_*`` vars
|
||||
(the names `op` actually exports for interactive sessions —
|
||||
``OP_SESSION_<account_shorthand>``). Signing out and into a different
|
||||
identity therefore changes the cache key, so a value cached under a
|
||||
previous identity is never served under a new one. Never logged or
|
||||
Folds in the service-account token, ``OP_ACCOUNT``, and *all*
|
||||
``OP_SESSION_*`` vars (the names `op` actually exports for interactive
|
||||
sessions — ``OP_SESSION_<account_shorthand>``). Signing out and into a
|
||||
different identity therefore changes the cache key, so a value cached under
|
||||
a previous identity is never served under a new one. Never logged or
|
||||
displayed; the raw token never leaves this hash.
|
||||
"""
|
||||
parts: List[str] = [
|
||||
f"token={os.environ.get(token_env, '')}",
|
||||
f"account={os.environ.get('OP_ACCOUNT', '')}",
|
||||
f"connect_host={os.environ.get('OP_CONNECT_HOST', '')}",
|
||||
f"connect_token={os.environ.get('OP_CONNECT_TOKEN', '')}",
|
||||
]
|
||||
for key in sorted(os.environ):
|
||||
if key.startswith("OP_SESSION_"):
|
||||
@@ -613,24 +607,6 @@ class OnePasswordSource(SecretSource):
|
||||
result.warnings.extend(fetch_warnings)
|
||||
return result
|
||||
|
||||
def remediation(self, kind, cfg: dict) -> str:
|
||||
if kind in (ErrorKind.AUTH_FAILED, ErrorKind.AUTH_EXPIRED):
|
||||
token_env = _DEFAULT_TOKEN_ENV
|
||||
if isinstance(cfg, dict):
|
||||
token_env = str(cfg.get("service_account_token_env") or token_env)
|
||||
return (
|
||||
"Run `hermes secrets onepassword token` to paste a fresh "
|
||||
f"service-account token ({token_env}), or `op signin` for an "
|
||||
"interactive session."
|
||||
)
|
||||
if kind == ErrorKind.BINARY_MISSING:
|
||||
return (
|
||||
"Install the 1Password CLI "
|
||||
"(https://developer.1password.com/docs/cli/get-started/) or "
|
||||
"set secrets.onepassword.binary_path."
|
||||
)
|
||||
return super().remediation(kind, cfg)
|
||||
|
||||
|
||||
def _classify_op_error(message: str) -> ErrorKind:
|
||||
"""Best-effort mapping of op failure text onto the shared taxonomy."""
|
||||
@@ -657,21 +633,11 @@ def _classify_op_error(message: str) -> ErrorKind:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def clear_caches(home_path: Optional[Path] = None) -> None:
|
||||
"""Drop in-process AND disk caches.
|
||||
|
||||
Used after a token rotation (`hermes secrets onepassword token`) so
|
||||
the next startup resolves fresh with the new credential instead of
|
||||
serving values cached under the old token's fingerprint.
|
||||
"""
|
||||
_CACHE.clear()
|
||||
_DISK_CACHE.clear(home_path)
|
||||
|
||||
|
||||
def _reset_cache_for_tests(home_path: Optional[Path] = None) -> None:
|
||||
"""Clear in-process AND disk caches.
|
||||
|
||||
Tests can pass ``home_path`` to scope the disk cleanup to a tmpdir.
|
||||
Without it we fall back to the same default resolution as the writer.
|
||||
"""
|
||||
clear_caches(home_path)
|
||||
_CACHE.clear()
|
||||
_DISK_CACHE.clear(home_path)
|
||||
|
||||
@@ -29,7 +29,6 @@ from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
@@ -175,13 +174,6 @@ def _ensure_builtin_sources() -> None:
|
||||
except Exception: # noqa: BLE001 — never block startup
|
||||
logger.warning("Failed to register bundled 1Password secret source",
|
||||
exc_info=True)
|
||||
try:
|
||||
from agent.secret_sources.command import CommandSource
|
||||
|
||||
register_source(CommandSource())
|
||||
except Exception: # noqa: BLE001 — never block startup
|
||||
logger.warning("Failed to register bundled command secret source",
|
||||
exc_info=True)
|
||||
|
||||
|
||||
def _reset_registry_for_tests() -> None:
|
||||
@@ -283,43 +275,6 @@ def _ordered_enabled_sources(secrets_cfg: dict) -> List[SecretSource]:
|
||||
return enabled
|
||||
|
||||
|
||||
def _active_profile_name(home_path: Optional[Path]) -> str:
|
||||
"""Best-effort active profile name for profile-scoped secret aliases.
|
||||
|
||||
A named profile's HERMES_HOME is ``~/.hermes/profiles/<name>``; the
|
||||
default profile (``~/.hermes``) returns "".
|
||||
"""
|
||||
if home_path is not None:
|
||||
resolved = Path(home_path)
|
||||
if resolved.parent.name == "profiles" and resolved.name:
|
||||
return resolved.name
|
||||
for env_name in ("HERMES_PROFILE_NAME", "HERMES_PROFILE"):
|
||||
value = os.environ.get(env_name, "").strip()
|
||||
if value and value != "default":
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
# Only credential-shaped names get auto-aliased — a random profile-suffixed
|
||||
# var should not silently hydrate an unsuffixed name.
|
||||
_ALIAS_SUFFIXES = ("_API_KEY", "_TOKEN", "_SECRET", "_KEY", "_PASSWORD")
|
||||
|
||||
|
||||
def _profile_alias_target(var: str, profile: str) -> Optional[str]:
|
||||
"""Map ``FOO_<PROFILE>`` to ``FOO`` for the active profile when safe."""
|
||||
if not profile:
|
||||
return None
|
||||
suffix = "_" + profile.replace("-", "_").upper()
|
||||
if not var.endswith(suffix):
|
||||
return None
|
||||
alias = var[: -len(suffix)]
|
||||
if not alias or not is_valid_env_name(alias):
|
||||
return None
|
||||
if not any(alias.endswith(s) for s in _ALIAS_SUFFIXES):
|
||||
return None
|
||||
return alias
|
||||
|
||||
|
||||
def apply_all(secrets_cfg: dict, home_path: Path,
|
||||
environ: Optional[Dict[str, str]] = None) -> ApplyReport:
|
||||
"""Fetch from every enabled source and apply the merged result to env.
|
||||
@@ -328,24 +283,14 @@ def apply_all(secrets_cfg: dict, home_path: Path,
|
||||
|
||||
Precedence per env var (most-specific intent wins):
|
||||
|
||||
1. ``secrets.preserve_existing`` names — a pre-existing env value always
|
||||
wins for these, even against a source with ``override_existing: true``
|
||||
(escape hatch for profile-local platform secrets, #58073).
|
||||
2. Pre-existing env (.env / shell) — unless the winning source has
|
||||
1. Pre-existing env (.env / shell) — unless the winning source has
|
||||
``override_existing: true``.
|
||||
3. Mapped sources, in configured order.
|
||||
4. Bulk sources, in configured order.
|
||||
2. Mapped sources, in configured order.
|
||||
3. Bulk sources, in configured order.
|
||||
|
||||
First claim wins. A later source that also carries the var gets a
|
||||
``skipped_claimed`` entry and a conflict warning — never a silent
|
||||
clobber, and ``override_existing`` never applies across sources.
|
||||
|
||||
Profile aliasing (#51447): when running under a named profile, an applied
|
||||
var ``FOO_<PROFILE>`` (credential-shaped suffixes only) also hydrates the
|
||||
canonical ``FOO`` so platform adapters and plugins that read fixed env
|
||||
names see the profile's value. The alias obeys the same protected /
|
||||
preserve / claimed / override guards and is disabled with
|
||||
``secrets.profile_alias: false``.
|
||||
"""
|
||||
import os as _os
|
||||
|
||||
@@ -357,14 +302,6 @@ def apply_all(secrets_cfg: dict, home_path: Path,
|
||||
if not enabled:
|
||||
return report
|
||||
|
||||
preserve_raw = secrets_cfg.get("preserve_existing")
|
||||
preserve: frozenset = frozenset(
|
||||
n.strip() for n in preserve_raw if isinstance(n, str) and n.strip()
|
||||
) if isinstance(preserve_raw, list) else frozenset()
|
||||
|
||||
alias_enabled = bool(secrets_cfg.get("profile_alias", True))
|
||||
profile = _active_profile_name(home_path) if alias_enabled else ""
|
||||
|
||||
# Mapped sources outrank bulk sources regardless of list order:
|
||||
# an explicit VAR→ref binding is stronger intent than a project dump.
|
||||
ordered = ([s for s in enabled if s.shape == "mapped"]
|
||||
@@ -384,15 +321,6 @@ def apply_all(secrets_cfg: dict, home_path: Path,
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# Every var any source supplies directly — an alias never shadows a
|
||||
# var that some source will (or tried to) claim by its real name.
|
||||
supplied_directly: set = set()
|
||||
for _, _, result in fetches:
|
||||
if result.ok:
|
||||
supplied_directly.update(
|
||||
v for v in result.secrets if isinstance(v, str)
|
||||
)
|
||||
|
||||
# Apply phase — sequential, first-wins, fully attributed.
|
||||
claimed: Dict[str, str] = {} # var → source name that won it
|
||||
for source, cfg, result in fetches:
|
||||
@@ -408,14 +336,15 @@ def apply_all(secrets_cfg: dict, home_path: Path,
|
||||
except Exception: # noqa: BLE001
|
||||
override = False
|
||||
|
||||
def _try_apply(var: str, value: str, *, is_alias: bool = False) -> bool:
|
||||
"""Apply one var through the shared guard chain. True = applied."""
|
||||
for var, value in result.secrets.items():
|
||||
if not isinstance(var, str) or not isinstance(value, str):
|
||||
continue
|
||||
if not is_valid_env_name(var):
|
||||
sr.skipped_invalid.append(var)
|
||||
return False
|
||||
continue
|
||||
if var in protected:
|
||||
sr.skipped_protected.append(var)
|
||||
return False
|
||||
continue
|
||||
if var in claimed:
|
||||
sr.skipped_claimed.append(var)
|
||||
report.conflicts.append(
|
||||
@@ -423,14 +352,11 @@ def apply_all(secrets_cfg: dict, home_path: Path,
|
||||
f"{source.name} also supplies it (first source wins — "
|
||||
"remove one binding or reorder secrets.sources)"
|
||||
)
|
||||
return False
|
||||
continue
|
||||
existed = bool(env.get(var))
|
||||
if existed and var in preserve:
|
||||
sr.skipped_existing.append(var)
|
||||
return False
|
||||
if existed and not override:
|
||||
sr.skipped_existing.append(var)
|
||||
return False
|
||||
continue
|
||||
env[var] = value
|
||||
claimed[var] = source.name
|
||||
sr.applied.append(var)
|
||||
@@ -440,21 +366,5 @@ def apply_all(secrets_cfg: dict, home_path: Path,
|
||||
shape=source.shape,
|
||||
overrode_env=existed,
|
||||
)
|
||||
return True
|
||||
|
||||
for var, value in result.secrets.items():
|
||||
if not isinstance(var, str) or not isinstance(value, str):
|
||||
continue
|
||||
applied = _try_apply(var, value)
|
||||
|
||||
if not applied or not profile:
|
||||
continue
|
||||
alias = _profile_alias_target(var, profile)
|
||||
if alias and alias not in supplied_directly and alias not in claimed:
|
||||
if _try_apply(alias, value, is_alias=True):
|
||||
result.warnings.append(
|
||||
f"applied profile-scoped {var} as {alias} "
|
||||
f"(active profile {profile!r})"
|
||||
)
|
||||
|
||||
return report
|
||||
|
||||
@@ -300,22 +300,14 @@ def build_subscription_state(*, timeout: float = 15.0) -> SubscriptionState:
|
||||
return subscription_state_from_payload(payload, portal_url=portal_url)
|
||||
|
||||
|
||||
def subscription_manage_url(
|
||||
state: SubscriptionState, tier_id: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""Build ``{portal_origin}/manage-subscription?org_id=<id>[&plan=<tier_id>]``.
|
||||
def subscription_manage_url(state: SubscriptionState) -> Optional[str]:
|
||||
"""Build ``{portal_origin}/manage-subscription?org_id=<id>`` from a state.
|
||||
|
||||
Mirrors the TUI's ``buildManageUrl`` (``subscription.ts``): the deep-link
|
||||
target is NAS's OWN ``/manage-subscription`` page (NOT the Stripe Billing
|
||||
Portal — decided Jun 23), which routes upgrade→Checkout / downgrade→scheduled
|
||||
internally. ``org_id`` pins the page to the right account in multi-org
|
||||
situations. Returns ``None`` when no portal URL is resolvable.
|
||||
|
||||
``tier_id`` (the stable ``tiers[]`` id, never a name/slug) is appended as
|
||||
``plan=`` so the portal preselects the picked plan — only for a NEW
|
||||
subscription / upgrade the user chose. The portal validates it and simply
|
||||
ignores an unknown tier, so the CLI appends unconditionally when a tier was
|
||||
picked (parity with the TUI's ``?plan=``).
|
||||
"""
|
||||
from urllib.parse import urlencode, urlsplit, urlunsplit
|
||||
|
||||
@@ -327,91 +319,13 @@ def subscription_manage_url(
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if parts.scheme not in ("http", "https") or not parts.netloc:
|
||||
if not parts.scheme or not parts.netloc:
|
||||
return None
|
||||
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
# Preserve unrelated portal query params; org_id / plan are contract-owned
|
||||
# (org_id before plan — insertion order is the emitted query order).
|
||||
params = dict(parse_qsl(parts.query, keep_blank_values=True))
|
||||
params.pop("org_id", None)
|
||||
params.pop("plan", None)
|
||||
if state.org_id:
|
||||
params["org_id"] = state.org_id
|
||||
if tier_id:
|
||||
params["plan"] = tier_id
|
||||
query = urlencode(params)
|
||||
query = urlencode({"org_id": state.org_id}) if state.org_id else ""
|
||||
return urlunsplit((parts.scheme, parts.netloc, "/manage-subscription", query, ""))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Shared plan-catalog helpers (consumed by the CLI Free catalog + paid picker)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _format_dollars_grouped(value: Optional[Decimal]) -> str:
|
||||
"""``$1,000`` / ``$1,234.50`` — the whole-vs-fractional rule of
|
||||
``billing_view.format_money`` but thousands-grouped, matching the TUI's
|
||||
``toLocaleString('en-US')``.
|
||||
|
||||
The shared ``format_money`` is intentionally ungrouped (and asserted so across
|
||||
other surfaces), so plan-catalog rows group locally to mirror the TUI.
|
||||
"""
|
||||
if value is None:
|
||||
return "—"
|
||||
if value == value.to_integral_value():
|
||||
return f"${format(value.to_integral_value(), ',f')}"
|
||||
return f"${format(value.quantize(Decimal('0.01')), ',f')}"
|
||||
|
||||
|
||||
def selectable_tiers(state: SubscriptionState) -> list[SubscriptionTier]:
|
||||
"""Enabled paid tiers other than the current plan, cheapest first.
|
||||
|
||||
One derivation shared by the CLI Free catalog and the paid change picker:
|
||||
``is_enabled and not is_current and tier_order > 0`` (free / no-sub excluded —
|
||||
dropping to free is a cancellation), sorted by ``tier_order``.
|
||||
"""
|
||||
return sorted(
|
||||
(
|
||||
t
|
||||
for t in (state.tiers or ())
|
||||
if t.is_enabled and not t.is_current and (t.tier_order or 0) > 0
|
||||
),
|
||||
key=lambda t: t.tier_order or 0,
|
||||
)
|
||||
|
||||
|
||||
def format_tier_row(tier: SubscriptionTier) -> str:
|
||||
"""``name · $X/mo[ · $Y credits/mo]`` — the shared plan-catalog row.
|
||||
|
||||
Mirrors the TUI Free rows (``subscriptionOverlay.tsx``): thousands-grouped
|
||||
money, and the ``$Y credits/mo`` suffix ONLY when monthly credits are present
|
||||
and > 0 (a ``None`` / zero-credits tier hides it — never ``· — credits/mo`` or
|
||||
``· $0 credits/mo``).
|
||||
"""
|
||||
row = f"{tier.name} · {_format_dollars_grouped(tier.dollars_per_month)}/mo"
|
||||
mc = tier.monthly_credits
|
||||
if mc is not None and mc > 0:
|
||||
row += f" · {_format_dollars_grouped(mc)} credits/mo"
|
||||
return row
|
||||
|
||||
|
||||
def is_upgrade(state: SubscriptionState, tier_id: str) -> bool:
|
||||
"""True when ``tier_id`` ranks above the current plan by ``tier_order``.
|
||||
|
||||
Prefers the active subscription's tier; falls back to the ``tiers[]``
|
||||
``is_current`` marker (what the picker derives from), else 0 (free).
|
||||
"""
|
||||
orders = {t.tier_id: (t.tier_order or 0) for t in (state.tiers or ())}
|
||||
cur_id = state.current.tier_id if state.current else None
|
||||
if cur_id is not None and cur_id in orders:
|
||||
cur_order = orders[cur_id]
|
||||
else:
|
||||
cur_order = next((t.tier_order or 0 for t in (state.tiers or ()) if t.is_current), 0)
|
||||
return orders.get(tier_id, 0) > cur_order
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
|
||||
# =============================================================================
|
||||
|
||||
+10
-34
@@ -53,28 +53,6 @@ from tools.budget_config import BudgetConfig, DEFAULT_BUDGET, budget_for_context
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ensure_file_checkpoint(
|
||||
agent,
|
||||
function_name: str,
|
||||
function_args: dict,
|
||||
effective_task_id: str,
|
||||
) -> None:
|
||||
"""Checkpoint the same workspace path that the file tool will mutate."""
|
||||
file_path = function_args.get("path", "")
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
# File tools resolve relative paths against the task's live/session cwd,
|
||||
# which can differ from the Hermes process cwd (notably in Docker). Resolve
|
||||
# through that same path pipeline before asking the checkpoint manager to
|
||||
# discover the project root.
|
||||
from tools.file_tools import _resolve_path_for_task
|
||||
|
||||
resolved_path = _resolve_path_for_task(file_path, effective_task_id or "default")
|
||||
work_dir = agent._checkpoint_mgr.get_working_dir_for_path(str(resolved_path))
|
||||
agent._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}")
|
||||
|
||||
|
||||
def _budget_for_agent(agent) -> BudgetConfig:
|
||||
"""Resolve a tool-result BudgetConfig scaled to the agent's context window.
|
||||
|
||||
@@ -524,12 +502,10 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
# Checkpoint for file-mutating tools
|
||||
if function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled:
|
||||
try:
|
||||
_ensure_file_checkpoint(
|
||||
agent,
|
||||
function_name,
|
||||
function_args,
|
||||
effective_task_id,
|
||||
)
|
||||
file_path = function_args.get("path", "")
|
||||
if file_path:
|
||||
work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path)
|
||||
agent._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1212,12 +1188,12 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
# Checkpoint: snapshot working dir before file-mutating tools
|
||||
if not _execution_blocked and function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled:
|
||||
try:
|
||||
_ensure_file_checkpoint(
|
||||
agent,
|
||||
function_name,
|
||||
function_args,
|
||||
effective_task_id,
|
||||
)
|
||||
file_path = function_args.get("path", "")
|
||||
if file_path:
|
||||
work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path)
|
||||
agent._checkpoint_mgr.ensure_checkpoint(
|
||||
work_dir, f"before {function_name}"
|
||||
)
|
||||
except Exception:
|
||||
pass # never block tool execution
|
||||
|
||||
|
||||
@@ -92,79 +92,6 @@ class TurnResult:
|
||||
_TURN_ABORTED_MARKERS = ("<turn_aborted>", "<turn_aborted/>")
|
||||
|
||||
|
||||
def _notification_scope_ids(
|
||||
note: dict,
|
||||
) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Extract the thread/turn identity carried by a notification."""
|
||||
if not isinstance(note, dict):
|
||||
return None, None
|
||||
params = note.get("params") or {}
|
||||
if not isinstance(params, dict):
|
||||
return None, None
|
||||
|
||||
nested_turn = params.get("turn") or {}
|
||||
nested_item = params.get("item") or {}
|
||||
|
||||
observed_thread_id = params.get("threadId") or params.get("thread_id")
|
||||
if observed_thread_id is None and isinstance(nested_turn, dict):
|
||||
observed_thread_id = (
|
||||
nested_turn.get("threadId")
|
||||
or nested_turn.get("thread_id")
|
||||
)
|
||||
if observed_thread_id is None and isinstance(nested_item, dict):
|
||||
observed_thread_id = (
|
||||
nested_item.get("threadId")
|
||||
or nested_item.get("thread_id")
|
||||
)
|
||||
|
||||
observed_turn_id = params.get("turnId") or params.get("turn_id")
|
||||
if observed_turn_id is None and isinstance(nested_turn, dict):
|
||||
observed_turn_id = nested_turn.get("id") or nested_turn.get("turnId")
|
||||
if observed_turn_id is None and isinstance(nested_item, dict):
|
||||
observed_turn_id = (
|
||||
nested_item.get("turnId")
|
||||
or nested_item.get("turn_id")
|
||||
)
|
||||
|
||||
return observed_thread_id, observed_turn_id
|
||||
|
||||
|
||||
def _notification_belongs_to_turn(
|
||||
note: dict,
|
||||
*,
|
||||
thread_id: Optional[str],
|
||||
turn_id: Optional[str],
|
||||
) -> bool:
|
||||
"""Return whether a multiplexed notification belongs to this turn.
|
||||
|
||||
Codex app-server can carry parent and hosted subagent threads over one
|
||||
JSON-RPC connection. An explicitly foreign child or
|
||||
stale-turn event must not mutate the active parent's transcript or mark
|
||||
its turn complete. Unscoped notifications remain accepted for protocol
|
||||
compatibility.
|
||||
"""
|
||||
if not isinstance(note, dict):
|
||||
return False
|
||||
|
||||
observed_thread_id, observed_turn_id = _notification_scope_ids(note)
|
||||
|
||||
if (
|
||||
thread_id is not None
|
||||
and observed_thread_id is not None
|
||||
and str(observed_thread_id) != str(thread_id)
|
||||
):
|
||||
return False
|
||||
|
||||
if (
|
||||
turn_id is not None
|
||||
and observed_turn_id is not None
|
||||
and str(observed_turn_id) != str(turn_id)
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _coerce_turn_input_text(user_input: Any) -> str:
|
||||
"""Collapse Hermes/OpenAI rich content into app-server text input.
|
||||
|
||||
@@ -578,17 +505,6 @@ class CodexAppServerSession:
|
||||
pending = self._client.take_notification(timeout=0)
|
||||
if pending is None:
|
||||
break
|
||||
if not _notification_belongs_to_turn(
|
||||
pending,
|
||||
thread_id=self._thread_id,
|
||||
turn_id=result.turn_id,
|
||||
):
|
||||
logger.debug(
|
||||
"ignoring foreign codex notification while draining "
|
||||
"server request: method=%s",
|
||||
pending.get("method"),
|
||||
)
|
||||
continue
|
||||
# Mirror the main notification-handling block below so
|
||||
# display events surface and stay in step with projector
|
||||
# state. Without this, item/started / item/completed
|
||||
@@ -634,16 +550,6 @@ class CodexAppServerSession:
|
||||
continue
|
||||
|
||||
method = note.get("method", "")
|
||||
if not _notification_belongs_to_turn(
|
||||
note,
|
||||
thread_id=self._thread_id,
|
||||
turn_id=result.turn_id,
|
||||
):
|
||||
logger.debug(
|
||||
"ignoring foreign codex notification: method=%s", method
|
||||
)
|
||||
continue
|
||||
|
||||
if self._on_event is not None:
|
||||
try:
|
||||
self._on_event(note)
|
||||
@@ -831,48 +737,6 @@ class CodexAppServerSession:
|
||||
continue
|
||||
|
||||
method = note.get("method", "")
|
||||
observed_thread_id, observed_turn_id = _notification_scope_ids(note)
|
||||
if result.turn_id is None:
|
||||
if method == "turn/started":
|
||||
if (
|
||||
observed_thread_id is not None
|
||||
and str(observed_thread_id) != str(self._thread_id)
|
||||
):
|
||||
logger.debug(
|
||||
"ignoring foreign compact turn/started: thread=%s",
|
||||
observed_thread_id,
|
||||
)
|
||||
continue
|
||||
if observed_turn_id is None:
|
||||
logger.debug(
|
||||
"ignoring compact turn/started without a turn id"
|
||||
)
|
||||
continue
|
||||
result.turn_id = str(observed_turn_id)
|
||||
elif observed_turn_id is not None or method in {
|
||||
"item/completed",
|
||||
"turn/completed",
|
||||
}:
|
||||
# thread/compact/start does not return a turn id. Until the
|
||||
# new turn/started arrives, any terminal/projectable event
|
||||
# is stale or cannot be safely attributed to this compaction.
|
||||
logger.debug(
|
||||
"ignoring codex notification before compact turn start: "
|
||||
"method=%s",
|
||||
method,
|
||||
)
|
||||
continue
|
||||
|
||||
if not _notification_belongs_to_turn(
|
||||
note,
|
||||
thread_id=self._thread_id,
|
||||
turn_id=result.turn_id,
|
||||
):
|
||||
logger.debug(
|
||||
"ignoring foreign codex notification: method=%s", method
|
||||
)
|
||||
continue
|
||||
|
||||
if self._on_event is not None:
|
||||
try:
|
||||
self._on_event(note)
|
||||
|
||||
+1
-42
@@ -210,23 +210,6 @@ def _compression_made_progress(
|
||||
return orig_tokens > 0 and new_tokens < orig_tokens * 0.95
|
||||
|
||||
|
||||
def _compression_warrants_another_preflight_pass(
|
||||
orig_tokens: int, new_tokens: int, threshold_tokens: int
|
||||
) -> bool:
|
||||
"""Whether an over-threshold request merits another immediate summary.
|
||||
|
||||
Row-count progress is enough to prove that a compression boundary was real,
|
||||
but not enough to justify another expensive pass before trying the provider.
|
||||
Continue only when the request remains over threshold *and* the previous pass
|
||||
materially reduced its estimated token pressure (>5%).
|
||||
"""
|
||||
return (
|
||||
new_tokens >= threshold_tokens
|
||||
and orig_tokens > 0
|
||||
and new_tokens < orig_tokens * 0.95
|
||||
)
|
||||
|
||||
|
||||
def _should_run_preflight_estimate(
|
||||
messages: List[Dict[str, Any]],
|
||||
protect_first_n: int,
|
||||
@@ -280,8 +263,6 @@ class TurnContext:
|
||||
plugin_user_context: str = ""
|
||||
# External-memory prefetch result, reused across loop iterations.
|
||||
ext_prefetch_cache: str = ""
|
||||
# Turn-start preflight already proved an immediate retry ineffective.
|
||||
preflight_compression_blocked: bool = False
|
||||
|
||||
|
||||
def build_turn_context(
|
||||
@@ -584,7 +565,6 @@ def build_turn_context(
|
||||
# See ``_should_run_preflight_estimate`` for the OR semantics that fix
|
||||
# issue #27405 (a few very large messages slipping past the count gate).
|
||||
_preflight_compressed = False
|
||||
_preflight_compression_blocked = False
|
||||
if agent.compression_enabled and _should_run_preflight_estimate(
|
||||
messages,
|
||||
agent.context_compressor.protect_first_n,
|
||||
@@ -665,13 +645,7 @@ def build_turn_context(
|
||||
f">= {_compressor.threshold_tokens:,} threshold. "
|
||||
"This may take a moment."
|
||||
)
|
||||
# Preflight passes honor the same configured per-turn cap
|
||||
# (compression.max_attempts) as the loop's compression sites;
|
||||
# default 3 preserves the prior hardcoded behavior.
|
||||
_max_preflight_passes = max(
|
||||
1, int(getattr(agent, "max_compression_attempts", 3) or 3)
|
||||
)
|
||||
for _pass in range(_max_preflight_passes):
|
||||
for _pass in range(3):
|
||||
_orig_len = len(messages)
|
||||
_orig_tokens = _preflight_tokens
|
||||
messages, active_system_prompt = agent._compress_context(
|
||||
@@ -690,7 +664,6 @@ def build_turn_context(
|
||||
if not _compression_made_progress(
|
||||
_orig_len, len(messages), _orig_tokens, _preflight_tokens
|
||||
):
|
||||
_preflight_compression_blocked = True
|
||||
break # Cannot compress further: neither rows nor tokens moved
|
||||
conversation_history = conversation_history_after_compression(
|
||||
agent, messages
|
||||
@@ -702,19 +675,6 @@ def build_turn_context(
|
||||
agent._mute_post_response = False
|
||||
if not _compressor.should_compress(_preflight_tokens):
|
||||
break
|
||||
if not _compression_warrants_another_preflight_pass(
|
||||
_orig_tokens,
|
||||
_preflight_tokens,
|
||||
_compressor.threshold_tokens,
|
||||
):
|
||||
_preflight_compression_blocked = True
|
||||
logger.warning(
|
||||
"Preflight compression made insufficient progress: "
|
||||
"~%s -> ~%s request tokens; skipping additional passes",
|
||||
f"{_orig_tokens:,}",
|
||||
f"{_preflight_tokens:,}",
|
||||
)
|
||||
break
|
||||
|
||||
if _preflight_compressed:
|
||||
# Compression rebuilt the list (tail messages are fresh compaction
|
||||
@@ -939,5 +899,4 @@ def build_turn_context(
|
||||
should_review_memory=should_review_memory,
|
||||
plugin_user_context=plugin_user_context,
|
||||
ext_prefetch_cache=ext_prefetch_cache,
|
||||
preflight_compression_blocked=_preflight_compression_blocked,
|
||||
)
|
||||
|
||||
@@ -125,11 +125,9 @@ normalization alike. Learn the shape, not a snapshot of the current rungs.
|
||||
Two auth-flavored corollaries worth naming because they are easy to get wrong:
|
||||
|
||||
- **One-time credentials are never reused.** An OAuth gateway connection mints a
|
||||
fresh WebSocket ticket on every dial and never falls back to the cached URL.
|
||||
Only a confirmed 401/403 (or an explicitly tagged auth rejection) means
|
||||
reauthentication; timeout, network, malformed-response, and server failures
|
||||
remain connectivity errors. Only long-lived token/local auth may reuse a
|
||||
cached URL as a lower rung.
|
||||
fresh WebSocket ticket on every dial; a mint failure means reauthentication,
|
||||
not "fall back to the cached URL." Only long-lived token/local auth may reuse
|
||||
a cached URL as a lower rung.
|
||||
- **A connection test must exercise the leg you'll actually use.** An HTTP
|
||||
status probe passing while the WebSocket/auth leg fails is a false positive
|
||||
that ships as "it said connected but nothing works."
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { allowErrorBanners, test } from './test'
|
||||
import { test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type DeadBackendFixture,
|
||||
@@ -26,12 +26,6 @@ test.afterAll(async () => {
|
||||
})
|
||||
|
||||
test.describe('boot failure with dead backend', () => {
|
||||
test.beforeEach(() => {
|
||||
// These tests deliberately trigger boot errors — error banners
|
||||
// (notifyError → [role="alert"]) are expected, not failures.
|
||||
allowErrorBanners()
|
||||
})
|
||||
|
||||
test('app shows error state', async () => {
|
||||
// Inject a fake boot error so the backend resolution "fails" with a
|
||||
// controlled error message. This is the only reliable way to trigger
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* Run from the nix devshell:
|
||||
* npm exec playwright test e2e/boot.spec.ts --reporter=list
|
||||
*/
|
||||
import { expect, test } from './test'
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { test } from './test'
|
||||
import { test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
|
||||
@@ -28,7 +28,6 @@ import * as path from 'node:path'
|
||||
import { _electron, type ElectronApplication, type Page } from '@playwright/test'
|
||||
|
||||
import { startMockServer } from './mock-server'
|
||||
import { installErrorBannerGuard } from './test'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
|
||||
@@ -97,7 +96,7 @@ export interface Sandbox {
|
||||
cleanup: () => void
|
||||
}
|
||||
|
||||
export function createSandbox(prefix: string): Sandbox {
|
||||
function createSandbox(prefix: string): Sandbox {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-e2e-${prefix}-${Math.random()}`))
|
||||
const hermesHome = path.join(root, 'hermes-home')
|
||||
const userDataDir = path.join(root, 'electron-user-data')
|
||||
@@ -141,7 +140,7 @@ export function createSandbox(prefix: string): Sandbox {
|
||||
* mock inference server. The provider is set as the active model provider so
|
||||
* the desktop app skips onboarding and boots straight to the chat UI.
|
||||
*/
|
||||
export function writeMockProviderConfig(hermesHome: string, mockUrl: string): void {
|
||||
function writeMockProviderConfig(hermesHome: string, mockUrl: string): void {
|
||||
const configPath = path.join(hermesHome, 'config.yaml')
|
||||
|
||||
const config = `# Auto-generated by E2E test fixtures
|
||||
@@ -166,7 +165,7 @@ providers:
|
||||
* Write a minimal .env with the mock API key. The key_env in config.yaml
|
||||
* references MOCK_API_KEY, so the backend resolves credentials from here.
|
||||
*/
|
||||
export function writeEnvFile(hermesHome: string, apiKey = 'e2e-mock-key'): void {
|
||||
function writeEnvFile(hermesHome: string, apiKey = 'e2e-mock-key'): void {
|
||||
const envPath = path.join(hermesHome, '.env')
|
||||
fs.writeFileSync(envPath, `MOCK_API_KEY=${apiKey}\n`, 'utf8')
|
||||
}
|
||||
@@ -194,7 +193,7 @@ function writeEmptyConfig(hermesHome: string): void {
|
||||
* - HERMES_DESKTOP_APP_NAME → unique-ish per test (avoids single-instance lock)
|
||||
* - XDG_RUNTIME_DIR → ensure Electron has a writable runtime dir on Linux
|
||||
*/
|
||||
export function buildAppEnv(sandbox: Sandbox, extra: Record<string, string> = {}): Record<string, string> {
|
||||
function buildAppEnv(sandbox: Sandbox, extra: Record<string, string> = {}): Record<string, string> {
|
||||
const clean = stripCredentials(process.env)
|
||||
|
||||
// XDG_RUNTIME_DIR is needed for Electron on Linux when running in a
|
||||
@@ -253,7 +252,7 @@ function assertDistBuilt(): void {
|
||||
* Find the Electron binary. In the nix devshell, `electron` is on PATH.
|
||||
* As a fallback, use the node_modules/.bin/electron from the desktop package.
|
||||
*/
|
||||
export function findElectron(): string {
|
||||
function findElectron(): string {
|
||||
// In dev mode, we use the `electron` binary directly (not the packaged app).
|
||||
// The dev:electron script in package.json does exactly this: `electron .`
|
||||
// after building. We replicate that here.
|
||||
@@ -284,7 +283,7 @@ export function findElectron(): string {
|
||||
* @param env - the process environment (already has HERMES_HOME etc.)
|
||||
* @returns the ElectronApplication + first Page
|
||||
*/
|
||||
export async function launchDesktop(
|
||||
async function launchDesktop(
|
||||
env: Record<string, string>,
|
||||
): Promise<{ app: ElectronApplication; page: Page }> {
|
||||
assertDistBuilt()
|
||||
@@ -306,10 +305,6 @@ export async function launchDesktop(
|
||||
|
||||
const page = await app.firstWindow()
|
||||
|
||||
// Install the error-banner guard so any [role="alert"] that appears
|
||||
// during a test is collected and surfaced in afterEach.
|
||||
installErrorBannerGuard(page)
|
||||
|
||||
return { app, page }
|
||||
}
|
||||
|
||||
@@ -519,7 +514,6 @@ export async function setupPackagedApp(): Promise<PackagedAppFixture> {
|
||||
})
|
||||
|
||||
const page = await app.firstWindow()
|
||||
installErrorBannerGuard(page)
|
||||
|
||||
return {
|
||||
app,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from './test'
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
PACKAGED_BINARY_PATH,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, test } from './test'
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, test } from './test'
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type NoProviderFixture,
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Seed a Hermes state.db with a session exported from a real conversation.
|
||||
|
||||
Usage: seed_session_db.py <state_db_path> <fixture_json_path>
|
||||
|
||||
Creates the database with the full SessionDB schema (if it doesn't exist)
|
||||
and imports the session from the JSON fixture. Uses the real
|
||||
SessionDB.import_sessions() so the data shape matches what the desktop
|
||||
backend expects.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add the repo root to sys.path so we can import hermes_state.
|
||||
# The script is invoked from apps/desktop/e2e/ — repo root is ../../..
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(repo_root))
|
||||
|
||||
from hermes_state import SessionDB # noqa: E402
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
print(f"Usage: {sys.argv[0]} <state_db_path> <fixture_json_path>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
db_path = Path(sys.argv[1])
|
||||
fixture_path = Path(sys.argv[2])
|
||||
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(fixture_path, "r", encoding="utf-8") as f:
|
||||
session_data = json.load(f)
|
||||
|
||||
db = SessionDB(db_path=db_path)
|
||||
result = db.import_sessions([session_data])
|
||||
|
||||
if not result.get("ok"):
|
||||
print(f"Import failed: {result}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
imported = result.get("imported", 0)
|
||||
skipped = result.get("skipped", 0)
|
||||
errors = result.get("errors", [])
|
||||
|
||||
if errors:
|
||||
print(f"Import had errors: {errors}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Seeded {imported} session(s), skipped {skipped} → {db_path}")
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,166 +0,0 @@
|
||||
/**
|
||||
* Extended Playwright test fixture that auto-fails any test if an error
|
||||
* banner (notification toast with role="alert") appears in the DOM.
|
||||
*
|
||||
* The desktop app surfaces errors as `[data-slot="alert"][role="alert"]`
|
||||
* elements (see components/notifications.tsx). When one appears during a
|
||||
* test, it means something went wrong (resume failed, boot error, etc.)
|
||||
* — the test should fail with the error message, not silently pass while
|
||||
* an error toast is visible on screen.
|
||||
*
|
||||
* Usage: import { test, expect } from './test' instead of
|
||||
* '@playwright/test'. The guard is auto-installed on every page — no
|
||||
* per-spec setup needed.
|
||||
*/
|
||||
|
||||
import { test as base, expect, type Page, type ElectronApplication, _electron } from '@playwright/test'
|
||||
|
||||
// Track error messages per test so afterEach can assert + report.
|
||||
const seenErrors: string[] = []
|
||||
let activePage: Page | null = null
|
||||
// When true, the afterEach guard skips the error-banner check.
|
||||
// Set by tests that deliberately trigger error states (e.g. boot-failure).
|
||||
let errorBannersAllowed = false
|
||||
|
||||
/**
|
||||
* Opt out of the error-banner guard for the current test. Call in
|
||||
* test.beforeEach or at the top of a test body when error banners are
|
||||
* expected (e.g. boot-failure tests that deliberately trigger errors).
|
||||
*/
|
||||
export function allowErrorBanners(): void {
|
||||
errorBannersAllowed = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the error-banner guard on a page. Watches for `[role="alert"]`
|
||||
* elements appearing in the DOM. When one is found, records its text
|
||||
* content for the afterEach assertion.
|
||||
*
|
||||
* Exported so e2e fixture functions (which create pages via _electron.launch)
|
||||
* can install the guard on their custom pages — the default Playwright `page`
|
||||
* fixture override only catches pages created by Playwright itself, not
|
||||
* pages created by the test's own Electron launch.
|
||||
*/
|
||||
export function installErrorBannerGuard(page: Page): void {
|
||||
activePage = page
|
||||
|
||||
// Clear any errors from a previous test when a new page is created.
|
||||
seenErrors.length = 0
|
||||
|
||||
// Use a MutationObserver to catch error banners as they appear.
|
||||
// We inject this via addInitScript so it runs before any app code.
|
||||
page.addInitScript(() => {
|
||||
const seen: string[] = []
|
||||
;(window as unknown as { __ERROR_BANNER_GUARD__?: string[] }).__ERROR_BANNER_GUARD__ = seen
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const alerts = document.querySelectorAll('[role="alert"]')
|
||||
|
||||
for (const alert of alerts) {
|
||||
const text = (alert.textContent ?? '').trim()
|
||||
|
||||
if (text && !seen.includes(text)) {
|
||||
seen.push(text)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Start observing once the DOM is ready.
|
||||
if (document.body) {
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Also poll via evaluate — MutationObserver via addInitScript can miss
|
||||
// elements that appear during the Electron renderer's initial mount
|
||||
// (before the observer is installed). A periodic poll catches those.
|
||||
page.on('console', () => {
|
||||
// Console messages are not errors — but we keep the listener to
|
||||
// ensure the page context is active for our evaluate calls.
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for error banners that appeared during the test. Called in
|
||||
* afterEach via the custom fixture below. Also exported so specs that
|
||||
* manage their own page lifecycle can call it directly.
|
||||
*/
|
||||
export async function collectErrorBanners(page: Page | null): Promise<string[]> {
|
||||
if (!page) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
// Read errors collected by the MutationObserver in the page context.
|
||||
const pageErrors = await page.evaluate(() => {
|
||||
const w = window as unknown as { __ERROR_BANNER_GUARD__?: string[] }
|
||||
|
||||
return [...(w.__ERROR_BANNER_GUARD__ ?? [])]
|
||||
})
|
||||
|
||||
// Also do a final DOM scan for any alert elements still visible.
|
||||
const domAlerts = await page
|
||||
.locator('[role="alert"]')
|
||||
.allTextContents()
|
||||
.catch(() => [] as string[])
|
||||
|
||||
const all = [...new Set([...pageErrors, ...domAlerts.map(t => t.trim()).filter(Boolean)])]
|
||||
seenErrors.push(...all)
|
||||
|
||||
return [...new Set(seenErrors)]
|
||||
} catch {
|
||||
// Page might be closed — return whatever we have.
|
||||
return [...new Set(seenErrors)]
|
||||
}
|
||||
}
|
||||
|
||||
// Extended test fixture: wraps the default page with the error guard.
|
||||
export const test = base.extend({
|
||||
// Override the page fixture to auto-install the guard.
|
||||
page: async ({ page }, use) => {
|
||||
installErrorBannerGuard(page)
|
||||
await use(page)
|
||||
},
|
||||
})
|
||||
|
||||
// afterEach: fail the test if any error banners appeared.
|
||||
// Always fires — even if the test already failed for another reason.
|
||||
// An error banner often IS the root cause (e.g. "resume failed" from a
|
||||
// backend bug), and suppressing it when the test also fails on an
|
||||
// assertion hides the real problem.
|
||||
//
|
||||
// Uses `activePage` (set by installErrorBannerGuard) instead of the
|
||||
// default `page` fixture — Electron tests create their own page via
|
||||
// app.firstWindow(), so the default `page` fixture is undefined.
|
||||
base.afterEach(async ({}, testInfo) => {
|
||||
const wasAllowed = errorBannersAllowed
|
||||
// Reset for the next test.
|
||||
errorBannersAllowed = false
|
||||
|
||||
if (wasAllowed) {
|
||||
// Test opted out — clear any collected errors without asserting.
|
||||
seenErrors.length = 0
|
||||
return
|
||||
}
|
||||
|
||||
const errors = await collectErrorBanners(activePage)
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(
|
||||
`Error banner(s) appeared during test "${testInfo.title}":\n` +
|
||||
errors.map(e => ` • ${e}`).join('\n'),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Reset for the next test file.
|
||||
base.afterAll(async () => {
|
||||
seenErrors.length = 0
|
||||
activePage = null
|
||||
})
|
||||
|
||||
export { expect, type Page, type ElectronApplication, _electron }
|
||||
@@ -1,100 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { applyConnectionChange, commitConnectionFailure, resolveTerminalConnection } from './connection-apply'
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void
|
||||
|
||||
const promise = new Promise<void>(done => {
|
||||
resolve = done
|
||||
})
|
||||
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('applyConnectionChange', () => {
|
||||
it.each([['SSH A to SSH B'], ['SSH to Cloud'], ['Cloud to SSH']])(
|
||||
'serializes %s behind bootstrap rollback before teardown and apply',
|
||||
async () => {
|
||||
const gate = deferred()
|
||||
const events: string[] = []
|
||||
|
||||
const run = applyConnectionChange({
|
||||
cancelAndWait: async () => {
|
||||
events.push('cancel')
|
||||
await gate.promise
|
||||
events.push('drained')
|
||||
},
|
||||
isPrimary: true,
|
||||
scope: '',
|
||||
sendApplied: () => events.push('applied'),
|
||||
stopPool: vi.fn(),
|
||||
teardownPrimary: async () => {
|
||||
events.push('primary')
|
||||
},
|
||||
teardownSsh: async () => {
|
||||
events.push('ssh')
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(events).toEqual(['cancel'])
|
||||
gate.resolve()
|
||||
await run
|
||||
expect(events).toEqual(['cancel', 'drained', 'ssh', 'primary', 'applied'])
|
||||
}
|
||||
)
|
||||
|
||||
it('tears down only a non-primary scope without applying the primary connection', async () => {
|
||||
const events: string[] = []
|
||||
await applyConnectionChange({
|
||||
cancelAndWait: async scope => {
|
||||
events.push(`cancel:${scope}`)
|
||||
},
|
||||
isPrimary: false,
|
||||
scope: 'worker',
|
||||
sendApplied: () => events.push('applied'),
|
||||
stopPool: scope => events.push(`pool:${scope}`),
|
||||
teardownPrimary: async () => {
|
||||
events.push('primary')
|
||||
},
|
||||
teardownSsh: async scope => {
|
||||
events.push(`ssh:${scope}`)
|
||||
}
|
||||
})
|
||||
expect(events).toEqual(['cancel:worker', 'ssh:worker', 'pool:worker'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTerminalConnection', () => {
|
||||
it('joins an in-flight backend before resolving the SSH terminal target', async () => {
|
||||
const target = { ssh: {}, scope: '' }
|
||||
const getTarget = vi.fn().mockReturnValueOnce('pending').mockReturnValueOnce(target)
|
||||
const ensureBackend = vi.fn(async () => undefined)
|
||||
|
||||
await expect(resolveTerminalConnection(getTarget, ensureBackend)).resolves.toBe(target)
|
||||
expect(ensureBackend).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not start a local terminal while configured SSH remains unavailable', async () => {
|
||||
await expect(
|
||||
resolveTerminalConnection(
|
||||
() => 'pending',
|
||||
async () => undefined
|
||||
)
|
||||
).rejects.toThrow('not ready')
|
||||
})
|
||||
})
|
||||
|
||||
describe('commitConnectionFailure', () => {
|
||||
it('prevents a stale bootstrap from publishing failure state', () => {
|
||||
const stale = Promise.resolve('stale')
|
||||
const current = Promise.resolve('current')
|
||||
const commit = vi.fn()
|
||||
|
||||
expect(commitConnectionFailure(current, stale, commit)).toBe(false)
|
||||
expect(commit).not.toHaveBeenCalled()
|
||||
expect(commitConnectionFailure(current, current, commit)).toBe(true)
|
||||
expect(commit).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
async function applyConnectionChange({
|
||||
cancelAndWait,
|
||||
isPrimary,
|
||||
scope,
|
||||
sendApplied,
|
||||
stopPool,
|
||||
teardownPrimary,
|
||||
teardownSsh
|
||||
}) {
|
||||
await cancelAndWait(scope)
|
||||
await teardownSsh(scope)
|
||||
|
||||
if (!isPrimary) {
|
||||
stopPool(scope)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await teardownPrimary()
|
||||
sendApplied()
|
||||
}
|
||||
|
||||
function commitConnectionFailure(current, starting, commit) {
|
||||
if (current !== starting) {
|
||||
return false
|
||||
}
|
||||
|
||||
commit()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function resolveTerminalConnection(getTarget, ensureBackend) {
|
||||
let target = getTarget()
|
||||
|
||||
if (target !== 'pending') {
|
||||
return target
|
||||
}
|
||||
|
||||
await ensureBackend()
|
||||
target = getTarget()
|
||||
|
||||
if (target === 'pending') {
|
||||
throw new Error('Remote connection is not ready yet. Try again in a moment.')
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
export { applyConnectionChange, commitConnectionFailure, resolveTerminalConnection }
|
||||
@@ -23,22 +23,14 @@ import {
|
||||
cookiesHaveLiveSession,
|
||||
cookiesHavePrivySession,
|
||||
cookiesHaveSession,
|
||||
gatewayTicketFailure,
|
||||
gatewayWsUrlIpcResult,
|
||||
isGatewayAuthRejection,
|
||||
localProfileEntry,
|
||||
modeIsRemoteLike,
|
||||
normalizeRemoteBaseUrl,
|
||||
normalizeSshConfig,
|
||||
normAuthMode,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileHasRemoteConnection,
|
||||
profileRemoteOverride,
|
||||
profileSshOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
RT_COOKIE_VARIANTS,
|
||||
savedProfileSsh,
|
||||
tokenPreview
|
||||
} from './connection-config'
|
||||
|
||||
@@ -132,61 +124,6 @@ test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
|
||||
assert.equal(profileRemoteOverride(null, 'coder'), null)
|
||||
})
|
||||
|
||||
test('SSH remains separate from URL-shaped remote modes', () => {
|
||||
assert.equal(modeIsRemoteLike('ssh'), false)
|
||||
const config = { profiles: { coder: { mode: 'ssh', host: 'alice@box:2222', keyPath: '/key' } } }
|
||||
assert.equal(profileRemoteOverride(config, 'coder'), null)
|
||||
assert.deepEqual(profileSshOverride(config, 'coder'), {
|
||||
mode: 'ssh',
|
||||
host: 'box',
|
||||
user: 'alice',
|
||||
port: 2222,
|
||||
keyPath: '/key'
|
||||
})
|
||||
})
|
||||
|
||||
test('normalizeSshConfig handles IPv6 and strict port bounds', () => {
|
||||
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: '::1', port: 22 }), {
|
||||
mode: 'ssh',
|
||||
host: '::1'
|
||||
})
|
||||
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: '[::1]:2222' }), {
|
||||
mode: 'ssh',
|
||||
host: '::1',
|
||||
port: 2222
|
||||
})
|
||||
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', port: '2222junk' }), {
|
||||
mode: 'ssh',
|
||||
host: 'box'
|
||||
})
|
||||
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', port: 65536 }), {
|
||||
mode: 'ssh',
|
||||
host: 'box'
|
||||
})
|
||||
})
|
||||
|
||||
test('localProfileEntry preserves inactive SSH drafts but drops Cloud state', () => {
|
||||
const ssh = { mode: 'ssh', host: 'box', user: 'alice', remoteHermesPath: '/hermes' }
|
||||
assert.deepEqual(localProfileEntry(ssh), { mode: 'local', savedSsh: ssh })
|
||||
assert.deepEqual(localProfileEntry({ mode: 'local', savedSsh: ssh }), {
|
||||
mode: 'local',
|
||||
savedSsh: ssh
|
||||
})
|
||||
assert.equal(localProfileEntry({ mode: 'cloud', url: 'https://agent' }), null)
|
||||
})
|
||||
|
||||
test('saved SSH drafts are inactive and explicit overrides take precedence', () => {
|
||||
const saved = { mode: 'ssh', host: 'saved' }
|
||||
const config: any = { profiles: { coder: { mode: 'local', savedSsh: saved } } }
|
||||
assert.deepEqual(savedProfileSsh(config, 'coder'), saved)
|
||||
assert.equal(profileSshOverride(config, 'coder'), null)
|
||||
assert.equal(profileHasRemoteConnection(config, 'coder'), false)
|
||||
|
||||
config.profiles.coder = { mode: 'ssh', host: 'active' }
|
||||
assert.deepEqual(profileSshOverride(config, 'coder'), { mode: 'ssh', host: 'active' })
|
||||
assert.equal(profileHasRemoteConnection(config, 'coder'), true)
|
||||
})
|
||||
|
||||
// --- pathWithGlobalRemoteProfile ---
|
||||
|
||||
test('pathWithGlobalRemoteProfile appends profile in global remote mode', () => {
|
||||
@@ -494,14 +431,12 @@ test('resolveTestWsUrl (oauth, mint ok) builds a ?ticket= URL', async () => {
|
||||
assert.equal(url, 'wss://gw.example.com/api/ws?ticket=tkt-9')
|
||||
})
|
||||
|
||||
test('resolveTestWsUrl (oauth, auth rejected) requests sign-in and does not skip WS validation', async () => {
|
||||
const cause = Object.assign(new Error('ticket mint failed'), { statusCode: 401 })
|
||||
|
||||
test('resolveTestWsUrl (oauth, mint FAILS) throws — must NOT skip WS validation', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
|
||||
mintTicket: async () => {
|
||||
throw cause
|
||||
throw new Error('401 ticket mint failed')
|
||||
}
|
||||
}),
|
||||
(err: any) => {
|
||||
@@ -517,66 +452,6 @@ test('resolveTestWsUrl (oauth, auth rejected) requests sign-in and does not skip
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveTestWsUrl (oauth, transport failure) remains a retryable connection error', async () => {
|
||||
const cause = new Error('socket timed out')
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
|
||||
mintTicket: async () => {
|
||||
throw cause
|
||||
}
|
||||
}),
|
||||
(err: any) => {
|
||||
assert.match(err.message, /could not mint a WebSocket ticket/i)
|
||||
assert.equal(err.needsOauthLogin, undefined)
|
||||
assert.equal(err.cause, cause)
|
||||
|
||||
return true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('gateway ticket failures classify only explicit auth rejection statuses as reauth', () => {
|
||||
assert.equal(isGatewayAuthRejection({ statusCode: 401 }), true)
|
||||
assert.equal(isGatewayAuthRejection({ statusCode: 403 }), true)
|
||||
assert.equal(isGatewayAuthRejection({ needsOauthLogin: true }), true)
|
||||
assert.equal(isGatewayAuthRejection({ statusCode: 500 }), false)
|
||||
assert.equal(isGatewayAuthRejection(new Error('network timeout')), false)
|
||||
|
||||
const serverFailure = gatewayTicketFailure(new Error('network timeout'), 'sign in', 'retry connection') as any
|
||||
assert.equal(serverFailure.message, 'retry connection')
|
||||
assert.equal(serverFailure.needsOauthLogin, undefined)
|
||||
})
|
||||
|
||||
test('gateway WS URL IPC result serializes success and the auth-vs-transport matrix', async () => {
|
||||
assert.deepEqual(await gatewayWsUrlIpcResult(async () => 'wss://gateway.example.com/api/ws?ticket=fresh'), {
|
||||
ok: true,
|
||||
wsUrl: 'wss://gateway.example.com/api/ws?ticket=fresh'
|
||||
})
|
||||
|
||||
for (const statusCode of [401, 403]) {
|
||||
const error = Object.assign(new Error(`${statusCode}: rejected`), { statusCode })
|
||||
|
||||
assert.deepEqual(await gatewayWsUrlIpcResult(async () => Promise.reject(error)), {
|
||||
error: `${statusCode}: rejected`,
|
||||
needsOauthLogin: true,
|
||||
ok: false
|
||||
})
|
||||
}
|
||||
|
||||
for (const error of [
|
||||
Object.assign(new Error('500: unavailable'), { statusCode: 500 }),
|
||||
new Error('Timed out connecting to Hermes backend after 8000ms'),
|
||||
Object.assign(new Error('socket reset'), { code: 'ECONNRESET' })
|
||||
]) {
|
||||
assert.deepEqual(await gatewayWsUrlIpcResult(async () => Promise.reject(error)), {
|
||||
error: error.message,
|
||||
ok: false
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveTestWsUrl (oauth) requires a mintTicket function', async () => {
|
||||
await assert.rejects(
|
||||
() => resolveTestWsUrl('https://gw.example.com', 'oauth', null),
|
||||
|
||||
@@ -88,43 +88,6 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
|
||||
return `${wsScheme}://${parsed.host}${prefix}/api/ws?ticket=${encodeURIComponent(ticket)}`
|
||||
}
|
||||
|
||||
/** True only when a gateway explicitly rejected the current OAuth session. */
|
||||
function isGatewayAuthRejection(error) {
|
||||
if (error && typeof error === 'object' && (error as any).needsOauthLogin === true) {
|
||||
return true
|
||||
}
|
||||
|
||||
const statusCode = Number(error && typeof error === 'object' ? (error as any).statusCode : NaN)
|
||||
|
||||
return statusCode === 401 || statusCode === 403
|
||||
}
|
||||
|
||||
function gatewayTicketFailure(error, authMessage, transportMessage) {
|
||||
const needsOauthLogin = isGatewayAuthRejection(error)
|
||||
const err = new Error(needsOauthLogin ? authMessage : transportMessage)
|
||||
|
||||
if (needsOauthLogin) {
|
||||
;(err as any).needsOauthLogin = true
|
||||
}
|
||||
|
||||
err.cause = error
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
/** Serialize a fresh-WS-URL attempt across Electron's IPC boundary. */
|
||||
async function gatewayWsUrlIpcResult(resolveWsUrl: () => Promise<string>) {
|
||||
try {
|
||||
return { ok: true as const, wsUrl: await resolveWsUrl() }
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
...(isGatewayAuthRejection(error) ? { needsOauthLogin: true as const } : {}),
|
||||
ok: false as const
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the WS URL the renderer would connect with, so the connection test can
|
||||
* exercise the same transport the app actually uses.
|
||||
@@ -139,10 +102,12 @@ async function gatewayWsUrlIpcResult(resolveWsUrl: () => Promise<string>) {
|
||||
* - oauth, mint ok → ws(s)://…/api/ws?ticket=…
|
||||
* - oauth, mint fails → THROWS (NOT a skip)
|
||||
*
|
||||
* The oauth-mint-failure throw is the important case: swallowing it here would
|
||||
* re-introduce the exact false-positive this test exists to catch. An explicit
|
||||
* 401/403 asks for sign-in; transport and server failures remain connectivity
|
||||
* errors so a temporary outage is not mislabeled as an expired session.
|
||||
* The oauth-mint-failure throw is the important case: the real boot path
|
||||
* (resolveRemoteBackend in main.ts) treats a mint failure as a hard
|
||||
* "session expired" auth error and refuses to connect. Swallowing it here
|
||||
* would re-introduce the exact false-positive this test exists to catch —
|
||||
* HTTP /api/status passes, the test reports "reachable", then the renderer
|
||||
* can't authenticate /api/ws and boot dies with "Could not connect".
|
||||
*
|
||||
* @param {string} baseUrl
|
||||
* @param {'token'|'oauth'} authMode
|
||||
@@ -163,12 +128,14 @@ async function resolveTestWsUrl(baseUrl, authMode, token, deps: any = {}) {
|
||||
try {
|
||||
ticket = await mintTicket(baseUrl)
|
||||
} catch (error) {
|
||||
throw gatewayTicketFailure(
|
||||
error,
|
||||
'Reached the gateway over HTTP, but the OAuth session was rejected while minting a WebSocket ticket. ' +
|
||||
'Open Settings → Gateway and sign in again.',
|
||||
'Reached the gateway over HTTP, but could not mint a WebSocket ticket. Check the remote gateway connection and try again.'
|
||||
const err = new Error(
|
||||
'Reached the gateway over HTTP, but could not mint a WebSocket ticket for the OAuth session ' +
|
||||
'(it may have expired). Open Settings → Gateway and sign in again.'
|
||||
)
|
||||
|
||||
;(err as any).needsOauthLogin = true
|
||||
err.cause = error
|
||||
throw err
|
||||
}
|
||||
|
||||
return buildGatewayWsUrlWithTicket(baseUrl, ticket)
|
||||
@@ -203,127 +170,6 @@ function modeIsRemoteLike(mode) {
|
||||
return mode === 'remote' || mode === 'cloud'
|
||||
}
|
||||
|
||||
function normalizeSshConfig(entry) {
|
||||
if (!entry || typeof entry !== 'object' || entry.mode !== 'ssh') {
|
||||
return null
|
||||
}
|
||||
|
||||
let host = String(entry.host || '').trim()
|
||||
|
||||
if (!host) {
|
||||
return null
|
||||
}
|
||||
|
||||
let parsedUser
|
||||
let parsedPort
|
||||
const at = host.indexOf('@')
|
||||
|
||||
if (at > 0) {
|
||||
parsedUser = host.slice(0, at)
|
||||
host = host.slice(at + 1)
|
||||
}
|
||||
|
||||
const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(host)
|
||||
|
||||
if (bracketed) {
|
||||
host = bracketed[1]
|
||||
|
||||
if (bracketed[2]) {
|
||||
parsedPort = Number(bracketed[2])
|
||||
}
|
||||
} else if ((host.match(/:/g) || []).length === 1) {
|
||||
const [name, rawPort] = host.split(':')
|
||||
|
||||
if (/^\d+$/.test(rawPort)) {
|
||||
host = name
|
||||
parsedPort = Number(rawPort)
|
||||
}
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
return null
|
||||
}
|
||||
|
||||
const out: any = { mode: 'ssh', host }
|
||||
const user = String(entry.user || '').trim() || parsedUser || ''
|
||||
|
||||
if (user) {
|
||||
out.user = user
|
||||
}
|
||||
|
||||
const rawExplicitPort = String(entry.port ?? '').trim()
|
||||
const explicitPort = /^\d+$/.test(rawExplicitPort) ? Number(rawExplicitPort) : null
|
||||
const port = explicitPort ?? parsedPort
|
||||
|
||||
if (Number.isInteger(port) && port > 0 && port <= 65535 && port !== 22) {
|
||||
out.port = port
|
||||
}
|
||||
|
||||
const keyPath = String(entry.keyPath || '').trim()
|
||||
|
||||
if (keyPath) {
|
||||
out.keyPath = keyPath
|
||||
}
|
||||
|
||||
const remoteHermesPath = String(entry.remoteHermesPath || '').trim()
|
||||
|
||||
if (remoteHermesPath) {
|
||||
out.remoteHermesPath = remoteHermesPath
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function profileSshOverride(config, profile) {
|
||||
const key = connectionScopeKey(profile)
|
||||
const entry = key ? config?.profiles?.[key] : null
|
||||
|
||||
return normalizeSshConfig(entry)
|
||||
}
|
||||
|
||||
function savedProfileSsh(config, profile) {
|
||||
const key = connectionScopeKey(profile)
|
||||
const entry = key ? config?.profiles?.[key] : null
|
||||
|
||||
if (!entry || entry.mode !== 'local') {
|
||||
return null
|
||||
}
|
||||
|
||||
return normalizeSshConfig(entry.savedSsh)
|
||||
}
|
||||
|
||||
function profileHasRemoteConnection(config, profile) {
|
||||
return Boolean(profileRemoteOverride(config, profile) || profileSshOverride(config, profile))
|
||||
}
|
||||
|
||||
function localProfileEntry(existing) {
|
||||
const ssh = normalizeSshConfig(existing) || normalizeSshConfig(existing?.savedSsh)
|
||||
|
||||
return ssh ? { mode: 'local', savedSsh: ssh } : null
|
||||
}
|
||||
|
||||
function hostLabelFromBaseUrl(baseUrl) {
|
||||
const raw = String(baseUrl || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(raw)
|
||||
|
||||
if (!parsed.hostname) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parsed.port && parsed.port !== '80' && parsed.port !== '443'
|
||||
? `${parsed.hostname}:${parsed.port}`
|
||||
: parsed.hostname
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a profile's explicit remote override from a connection config, or null
|
||||
* when it has none (so the caller falls back to env → global remote → local).
|
||||
@@ -491,23 +337,14 @@ export {
|
||||
cookiesHaveLiveSession,
|
||||
cookiesHavePrivySession,
|
||||
cookiesHaveSession,
|
||||
gatewayTicketFailure,
|
||||
gatewayWsUrlIpcResult,
|
||||
hostLabelFromBaseUrl,
|
||||
isGatewayAuthRejection,
|
||||
localProfileEntry,
|
||||
modeIsRemoteLike,
|
||||
normalizeRemoteBaseUrl,
|
||||
normalizeSshConfig,
|
||||
normAuthMode,
|
||||
pathWithGlobalRemoteProfile,
|
||||
PRIVY_SESSION_COOKIE_VARIANTS,
|
||||
profileHasRemoteConnection,
|
||||
profileRemoteOverride,
|
||||
profileSshOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
RT_COOKIE_VARIANTS,
|
||||
savedProfileSsh,
|
||||
tokenPreview
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { loadOrCreateInstallationId, parseInstallationId, sshOwnershipId } from './desktop-installation'
|
||||
|
||||
const ID_A = '11111111-1111-4111-8111-111111111111'
|
||||
const ID_B = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function withTempDir(run) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-installation-'))
|
||||
|
||||
try {
|
||||
return run(directory)
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
test('parseInstallationId accepts only a version-4 UUID record', () => {
|
||||
assert.equal(parseInstallationId(JSON.stringify({ installationId: ID_A.toUpperCase() })), ID_A)
|
||||
assert.equal(parseInstallationId(JSON.stringify({ installationId: 'not-an-id' })), '')
|
||||
assert.equal(parseInstallationId('{}'), '')
|
||||
assert.equal(parseInstallationId('{'), '')
|
||||
})
|
||||
|
||||
test('loadOrCreateInstallationId persists and reuses one installation ID', () =>
|
||||
withTempDir(directory => {
|
||||
const filePath = path.join(directory, 'desktop-installation.json')
|
||||
assert.equal(
|
||||
loadOrCreateInstallationId(filePath, () => ID_A),
|
||||
ID_A
|
||||
)
|
||||
assert.equal(
|
||||
loadOrCreateInstallationId(filePath, () => ID_B),
|
||||
ID_A
|
||||
)
|
||||
assert.equal(fs.statSync(filePath).mode & 0o777, 0o600)
|
||||
}))
|
||||
|
||||
test('loadOrCreateInstallationId tightens an existing identity file', () =>
|
||||
withTempDir(directory => {
|
||||
const filePath = path.join(directory, 'desktop-installation.json')
|
||||
fs.writeFileSync(filePath, JSON.stringify({ installationId: ID_A }), { mode: 0o644 })
|
||||
assert.equal(
|
||||
loadOrCreateInstallationId(filePath, () => ID_B),
|
||||
ID_A
|
||||
)
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
assert.equal(fs.statSync(filePath).mode & 0o777, 0o600)
|
||||
}
|
||||
}))
|
||||
|
||||
test('loadOrCreateInstallationId replaces a malformed existing record', () =>
|
||||
withTempDir(directory => {
|
||||
const filePath = path.join(directory, 'desktop-installation.json')
|
||||
fs.writeFileSync(filePath, '{', { mode: 0o600 })
|
||||
assert.equal(
|
||||
loadOrCreateInstallationId(filePath, () => ID_A),
|
||||
ID_A
|
||||
)
|
||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).installationId, ID_A)
|
||||
}))
|
||||
|
||||
test('loadOrCreateInstallationId replaces an existing symlink', () =>
|
||||
withTempDir(directory => {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
|
||||
const target = path.join(directory, 'target.json')
|
||||
const filePath = path.join(directory, 'desktop-installation.json')
|
||||
fs.writeFileSync(target, JSON.stringify({ installationId: ID_B }), { mode: 0o600 })
|
||||
fs.symlinkSync(target, filePath)
|
||||
assert.equal(
|
||||
loadOrCreateInstallationId(filePath, () => ID_A),
|
||||
ID_A
|
||||
)
|
||||
assert.equal(fs.lstatSync(filePath).isSymbolicLink(), false)
|
||||
assert.equal(JSON.parse(fs.readFileSync(target, 'utf8')).installationId, ID_B)
|
||||
}))
|
||||
|
||||
test('loadOrCreateInstallationId replaces a malformed destination without a repair lock', () =>
|
||||
withTempDir(directory => {
|
||||
const filePath = path.join(directory, 'desktop-installation.json')
|
||||
fs.writeFileSync(filePath, '{', { mode: 0o600 })
|
||||
assert.equal(
|
||||
loadOrCreateInstallationId(filePath, () => ID_A),
|
||||
ID_A
|
||||
)
|
||||
assert.equal(fs.existsSync(`${filePath}.lock`), false)
|
||||
}))
|
||||
|
||||
test('sshOwnershipId is stable, scoped, and does not disclose the UUID', () => {
|
||||
const global = sshOwnershipId(ID_A, '')
|
||||
assert.match(global, /^[0-9a-f]{32}$/)
|
||||
assert.equal(global, sshOwnershipId(ID_A, ''))
|
||||
assert.notEqual(global, sshOwnershipId(ID_A, 'worker'))
|
||||
assert.ok(!global.includes(ID_A.slice(0, 8)))
|
||||
assert.throws(() => sshOwnershipId('bad', ''))
|
||||
})
|
||||
@@ -1,137 +0,0 @@
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const INSTALLATION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
function parseInstallationId(raw) {
|
||||
try {
|
||||
const value = JSON.parse(String(raw || ''))?.installationId
|
||||
|
||||
return INSTALLATION_ID_RE.test(value) ? value.toLowerCase() : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function readInstallationId(filePath) {
|
||||
try {
|
||||
const stat = fs.lstatSync(filePath)
|
||||
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32' && (stat.mode & 0o777) !== 0o600) {
|
||||
fs.chmodSync(filePath, 0o600)
|
||||
}
|
||||
|
||||
return parseInstallationId(fs.readFileSync(filePath, 'utf8'))
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function waitForRepair() {
|
||||
const buffer = new SharedArrayBuffer(4)
|
||||
Atomics.wait(new Int32Array(buffer), 0, 0, 25)
|
||||
}
|
||||
|
||||
function loadOrCreateInstallationId(filePath, randomUUID = crypto.randomUUID) {
|
||||
const existing = readInstallationId(filePath)
|
||||
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||
const installationId = randomUUID().toLowerCase()
|
||||
|
||||
if (!INSTALLATION_ID_RE.test(installationId)) {
|
||||
throw new Error('Could not generate a valid desktop installation ID.')
|
||||
}
|
||||
|
||||
const repairPath = `${filePath}.repair.lock`
|
||||
|
||||
for (let attempt = 0; attempt < 40; attempt++) {
|
||||
let repairFd
|
||||
|
||||
try {
|
||||
repairFd = fs.openSync(repairPath, 'wx', 0o600)
|
||||
} catch (error: any) {
|
||||
if (error?.code !== 'EEXIST') {
|
||||
throw error
|
||||
}
|
||||
|
||||
const winner = readInstallationId(filePath)
|
||||
|
||||
if (winner) {
|
||||
return winner
|
||||
}
|
||||
|
||||
waitForRepair()
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const winner = readInstallationId(filePath)
|
||||
|
||||
if (winner) {
|
||||
return winner
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = fs.lstatSync(filePath)
|
||||
|
||||
if (!stat.isFile() && !stat.isSymbolicLink()) {
|
||||
throw new Error('Desktop installation ID path is not a regular file.')
|
||||
}
|
||||
|
||||
if (!stat.isSymbolicLink() && typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
||||
throw new Error('Desktop installation ID is owned by another user.')
|
||||
}
|
||||
|
||||
fs.unlinkSync(filePath)
|
||||
} catch (error: any) {
|
||||
if (error?.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify({ installationId }), { encoding: 'utf8', flag: 'wx', mode: 0o600 })
|
||||
|
||||
return installationId
|
||||
} finally {
|
||||
if (repairFd !== undefined) {
|
||||
fs.closeSync(repairFd)
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(repairPath)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Could not repair the desktop installation ID.')
|
||||
}
|
||||
|
||||
function sshOwnershipId(installationId, scope) {
|
||||
if (!INSTALLATION_ID_RE.test(String(installationId || ''))) {
|
||||
throw new Error('Desktop installation ID is invalid.')
|
||||
}
|
||||
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(`${installationId}\0${String(scope || '')}`)
|
||||
.digest('hex')
|
||||
.slice(0, 32)
|
||||
}
|
||||
|
||||
export { INSTALLATION_ID_RE, loadOrCreateInstallationId, parseInstallationId, readInstallationId, sshOwnershipId }
|
||||
@@ -1,37 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { createEventDeduper } from './event-dedupe'
|
||||
|
||||
test('collapses the same key inside the window (two windows, one event)', () => {
|
||||
const isDup = createEventDeduper(1000)
|
||||
|
||||
assert.equal(isDup('input:s1', 0), false, 'first window claims')
|
||||
assert.equal(isDup('input:s1', 5), true, 'second window is deduped')
|
||||
})
|
||||
|
||||
test('distinct keys are independent', () => {
|
||||
const isDup = createEventDeduper(1000)
|
||||
|
||||
assert.equal(isDup('input:s1', 0), false)
|
||||
assert.equal(isDup('approval:s1', 0), false, 'different kind')
|
||||
assert.equal(isDup('input:s2', 0), false, 'different session')
|
||||
})
|
||||
|
||||
test('re-fires once the window elapses', () => {
|
||||
const isDup = createEventDeduper(1000)
|
||||
|
||||
assert.equal(isDup('turnDone:s1', 0), false)
|
||||
assert.equal(isDup('turnDone:s1', 999), true, 'still within window')
|
||||
assert.equal(isDup('turnDone:s1', 1000), false, 'window elapsed → fires again')
|
||||
})
|
||||
|
||||
test('prunes stale keys so the map cannot grow unbounded', () => {
|
||||
const isDup = createEventDeduper(1000)
|
||||
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
// Each far-apart key is pruned before the next, so none linger as duplicates.
|
||||
assert.equal(isDup(`turnDone:s${i}`, i * 2000), false)
|
||||
}
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
// Cross-window de-dupe for one-shot side-effects (OS notifications, the turn-end
|
||||
// sound, spoken replies). Every desktop window is its own renderer process, so N
|
||||
// open windows each independently react to the same backend event. The main
|
||||
// process is the one place they all share and it handles IPC serially, so it's
|
||||
// the race-free owner: the first window to claim a key within the interval wins;
|
||||
// peers see it's taken and stay quiet. Pure + injectable clock, so it's
|
||||
// unit-testable without Electron.
|
||||
|
||||
const DEDUPE_INTERVAL_MS = 1000
|
||||
|
||||
// Returns true when `key` was already claimed within the interval (caller drops
|
||||
// this one). Self-evicting: stale keys are pruned on every call, so the map
|
||||
// can't grow unbounded.
|
||||
export function createEventDeduper(intervalMs = DEDUPE_INTERVAL_MS) {
|
||||
const lastSeenAt = new Map<string, number>()
|
||||
|
||||
return function isDuplicate(key: string, now = Date.now()): boolean {
|
||||
for (const [k, at] of lastSeenAt) {
|
||||
if (now - at >= intervalMs) {
|
||||
lastSeenAt.delete(k)
|
||||
}
|
||||
}
|
||||
|
||||
if (lastSeenAt.has(key)) {
|
||||
return true
|
||||
}
|
||||
|
||||
lastSeenAt.set(key, now)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { normalizeRepoScanPath, repoScanPathIsWithin, scanGitRepos } from './git-repo-scan'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
function tempDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-repo-scan-'))
|
||||
tempDirs.push(dir)
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
function makeRepo(root: string, valid = true): void {
|
||||
fs.mkdirSync(path.join(root, '.git'), { recursive: true })
|
||||
|
||||
if (valid) {
|
||||
fs.writeFileSync(path.join(root, '.git', 'HEAD'), 'ref: refs/heads/main\n')
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('scanGitRepos', () => {
|
||||
it('does not read the filesystem when discovery is disabled', async () => {
|
||||
const read = vi.spyOn(fs.promises, 'readdir')
|
||||
|
||||
await expect(scanGitRepos([], { enabled: false })).resolves.toEqual([])
|
||||
expect(read).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('scans only configured roots and excludes complete subtrees', async () => {
|
||||
const root = tempDir()
|
||||
const included = path.join(root, 'included')
|
||||
const excluded = path.join(root, 'excluded')
|
||||
const invalid = path.join(root, 'invalid')
|
||||
makeRepo(included)
|
||||
makeRepo(excluded)
|
||||
makeRepo(invalid, false)
|
||||
|
||||
await expect(scanGitRepos([root], { enabled: true, excludePaths: [excluded], maxDepth: 2 })).resolves.toEqual([
|
||||
{ label: 'included', root: included }
|
||||
])
|
||||
})
|
||||
|
||||
it('deduplicates overlapping roots', async () => {
|
||||
const root = tempDir()
|
||||
const repo = path.join(root, 'repo')
|
||||
makeRepo(repo)
|
||||
|
||||
const result = await scanGitRepos([root, repo], { enabled: true })
|
||||
expect(result).toEqual([{ label: 'repo', root: repo }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('repository scan path normalization', () => {
|
||||
it('expands tilde and resolves relative paths from home', () => {
|
||||
expect(normalizeRepoScanPath('~/src', { homeDir: '/Users/rudi', platform: 'darwin' })?.value).toBe(
|
||||
'/Users/rudi/src'
|
||||
)
|
||||
expect(normalizeRepoScanPath('src', { homeDir: '/Users/rudi', platform: 'linux' })?.value).toBe('/Users/rudi/src')
|
||||
})
|
||||
|
||||
it('uses segment-aware, case-insensitive containment on Windows', () => {
|
||||
const options = { homeDir: 'C:\\Users\\Rudi', platform: 'win32' as const }
|
||||
expect(repoScanPathIsWithin('c:\\SRC\\Fever\\repo', 'C:\\src\\fever', options)).toBe(true)
|
||||
expect(repoScanPathIsWithin('C:\\src\\feverish', 'C:\\src\\fever', options)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,82 +1,32 @@
|
||||
// Repo-first discovery: walk bounded roots for Git repositories using only
|
||||
// Node's fs APIs. Electron owns this machine-local capability; the renderer
|
||||
// supplies the profile-scoped policy from Hermes config.
|
||||
// Repo-first discovery: walk bounded roots for git repos using only Node's `fs`
|
||||
// — no native addon, so it just works for anyone who pulls main (no
|
||||
// electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git`
|
||||
// (don't descend into a repo), cap depth, and skip heavy non-repo trees so the
|
||||
// first scan stays fast. Results are cached by the backend after the first run.
|
||||
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const fsp = fs.promises
|
||||
|
||||
// Shallow on purpose: real projects live a few levels under home
|
||||
// (`~/www/repo`, `~/code/org/repo`); deeper `.git` dirs are almost always
|
||||
// fixtures/vendored/eval checkouts (e.g. `~/www/ha-evals/tasks/*/repo`). Repos
|
||||
// you actually use but keep deeper still surface via session-derived discovery,
|
||||
// so this only prunes noise, never repos with history.
|
||||
const DEFAULT_MAX_DEPTH = 3
|
||||
const MAX_CONCURRENCY = 32
|
||||
|
||||
// Big trees that are never themselves repos and would waste the walk. Anything
|
||||
// hidden (dotdirs like .cache/.Trash/.npm) is skipped wholesale below, so this
|
||||
// only needs the non-hidden heavyweights.
|
||||
const JUNK_DIRS = new Set(['Applications', 'Library', 'node_modules', 'site-packages', 'vendor', 'venv'])
|
||||
|
||||
export interface RepoScanOptions {
|
||||
maxDepth?: number
|
||||
enabled?: boolean
|
||||
excludePaths?: string[]
|
||||
}
|
||||
|
||||
export interface RepoScanPathOptions {
|
||||
homeDir?: string
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
interface NormalizedScanPath {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
function pathApiFor(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
|
||||
return platform === 'win32' ? path.win32 : path.posix
|
||||
}
|
||||
|
||||
export function normalizeRepoScanPath(rawPath: string, options: RepoScanPathOptions = {}): NormalizedScanPath | null {
|
||||
const platform = options.platform ?? process.platform
|
||||
const homeDir = options.homeDir ?? os.homedir()
|
||||
const pathApi = pathApiFor(platform)
|
||||
const raw = String(rawPath ?? '').trim()
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
let expanded = raw
|
||||
|
||||
if (raw === '~') {
|
||||
expanded = homeDir
|
||||
} else if (raw.startsWith('~/') || raw.startsWith('~\\')) {
|
||||
expanded = pathApi.join(homeDir, raw.slice(2))
|
||||
}
|
||||
|
||||
const absolute = pathApi.isAbsolute(expanded) ? expanded : pathApi.resolve(homeDir, expanded)
|
||||
const value = pathApi.normalize(absolute)
|
||||
const key = platform === 'win32' ? value.toLocaleLowerCase('en-US') : value
|
||||
|
||||
return { key, value }
|
||||
}
|
||||
|
||||
export function repoScanPathIsWithin(candidate: string, parent: string, options: RepoScanPathOptions = {}): boolean {
|
||||
const platform = options.platform ?? process.platform
|
||||
const pathApi = pathApiFor(platform)
|
||||
const candidatePath = normalizeRepoScanPath(candidate, options)
|
||||
const parentPath = normalizeRepoScanPath(parent, options)
|
||||
|
||||
if (!candidatePath || !parentPath) {
|
||||
return false
|
||||
}
|
||||
|
||||
const relative = pathApi.relative(parentPath.key, candidatePath.key)
|
||||
|
||||
return (
|
||||
relative === '' || (relative !== '..' && !relative.startsWith(`..${pathApi.sep}`) && !pathApi.isAbsolute(relative))
|
||||
)
|
||||
}
|
||||
|
||||
async function mapLimit<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
|
||||
async function mapLimit(items, limit, fn) {
|
||||
let cursor = 0
|
||||
|
||||
async function worker(): Promise<void> {
|
||||
async function worker() {
|
||||
while (cursor < items.length) {
|
||||
const index = cursor
|
||||
cursor += 1
|
||||
@@ -84,85 +34,63 @@ async function mapLimit<T>(items: T[], limit: number, fn: (item: T) => Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()))
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) } as any, worker))
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan roots for Git repositories. An empty root list preserves the historical
|
||||
* home-directory scan. Disabled discovery returns before resolving home or
|
||||
* reading the filesystem.
|
||||
* Scan `roots` (default: the home dir) for git repositories. Returns deduped
|
||||
* `{ root, label }` entries. `options.maxDepth` caps recursion (default 3).
|
||||
*/
|
||||
export async function scanGitRepos(roots: string[], options: RepoScanOptions = {}) {
|
||||
if (options.enabled === false) {
|
||||
return []
|
||||
}
|
||||
async function scanGitRepos(roots, options: any = {}) {
|
||||
const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH
|
||||
const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()]
|
||||
const found = new Map()
|
||||
|
||||
const maxDepthValue = Number(options.maxDepth)
|
||||
const maxDepth = Number.isFinite(maxDepthValue) && maxDepthValue >= 0 ? maxDepthValue : DEFAULT_MAX_DEPTH
|
||||
const pathOptions: RepoScanPathOptions = {}
|
||||
const requestedRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()]
|
||||
|
||||
const searchRoots = [
|
||||
...new Map(
|
||||
requestedRoots
|
||||
.map(root => normalizeRepoScanPath(root, pathOptions))
|
||||
.filter((entry): entry is NormalizedScanPath => entry !== null)
|
||||
.map(entry => [entry.key, entry.value])
|
||||
).values()
|
||||
]
|
||||
|
||||
const exclusions = (options.excludePaths ?? [])
|
||||
.map(excluded => normalizeRepoScanPath(excluded, pathOptions))
|
||||
.filter((entry): entry is NormalizedScanPath => entry !== null)
|
||||
|
||||
const found = new Map<string, { root: string; label: string }>()
|
||||
|
||||
function isExcluded(candidate: string): boolean {
|
||||
return exclusions.some(excluded => repoScanPathIsWithin(candidate, excluded.value, pathOptions))
|
||||
}
|
||||
|
||||
async function walk(dir: string, depth: number): Promise<void> {
|
||||
if (depth > maxDepth || isExcluded(dir)) {
|
||||
async function walk(dir, depth) {
|
||||
if (depth > maxDepth) {
|
||||
return
|
||||
}
|
||||
|
||||
let entries: fs.Dirent[]
|
||||
let entries
|
||||
|
||||
try {
|
||||
entries = await fsp.readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
return // unreadable / permission denied
|
||||
}
|
||||
|
||||
const gitDir = entries.find(entry => entry.name === '.git' && entry.isDirectory())
|
||||
|
||||
if (gitDir) {
|
||||
try {
|
||||
await fsp.access(path.join(dir, '.git', 'HEAD'), fs.constants.R_OK)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const normalized = normalizeRepoScanPath(dir, pathOptions)
|
||||
|
||||
if (normalized) {
|
||||
found.set(normalized.key, {
|
||||
root: normalized.value,
|
||||
label: path.basename(normalized.value) || normalized.value
|
||||
})
|
||||
}
|
||||
// A `.git` DIRECTORY marks a real repo root (a main checkout). A `.git`
|
||||
// FILE is a linked worktree or submodule — those belong to their parent
|
||||
// repo as lanes, not as separate projects, so we don't list them (and we
|
||||
// keep descending in case a real repo sits deeper). This is what kills the
|
||||
// worktree/eval-repo duplicate explosion.
|
||||
if (entries.some(entry => entry.name === '.git' && entry.isDirectory())) {
|
||||
const root = dir.replace(/[/\\]+$/, '')
|
||||
found.set(root, path.basename(root) || root)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const subdirs = entries
|
||||
.filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && !JUNK_DIRS.has(entry.name))
|
||||
.map(entry => path.join(dir, entry.name))
|
||||
const subdirs = []
|
||||
|
||||
await mapLimit(subdirs, MAX_CONCURRENCY, subdir => walk(subdir, depth + 1))
|
||||
for (const entry of entries) {
|
||||
// Real directories only (skip symlinks to avoid loops), no hidden dirs, no
|
||||
// known heavy trees.
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.') || JUNK_DIRS.has(entry.name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
subdirs.push(path.join(dir, entry.name))
|
||||
}
|
||||
|
||||
await mapLimit(subdirs, MAX_CONCURRENCY, sub => walk(sub, depth + 1))
|
||||
}
|
||||
|
||||
await mapLimit(searchRoots, MAX_CONCURRENCY, root => walk(root, 0))
|
||||
await mapLimit(searchRoots.map(root => String(root || '').trim()).filter(Boolean), MAX_CONCURRENCY, root =>
|
||||
walk(root, 0)
|
||||
)
|
||||
|
||||
return [...found.values()]
|
||||
return [...found.entries()].map(([root, label]) => ({ label, root }))
|
||||
}
|
||||
|
||||
export { scanGitRepos }
|
||||
|
||||
+124
-1162
File diff suppressed because it is too large
Load Diff
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* Regression tests for electron/native-auth-decisions.ts — the three pure
|
||||
* decision seams behind the RFC 8252 native-app auth flow, each of which was a
|
||||
* real runtime bug that the mocked flow tests could not catch.
|
||||
*
|
||||
* Run via the vitest `electron` project (electron/**\/*.test.ts).
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
oauthSessionIsLive,
|
||||
resolveJsonBody,
|
||||
resolveOauthRestAuth
|
||||
} from './native-auth-decisions'
|
||||
|
||||
// --- 1. body encoding (guards the double-JSON.stringify 422) ---
|
||||
|
||||
test('resolveJsonBody returns the object unchanged (no pre-stringify)', () => {
|
||||
const body = { code: 'abc', code_verifier: 'xyz' }
|
||||
const out = resolveJsonBody(body)
|
||||
|
||||
// Must be the SAME object reference / shape — NOT a JSON string. Pre-
|
||||
// stringifying here is what produced the gateway 422 "Input should be a
|
||||
// valid dictionary" at /auth/native/token.
|
||||
assert.equal(typeof out, 'object')
|
||||
assert.deepEqual(out, body)
|
||||
})
|
||||
|
||||
test('resolveJsonBody does not stringify — a string stays a string, an object stays an object', () => {
|
||||
assert.equal(typeof resolveJsonBody({ a: 1 }), 'object')
|
||||
// If a caller ever passes an already-encoded string (the bug), we return it
|
||||
// as-is rather than re-wrapping — the contract is "fetchJson owns encoding".
|
||||
assert.equal(typeof resolveJsonBody('{"a":1}'), 'string')
|
||||
})
|
||||
|
||||
// --- 2. oauth liveness (guards the needsOauthLogin loop) ---
|
||||
|
||||
test('oauthSessionIsLive is true when a native bearer token exists, even with no cookie', () => {
|
||||
// The exact bug: native login stores a bearer, sets no cookie. Gating on the
|
||||
// cookie alone looped the UI into "not signed in".
|
||||
assert.equal(oauthSessionIsLive(true, false), true)
|
||||
})
|
||||
|
||||
test('oauthSessionIsLive is true when a live cookie exists with no native token', () => {
|
||||
assert.equal(oauthSessionIsLive(false, true), true)
|
||||
})
|
||||
|
||||
test('oauthSessionIsLive is true when both are present', () => {
|
||||
assert.equal(oauthSessionIsLive(true, true), true)
|
||||
})
|
||||
|
||||
test('oauthSessionIsLive is false only when neither is present', () => {
|
||||
assert.equal(oauthSessionIsLive(false, false), false)
|
||||
})
|
||||
|
||||
// --- 3. REST auth selection (guards the 401 no_cookie) ---
|
||||
|
||||
test('resolveOauthRestAuth prefers the native bearer when a token is present', () => {
|
||||
const auth = resolveOauthRestAuth('bearer-token-123')
|
||||
|
||||
assert.deepEqual(auth, { kind: 'bearer', token: 'bearer-token-123' })
|
||||
})
|
||||
|
||||
test('resolveOauthRestAuth falls back to cookie when there is no native token', () => {
|
||||
assert.deepEqual(resolveOauthRestAuth(null), { kind: 'cookie' })
|
||||
assert.deepEqual(resolveOauthRestAuth(undefined), { kind: 'cookie' })
|
||||
// Empty string is not a usable bearer — must fall back, not send "Bearer ".
|
||||
assert.deepEqual(resolveOauthRestAuth(''), { kind: 'cookie' })
|
||||
})
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* native-auth-decisions.ts
|
||||
*
|
||||
* Pure decision helpers extracted from main.ts for the RFC 8252 native-app
|
||||
* auth flow. These encode three choices that were each the site of a real
|
||||
* runtime bug — invisible to the mocked flow tests because the tests never
|
||||
* exercised the real main.ts internals. Keeping them pure + unit-tested here
|
||||
* prevents silent regressions:
|
||||
*
|
||||
* 1. resolveJsonBody — the token/refresh POST body must be the raw
|
||||
* object (fetchJson owns JSON.stringify). Pre-stringifying double-encodes
|
||||
* it into a JSON string, which the gateway's Pydantic model rejects with
|
||||
* 422 "Input should be a valid dictionary".
|
||||
*
|
||||
* 2. oauthSessionIsLive — an OAuth gateway is "signed in" when EITHER a
|
||||
* native bearer token OR a live cookie session exists. Gating on the
|
||||
* cookie alone rejects a completed native login and loops the UI into
|
||||
* "not signed in".
|
||||
*
|
||||
* 3. resolveOauthRestAuth — an oauth-mode REST call authenticates with the
|
||||
* native bearer when present, else the cookie partition. Cookie-only
|
||||
* routing returns 401 no_cookie for a cookieless native session.
|
||||
*
|
||||
* All three are trivial once named; the value is the test that pins the
|
||||
* contract so the god-file call sites can't drift back to the buggy shape.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Decide the request body to hand to fetchJson (which JSON.stringifies it).
|
||||
* Returns the object UNCHANGED — callers must NOT pre-stringify. A string here
|
||||
* would be double-encoded downstream; this function exists to document and
|
||||
* pin that contract at the one seam that got it wrong.
|
||||
*/
|
||||
export function resolveJsonBody<T>(body: T): T {
|
||||
return body
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an oauth gateway should be treated as signed-in. `hasNativeToken`
|
||||
* is whether a native bearer token is stored; `hasCookieSession` is whether a
|
||||
* live AT-or-RT cookie exists in the OAuth partition. Either suffices.
|
||||
*/
|
||||
export function oauthSessionIsLive(hasNativeToken: boolean, hasCookieSession: boolean): boolean {
|
||||
return hasNativeToken || hasCookieSession
|
||||
}
|
||||
|
||||
export type OauthRestAuth =
|
||||
| { kind: 'bearer'; token: string }
|
||||
| { kind: 'cookie' }
|
||||
|
||||
/**
|
||||
* Decide how an oauth-mode REST request authenticates: prefer the native
|
||||
* bearer (cookieless RFC 8252 flow) when a non-empty access token is present,
|
||||
* otherwise fall back to the cookie partition. `nativeAccessToken` is the
|
||||
* result of ensureNativeAccessToken (null/empty when there is no native
|
||||
* session or the refresh terminally failed).
|
||||
*/
|
||||
export function resolveOauthRestAuth(nativeAccessToken: string | null | undefined): OauthRestAuth {
|
||||
if (nativeAccessToken) {
|
||||
return { kind: 'bearer', token: nativeAccessToken }
|
||||
}
|
||||
|
||||
return { kind: 'cookie' }
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* Tests for electron/native-oauth-login.ts — the loopback-listener
|
||||
* orchestration of the RFC 8252 native login, with all I/O injected (fake
|
||||
* http server, fake openExternal, fake token POST) so no real socket or
|
||||
* browser is needed.
|
||||
*
|
||||
* Run with: node --test electron/native-oauth-login.test.ts
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { runNativeLogin } from './native-oauth-login'
|
||||
|
||||
// A fake http.Server: captures the request handler, lets the test drive a
|
||||
// synthetic browser callback, and records listen/close lifecycle.
|
||||
function makeFakeServerFactory(port = 51234) {
|
||||
const state: any = { handler: null, listening: false, closed: false, openedUrl: null }
|
||||
|
||||
const createServer: any = (handler: any) => {
|
||||
state.handler = handler
|
||||
const server: any = new EventEmitter()
|
||||
server.listen = (_port: number, _host: string, cb: () => void) => {
|
||||
state.listening = true
|
||||
cb()
|
||||
}
|
||||
server.address = () => ({ address: '127.0.0.1', family: 'IPv4', port })
|
||||
server.close = () => {
|
||||
state.closed = true
|
||||
}
|
||||
state.server = server
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
// Drive a synthetic browser hit to the loopback callback.
|
||||
state.hitCallback = (query: string) => {
|
||||
const res: any = { writeHead: () => undefined, end: () => undefined }
|
||||
state.handler({ url: `/callback?${query}` }, res)
|
||||
}
|
||||
|
||||
return { createServer, state }
|
||||
}
|
||||
|
||||
test('runNativeLogin completes the loopback round trip and returns tokens', async () => {
|
||||
const { createServer, state } = makeFakeServerFactory()
|
||||
let capturedAuthorizeUrl = ''
|
||||
let tokenPostBody: any = null
|
||||
|
||||
const promise = runNativeLogin(
|
||||
'https://gw.example.com',
|
||||
{
|
||||
openExternal: async url => {
|
||||
capturedAuthorizeUrl = url
|
||||
},
|
||||
postJson: async (_url, body) => {
|
||||
tokenPostBody = body
|
||||
|
||||
return {
|
||||
access_token: 'AT-native',
|
||||
refresh_token: 'RT-native',
|
||||
token_type: 'Bearer',
|
||||
expires_at: 1893456000,
|
||||
provider: 'nous',
|
||||
user_id: 'u-9'
|
||||
}
|
||||
},
|
||||
createServer,
|
||||
timeoutMs: 5_000
|
||||
},
|
||||
{ provider: 'nous' }
|
||||
)
|
||||
|
||||
// Give the listen callback a tick to open the browser + capture the URL.
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
|
||||
// The authorize URL must carry OUR challenge + loopback redirect + state.
|
||||
const authorize = new URL(capturedAuthorizeUrl)
|
||||
assert.equal(authorize.pathname, '/auth/native/authorize')
|
||||
const challenge = authorize.searchParams.get('code_challenge')
|
||||
const stateParam = authorize.searchParams.get('state')
|
||||
assert.ok(challenge && challenge.length > 0)
|
||||
assert.match(authorize.searchParams.get('redirect_uri') || '', /^http:\/\/127\.0\.0\.1:\d+\/callback$/)
|
||||
|
||||
// Synthetic browser redirect back with the matching state + a code.
|
||||
state.hitCallback(`code=gw-code-1&state=${encodeURIComponent(stateParam!)}`)
|
||||
|
||||
const tokens = await promise
|
||||
assert.equal(tokens.accessToken, 'AT-native')
|
||||
assert.equal(tokens.refreshToken, 'RT-native')
|
||||
assert.equal(tokens.userId, 'u-9')
|
||||
// The token POST carried the code + a verifier whose hash is the challenge.
|
||||
assert.equal(tokenPostBody.code, 'gw-code-1')
|
||||
assert.ok(tokenPostBody.code_verifier && tokenPostBody.code_verifier.length >= 43)
|
||||
// Listener was cleaned up.
|
||||
assert.equal(state.closed, true)
|
||||
})
|
||||
|
||||
test('runNativeLogin rejects on a state mismatch (CSRF) without redeeming', async () => {
|
||||
const { createServer, state } = makeFakeServerFactory()
|
||||
let tokenPostCalled = false
|
||||
|
||||
const promise = runNativeLogin('https://gw.example.com', {
|
||||
openExternal: async () => undefined,
|
||||
postJson: async () => {
|
||||
tokenPostCalled = true
|
||||
|
||||
return {}
|
||||
},
|
||||
createServer,
|
||||
timeoutMs: 5_000
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
// Wrong state — must not redeem the code.
|
||||
state.hitCallback('code=evil&state=not-the-real-state')
|
||||
|
||||
await assert.rejects(promise, /state mismatch/i)
|
||||
assert.equal(tokenPostCalled, false)
|
||||
assert.equal(state.closed, true)
|
||||
})
|
||||
|
||||
test('runNativeLogin surfaces a gateway error param', async () => {
|
||||
const { createServer, state } = makeFakeServerFactory()
|
||||
|
||||
const promise = runNativeLogin('https://gw.example.com', {
|
||||
openExternal: async () => undefined,
|
||||
postJson: async () => ({}),
|
||||
createServer,
|
||||
timeoutMs: 5_000
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
state.hitCallback('error=access_denied&error_description=user_declined')
|
||||
|
||||
await assert.rejects(promise, /access_denied/i)
|
||||
})
|
||||
|
||||
test('runNativeLogin times out when no callback arrives', async () => {
|
||||
const { createServer } = makeFakeServerFactory()
|
||||
|
||||
await assert.rejects(
|
||||
runNativeLogin('https://gw.example.com', {
|
||||
openExternal: async () => undefined,
|
||||
postJson: async () => ({}),
|
||||
createServer,
|
||||
timeoutMs: 20
|
||||
}),
|
||||
/timed out/i
|
||||
)
|
||||
})
|
||||
|
||||
test('runNativeLogin fails if the browser cannot be opened', async () => {
|
||||
const { createServer } = makeFakeServerFactory()
|
||||
|
||||
await assert.rejects(
|
||||
runNativeLogin('https://gw.example.com', {
|
||||
openExternal: async () => {
|
||||
throw new Error('no browser')
|
||||
},
|
||||
postJson: async () => ({}),
|
||||
createServer,
|
||||
timeoutMs: 5_000
|
||||
}),
|
||||
/could not open the system browser/i
|
||||
)
|
||||
})
|
||||
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* native-oauth-login.ts
|
||||
*
|
||||
* Electron-coupled driver for the RFC 8252 native-app login: it runs the
|
||||
* loopback HTTP listener that catches the gateway's browser redirect, opens
|
||||
* the system browser, redeems the one-time code for tokens, and hands them
|
||||
* back. The PURE logic (PKCE, URL building, callback parsing, token-response
|
||||
* normalization) lives in native-oauth.ts and is unit-tested separately; this
|
||||
* module is the thin I/O shell around it.
|
||||
*
|
||||
* Dependencies are INJECTED (openExternal, a JSON-POST fn, an http-server
|
||||
* factory, a clock) so the orchestration is testable without booting Electron
|
||||
* or opening real sockets — mirroring how connection-config.ts injects
|
||||
* `mintTicket`. main.ts supplies the real electron shell.openExternal,
|
||||
* electron.net POST, and node:http server.
|
||||
*
|
||||
* Security posture (see native-oauth.ts for the flow-level rationale):
|
||||
* - the loopback server binds 127.0.0.1 on an EPHEMERAL port and shuts down
|
||||
* the instant it receives the callback (or times out) — no long-lived
|
||||
* local listener;
|
||||
* - the `state` is verified before the code is redeemed (CSRF);
|
||||
* - the PKCE verifier never leaves this process until the token POST, and
|
||||
* the gateway enforces SHA256(verifier)==challenge server-side;
|
||||
* - the browser sees only a minimal "you can close this window" HTML page,
|
||||
* never the tokens.
|
||||
*/
|
||||
|
||||
import http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
|
||||
import {
|
||||
buildNativeAuthorizeUrl,
|
||||
generatePkcePair,
|
||||
generateState,
|
||||
nativeTokenUrl,
|
||||
parseLoopbackCallback,
|
||||
parseTokenResponse,
|
||||
type NativeTokenSet
|
||||
} from './native-oauth'
|
||||
|
||||
// Loopback login must complete inside this window (user opens browser,
|
||||
// authenticates, gets redirected back). Matches the server-side pending TTL.
|
||||
const DEFAULT_LOGIN_TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
// The minimal page the browser lands on after the gateway redirect. No tokens,
|
||||
// no secrets — just a close affordance. Served for any loopback request so a
|
||||
// favicon probe doesn't look like a failure.
|
||||
const DONE_HTML =
|
||||
'<!doctype html><meta charset="utf-8"><title>Signed in</title>' +
|
||||
'<body style="font:15px system-ui;margin:3rem;text-align:center">' +
|
||||
'<h2>✓ Signed in to Hermes</h2>' +
|
||||
'<p>You can close this window and return to the app.</p>' +
|
||||
'<script>setTimeout(()=>window.close(),800)</script>'
|
||||
|
||||
export interface NativeLoginDeps {
|
||||
/** Open a URL in the user's system browser (shell.openExternal). */
|
||||
openExternal: (url: string) => Promise<void>
|
||||
/** POST JSON and resolve the parsed body (electron.net-backed in prod). */
|
||||
postJson: (url: string, body: unknown, opts?: { timeoutMs?: number }) => Promise<any>
|
||||
/** http.createServer, injectable for tests. */
|
||||
createServer?: typeof http.createServer
|
||||
/** Clock + timeout, injectable for tests. */
|
||||
now?: () => number
|
||||
timeoutMs?: number
|
||||
/** Optional logger for boot diagnostics. */
|
||||
rememberLog?: (line: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive a full native login against `baseUrl` and return the token set.
|
||||
*
|
||||
* Steps: bind a loopback listener → open the system browser at the gateway's
|
||||
* /auth/native/authorize with our PKCE challenge + loopback redirect_uri →
|
||||
* await the ?code= redirect → verify state → POST /auth/native/token with the
|
||||
* verifier → return tokens. Rejects on timeout, state mismatch, a gateway
|
||||
* error param, or a token-exchange failure. Always tears the listener down.
|
||||
*/
|
||||
export async function runNativeLogin(
|
||||
baseUrl: string,
|
||||
deps: NativeLoginDeps,
|
||||
opts: { provider?: string } = {}
|
||||
): Promise<NativeTokenSet> {
|
||||
const createServer = deps.createServer || http.createServer
|
||||
const timeoutMs = deps.timeoutMs ?? DEFAULT_LOGIN_TIMEOUT_MS
|
||||
const log = deps.rememberLog || (() => undefined)
|
||||
|
||||
const { verifier, challenge } = generatePkcePair()
|
||||
const state = generateState()
|
||||
|
||||
return new Promise<NativeTokenSet>((resolve, reject) => {
|
||||
let settled = false
|
||||
let timer: NodeJS.Timeout | null = null
|
||||
const server = createServer((req, res) => {
|
||||
// Only the callback path carries the code; any other path (favicon,
|
||||
// etc.) still gets the friendly page so the browser tab looks sane.
|
||||
const url = req.url || '/'
|
||||
|
||||
// Always answer the browser with the close page — we never surface the
|
||||
// outcome to the browser, only to the app.
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
||||
res.end(DONE_HTML)
|
||||
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore non-callback noise (e.g. /favicon.ico) — wait for the ?code=.
|
||||
if (!/[?&](code|error)=/.test(url)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { code } = parseLoopbackCallback(url, state)
|
||||
finishWith(async () => {
|
||||
const tokenBody = await deps.postJson(
|
||||
nativeTokenUrl(baseUrl),
|
||||
{ code, code_verifier: verifier },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
|
||||
return parseTokenResponse(tokenBody)
|
||||
})
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
try {
|
||||
server.close()
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
}
|
||||
|
||||
const fail = (error: Error) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
|
||||
const finishWith = (produce: () => Promise<NativeTokenSet>) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
// Keep the listener up just long enough to have answered the browser,
|
||||
// then redeem the code out-of-band.
|
||||
produce()
|
||||
.then(tokens => {
|
||||
cleanup()
|
||||
resolve(tokens)
|
||||
})
|
||||
.catch(error => {
|
||||
cleanup()
|
||||
reject(error instanceof Error ? error : new Error(String(error)))
|
||||
})
|
||||
}
|
||||
|
||||
server.on('error', err => fail(err instanceof Error ? err : new Error(String(err))))
|
||||
|
||||
// Bind an ephemeral loopback port, then open the browser.
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address() as AddressInfo | null
|
||||
|
||||
if (!addr || typeof addr === 'string') {
|
||||
fail(new Error('Failed to bind loopback listener for native login'))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const redirectUri = `http://127.0.0.1:${addr.port}/callback`
|
||||
const authorizeUrl = buildNativeAuthorizeUrl(baseUrl, {
|
||||
challenge,
|
||||
redirectUri,
|
||||
state,
|
||||
provider: opts.provider
|
||||
})
|
||||
|
||||
timer = setTimeout(() => {
|
||||
fail(
|
||||
new Error(
|
||||
'Native sign-in timed out. The browser window may not have completed ' +
|
||||
'sign-in; open Settings → Gateway and try again.'
|
||||
)
|
||||
)
|
||||
}, timeoutMs)
|
||||
|
||||
log(`[native-oauth] loopback listening on 127.0.0.1:${addr.port}; opening system browser`)
|
||||
|
||||
deps.openExternal(authorizeUrl).catch(error => {
|
||||
fail(
|
||||
new Error(
|
||||
`Could not open the system browser for native sign-in: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export { DEFAULT_LOGIN_TIMEOUT_MS }
|
||||
@@ -1,190 +0,0 @@
|
||||
/**
|
||||
* Tests for electron/native-oauth.ts — the pure RFC 8252 native-app login
|
||||
* helpers (PKCE, capability detection, URL building, loopback callback
|
||||
* parsing, token-response normalization, refresh-timing).
|
||||
*
|
||||
* Run with: node --test electron/native-oauth.test.ts
|
||||
* (Wired into the vitest `electron` project via electron/**\/*.test.ts.)
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
buildNativeAuthorizeUrl,
|
||||
generatePkcePair,
|
||||
generateState,
|
||||
NATIVE_FLOW_ID,
|
||||
nativeRefreshUrl,
|
||||
nativeTokenUrl,
|
||||
parseLoopbackCallback,
|
||||
parseTokenResponse,
|
||||
resolveLoginStrategy,
|
||||
statusSupportsNativeFlow,
|
||||
tokenNeedsRefresh
|
||||
} from './native-oauth'
|
||||
|
||||
// --- PKCE ---
|
||||
|
||||
test('generatePkcePair produces a valid S256 verifier/challenge', () => {
|
||||
const pair = generatePkcePair()
|
||||
|
||||
assert.equal(pair.method, 'S256')
|
||||
// Verifier length within RFC 7636 range (43–128).
|
||||
assert.ok(pair.verifier.length >= 43 && pair.verifier.length <= 128)
|
||||
// Challenge must be the base64url SHA-256 of the verifier.
|
||||
const expected = createHash('sha256')
|
||||
.update(pair.verifier, 'ascii')
|
||||
.digest('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
assert.equal(pair.challenge, expected)
|
||||
// No padding / URL-unsafe chars.
|
||||
assert.doesNotMatch(pair.verifier, /[+/=]/)
|
||||
assert.doesNotMatch(pair.challenge, /[+/=]/)
|
||||
})
|
||||
|
||||
test('generatePkcePair is unique per call', () => {
|
||||
assert.notEqual(generatePkcePair().verifier, generatePkcePair().verifier)
|
||||
})
|
||||
|
||||
test('generateState is non-empty and URL-safe', () => {
|
||||
const s = generateState()
|
||||
|
||||
assert.ok(s.length > 0)
|
||||
assert.doesNotMatch(s, /[+/=]/)
|
||||
})
|
||||
|
||||
// --- capability detection ---
|
||||
|
||||
test('statusSupportsNativeFlow reads the auth_flows array', () => {
|
||||
assert.equal(statusSupportsNativeFlow({ auth_flows: ['cookie', NATIVE_FLOW_ID] }), true)
|
||||
assert.equal(statusSupportsNativeFlow({ auth_flows: ['cookie'] }), false)
|
||||
// Older gateway: no auth_flows field at all ⇒ not supported.
|
||||
assert.equal(statusSupportsNativeFlow({ auth_required: true }), false)
|
||||
assert.equal(statusSupportsNativeFlow({}), false)
|
||||
assert.equal(statusSupportsNativeFlow(null), false)
|
||||
// Malformed field shapes never throw.
|
||||
assert.equal(statusSupportsNativeFlow({ auth_flows: 'native_pkce' }), false)
|
||||
})
|
||||
|
||||
test('resolveLoginStrategy picks native only when advertised and not forced', () => {
|
||||
const gated = { auth_required: true, auth_flows: ['cookie', 'native_pkce'] }
|
||||
const legacy = { auth_required: true, auth_flows: ['cookie'] }
|
||||
|
||||
assert.equal(resolveLoginStrategy(gated), 'native')
|
||||
// Compatibility fallback: an older gateway lacking native_pkce ⇒ embedded.
|
||||
assert.equal(resolveLoginStrategy(legacy), 'embedded')
|
||||
// A user/env override can pin the legacy flow even on a capable gateway.
|
||||
assert.equal(resolveLoginStrategy(gated, { forceEmbedded: true }), 'embedded')
|
||||
})
|
||||
|
||||
// --- URL building ---
|
||||
|
||||
test('buildNativeAuthorizeUrl encodes params and honours a path prefix', () => {
|
||||
const url = buildNativeAuthorizeUrl('https://gw.example.com', {
|
||||
challenge: 'CHAL',
|
||||
redirectUri: 'http://127.0.0.1:51000/callback',
|
||||
state: 'STATE',
|
||||
provider: 'nous'
|
||||
})
|
||||
const parsed = new URL(url)
|
||||
|
||||
assert.equal(parsed.origin, 'https://gw.example.com')
|
||||
assert.equal(parsed.pathname, '/auth/native/authorize')
|
||||
assert.equal(parsed.searchParams.get('code_challenge'), 'CHAL')
|
||||
assert.equal(parsed.searchParams.get('code_challenge_method'), 'S256')
|
||||
assert.equal(parsed.searchParams.get('redirect_uri'), 'http://127.0.0.1:51000/callback')
|
||||
assert.equal(parsed.searchParams.get('state'), 'STATE')
|
||||
assert.equal(parsed.searchParams.get('provider'), 'nous')
|
||||
})
|
||||
|
||||
test('buildNativeAuthorizeUrl omits provider when not given and preserves prefix', () => {
|
||||
const url = buildNativeAuthorizeUrl('https://gw.example.com/hermes', {
|
||||
challenge: 'C',
|
||||
redirectUri: 'http://127.0.0.1:1/cb',
|
||||
state: 'S'
|
||||
})
|
||||
const parsed = new URL(url)
|
||||
|
||||
assert.equal(parsed.pathname, '/hermes/auth/native/authorize')
|
||||
assert.equal(parsed.searchParams.get('provider'), null)
|
||||
})
|
||||
|
||||
test('nativeTokenUrl / nativeRefreshUrl build the right endpoints', () => {
|
||||
assert.equal(nativeTokenUrl('https://gw.example.com'), 'https://gw.example.com/auth/native/token')
|
||||
assert.equal(nativeRefreshUrl('https://gw.example.com/hermes'), 'https://gw.example.com/hermes/auth/native/refresh')
|
||||
})
|
||||
|
||||
// --- loopback callback parsing ---
|
||||
|
||||
test('parseLoopbackCallback returns the code on a state match', () => {
|
||||
const { code } = parseLoopbackCallback('/callback?code=abc123&state=xyz', 'xyz')
|
||||
|
||||
assert.equal(code, 'abc123')
|
||||
})
|
||||
|
||||
test('parseLoopbackCallback throws on state mismatch (CSRF)', () => {
|
||||
assert.throws(
|
||||
() => parseLoopbackCallback('/callback?code=abc&state=attacker', 'expected'),
|
||||
/state mismatch/i
|
||||
)
|
||||
})
|
||||
|
||||
test('parseLoopbackCallback surfaces a gateway error param', () => {
|
||||
assert.throws(
|
||||
() => parseLoopbackCallback('/callback?error=access_denied&error_description=nope', 'xyz'),
|
||||
/access_denied.*nope/i
|
||||
)
|
||||
})
|
||||
|
||||
test('parseLoopbackCallback throws when the code is absent', () => {
|
||||
assert.throws(() => parseLoopbackCallback('/callback?state=xyz', 'xyz'), /missing authorization code/i)
|
||||
})
|
||||
|
||||
// --- token response normalization ---
|
||||
|
||||
test('parseTokenResponse maps a well-formed body', () => {
|
||||
const t = parseTokenResponse({
|
||||
access_token: 'AT',
|
||||
refresh_token: 'RT',
|
||||
token_type: 'Bearer',
|
||||
expires_at: 1893456000,
|
||||
provider: 'nous',
|
||||
user_id: 'u-1'
|
||||
})
|
||||
|
||||
assert.equal(t.accessToken, 'AT')
|
||||
assert.equal(t.refreshToken, 'RT')
|
||||
assert.equal(t.expiresAt, 1893456000)
|
||||
assert.equal(t.provider, 'nous')
|
||||
assert.equal(t.userId, 'u-1')
|
||||
})
|
||||
|
||||
test('parseTokenResponse throws on a missing access token', () => {
|
||||
assert.throws(() => parseTokenResponse({ refresh_token: 'RT' }), /missing access_token/i)
|
||||
})
|
||||
|
||||
test('parseTokenResponse tolerates an absent refresh token / expiry', () => {
|
||||
const t = parseTokenResponse({ access_token: 'AT' })
|
||||
|
||||
assert.equal(t.refreshToken, '')
|
||||
assert.equal(t.expiresAt, 0)
|
||||
})
|
||||
|
||||
// --- refresh timing ---
|
||||
|
||||
test('tokenNeedsRefresh respects the skew window', () => {
|
||||
const now = 1_000_000
|
||||
// Expires comfortably in the future ⇒ no refresh.
|
||||
assert.equal(tokenNeedsRefresh({ expiresAt: now + 3600 }, now), false)
|
||||
// Within the 60s skew ⇒ refresh early.
|
||||
assert.equal(tokenNeedsRefresh({ expiresAt: now + 30 }, now), true)
|
||||
// Already expired ⇒ refresh.
|
||||
assert.equal(tokenNeedsRefresh({ expiresAt: now - 10 }, now), true)
|
||||
// Unknown expiry ⇒ refresh (validate before use).
|
||||
assert.equal(tokenNeedsRefresh({ expiresAt: 0 }, now), true)
|
||||
})
|
||||
@@ -1,215 +0,0 @@
|
||||
/**
|
||||
* native-oauth.ts
|
||||
*
|
||||
* Pure, electron-free helpers for the desktop's RFC 8252 (OAuth 2.0 for Native
|
||||
* Apps) login to a gated Hermes gateway: system-browser + loopback redirect +
|
||||
* PKCE, with tokens returned to the app (never browser session cookies).
|
||||
*
|
||||
* Kept standalone (no `import 'electron'`) so it unit-tests with `node --test`
|
||||
* — same pattern as connection-config.ts. main.ts owns the electron-coupled
|
||||
* parts (the actual http.Server loopback listener, shell.openExternal, and
|
||||
* safeStorage keychain writes) and calls these helpers for the pure logic.
|
||||
*
|
||||
* Why the gateway brokers the flow (not a direct desktop→IDP client): the
|
||||
* upstream IDP (Nous Portal) issues a per-gateway-instance client_id and only
|
||||
* accepts a redirect_uri on the gateway's own origin, so a desktop loopback
|
||||
* redirect can't be a direct Portal client. Instead the gateway exposes
|
||||
* /auth/native/{authorize,token,refresh}: it is the authorization server to
|
||||
* the desktop and an OAuth client to Portal. The desktop still gets the full
|
||||
* RFC 8252 experience — its own PKCE pair, its own loopback redirect, tokens
|
||||
* it stores itself.
|
||||
*
|
||||
* Capability detection: the gateway advertises supported flows on the public
|
||||
* /api/status `auth_flows` array. `native_pkce` present ⇒ use this flow;
|
||||
* absent (older gateway) ⇒ the caller falls back to the embedded-webview
|
||||
* cookie flow. This is the "observable ladder / compatibility fallback tied to
|
||||
* an identified older runtime" the desktop guide requires.
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
|
||||
// The gateway status field that lists supported auth flows. See
|
||||
// hermes_cli/web_server.py status handler.
|
||||
const NATIVE_FLOW_ID = 'native_pkce'
|
||||
|
||||
export interface NativePkcePair {
|
||||
verifier: string
|
||||
challenge: string
|
||||
method: 'S256'
|
||||
}
|
||||
|
||||
export interface NativeTokenSet {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresAt: number
|
||||
provider: string
|
||||
userId: string
|
||||
}
|
||||
|
||||
/** base64url without `=` padding (RFC 7636 §4). */
|
||||
function b64url(raw: Buffer): string {
|
||||
return raw.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a PKCE verifier/challenge pair (S256). The verifier is 32 random
|
||||
* bytes base64url-encoded (43 chars, within RFC 7636's 43–128 range).
|
||||
*/
|
||||
export function generatePkcePair(randomImpl: (n: number) => Buffer = randomBytes): NativePkcePair {
|
||||
const verifier = b64url(randomImpl(32))
|
||||
const challenge = b64url(createHash('sha256').update(verifier, 'ascii').digest())
|
||||
|
||||
return { verifier, challenge, method: 'S256' }
|
||||
}
|
||||
|
||||
/** A high-entropy CSRF `state` value for the loopback round trip. */
|
||||
export function generateState(randomImpl: (n: number) => Buffer = randomBytes): string {
|
||||
return b64url(randomImpl(24))
|
||||
}
|
||||
|
||||
/**
|
||||
* True if a gateway `/api/status` body advertises the native PKCE flow.
|
||||
* Tolerant of the field being absent (older gateway) or malformed.
|
||||
*/
|
||||
export function statusSupportsNativeFlow(statusBody: any): boolean {
|
||||
const flows = statusBody && statusBody.auth_flows
|
||||
|
||||
return Array.isArray(flows) && flows.includes(NATIVE_FLOW_ID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the login strategy for a gated gateway from its status body.
|
||||
* Returns 'native' when the gateway can do RFC 8252 AND we're not forced to
|
||||
* the legacy path; 'embedded' otherwise (older gateway ⇒ webview fallback).
|
||||
*
|
||||
* `forceEmbedded` lets a user/setting or an env override pin the legacy flow
|
||||
* (e.g. a corporate proxy that blocks loopback). Precedence written down here,
|
||||
* in one place, as a pure function — per the desktop "observable ladder" rule.
|
||||
*/
|
||||
export function resolveLoginStrategy(
|
||||
statusBody: any,
|
||||
opts: { forceEmbedded?: boolean } = {}
|
||||
): 'native' | 'embedded' {
|
||||
if (opts.forceEmbedded) {
|
||||
return 'embedded'
|
||||
}
|
||||
|
||||
return statusSupportsNativeFlow(statusBody) ? 'native' : 'embedded'
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the gateway `/auth/native/authorize` URL the system browser opens.
|
||||
* `redirectUri` is the desktop's loopback callback (127.0.0.1:<port>/...).
|
||||
* `provider` is optional — omitted lets the gateway pick when it has exactly
|
||||
* one session provider (the common hosted case).
|
||||
*/
|
||||
export function buildNativeAuthorizeUrl(
|
||||
baseUrl: string,
|
||||
params: { challenge: string; redirectUri: string; state: string; provider?: string }
|
||||
): string {
|
||||
const parsed = new URL(baseUrl)
|
||||
const prefix = parsed.pathname.replace(/\/+$/, '')
|
||||
const q = new URLSearchParams({
|
||||
code_challenge: params.challenge,
|
||||
code_challenge_method: 'S256',
|
||||
redirect_uri: params.redirectUri,
|
||||
state: params.state
|
||||
})
|
||||
|
||||
if (params.provider) {
|
||||
q.set('provider', params.provider)
|
||||
}
|
||||
|
||||
return `${parsed.protocol}//${parsed.host}${prefix}/auth/native/authorize?${q.toString()}`
|
||||
}
|
||||
|
||||
/** The `/auth/native/token` endpoint URL for a gateway base URL. */
|
||||
export function nativeTokenUrl(baseUrl: string): string {
|
||||
const parsed = new URL(baseUrl)
|
||||
const prefix = parsed.pathname.replace(/\/+$/, '')
|
||||
|
||||
return `${parsed.protocol}//${parsed.host}${prefix}/auth/native/token`
|
||||
}
|
||||
|
||||
/** The `/auth/native/refresh` endpoint URL for a gateway base URL. */
|
||||
export function nativeRefreshUrl(baseUrl: string): string {
|
||||
const parsed = new URL(baseUrl)
|
||||
const prefix = parsed.pathname.replace(/\/+$/, '')
|
||||
|
||||
return `${parsed.protocol}//${parsed.host}${prefix}/auth/native/refresh`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the loopback redirect the gateway sends the browser to. Returns the
|
||||
* `code` + `state`, or throws with the gateway's `error` if the flow failed.
|
||||
* `expectedState` MUST match (CSRF defense — RFC 6749 §10.12); a mismatch
|
||||
* throws rather than proceeding.
|
||||
*/
|
||||
export function parseLoopbackCallback(
|
||||
requestUrl: string,
|
||||
expectedState: string
|
||||
): { code: string } {
|
||||
// requestUrl is the path+query the loopback server received, e.g.
|
||||
// "/callback?code=...&state=...". Resolve against a dummy origin to parse.
|
||||
const parsed = new URL(requestUrl, 'http://127.0.0.1')
|
||||
const error = parsed.searchParams.get('error')
|
||||
|
||||
if (error) {
|
||||
const desc = parsed.searchParams.get('error_description') || ''
|
||||
throw new Error(`Gateway rejected native login: ${error}${desc ? ` (${desc})` : ''}`)
|
||||
}
|
||||
|
||||
const code = parsed.searchParams.get('code') || ''
|
||||
const state = parsed.searchParams.get('state') || ''
|
||||
|
||||
if (!code) {
|
||||
throw new Error('Loopback callback missing authorization code')
|
||||
}
|
||||
|
||||
if (!expectedState || state !== expectedState) {
|
||||
// Never redeem a code that arrived with a mismatched state — it may be a
|
||||
// forged callback trying to inject an attacker's code.
|
||||
throw new Error('Loopback callback state mismatch (possible CSRF)')
|
||||
}
|
||||
|
||||
return { code }
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a `/auth/native/token` (or refresh) JSON response into a
|
||||
* NativeTokenSet, validating the shape. Throws on a missing/short access
|
||||
* token so a malformed response fails loudly rather than storing junk.
|
||||
*/
|
||||
export function parseTokenResponse(body: any): NativeTokenSet {
|
||||
const accessToken = String(body?.access_token || '')
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error('Gateway token response missing access_token')
|
||||
}
|
||||
|
||||
const expiresAt = Number(body?.expires_at)
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: String(body?.refresh_token || ''),
|
||||
expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0,
|
||||
provider: String(body?.provider || ''),
|
||||
userId: String(body?.user_id || '')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a stored token set is at/near expiry and should be refreshed
|
||||
* before use. `skewSeconds` refreshes slightly early to avoid a race where
|
||||
* the token expires in flight (mirrors the server's 60s cookie floor).
|
||||
*/
|
||||
export function tokenNeedsRefresh(tokens: Pick<NativeTokenSet, 'expiresAt'>, nowSeconds: number, skewSeconds = 60): boolean {
|
||||
if (!tokens || !Number.isFinite(tokens.expiresAt) || tokens.expiresAt <= 0) {
|
||||
// Unknown expiry ⇒ treat as needing refresh so we validate before use.
|
||||
return true
|
||||
}
|
||||
|
||||
return nowSeconds >= tokens.expiresAt - skewSeconds
|
||||
}
|
||||
|
||||
export { NATIVE_FLOW_ID }
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createKeepAwake, type PowerSaveBlockerLike } from './power-save'
|
||||
|
||||
function fakeBlocker() {
|
||||
let next = 1
|
||||
const started = new Set<number>()
|
||||
|
||||
const blocker: PowerSaveBlockerLike = {
|
||||
isStarted: id => started.has(id),
|
||||
start: vi.fn(() => {
|
||||
const id = next++
|
||||
started.add(id)
|
||||
|
||||
return id
|
||||
}),
|
||||
stop: vi.fn(id => void started.delete(id))
|
||||
}
|
||||
|
||||
return { blocker, started }
|
||||
}
|
||||
|
||||
describe('createKeepAwake', () => {
|
||||
it('starts once, is idempotent, and stops', () => {
|
||||
const { blocker } = fakeBlocker()
|
||||
const keepAwake = createKeepAwake(blocker)
|
||||
|
||||
expect(keepAwake.isActive()).toBe(false)
|
||||
expect(keepAwake.set(true)).toBe(true)
|
||||
keepAwake.set(true) // idempotent — no second blocker
|
||||
expect(blocker.start).toHaveBeenCalledTimes(1)
|
||||
expect(blocker.start).toHaveBeenCalledWith('prevent-app-suspension')
|
||||
|
||||
expect(keepAwake.set(false)).toBe(false)
|
||||
keepAwake.set(false)
|
||||
expect(blocker.stop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('re-arms after the OS dropped the blocker', () => {
|
||||
const { blocker, started } = fakeBlocker()
|
||||
const keepAwake = createKeepAwake(blocker)
|
||||
|
||||
keepAwake.set(true)
|
||||
started.clear() // system released it out from under us
|
||||
expect(keepAwake.isActive()).toBe(false)
|
||||
|
||||
keepAwake.set(true)
|
||||
expect(blocker.start).toHaveBeenCalledTimes(2)
|
||||
expect(keepAwake.isActive()).toBe(true)
|
||||
})
|
||||
|
||||
it('honors a custom blocker type', () => {
|
||||
const { blocker } = fakeBlocker()
|
||||
createKeepAwake(blocker, 'prevent-display-sleep').set(true)
|
||||
|
||||
expect(blocker.start).toHaveBeenCalledWith('prevent-display-sleep')
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Keep-awake — hold a single machine-global power-save blocker.
|
||||
*
|
||||
* `prevent-app-suspension` stops the system from sleeping (long overnight
|
||||
* agent runs keep going) while still letting the display dim. The renderer
|
||||
* owns the preference (persisted in localStorage) and mirrors it here over
|
||||
* IPC; the main process owns the one native blocker, same authority split as
|
||||
* translucency/zoom. Electron auto-releases the blocker on quit.
|
||||
*/
|
||||
|
||||
export type KeepAwakeType = 'prevent-app-suspension' | 'prevent-display-sleep'
|
||||
|
||||
/** The slice of Electron's `powerSaveBlocker` we use (injected for testing). */
|
||||
export interface PowerSaveBlockerLike {
|
||||
start(type: KeepAwakeType): number
|
||||
stop(id: number): void
|
||||
isStarted(id: number): boolean
|
||||
}
|
||||
|
||||
export interface KeepAwake {
|
||||
/** Turn the blocker on/off (idempotent). Returns the resulting state. */
|
||||
set(on: boolean): boolean
|
||||
isActive(): boolean
|
||||
}
|
||||
|
||||
export function createKeepAwake(
|
||||
blocker: PowerSaveBlockerLike,
|
||||
type: KeepAwakeType = 'prevent-app-suspension'
|
||||
): KeepAwake {
|
||||
let id: null | number = null
|
||||
|
||||
const isActive = () => id !== null && blocker.isStarted(id)
|
||||
|
||||
return {
|
||||
isActive,
|
||||
set(on) {
|
||||
if (on && !isActive()) {
|
||||
id = blocker.start(type)
|
||||
} else if (!on && id !== null) {
|
||||
if (blocker.isStarted(id)) {
|
||||
blocker.stop(id)
|
||||
}
|
||||
|
||||
id = null
|
||||
}
|
||||
|
||||
return isActive()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
|
||||
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
|
||||
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
|
||||
openWindow: () => ipcRenderer.invoke('hermes:window:openInstance'),
|
||||
claimAmbientCue: key => ipcRenderer.invoke('hermes:ambient:claim', key),
|
||||
openNewSessionWindow: () => ipcRenderer.invoke('hermes:window:openNewSession'),
|
||||
petOverlay: {
|
||||
// Main renderer → main process: window lifecycle + drag. `request` is
|
||||
// `{ bounds, screen }`; resolves with the screen bounds it actually used.
|
||||
@@ -41,8 +40,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
|
||||
applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),
|
||||
testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),
|
||||
sshConfigHosts: () => ipcRenderer.invoke('hermes:ssh-config:hosts'),
|
||||
sshResolveHost: host => ipcRenderer.invoke('hermes:ssh-config:resolve', host),
|
||||
probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl),
|
||||
oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl),
|
||||
oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl),
|
||||
@@ -82,7 +79,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload),
|
||||
setNativeTheme: mode => ipcRenderer.send('hermes:native-theme', mode),
|
||||
setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload),
|
||||
setKeepAwake: on => ipcRenderer.send('hermes:keep-awake', on),
|
||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||
openPreviewInBrowser: url => ipcRenderer.invoke('hermes:openPreviewInBrowser', url),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,906 +0,0 @@
|
||||
/**
|
||||
* remote-lifecycle.ts
|
||||
*
|
||||
* Pure, electron-free remote Hermes dashboard lifecycle over SSH for Desktop
|
||||
* SSH remote mode. Composes an SshConnection (injected) with HTTP probes
|
||||
* through the established tunnel (injected fetch) and the served-token adoption
|
||||
* step (injected). Knows how to:
|
||||
*
|
||||
* - locate the Hermes install on the remote (login-shell probe),
|
||||
* - gate the remote platform to Linux/macOS via `uname`,
|
||||
* - reuse an existing desktop-dedicated dashboard via a lockfile + an
|
||||
* AUTHENTICATED /api/status probe (pid liveness alone is insufficient),
|
||||
* - spawn a fresh detached `--isolated --port 0` dashboard and scrape its
|
||||
* `HERMES_DASHBOARD_READY port=<n>` readiness line,
|
||||
* - adopt the token the dashboard actually serves (served-token adoption),
|
||||
* - clean up a stale dashboard only when it is provably ours.
|
||||
*
|
||||
* No `import 'electron'` so it's unit-testable with `node --test`. main.ts wires
|
||||
* the real SshConnection, fetch, adoptServedDashboardToken, and waitForHermes in.
|
||||
*
|
||||
* The minted HERMES_DASHBOARD_SESSION_TOKEN is the SPAWN credential. After
|
||||
* readiness the caller runs served-token adoption against the tunneled baseUrl
|
||||
* and the SERVED token's fingerprint is what lands in the lockfile — so the
|
||||
* reuse probe checks the credential that actually authenticates /api/ws, not
|
||||
* the minted one (which the dashboard may regen).
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
const LOCKFILE_SCHEMA_VERSION = 2
|
||||
// Bumped when the desktop<->dashboard reuse contract changes in a way that makes
|
||||
// an old running dashboard unsafe to reattach to (token handling, readiness/spawn
|
||||
// args, served-token reconciliation). A mismatch forces a clean respawn.
|
||||
const PROTOCOL_VERSION = 1
|
||||
const READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\d+)/m
|
||||
const REMOTE_LOCK_DIR = '~/.hermes/desktop-ssh'
|
||||
const SUPPORTED_REMOTE_OS = new Set(['Linux', 'Darwin'])
|
||||
const DEFAULT_READY_TIMEOUT_MS = 45_000
|
||||
const READY_POLL_INTERVAL_MS = 750
|
||||
|
||||
function mintToken() {
|
||||
return crypto.randomBytes(32).toString('hex')
|
||||
}
|
||||
|
||||
// Fingerprint a token for the lockfile — never store the raw secret on the
|
||||
// remote. SHA256, truncated.
|
||||
function fingerprintToken(token) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(String(token || ''))
|
||||
.digest('hex')
|
||||
.slice(0, 32)
|
||||
}
|
||||
|
||||
function validateOwnershipId(ownershipId) {
|
||||
const value = String(ownershipId || '')
|
||||
|
||||
if (!/^[0-9a-f]{32}$/.test(value)) {
|
||||
throw new Error('SSH ownership ID is invalid.')
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function validateSpawnNonce(spawnNonce) {
|
||||
const value = String(spawnNonce || '')
|
||||
|
||||
if (!/^[0-9a-f]{16}$/.test(value)) {
|
||||
throw new Error('SSH spawn nonce is invalid.')
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function ownershipDirectory(ownershipId) {
|
||||
return `${REMOTE_LOCK_DIR}/${validateOwnershipId(ownershipId)}`
|
||||
}
|
||||
|
||||
function lockfilePath(ownershipId) {
|
||||
return `${ownershipDirectory(ownershipId)}/backend.lock.json`
|
||||
}
|
||||
|
||||
function spawnLogPath(ownershipId, spawnNonce) {
|
||||
return `${ownershipDirectory(ownershipId)}/${validateSpawnNonce(spawnNonce)}.log`
|
||||
}
|
||||
|
||||
// shell-single-quote a value for safe interpolation into a remote command.
|
||||
function shq(value) {
|
||||
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
function validateRemotePath(p) {
|
||||
const s = String(p || '')
|
||||
|
||||
if (!s) {
|
||||
throw new Error('Remote path must not be empty.')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-control-regex -- deliberately reject NUL in remote paths
|
||||
if (/[\x00\n\r]/.test(s)) {
|
||||
throw new Error('Unsafe remote path: contains NUL or newline.')
|
||||
}
|
||||
|
||||
if (s === '~' || s.startsWith('~/') || s.startsWith('/')) {
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(`Remote path must be absolute or start with ~/: "${s}"`)
|
||||
}
|
||||
|
||||
function expandRemotePath(p) {
|
||||
validateRemotePath(p)
|
||||
|
||||
if (p === '~') {
|
||||
return '"$HOME"'
|
||||
}
|
||||
|
||||
if (p.startsWith('~/')) {
|
||||
return '"$HOME"' + shq(p.slice(1))
|
||||
}
|
||||
|
||||
return shq(p)
|
||||
}
|
||||
|
||||
// Resolve the remote hermes executable. An EXPLICIT path is honored strictly
|
||||
// (throws a path-naming error if not executable — never silently falls back to a
|
||||
// different install). A BLANK path auto-detects: login-shell `command -v` (a
|
||||
// non-login `ssh host cmd` PATH misses user installs), then known install paths.
|
||||
async function locateHermes(ssh, remoteHermesPath) {
|
||||
const resolveLauncher = async (candidate: string) => {
|
||||
const script =
|
||||
'import os,shlex,sys\n' +
|
||||
`p=os.path.expanduser(${shq(candidate)})\n` +
|
||||
'out=p\n' +
|
||||
'try:\n' +
|
||||
' data=open(p,"r",encoding="utf-8",errors="ignore").read(4096)\n' +
|
||||
' for line in data.splitlines():\n' +
|
||||
' words=shlex.split(line)\n' +
|
||||
' if len(words)>1 and words[0]=="exec":\n' +
|
||||
' target=os.path.expanduser(words[1])\n' +
|
||||
' if os.path.isabs(target) and os.access(target,os.X_OK):out=target\n' +
|
||||
' break\n' +
|
||||
'except (OSError,ValueError):pass\n' +
|
||||
'print(out)'
|
||||
|
||||
const resolved = (await ssh.exec(`python3 -c ${shq(script)}`)).trim()
|
||||
|
||||
return resolved || candidate
|
||||
}
|
||||
|
||||
const isExecutable = async (candidate: string) => {
|
||||
try {
|
||||
validateRemotePath(candidate)
|
||||
const ok = (await ssh.exec(`[ -x ${expandRemotePath(candidate)} ] && echo OK || true`)).trim()
|
||||
|
||||
return ok === 'OK'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (remoteHermesPath) {
|
||||
if (await isExecutable(remoteHermesPath)) {
|
||||
return resolveLauncher(remoteHermesPath)
|
||||
}
|
||||
|
||||
const err: any = new Error(
|
||||
`The Hermes path you set is not an executable on the remote host: "${remoteHermesPath}". ` +
|
||||
'Check the path (it must be the full path to the `hermes` binary on the remote, e.g. ' +
|
||||
'~/hermes-agent/.venv/bin/hermes), or clear it to auto-detect.'
|
||||
)
|
||||
|
||||
err.kind = 'hermes-not-found'
|
||||
throw err
|
||||
}
|
||||
|
||||
const candidates: string[] = []
|
||||
|
||||
try {
|
||||
const found = (await ssh.exec(`bash -lc ${shq('command -v hermes')}`)).trim()
|
||||
|
||||
if (found) {
|
||||
candidates.push(found.split('\n').pop().trim())
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Fallback candidates when the login-shell probe misses: the installer's
|
||||
// command locations (scripts/install.sh) — per-user, root/FHS, legacy venv.
|
||||
candidates.push('~/.local/bin/hermes')
|
||||
candidates.push('/usr/local/bin/hermes')
|
||||
candidates.push('~/.hermes/hermes-agent/venv/bin/hermes')
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (await isExecutable(candidate)) {
|
||||
return resolveLauncher(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
const err: any = new Error(
|
||||
'Hermes is not installed on the remote host (could not find a `hermes` executable). ' +
|
||||
'Install it on the remote with: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh ' +
|
||||
'— or set the Hermes path explicitly in the SSH connection settings.'
|
||||
)
|
||||
|
||||
err.kind = 'hermes-not-found'
|
||||
throw err
|
||||
}
|
||||
|
||||
// Probe the resolved binary's version string (first line of `<hermes> --version`,
|
||||
// e.g. "Hermes Agent v0.18.2 ..."), or '' on failure. Surfaces WHICH hermes a
|
||||
// connection uses, so a stale/unexpected install is visible.
|
||||
async function probeHermesVersion(ssh, hermesPath) {
|
||||
try {
|
||||
const out = (await ssh.exec(`${expandRemotePath(hermesPath)} --version 2>&1`)).trim()
|
||||
|
||||
return (out.split('\n')[0] || '').trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function probeRemotePlatform(ssh) {
|
||||
const out = (await ssh.exec('uname -s; uname -m')).trim().split('\n')
|
||||
const osName = (out[0] || '').trim()
|
||||
const arch = (out[1] || '').trim()
|
||||
|
||||
if (!SUPPORTED_REMOTE_OS.has(osName)) {
|
||||
const err: any = new Error(
|
||||
`Unsupported remote platform "${osName || 'unknown'}". Hermes Desktop SSH mode supports Linux, macOS, and Windows remote hosts.`
|
||||
)
|
||||
|
||||
err.kind = 'unsupported-platform'
|
||||
throw err
|
||||
}
|
||||
|
||||
return { os: osName, arch }
|
||||
}
|
||||
|
||||
// The HERMES_HOME the remote dashboard will use (explicit env wins, else
|
||||
// ~/.hermes). Recorded in the lockfile so a future reuse can tell it's the same
|
||||
// state store; best-effort.
|
||||
async function probeRemoteHermesHome(ssh) {
|
||||
try {
|
||||
const out = (await ssh.exec('echo "${HERMES_HOME:-$HOME/.hermes}"')).trim().split('\n').pop()
|
||||
|
||||
return out || '~/.hermes'
|
||||
} catch (cause) {
|
||||
const error: any = new Error('Could not resolve the remote Hermes home.')
|
||||
error.kind = 'transient-transport-error'
|
||||
error.cause = cause
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function readLockfile(ssh, ownershipId) {
|
||||
const lpath = lockfilePath(ownershipId)
|
||||
let raw
|
||||
|
||||
try {
|
||||
raw = await ssh.exec(`if [ ! -e ${expandRemotePath(lpath)} ]; then exit 0; fi; cat ${expandRemotePath(lpath)}`)
|
||||
} catch (cause) {
|
||||
const error: any = new Error('Could not read the SSH backend ownership record.')
|
||||
error.kind = 'transient-transport-error'
|
||||
error.cause = cause
|
||||
throw error
|
||||
}
|
||||
|
||||
const text = String(raw || '').trim()
|
||||
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
let parsed
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!parsed || parsed.schemaVersion !== LOCKFILE_SCHEMA_VERSION) {
|
||||
return null
|
||||
}
|
||||
|
||||
const pid = parsed.pid
|
||||
const port = parsed.port
|
||||
|
||||
if (!Number.isInteger(pid) || pid <= 0 || pid > 4194304) {
|
||||
return null
|
||||
}
|
||||
|
||||
// port 0 = spawn-in-progress record (written before readiness); valid
|
||||
// ownership proof for cleanup, but never reusable.
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (parsed.ownershipId !== ownershipId || !/^[0-9a-f]{16}$/.test(parsed.spawnNonce || '')) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!/^[0-9a-f]{32}$/.test(parsed.tokenFingerprint || '')) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (parsed.protocolVersion !== PROTOCOL_VERSION) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (parsed.logPath !== spawnLogPath(ownershipId, parsed.spawnNonce)) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const field of ['profile', 'hermesPath', 'hermesHome', 'logPath', 'startedAt']) {
|
||||
if (typeof parsed[field] !== 'string' || parsed[field].length > 1024) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
async function writeLockfile(ssh, ownershipId, lock) {
|
||||
const directory = ownershipDirectory(ownershipId)
|
||||
const lpath = lockfilePath(ownershipId)
|
||||
const temporaryPath = `${directory}/.${crypto.randomBytes(8).toString('hex')}.lock.tmp`
|
||||
const json = JSON.stringify({ ...lock, schemaVersion: LOCKFILE_SCHEMA_VERSION })
|
||||
await ssh.exec(
|
||||
`umask 077 && mkdir -p ${expandRemotePath(directory)} && ` +
|
||||
`printf '%s' ${shq(json)} > ${expandRemotePath(temporaryPath)} && ` +
|
||||
`mv -f ${expandRemotePath(temporaryPath)} ${expandRemotePath(lpath)}`
|
||||
)
|
||||
}
|
||||
|
||||
async function removeLockfile(ssh, ownershipId) {
|
||||
const lpath = lockfilePath(ownershipId)
|
||||
|
||||
try {
|
||||
await ssh.exec(`rm -f ${expandRemotePath(lpath)}`)
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
async function remotePidAlive(ssh, pid) {
|
||||
if (!pid || !Number.isInteger(Number(pid))) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const out = (await ssh.exec(`kill -0 ${Number(pid)} 2>/dev/null && echo ALIVE || echo DEAD`)).trim()
|
||||
|
||||
return out === 'ALIVE'
|
||||
} catch (cause) {
|
||||
const error: any = new Error('Could not verify the SSH backend process.')
|
||||
error.kind = 'transient-transport-error'
|
||||
error.cause = cause
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// A pid is "provably ours" only if its remote cmdline carries our dashboard
|
||||
// args — never kill a pid we can't positively identify as our dashboard.
|
||||
async function pidIsOurDashboard(ssh, pid, spawnNonce, hermesPath = '') {
|
||||
if (!pid || !/^[0-9a-f]{16}$/.test(String(spawnNonce || '')) || !hermesPath) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const script =
|
||||
'import os,shlex,subprocess,sys\n' +
|
||||
`pid=${Number(pid)}\n` +
|
||||
`expected=os.path.expanduser(${shq(hermesPath)})\n` +
|
||||
`nonce=${shq(spawnNonce)}\n` +
|
||||
'try:\n' +
|
||||
' raw=open(f"/proc/{pid}/cmdline","rb").read()\n' +
|
||||
' args=[x.decode("utf-8","surrogateescape") for x in raw.split(b"\\0") if x]\n' +
|
||||
'except OSError:\n' +
|
||||
' line=subprocess.check_output(["ps","-o","command=","-p",str(pid)],text=True).strip()\n' +
|
||||
' args=shlex.split(line)\n' +
|
||||
'ok=False\n' +
|
||||
'try:\n' +
|
||||
' serve=args.index("serve")\n' +
|
||||
' owner=args.index("--ssh-owner-nonce",serve+1)\n' +
|
||||
' direct=args[0]==expected\n' +
|
||||
' python_entry=len(args)>1 and args[1]==expected and os.path.basename(args[0]).startswith("python")\n' +
|
||||
' ok=(direct or python_entry) and "--isolated" in args[serve+1:] and args[owner+1]==nonce\n' +
|
||||
'except (ValueError,IndexError):pass\n' +
|
||||
'print("OWNED" if ok else "FOREIGN")'
|
||||
|
||||
const out = await ssh.exec(`python3 -c ${shq(script)}`)
|
||||
|
||||
return String(out || '').trim() === 'OWNED'
|
||||
} catch (cause) {
|
||||
const error: any = new Error('Could not verify SSH backend process ownership.')
|
||||
error.kind = 'transient-transport-error'
|
||||
error.cause = cause
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Kill the stale dashboard ONLY if provably ours, then drop the lockfile.
|
||||
async function cleanupStale(ssh, ownershipId, lock, pidAlive = true) {
|
||||
if (pidAlive && lock && (await pidIsOurDashboard(ssh, lock.pid, lock.spawnNonce, lock.hermesPath))) {
|
||||
try {
|
||||
const result = (
|
||||
await ssh.exec(
|
||||
`kill ${Number(lock.pid)} && ` +
|
||||
`i=0; while kill -0 ${Number(lock.pid)} 2>/dev/null; do ` +
|
||||
`i=$((i+1)); [ "$i" -ge 50 ] && exit 1; sleep 0.1; done`
|
||||
)
|
||||
).trim()
|
||||
|
||||
void result
|
||||
} catch (cause) {
|
||||
const error: any = new Error('Could not terminate the stale SSH backend.')
|
||||
error.kind = 'transient-transport-error'
|
||||
error.cause = cause
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const expectedLogPath = lock?.spawnNonce ? spawnLogPath(ownershipId, lock.spawnNonce) : ''
|
||||
|
||||
if (lock?.logPath === expectedLogPath) {
|
||||
try {
|
||||
await ssh.exec(`rm -f ${expandRemotePath(lock.logPath)}`)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
await removeLockfile(ssh, ownershipId)
|
||||
}
|
||||
|
||||
// Detach so the backend survives the SSH channel closing: setsid (Linux)
|
||||
// starts a new session; macOS has no setsid, so fall back to nohup (HUP-immune;
|
||||
// fd-detachment is already handled by </dev/null + redirect + &).
|
||||
function buildSpawnCommand(hermesPath, profile, opts: any = {}) {
|
||||
const hermes = expandRemotePath(hermesPath)
|
||||
const profileArgs = profile ? `--profile ${shq(profile)} ` : ''
|
||||
const logPath = expandRemotePath(opts.logPath)
|
||||
const tokenFilePath = opts.tokenFilePath
|
||||
const tokenArg = tokenFilePath ? ` --ssh-session-token-file ${expandRemotePath(tokenFilePath)}` : ''
|
||||
const ownerArg = opts.spawnNonce ? ` --ssh-owner-nonce ${validateSpawnNonce(opts.spawnNonce)}` : ''
|
||||
const subCmd = `serve --isolated --host 127.0.0.1 --port 0${tokenArg}${ownerArg}`
|
||||
const dashCmd = `env HERMES_DESKTOP=1 ${hermes} ${profileArgs}${subCmd}`
|
||||
|
||||
return (
|
||||
`mkdir -p "$(dirname ${logPath})" && ` +
|
||||
`"$(command -v setsid || echo nohup)" sh -c ${shq(`${dashCmd} </dev/null >> ${logPath} 2>&1 & echo $!`)}`
|
||||
)
|
||||
}
|
||||
|
||||
async function remoteSupportsSshOwnership(ssh, hermesPath) {
|
||||
const hermes = expandRemotePath(hermesPath)
|
||||
|
||||
const out = await ssh.exec(
|
||||
`help="$(${hermes} serve --help 2>&1)"; ` +
|
||||
`printf '%s' "$help" | grep -q ssh-session-token-file && ` +
|
||||
`printf '%s' "$help" | grep -q ssh-owner-nonce && echo YES || echo NO`
|
||||
)
|
||||
|
||||
return String(out || '')
|
||||
.trim()
|
||||
.endsWith('YES')
|
||||
}
|
||||
|
||||
async function scrapeReadyPort(ssh, logPath, { timeoutMs = DEFAULT_READY_TIMEOUT_MS, isAlive, signal }: any = {}) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
const remoteLog = expandRemotePath(logPath)
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
assertNotAborted(signal)
|
||||
|
||||
if (isAlive && !(await isAlive())) {
|
||||
const err: any = new Error('Remote dashboard process exited before announcing its port.')
|
||||
err.kind = 'spawn-failed'
|
||||
throw err
|
||||
}
|
||||
|
||||
let tail
|
||||
|
||||
try {
|
||||
tail = await ssh.exec(`cat ${remoteLog} 2>/dev/null || true`)
|
||||
} catch {
|
||||
tail = ''
|
||||
}
|
||||
|
||||
const m = READY_RE.exec(String(tail || ''))
|
||||
|
||||
if (m) {
|
||||
return parseInt(m[1], 10)
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, READY_POLL_INTERVAL_MS))
|
||||
}
|
||||
|
||||
const err: any = new Error(`Timed out waiting for the remote dashboard to announce its port (${timeoutMs}ms).`)
|
||||
err.kind = 'ready-timeout'
|
||||
throw err
|
||||
}
|
||||
|
||||
async function spawnRemoteDashboard(ssh, { hermesPath, profile, token, ownershipId }) {
|
||||
if (!(await remoteSupportsSshOwnership(ssh, hermesPath))) {
|
||||
const err: any = new Error(
|
||||
'The remote Hermes install does not support --ssh-session-token-file and --ssh-owner-nonce. ' +
|
||||
'Update Hermes on the remote host to continue using Desktop SSH mode.'
|
||||
)
|
||||
|
||||
err.kind = 'update-required'
|
||||
throw err
|
||||
}
|
||||
|
||||
const spawnNonce = crypto.randomBytes(8).toString('hex')
|
||||
const tokenDir = ownershipDirectory(ownershipId)
|
||||
const tokenFilePath = `${tokenDir}/${spawnNonce}.token`
|
||||
const logPath = spawnLogPath(ownershipId, spawnNonce)
|
||||
|
||||
const tokenUploadPy =
|
||||
'import os,sys,stat\n' +
|
||||
`p=os.path.expanduser(${shq(tokenFilePath)})\n` +
|
||||
'd=os.path.dirname(p)\n' +
|
||||
'n=os.path.basename(p)\n' +
|
||||
'os.makedirs(d,mode=0o700,exist_ok=True)\n' +
|
||||
'df=os.O_RDONLY|getattr(os,"O_DIRECTORY",0)|getattr(os,"O_NOFOLLOW",0)\n' +
|
||||
'dd=os.open(d,df)\n' +
|
||||
'try:\n' +
|
||||
' s=os.fstat(dd)\n' +
|
||||
' if not stat.S_ISDIR(s.st_mode):raise SystemExit("unsafe token directory")\n' +
|
||||
' if hasattr(os,"getuid") and s.st_uid!=os.getuid():raise SystemExit("token directory owner mismatch")\n' +
|
||||
' if (s.st_mode&0o777)!=0o700:os.fchmod(dd,0o700)\n' +
|
||||
' fl=os.O_WRONLY|os.O_CREAT|os.O_EXCL|getattr(os,"O_NOFOLLOW",0)\n' +
|
||||
' now=__import__("time").time()\n' +
|
||||
' for stale in os.listdir(dd):\n' +
|
||||
' if stale.endswith(".token") and len(stale)==22:\n' +
|
||||
' try:\n' +
|
||||
' ss=os.stat(stale,dir_fd=dd,follow_symlinks=False)\n' +
|
||||
' if stat.S_ISREG(ss.st_mode) and now-ss.st_mtime>3600:os.unlink(stale,dir_fd=dd)\n' +
|
||||
' except OSError:pass\n' +
|
||||
' fd=os.open(n,fl,0o600,dir_fd=dd)\n' +
|
||||
' try:os.write(fd,sys.stdin.buffer.read())\n' +
|
||||
' except BaseException:\n' +
|
||||
' try:os.unlink(n,dir_fd=dd)\n' +
|
||||
' except OSError:pass\n' +
|
||||
' raise\n' +
|
||||
' finally:os.close(fd)\n' +
|
||||
'finally:os.close(dd)'
|
||||
|
||||
try {
|
||||
await ssh.exec(`python3 -c ${shq(tokenUploadPy)}`, { stdinData: token })
|
||||
} catch (error) {
|
||||
try {
|
||||
await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
let out
|
||||
|
||||
try {
|
||||
out = await ssh.exec(buildSpawnCommand(hermesPath, profile, { spawnNonce, tokenFilePath, logPath }))
|
||||
} catch (error) {
|
||||
try {
|
||||
await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
const pid = parseInt(
|
||||
String(out || '')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.pop(),
|
||||
10
|
||||
)
|
||||
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
try {
|
||||
await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
const err: any = new Error('Failed to launch the remote dashboard (no pid returned).')
|
||||
err.kind = 'spawn-failed'
|
||||
throw err
|
||||
}
|
||||
|
||||
return { pid, spawnNonce, logPath, tokenFilePath }
|
||||
}
|
||||
|
||||
// Best-effort forward teardown when a reuse attempt fails mid-flight, so we
|
||||
// don't leak a forward before respawning. `deps.cancelForward` is optional.
|
||||
async function cancelForwardSafe(deps, localPort, remotePort) {
|
||||
if (typeof deps.cancelForward !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.cancelForward(localPort, remotePort)
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotAborted(signal) {
|
||||
if (signal?.aborted) {
|
||||
const error: any = new Error('SSH bootstrap was cancelled.')
|
||||
error.kind = 'superseded'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function isForwardBindCollision(error) {
|
||||
return /address already in use|cannot listen to port|bind.*failed/i.test(String(error?.message || error || ''))
|
||||
}
|
||||
|
||||
async function openForward(deps, remotePort, attempts = 3) {
|
||||
let lastError
|
||||
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
const localPort = await deps.pickLocalPort()
|
||||
|
||||
try {
|
||||
await deps.forward(localPort, remotePort)
|
||||
|
||||
return localPort
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
|
||||
if (!isForwardBindCollision(error) || attempt === attempts - 1) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish (or reuse) a remote dashboard and a tunnel to it. `deps` injects the
|
||||
* opened SshConnection, forward/pickLocalPort/waitForHermes, a token-gated
|
||||
* probeReuseProof, and adoptServedToken. Returns the connection descriptor
|
||||
* { baseUrl, token, tokenFingerprint, remotePort, localPort, pid, reused, platform }.
|
||||
*/
|
||||
async function adoptOwnedServedToken(adoptServedToken, baseUrl, expectedToken, ssh, pid, label) {
|
||||
const token = await adoptServedToken(baseUrl, expectedToken, {
|
||||
childAlive: () => true,
|
||||
label
|
||||
})
|
||||
|
||||
if (!(await remotePidAlive(ssh, pid))) {
|
||||
const error: any = new Error(`${label} exited while its served token was being resolved.`)
|
||||
error.kind = token === expectedToken ? 'spawn-failed' : 'foreign-backend'
|
||||
throw error
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
async function connect(deps) {
|
||||
const {
|
||||
ssh,
|
||||
profile = '',
|
||||
remoteHermesPath = '',
|
||||
ownershipId,
|
||||
forward,
|
||||
pickLocalPort,
|
||||
waitForHermes,
|
||||
probeReuseProof,
|
||||
adoptServedToken,
|
||||
rememberLog = () => {},
|
||||
readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS,
|
||||
signal
|
||||
} = deps
|
||||
|
||||
const log = msg => rememberLog(`[ssh-lifecycle] ${msg}`)
|
||||
|
||||
assertNotAborted(signal)
|
||||
const platform = await probeRemotePlatform(ssh)
|
||||
log(`remote platform ${platform.os}/${platform.arch}`)
|
||||
const hermesPath = await locateHermes(ssh, remoteHermesPath)
|
||||
log(`located hermes at ${hermesPath}`)
|
||||
const hermesVersion = await probeHermesVersion(ssh, hermesPath)
|
||||
|
||||
if (hermesVersion) {
|
||||
log(`remote hermes version: ${hermesVersion}`)
|
||||
}
|
||||
|
||||
const reuseToken = deps.reuseToken || ''
|
||||
const hermesHome = await probeRemoteHermesHome(ssh)
|
||||
const lock = await readLockfile(ssh, ownershipId)
|
||||
|
||||
if (lock) {
|
||||
const pidAlive = await remotePidAlive(ssh, lock.pid)
|
||||
const owned = pidAlive && (await pidIsOurDashboard(ssh, lock.pid, lock.spawnNonce, lock.hermesPath))
|
||||
|
||||
const reusable =
|
||||
pidAlive &&
|
||||
owned &&
|
||||
lock.port > 0 &&
|
||||
Boolean(reuseToken) &&
|
||||
lock.tokenFingerprint === fingerprintToken(reuseToken) &&
|
||||
lock.hermesPath === hermesPath &&
|
||||
lock.hermesHome === hermesHome
|
||||
|
||||
if (reusable) {
|
||||
assertNotAborted(signal)
|
||||
const localPort = await openForward(deps, lock.port)
|
||||
|
||||
try {
|
||||
const baseUrl = `http://127.0.0.1:${localPort}`
|
||||
let reuseClassification
|
||||
|
||||
try {
|
||||
reuseClassification = await probeReuseProof(baseUrl, reuseToken, lock.spawnNonce)
|
||||
} catch (cause) {
|
||||
const error: any = new Error('Could not verify the existing SSH backend.')
|
||||
error.kind = 'transient-transport-error'
|
||||
error.cause = cause
|
||||
throw error
|
||||
}
|
||||
|
||||
if (reuseClassification === 'authenticated-stale') {
|
||||
assertNotAborted(signal)
|
||||
await cancelForwardSafe(deps, localPort, lock.port)
|
||||
await cleanupStale(ssh, ownershipId, lock)
|
||||
} else if (reuseClassification === 'authenticated-ok') {
|
||||
const token = await adoptOwnedServedToken(
|
||||
adoptServedToken,
|
||||
baseUrl,
|
||||
reuseToken,
|
||||
ssh,
|
||||
lock.pid,
|
||||
'reused remote dashboard'
|
||||
)
|
||||
|
||||
assertNotAborted(signal)
|
||||
log(`reusing remote dashboard pid=${lock.pid} port=${lock.port}`)
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
token,
|
||||
tokenFingerprint: fingerprintToken(token),
|
||||
remotePort: lock.port,
|
||||
localPort,
|
||||
pid: lock.pid,
|
||||
reused: true,
|
||||
platform,
|
||||
hermesPath,
|
||||
hermesVersion,
|
||||
ownershipId,
|
||||
spawnNonce: lock.spawnNonce,
|
||||
logPath: lock.logPath
|
||||
}
|
||||
} else {
|
||||
const error: any = new Error('SSH reuse proof returned an invalid classification.')
|
||||
error.kind = 'transient-transport-error'
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
await cancelForwardSafe(deps, localPort, lock.port)
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
assertNotAborted(signal)
|
||||
await cleanupStale(ssh, ownershipId, lock, pidAlive)
|
||||
}
|
||||
}
|
||||
|
||||
assertNotAborted(signal)
|
||||
const spawnToken = mintToken()
|
||||
|
||||
const { pid, spawnNonce, logPath, tokenFilePath } = await spawnRemoteDashboard(ssh, {
|
||||
hermesPath,
|
||||
profile,
|
||||
token: spawnToken,
|
||||
ownershipId
|
||||
})
|
||||
|
||||
log(`spawned remote dashboard pid=${pid}`)
|
||||
|
||||
const ownedSpawn = {
|
||||
ownershipId,
|
||||
spawnNonce,
|
||||
pid,
|
||||
port: 0,
|
||||
profile,
|
||||
hermesPath,
|
||||
hermesHome,
|
||||
logPath,
|
||||
tokenFingerprint: fingerprintToken(spawnToken),
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
startedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
let localPort = 0
|
||||
let remotePort = 0
|
||||
|
||||
try {
|
||||
// Write the ownership record IMMEDIATELY (port=0): a supersede between
|
||||
// spawn and readiness whose cleanup cannot reach the box must not leave a
|
||||
// lockless orphan — the next connect reaps it by exact ownership via this
|
||||
// record. Inside the try: if this write itself fails, the catch still
|
||||
// kills the just-spawned process via the in-memory record.
|
||||
await writeLockfile(ssh, ownershipId, ownedSpawn)
|
||||
remotePort = await scrapeReadyPort(ssh, logPath, {
|
||||
timeoutMs: readyTimeoutMs,
|
||||
isAlive: () => remotePidAlive(ssh, pid),
|
||||
signal
|
||||
})
|
||||
assertNotAborted(signal)
|
||||
log(`remote dashboard bound port ${remotePort}`)
|
||||
|
||||
localPort = await openForward(deps, remotePort)
|
||||
assertNotAborted(signal)
|
||||
const baseUrl = `http://127.0.0.1:${localPort}`
|
||||
await waitForHermes(baseUrl, spawnToken)
|
||||
assertNotAborted(signal)
|
||||
|
||||
const token = await adoptOwnedServedToken(adoptServedToken, baseUrl, spawnToken, ssh, pid, 'remote dashboard')
|
||||
|
||||
assertNotAborted(signal)
|
||||
const tokenFingerprint = fingerprintToken(token)
|
||||
await writeLockfile(ssh, ownershipId, { ...ownedSpawn, port: remotePort, tokenFingerprint })
|
||||
assertNotAborted(signal)
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
token,
|
||||
tokenFingerprint,
|
||||
remotePort,
|
||||
localPort,
|
||||
pid,
|
||||
reused: false,
|
||||
platform,
|
||||
hermesPath,
|
||||
hermesVersion,
|
||||
ownershipId,
|
||||
spawnNonce,
|
||||
logPath
|
||||
}
|
||||
} catch (error) {
|
||||
if (localPort && remotePort) {
|
||||
await cancelForwardSafe(deps, localPort, remotePort)
|
||||
}
|
||||
|
||||
try {
|
||||
await ssh.exec(`rm -f ${expandRemotePath(tokenFilePath)}`)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
await cleanupStale(ssh, ownershipId, ownedSpawn)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
adoptOwnedServedToken,
|
||||
buildSpawnCommand,
|
||||
cleanupStale,
|
||||
connect,
|
||||
DEFAULT_READY_TIMEOUT_MS,
|
||||
expandRemotePath,
|
||||
fingerprintToken,
|
||||
isForwardBindCollision,
|
||||
locateHermes,
|
||||
LOCKFILE_SCHEMA_VERSION,
|
||||
lockfilePath,
|
||||
mintToken,
|
||||
openForward,
|
||||
ownershipDirectory,
|
||||
pidIsOurDashboard,
|
||||
probeHermesVersion,
|
||||
probeRemoteHermesHome,
|
||||
probeRemotePlatform,
|
||||
PROTOCOL_VERSION,
|
||||
readLockfile,
|
||||
READY_RE,
|
||||
REMOTE_LOCK_DIR,
|
||||
remotePidAlive,
|
||||
remoteSupportsSshOwnership,
|
||||
removeLockfile,
|
||||
scrapeReadyPort,
|
||||
shq,
|
||||
spawnLogPath,
|
||||
spawnRemoteDashboard,
|
||||
SUPPORTED_REMOTE_OS,
|
||||
validateRemotePath,
|
||||
writeLockfile
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
REMOTE_LIVENESS_FAILURE_LIMIT,
|
||||
REMOTE_LIVENESS_FAILURE_WINDOW_MS,
|
||||
REMOTE_LIVENESS_TIMEOUT_MS,
|
||||
RemoteLivenessTracker,
|
||||
RemoteRevalidationCoordinator,
|
||||
revalidateRemoteConnection
|
||||
} from './remote-liveness'
|
||||
|
||||
describe('RemoteLivenessTracker', () => {
|
||||
it('requires consecutive failures before resetting a connection', () => {
|
||||
const tracker = new RemoteLivenessTracker()
|
||||
|
||||
for (let failures = 1; failures < REMOTE_LIVENESS_FAILURE_LIMIT; failures += 1) {
|
||||
expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures, shouldReset: false })
|
||||
}
|
||||
|
||||
expect(tracker.recordFailure('https://gateway.example.com')).toEqual({
|
||||
failures: REMOTE_LIVENESS_FAILURE_LIMIT,
|
||||
shouldReset: true
|
||||
})
|
||||
})
|
||||
|
||||
it('clears a failure streak after a successful probe', () => {
|
||||
const tracker = new RemoteLivenessTracker()
|
||||
|
||||
tracker.recordFailure('https://gateway.example.com')
|
||||
tracker.recordFailure('https://gateway.example.com')
|
||||
tracker.recordSuccess('https://gateway.example.com')
|
||||
|
||||
expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: false })
|
||||
})
|
||||
|
||||
it('tracks different gateways independently', () => {
|
||||
const tracker = new RemoteLivenessTracker(2)
|
||||
|
||||
expect(tracker.recordFailure('https://one.example.com')).toEqual({ failures: 1, shouldReset: false })
|
||||
expect(tracker.recordFailure('https://two.example.com')).toEqual({ failures: 1, shouldReset: false })
|
||||
expect(tracker.recordFailure('https://one.example.com')).toEqual({ failures: 2, shouldReset: true })
|
||||
expect(tracker.recordFailure('https://two.example.com')).toEqual({ failures: 2, shouldReset: true })
|
||||
})
|
||||
|
||||
it('clears only the successful gateway streak', () => {
|
||||
const tracker = new RemoteLivenessTracker(3)
|
||||
|
||||
tracker.recordFailure('https://one.example.com')
|
||||
tracker.recordFailure('https://two.example.com')
|
||||
tracker.recordSuccess('https://one.example.com')
|
||||
|
||||
expect(tracker.recordFailure('https://one.example.com')).toEqual({ failures: 1, shouldReset: false })
|
||||
expect(tracker.recordFailure('https://two.example.com')).toEqual({ failures: 2, shouldReset: false })
|
||||
})
|
||||
|
||||
it('does not accumulate isolated failures across separate reconnect episodes', () => {
|
||||
let now = 0
|
||||
const tracker = new RemoteLivenessTracker(3, REMOTE_LIVENESS_FAILURE_WINDOW_MS, () => now)
|
||||
|
||||
expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: false })
|
||||
now += REMOTE_LIVENESS_FAILURE_WINDOW_MS + 1
|
||||
expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: false })
|
||||
})
|
||||
|
||||
it('clears all failure streaks when the connection state resets', () => {
|
||||
const tracker = new RemoteLivenessTracker(3)
|
||||
|
||||
tracker.recordFailure('https://one.example.com')
|
||||
tracker.recordFailure('https://two.example.com')
|
||||
tracker.clear()
|
||||
|
||||
expect(tracker.recordFailure('https://one.example.com')).toEqual({ failures: 1, shouldReset: false })
|
||||
expect(tracker.recordFailure('https://two.example.com')).toEqual({ failures: 1, shouldReset: false })
|
||||
})
|
||||
|
||||
it('starts a fresh streak after the reset threshold is consumed', () => {
|
||||
const tracker = new RemoteLivenessTracker(1)
|
||||
|
||||
expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: true })
|
||||
expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: true })
|
||||
})
|
||||
|
||||
it('rejects invalid failure limits', () => {
|
||||
expect(() => new RemoteLivenessTracker(0)).toThrow(/positive integer/i)
|
||||
expect(() => new RemoteLivenessTracker(1.5)).toThrow(/positive integer/i)
|
||||
expect(() => new RemoteLivenessTracker(1, 0)).toThrow(/window must be positive/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RemoteRevalidationCoordinator', () => {
|
||||
it('coalesces simultaneous probes for the same cached connection', async () => {
|
||||
const coordinator = new RemoteRevalidationCoordinator()
|
||||
const connection = Promise.resolve({ baseUrl: 'https://gateway.example.com' })
|
||||
let resolveProbe: (value: string) => void = () => undefined
|
||||
|
||||
const probe = vi.fn(
|
||||
() =>
|
||||
new Promise<string>(resolve => {
|
||||
resolveProbe = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const first = coordinator.run(connection, probe)
|
||||
const second = coordinator.run(connection, probe)
|
||||
const third = coordinator.run(connection, probe)
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(third).toBe(first)
|
||||
expect(probe).toHaveBeenCalledOnce()
|
||||
|
||||
resolveProbe('healthy')
|
||||
await expect(Promise.all([first, second, third])).resolves.toEqual(['healthy', 'healthy', 'healthy'])
|
||||
})
|
||||
|
||||
it('runs a fresh probe after the prior one settles', async () => {
|
||||
const coordinator = new RemoteRevalidationCoordinator()
|
||||
const connection = Promise.resolve({ baseUrl: 'https://gateway.example.com' })
|
||||
const probe = vi.fn().mockResolvedValue('healthy')
|
||||
|
||||
await coordinator.run(connection, probe)
|
||||
await coordinator.run(connection, probe)
|
||||
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not coalesce different cached connections', async () => {
|
||||
const coordinator = new RemoteRevalidationCoordinator()
|
||||
const probe = vi.fn().mockResolvedValue('healthy')
|
||||
|
||||
await Promise.all([coordinator.run(Promise.resolve('one'), probe), coordinator.run(Promise.resolve('two'), probe)])
|
||||
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('cleans up a rejected probe so it can be retried', async () => {
|
||||
const coordinator = new RemoteRevalidationCoordinator()
|
||||
const connection = Promise.resolve({ baseUrl: 'https://gateway.example.com' })
|
||||
const probe = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce('healthy')
|
||||
|
||||
await expect(coordinator.run(connection, probe)).rejects.toThrow('offline')
|
||||
await expect(coordinator.run(connection, probe)).resolves.toBe('healthy')
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('revalidateRemoteConnection', () => {
|
||||
function harness(overrides: Record<string, unknown> = {}) {
|
||||
const connection = { baseUrl: 'https://gateway.example.com/', mode: 'remote' }
|
||||
const connectionPromise = Promise.resolve(connection)
|
||||
const current = { promise: connectionPromise as null | Promise<typeof connection> }
|
||||
const log = vi.fn()
|
||||
const probe = vi.fn().mockResolvedValue({ ok: true })
|
||||
const resetConnection = vi.fn()
|
||||
const tracker = new RemoteLivenessTracker()
|
||||
|
||||
return {
|
||||
connectionPromise,
|
||||
current,
|
||||
log,
|
||||
options: {
|
||||
connectionPromise,
|
||||
currentConnectionPromise: () => current.promise,
|
||||
log,
|
||||
probe,
|
||||
resetConnection,
|
||||
tracker,
|
||||
...overrides
|
||||
},
|
||||
probe,
|
||||
resetConnection,
|
||||
tracker
|
||||
}
|
||||
}
|
||||
|
||||
it('probes the normalized status URL with the production timeout', async () => {
|
||||
const test = harness()
|
||||
|
||||
await expect(revalidateRemoteConnection(test.options)).resolves.toEqual({ ok: true, rebuilt: false })
|
||||
expect(test.probe).toHaveBeenCalledWith('https://gateway.example.com/api/status', {
|
||||
timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS
|
||||
})
|
||||
expect(test.resetConnection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps failures one and two, then resets on the third failure', async () => {
|
||||
const probe = vi.fn().mockRejectedValue(new Error('offline'))
|
||||
const test = harness({ probe })
|
||||
|
||||
await expect(revalidateRemoteConnection(test.options)).resolves.toEqual({ ok: true, rebuilt: false })
|
||||
await expect(revalidateRemoteConnection(test.options)).resolves.toEqual({ ok: true, rebuilt: false })
|
||||
await expect(revalidateRemoteConnection(test.options)).resolves.toEqual({ ok: true, rebuilt: true })
|
||||
|
||||
expect(probe).toHaveBeenCalledTimes(3)
|
||||
expect(test.resetConnection).toHaveBeenCalledOnce()
|
||||
expect(test.log).toHaveBeenNthCalledWith(1, expect.stringContaining('(1/3)'))
|
||||
expect(test.log).toHaveBeenNthCalledWith(2, expect.stringContaining('(2/3)'))
|
||||
expect(test.log).toHaveBeenLastCalledWith(expect.stringContaining('dropping stale connection'))
|
||||
})
|
||||
|
||||
it('ignores a late failed probe after the cached connection is replaced', async () => {
|
||||
let rejectProbe: (error: Error) => void = () => undefined
|
||||
|
||||
const probe = vi.fn(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectProbe = reject
|
||||
})
|
||||
)
|
||||
|
||||
const test = harness({ probe })
|
||||
const pending = revalidateRemoteConnection(test.options)
|
||||
|
||||
await Promise.resolve()
|
||||
test.current.promise = Promise.resolve({ baseUrl: 'https://new.example.com', mode: 'remote' })
|
||||
rejectProbe(new Error('old connection failed'))
|
||||
|
||||
await expect(pending).resolves.toEqual({ ok: true, rebuilt: false })
|
||||
expect(test.resetConnection).not.toHaveBeenCalled()
|
||||
expect(test.log).not.toHaveBeenCalled()
|
||||
expect(test.tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: false })
|
||||
})
|
||||
|
||||
it('does not probe a local, rejected, or already replaced connection', async () => {
|
||||
const replaced = harness()
|
||||
|
||||
replaced.current.promise = null
|
||||
await expect(revalidateRemoteConnection(replaced.options)).resolves.toEqual({ ok: true, rebuilt: false })
|
||||
expect(replaced.probe).not.toHaveBeenCalled()
|
||||
|
||||
const localConnection = { baseUrl: 'http://127.0.0.1:3000', mode: 'local' }
|
||||
const localPromise = Promise.resolve(localConnection)
|
||||
|
||||
const local = harness({
|
||||
connectionPromise: localPromise,
|
||||
currentConnectionPromise: () => localPromise
|
||||
})
|
||||
|
||||
await expect(revalidateRemoteConnection(local.options)).resolves.toEqual({ ok: true, rebuilt: false })
|
||||
expect(local.probe).not.toHaveBeenCalled()
|
||||
|
||||
const rejectedPromise = Promise.reject(new Error('boot failed'))
|
||||
|
||||
const rejected = harness({
|
||||
connectionPromise: rejectedPromise,
|
||||
currentConnectionPromise: () => rejectedPromise
|
||||
})
|
||||
|
||||
await expect(revalidateRemoteConnection(rejected.options)).resolves.toEqual({ ok: true, rebuilt: false })
|
||||
expect(rejected.probe).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,182 +0,0 @@
|
||||
export const REMOTE_LIVENESS_TIMEOUT_MS = 10_000
|
||||
export const REMOTE_LIVENESS_FAILURE_LIMIT = 3
|
||||
// Even at the capped retry path, consecutive liveness observations are at most
|
||||
// about 48s apart (ticket mint + socket open + backoff + the next status probe).
|
||||
// One minute keeps a continuous outage together without carrying old failures.
|
||||
export const REMOTE_LIVENESS_FAILURE_WINDOW_MS = 60_000
|
||||
|
||||
export interface RemoteLivenessFailure {
|
||||
failures: number
|
||||
shouldReset: boolean
|
||||
}
|
||||
|
||||
interface RemoteConnectionDescriptor {
|
||||
baseUrl?: null | string
|
||||
mode?: null | string
|
||||
}
|
||||
|
||||
export interface RevalidateRemoteConnectionOptions<TConnection extends RemoteConnectionDescriptor> {
|
||||
connectionPromise: Promise<TConnection>
|
||||
currentConnectionPromise: () => null | Promise<TConnection>
|
||||
log: (message: string) => void
|
||||
probe: (url: string, options: { timeoutMs: number }) => Promise<unknown>
|
||||
resetConnection: () => void
|
||||
tracker: RemoteLivenessTracker
|
||||
}
|
||||
|
||||
export interface RemoteRevalidationResult {
|
||||
ok: true
|
||||
rebuilt: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesces revalidation work for one cached connection promise.
|
||||
*
|
||||
* Every Desktop BrowserWindow owns a renderer gateway loop. When several
|
||||
* windows observe the same disconnect they can all ask the Electron main
|
||||
* process to revalidate the shared primary connection at once. Those calls
|
||||
* must count as one probe, not several consecutive failures.
|
||||
*/
|
||||
export class RemoteRevalidationCoordinator {
|
||||
readonly #inflightByConnection = new WeakMap<object, Promise<unknown>>()
|
||||
|
||||
run<T>(connection: object, task: () => Promise<T>): Promise<T> {
|
||||
const existing = this.#inflightByConnection.get(connection) as Promise<T> | undefined
|
||||
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const pending = Promise.resolve().then(task)
|
||||
|
||||
const clear = () => {
|
||||
if (this.#inflightByConnection.get(connection) === pending) {
|
||||
this.#inflightByConnection.delete(connection)
|
||||
}
|
||||
}
|
||||
|
||||
this.#inflightByConnection.set(connection, pending)
|
||||
// Clean up on both outcomes without creating an unhandled rejected branch.
|
||||
void pending.then(clear, clear)
|
||||
|
||||
return pending
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks consecutive remote liveness failures independently per gateway.
|
||||
* A successful probe clears the streak, and reaching the limit consumes it so
|
||||
* a rebuilt connection starts from a clean state.
|
||||
*/
|
||||
export class RemoteLivenessTracker {
|
||||
readonly #failureLimit: number
|
||||
readonly #failureWindowMs: number
|
||||
readonly #failuresByBaseUrl = new Map<string, { failures: number; lastFailureAt: number }>()
|
||||
readonly #now: () => number
|
||||
|
||||
constructor(
|
||||
failureLimit = REMOTE_LIVENESS_FAILURE_LIMIT,
|
||||
failureWindowMs = REMOTE_LIVENESS_FAILURE_WINDOW_MS,
|
||||
now: () => number = Date.now
|
||||
) {
|
||||
if (!Number.isInteger(failureLimit) || failureLimit < 1) {
|
||||
throw new Error('Remote liveness failure limit must be a positive integer.')
|
||||
}
|
||||
|
||||
if (!Number.isFinite(failureWindowMs) || failureWindowMs < 1) {
|
||||
throw new Error('Remote liveness failure window must be positive.')
|
||||
}
|
||||
|
||||
this.#failureLimit = failureLimit
|
||||
this.#failureWindowMs = failureWindowMs
|
||||
this.#now = now
|
||||
}
|
||||
|
||||
recordSuccess(baseUrl: string): void {
|
||||
this.#failuresByBaseUrl.delete(baseUrl)
|
||||
}
|
||||
|
||||
recordFailure(baseUrl: string): RemoteLivenessFailure {
|
||||
const now = this.#now()
|
||||
const previous = this.#failuresByBaseUrl.get(baseUrl)
|
||||
const withinFailureWindow = previous && now - previous.lastFailureAt <= this.#failureWindowMs
|
||||
const failures = (withinFailureWindow ? previous.failures : 0) + 1
|
||||
const shouldReset = failures >= this.#failureLimit
|
||||
|
||||
if (shouldReset) {
|
||||
this.#failuresByBaseUrl.delete(baseUrl)
|
||||
} else {
|
||||
this.#failuresByBaseUrl.set(baseUrl, { failures, lastFailureAt: now })
|
||||
}
|
||||
|
||||
return { failures, shouldReset }
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.#failuresByBaseUrl.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the cached primary remote connection and apply the failure policy.
|
||||
* The caller owns single-flight coordination; identity checks here ensure an
|
||||
* old async result cannot mutate or reset a replacement connection.
|
||||
*/
|
||||
export async function revalidateRemoteConnection<TConnection extends RemoteConnectionDescriptor>({
|
||||
connectionPromise,
|
||||
currentConnectionPromise,
|
||||
log,
|
||||
probe,
|
||||
resetConnection,
|
||||
tracker
|
||||
}: RevalidateRemoteConnectionOptions<TConnection>): Promise<RemoteRevalidationResult> {
|
||||
let connection: TConnection
|
||||
|
||||
try {
|
||||
connection = await connectionPromise
|
||||
} catch {
|
||||
// The cached boot already rejected; its own recovery path will clear it.
|
||||
return { ok: true, rebuilt: false }
|
||||
}
|
||||
|
||||
if (currentConnectionPromise() !== connectionPromise) {
|
||||
return { ok: true, rebuilt: false }
|
||||
}
|
||||
|
||||
if (connection.mode !== 'remote' || !connection.baseUrl) {
|
||||
return { ok: true, rebuilt: false }
|
||||
}
|
||||
|
||||
const baseUrl = connection.baseUrl.replace(/\/+$/, '')
|
||||
|
||||
try {
|
||||
await probe(`${baseUrl}/api/status`, { timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS })
|
||||
|
||||
if (currentConnectionPromise() !== connectionPromise) {
|
||||
return { ok: true, rebuilt: false }
|
||||
}
|
||||
|
||||
tracker.recordSuccess(baseUrl)
|
||||
|
||||
return { ok: true, rebuilt: false }
|
||||
} catch {
|
||||
if (currentConnectionPromise() !== connectionPromise) {
|
||||
return { ok: true, rebuilt: false }
|
||||
}
|
||||
|
||||
const failure = tracker.recordFailure(baseUrl)
|
||||
|
||||
if (!failure.shouldReset) {
|
||||
log(
|
||||
`Cached remote Hermes backend failed liveness probe (${failure.failures}/${REMOTE_LIVENESS_FAILURE_LIMIT}); keeping connection for retry.`
|
||||
)
|
||||
|
||||
return { ok: true, rebuilt: false }
|
||||
}
|
||||
|
||||
log('Cached remote Hermes backend failed liveness probe; dropping stale connection.')
|
||||
resetConnection()
|
||||
|
||||
return { ok: true, rebuilt: true }
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,7 @@ import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
buildSessionWindowUrl,
|
||||
chatWindowWebPreferences,
|
||||
createSessionWindowRegistry,
|
||||
instanceWindowBounds
|
||||
} from './session-windows'
|
||||
import { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry } from './session-windows'
|
||||
|
||||
// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a
|
||||
// test fire the 'closed' event, mirroring the slice of the Electron API the
|
||||
@@ -88,16 +83,10 @@ test('buildSessionWindowUrl adds the watch flag for spectator windows, before th
|
||||
assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc')
|
||||
})
|
||||
|
||||
test('instanceWindowBounds cascades a new window off its source bounds', () => {
|
||||
const bounds = instanceWindowBounds({ x: 100, y: 120, width: 1400, height: 900 }, { width: 1, height: 1 })
|
||||
test('buildSessionWindowUrl routes new-session windows to the draft (#/)', () => {
|
||||
const url = buildSessionWindowUrl(null, { devServer: 'http://localhost:5173', newSession: true })
|
||||
|
||||
assert.deepEqual(bounds, { width: 1400, height: 900, x: 132, y: 152 })
|
||||
})
|
||||
|
||||
test('instanceWindowBounds falls back to the persisted geometry with no source window', () => {
|
||||
const fallback = { width: 1280, height: 800 }
|
||||
|
||||
assert.equal(instanceWindowBounds(null, fallback), fallback)
|
||||
assert.equal(url, 'http://localhost:5173/?win=secondary&new=1#/')
|
||||
})
|
||||
|
||||
test('registry opens one window per session and focuses on re-open', () => {
|
||||
|
||||
@@ -38,12 +38,13 @@ function chatWindowWebPreferences(preloadPath: string) {
|
||||
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
|
||||
// treated as the route by HashRouter and would break routeSessionId(). The
|
||||
// renderer reads the flag from window.location.search to suppress the install /
|
||||
// onboarding overlays and the global session sidebar. `watch=1` marks a
|
||||
// spectator window (e.g. a running subagent's session): the renderer resumes it
|
||||
// lazily so the gateway never builds an agent just to stream into it.
|
||||
function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath, watch }: any = {}) {
|
||||
const query = `?win=secondary${watch ? '&watch=1' : ''}`
|
||||
const route = `#/${encodeURIComponent(sessionId)}`
|
||||
// onboarding overlays and the global session sidebar. `new=1` marks the compact
|
||||
// scratch window; `watch=1` marks a spectator window (e.g. a running subagent's
|
||||
// session): the renderer resumes it lazily so the gateway never builds an agent
|
||||
// just to stream into it.
|
||||
function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath, watch, newSession }: any = {}) {
|
||||
const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}`
|
||||
const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}`
|
||||
|
||||
if (devServer) {
|
||||
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
|
||||
@@ -54,28 +55,6 @@ function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath
|
||||
return `${pathToFileURL(rendererIndexPath).toString()}${query}${route}`
|
||||
}
|
||||
|
||||
// Full "instance" windows (⌘⇧N / the "New Window" command) open a complete app
|
||||
// peer, not a compact chat. Cascade each one off its source window's bounds so a
|
||||
// new window doesn't land exactly on top of the one it was spawned from. Pure so
|
||||
// it's unit-testable; the Electron glue (reading the focused window's bounds,
|
||||
// constructing the BrowserWindow) stays in main.ts. `base` is the source
|
||||
// window's current bounds, or null when there's no live source window — then the
|
||||
// persisted primary geometry (`fallback`) is used as-is.
|
||||
const INSTANCE_CASCADE_OFFSET = 32
|
||||
|
||||
function instanceWindowBounds(base: { x: number; y: number; width: number; height: number } | null, fallback: any) {
|
||||
if (!base) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return {
|
||||
width: base.width,
|
||||
height: base.height,
|
||||
x: base.x + INSTANCE_CASCADE_OFFSET,
|
||||
y: base.y + INSTANCE_CASCADE_OFFSET
|
||||
}
|
||||
}
|
||||
|
||||
// A small registry keyed by sessionId that guarantees one window per chat:
|
||||
// opening a session that already has a live window focuses it instead of
|
||||
// spawning a duplicate, and a window removes itself from the registry when it
|
||||
@@ -140,7 +119,6 @@ export {
|
||||
buildSessionWindowUrl,
|
||||
chatWindowWebPreferences,
|
||||
createSessionWindowRegistry,
|
||||
instanceWindowBounds,
|
||||
SESSION_WINDOW_MIN_HEIGHT,
|
||||
SESSION_WINDOW_MIN_WIDTH
|
||||
}
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator'
|
||||
|
||||
function deferred() {
|
||||
let resolve
|
||||
let reject
|
||||
|
||||
const promise = new Promise((ok, fail) => {
|
||||
resolve = ok
|
||||
reject = fail
|
||||
})
|
||||
|
||||
return { promise, reject, resolve }
|
||||
}
|
||||
|
||||
const config = { host: 'box', user: 'alice', port: 22, keyPath: '/key', remoteHermesPath: '/hermes' }
|
||||
|
||||
test('sshConfigFingerprint covers scope and every connection field', () => {
|
||||
const base = sshConfigFingerprint('', config)
|
||||
assert.equal(base, sshConfigFingerprint('', { ...config }))
|
||||
|
||||
for (const [field, value] of Object.entries({
|
||||
host: 'other',
|
||||
user: 'bob',
|
||||
port: 2222,
|
||||
keyPath: '/other',
|
||||
remoteHermesPath: '/other-hermes',
|
||||
effectiveConfigFingerprint: 'changed-config'
|
||||
})) {
|
||||
assert.notEqual(base, sshConfigFingerprint('', { ...config, [field]: value }))
|
||||
}
|
||||
|
||||
assert.notEqual(base, sshConfigFingerprint('profile', config))
|
||||
})
|
||||
|
||||
test('same scope and fingerprint share one bootstrap', async () => {
|
||||
const coordinator = createBootstrapCoordinator()
|
||||
const gate = deferred()
|
||||
let runs = 0
|
||||
|
||||
const first = coordinator.start('', 'same', async () => {
|
||||
runs++
|
||||
|
||||
return gate.promise
|
||||
})
|
||||
|
||||
const second = coordinator.start('', 'same', async () => {
|
||||
runs++
|
||||
|
||||
return 'wrong'
|
||||
})
|
||||
|
||||
assert.equal(first, second)
|
||||
gate.resolve('done')
|
||||
assert.equal(await second, 'done')
|
||||
assert.equal(runs, 1)
|
||||
})
|
||||
|
||||
test('changed fingerprint waits for old rollback before starting', async () => {
|
||||
const coordinator = createBootstrapCoordinator()
|
||||
const gate = deferred()
|
||||
const events: string[] = []
|
||||
let oldLease
|
||||
|
||||
const oldPromise = coordinator.start('', 'old', async lease => {
|
||||
oldLease = lease
|
||||
events.push('old-start')
|
||||
await gate.promise
|
||||
events.push('old-rollback')
|
||||
lease.assertCurrent()
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
const newPromise = coordinator.start('', 'new', async lease => {
|
||||
events.push('new-start')
|
||||
lease.assertCurrent()
|
||||
|
||||
return 'new'
|
||||
})
|
||||
|
||||
assert.equal(oldLease.signal.aborted, true)
|
||||
await Promise.resolve()
|
||||
assert.deepEqual(events, ['old-start'])
|
||||
gate.resolve()
|
||||
await assert.rejects(oldPromise, (error: any) => error.kind === 'superseded')
|
||||
assert.equal(await newPromise, 'new')
|
||||
assert.deepEqual(events, ['old-start', 'old-rollback', 'new-start'])
|
||||
})
|
||||
|
||||
test('forceCleanupAll runs registered pending resource cleanup', async () => {
|
||||
const coordinator = createBootstrapCoordinator()
|
||||
const gate = deferred()
|
||||
let cleaned = 0
|
||||
|
||||
const promise = coordinator.start('', 'x', async lease => {
|
||||
lease.onForceCleanup(async () => {
|
||||
cleaned++
|
||||
})
|
||||
await gate.promise
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
await coordinator.forceCleanupAll()
|
||||
assert.equal(cleaned, 1)
|
||||
gate.resolve()
|
||||
await promise
|
||||
})
|
||||
|
||||
test('cancelAll invalidates every pending scope and exposes promises for quit', async () => {
|
||||
const coordinator = createBootstrapCoordinator()
|
||||
const gates = [deferred(), deferred()]
|
||||
|
||||
const promises = gates.map((gate, index) =>
|
||||
coordinator.start(String(index), 'x', async lease => {
|
||||
await gate.promise
|
||||
lease.assertCurrent()
|
||||
})
|
||||
)
|
||||
|
||||
assert.equal(coordinator.promises().length, 2)
|
||||
coordinator.cancelAll()
|
||||
gates.forEach(gate => gate.resolve())
|
||||
const results = await Promise.allSettled(promises)
|
||||
assert.ok(results.every(result => result.status === 'rejected' && (result.reason as any).kind === 'superseded'))
|
||||
})
|
||||
|
||||
test('cancelAndWait drains only the requested scope', async () => {
|
||||
const coordinator = createBootstrapCoordinator()
|
||||
const firstGate = deferred()
|
||||
const secondGate = deferred()
|
||||
|
||||
const first = coordinator.start('first', 'x', async lease => {
|
||||
await firstGate.promise
|
||||
lease.assertCurrent()
|
||||
})
|
||||
|
||||
const second = coordinator.start('second', 'x', async lease => {
|
||||
await secondGate.promise
|
||||
lease.assertCurrent()
|
||||
|
||||
return 'second'
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
let drained = false
|
||||
|
||||
const drain = coordinator.cancelAndWait('first').then(() => {
|
||||
drained = true
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
assert.equal(drained, false)
|
||||
firstGate.resolve()
|
||||
await drain
|
||||
await assert.rejects(first, (error: any) => error.kind === 'superseded')
|
||||
assert.equal(coordinator.pending.has('second'), true)
|
||||
secondGate.resolve()
|
||||
assert.equal(await second, 'second')
|
||||
})
|
||||
|
||||
test('a generation started during cancelAndWait cannot run before the drain completes', async () => {
|
||||
const coordinator = createBootstrapCoordinator()
|
||||
const oldGate = deferred()
|
||||
const events: string[] = []
|
||||
|
||||
const old = coordinator.start('scope', 'old', async lease => {
|
||||
events.push('old-start')
|
||||
await oldGate.promise
|
||||
lease.assertCurrent()
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
const drain = coordinator.cancelAndWait('scope')
|
||||
|
||||
const next = coordinator.start('scope', 'new', async () => {
|
||||
events.push('new-start')
|
||||
|
||||
return 'new'
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
assert.deepEqual(events, ['old-start'])
|
||||
oldGate.resolve()
|
||||
await drain
|
||||
await assert.rejects(old, (error: any) => error.kind === 'superseded')
|
||||
assert.equal(await next, 'new')
|
||||
assert.deepEqual(events, ['old-start', 'new-start'])
|
||||
})
|
||||
@@ -1,130 +0,0 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
function sshConfigFingerprint(scope, config) {
|
||||
const parts = [
|
||||
scope,
|
||||
config.host,
|
||||
config.user,
|
||||
config.port,
|
||||
config.keyPath,
|
||||
config.remoteHermesPath,
|
||||
config.effectiveConfigFingerprint
|
||||
]
|
||||
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(parts.map(value => value ?? '')))
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
function createBootstrapCoordinator() {
|
||||
const active = new Set<any>()
|
||||
const pending = new Map<string, any>()
|
||||
const generations = new Map<string, number>()
|
||||
const drains = new Map<string, Promise<void>>()
|
||||
|
||||
function start(scope, fingerprint, run) {
|
||||
const current = pending.get(scope)
|
||||
|
||||
if (current?.fingerprint === fingerprint) {
|
||||
return current.promise
|
||||
}
|
||||
|
||||
current?.controller.abort()
|
||||
|
||||
const generation = (generations.get(scope) || 0) + 1
|
||||
generations.set(scope, generation)
|
||||
const controller = new AbortController()
|
||||
const forceCleanups = new Set<() => any>()
|
||||
|
||||
const lease = {
|
||||
signal: controller.signal,
|
||||
onForceCleanup(cleanup) {
|
||||
forceCleanups.add(cleanup)
|
||||
|
||||
return () => forceCleanups.delete(cleanup)
|
||||
},
|
||||
isCurrent: () => !controller.signal.aborted && generations.get(scope) === generation,
|
||||
assertCurrent() {
|
||||
if (!this.isCurrent()) {
|
||||
const error: any = new Error('SSH bootstrap was superseded by newer connection settings.')
|
||||
error.kind = 'superseded'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const drain = drains.get(scope) || Promise.resolve()
|
||||
const predecessor = current ? Promise.allSettled([current.promise, drain]) : drain
|
||||
const entry: any = { controller, fingerprint, forceCleanups, generation, promise: null, scope }
|
||||
|
||||
const promise = predecessor
|
||||
.then(() => {
|
||||
lease.assertCurrent()
|
||||
|
||||
return run(lease)
|
||||
})
|
||||
.finally(() => {
|
||||
forceCleanups.clear()
|
||||
active.delete(entry)
|
||||
|
||||
if (pending.get(scope)?.generation === generation) {
|
||||
pending.delete(scope)
|
||||
}
|
||||
})
|
||||
|
||||
entry.promise = promise
|
||||
active.add(entry)
|
||||
pending.set(scope, entry)
|
||||
|
||||
return promise
|
||||
}
|
||||
|
||||
function cancel(scope) {
|
||||
pending.get(scope)?.controller.abort()
|
||||
}
|
||||
|
||||
async function cancelAndWait(scope) {
|
||||
let release
|
||||
|
||||
const barrier = new Promise<void>(resolve => {
|
||||
release = resolve
|
||||
})
|
||||
|
||||
drains.set(scope, barrier)
|
||||
const entries = [...active].filter(entry => entry.scope === scope)
|
||||
|
||||
for (const entry of entries) {
|
||||
entry.controller.abort()
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.allSettled(entries.map(entry => entry.promise))
|
||||
} finally {
|
||||
if (drains.get(scope) === barrier) {
|
||||
drains.delete(scope)
|
||||
}
|
||||
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
function cancelAll() {
|
||||
for (const entry of active) {
|
||||
entry.controller.abort()
|
||||
}
|
||||
}
|
||||
|
||||
async function forceCleanupAll() {
|
||||
const cleanups = [...active].flatMap(entry => [...entry.forceCleanups])
|
||||
await Promise.allSettled(cleanups.map(cleanup => cleanup()))
|
||||
}
|
||||
|
||||
function promises() {
|
||||
return [...active].map(entry => entry.promise)
|
||||
}
|
||||
|
||||
return { active, cancel, cancelAll, cancelAndWait, forceCleanupAll, pending, promises, start }
|
||||
}
|
||||
|
||||
export { createBootstrapCoordinator, sshConfigFingerprint }
|
||||
@@ -1,104 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { collectSshConfigHosts, parseSshConfigHosts, parseSshConfigIncludes, parseSshGOutput } from './ssh-config'
|
||||
|
||||
test('parseSshConfigHosts keeps literal aliases and drops wildcard/negated patterns', () => {
|
||||
const cfg = [
|
||||
'Host devbox',
|
||||
' HostName 10.0.0.5',
|
||||
'Host *.internal prod !staging glob*',
|
||||
'Host alpha beta',
|
||||
'# Host commented-out',
|
||||
'host lower-case'
|
||||
].join('\n')
|
||||
|
||||
assert.deepEqual(parseSshConfigHosts(cfg), ['devbox', 'prod', 'alpha', 'beta', 'lower-case'])
|
||||
})
|
||||
|
||||
test('parseSshConfigHosts de-duplicates', () => {
|
||||
assert.deepEqual(parseSshConfigHosts('Host box\nHost box\nHost box other'), ['box', 'other'])
|
||||
})
|
||||
|
||||
test('parseSshConfigIncludes extracts include tokens', () => {
|
||||
const cfg = 'Include ~/.ssh/config.d/*\nInclude work_hosts personal_hosts\n# Include ignored'
|
||||
assert.deepEqual(parseSshConfigIncludes(cfg), ['~/.ssh/config.d/*', 'work_hosts', 'personal_hosts'])
|
||||
})
|
||||
|
||||
test('collectSshConfigHosts follows Include directives (read-only)', () => {
|
||||
const files = {
|
||||
'/home/u/.ssh/config': 'Host main\nInclude work\nInclude ~/abs_inc',
|
||||
'/home/u/.ssh/work': 'Host work-box\nInclude nested',
|
||||
'/home/u/.ssh/nested': 'Host deep',
|
||||
'/home/u/abs_inc': 'Host home-abs'
|
||||
}
|
||||
|
||||
const hosts = collectSshConfigHosts('/home/u/.ssh/config', {
|
||||
homeDir: '/home/u',
|
||||
readFile: p => files[p] ?? null
|
||||
})
|
||||
|
||||
assert.deepEqual(hosts.sort(), ['deep', 'home-abs', 'main', 'work-box'].sort())
|
||||
})
|
||||
|
||||
test('collectSshConfigHosts tolerates a missing config file', () => {
|
||||
assert.deepEqual(collectSshConfigHosts('/nope/config', { homeDir: '/home/u', readFile: () => null }), [])
|
||||
})
|
||||
|
||||
test('collectSshConfigHosts does not loop on a self-include cycle', () => {
|
||||
const files = {
|
||||
'/home/u/.ssh/config': 'Host a\nInclude loop',
|
||||
'/home/u/.ssh/loop': 'Host b\nInclude config' // points back at config
|
||||
}
|
||||
|
||||
const hosts = collectSshConfigHosts('/home/u/.ssh/config', {
|
||||
homeDir: '/home/u',
|
||||
readFile: p => files[p] ?? null
|
||||
})
|
||||
|
||||
assert.deepEqual(hosts.sort(), ['a', 'b'])
|
||||
})
|
||||
|
||||
test('collectSshConfigHosts expands globbed includes via injected globSync', () => {
|
||||
const files = {
|
||||
'/home/u/.ssh/config': 'Host root\nInclude config.d/*',
|
||||
'/home/u/.ssh/config.d/10-work': 'Host work',
|
||||
'/home/u/.ssh/config.d/20-home': 'Host home'
|
||||
}
|
||||
|
||||
const hosts = collectSshConfigHosts('/home/u/.ssh/config', {
|
||||
homeDir: '/home/u',
|
||||
readFile: p => files[p] ?? null,
|
||||
globSync: pattern =>
|
||||
pattern.endsWith('config.d/*') ? ['/home/u/.ssh/config.d/10-work', '/home/u/.ssh/config.d/20-home'] : [pattern]
|
||||
})
|
||||
|
||||
assert.deepEqual(hosts.sort(), ['home', 'root', 'work'].sort())
|
||||
})
|
||||
|
||||
test('parseSshGOutput pulls hostname/user/port/identityfile', () => {
|
||||
const out = [
|
||||
'host devbox',
|
||||
'hostname 10.0.0.5',
|
||||
'user alice',
|
||||
'port 2222',
|
||||
'identityfile ~/.ssh/id_ed25519',
|
||||
'forwardagent no'
|
||||
].join('\n')
|
||||
|
||||
assert.deepEqual(parseSshGOutput(out), {
|
||||
hostname: '10.0.0.5',
|
||||
user: 'alice',
|
||||
port: 2222,
|
||||
identityFile: '~/.ssh/id_ed25519'
|
||||
})
|
||||
})
|
||||
|
||||
test('parseSshGOutput takes the FIRST identityfile and tolerates missing keys', () => {
|
||||
const out = 'hostname box\nidentityfile ~/.ssh/a\nidentityfile ~/.ssh/b'
|
||||
const parsed = parseSshGOutput(out)
|
||||
assert.equal(parsed.identityFile, '~/.ssh/a')
|
||||
assert.equal(parsed.user, null)
|
||||
assert.equal(parsed.port, null)
|
||||
})
|
||||
@@ -1,175 +0,0 @@
|
||||
/**
|
||||
* ssh-config.ts
|
||||
*
|
||||
* Pure, electron-free helpers for reading the user's OpenSSH client config:
|
||||
* `Host` aliases for the settings UI's suggestions, `Include` traversal
|
||||
* (read-only), and `ssh -G` output parsing. No `import 'electron'` so it's
|
||||
* unit-testable without Electron; main.ts wires the fs + `ssh -G` exec in.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
function parseSshConfigHosts(text) {
|
||||
const hosts: string[] = []
|
||||
const seen = new Set()
|
||||
|
||||
for (const rawLine of String(text || '').split('\n')) {
|
||||
const line = rawLine.trim()
|
||||
|
||||
if (!line || line.startsWith('#')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const m = /^host\s+(.+)$/i.exec(line)
|
||||
|
||||
if (!m) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const pattern of m[1].split(/\s+/)) {
|
||||
if (!pattern || pattern.includes('*') || pattern.includes('?') || pattern.startsWith('!')) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!seen.has(pattern)) {
|
||||
seen.add(pattern)
|
||||
hosts.push(pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hosts
|
||||
}
|
||||
|
||||
function parseSshConfigIncludes(text) {
|
||||
const includes: string[] = []
|
||||
|
||||
for (const rawLine of String(text || '').split('\n')) {
|
||||
const line = rawLine.trim()
|
||||
|
||||
if (!line || line.startsWith('#')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const m = /^include\s+(.+)$/i.exec(line)
|
||||
|
||||
if (!m) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const token of m[1].split(/\s+/)) {
|
||||
if (token) {
|
||||
includes.push(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return includes
|
||||
}
|
||||
|
||||
function collectSshConfigHosts(rootPath = '', deps: any = {}) {
|
||||
const readFile =
|
||||
deps.readFile ||
|
||||
(p => {
|
||||
try {
|
||||
return fs.readFileSync(p, 'utf8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const homeDir = deps.homeDir || os.homedir()
|
||||
const root = rootPath || path.join(homeDir, '.ssh', 'config')
|
||||
const sshDir = path.join(homeDir, '.ssh')
|
||||
|
||||
const out: string[] = []
|
||||
const seen = new Set()
|
||||
const visited = new Set()
|
||||
|
||||
const resolveIncludePath = token => {
|
||||
if (token.startsWith('~/')) {
|
||||
return path.join(homeDir, token.slice(2))
|
||||
}
|
||||
|
||||
if (path.isAbsolute(token)) {
|
||||
return token
|
||||
}
|
||||
|
||||
return path.join(sshDir, token)
|
||||
}
|
||||
|
||||
const walk = (filePath, depth) => {
|
||||
if (depth > 8 || visited.has(filePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
visited.add(filePath)
|
||||
const text = readFile(filePath)
|
||||
|
||||
if (text == null) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const host of parseSshConfigHosts(text)) {
|
||||
if (!seen.has(host)) {
|
||||
seen.add(host)
|
||||
out.push(host)
|
||||
}
|
||||
}
|
||||
|
||||
for (const token of parseSshConfigIncludes(text)) {
|
||||
const target = resolveIncludePath(token)
|
||||
const expanded = deps.globSync ? deps.globSync(target) : [target]
|
||||
|
||||
for (const p of expanded) {
|
||||
walk(p, depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(root, 0)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function parseSshGOutput(text) {
|
||||
const out: { hostname: string | null; user: string | null; port: number | null; identityFile: string | null } = {
|
||||
hostname: null,
|
||||
user: null,
|
||||
port: null,
|
||||
identityFile: null
|
||||
}
|
||||
|
||||
for (const rawLine of String(text || '').split('\n')) {
|
||||
const line = rawLine.trim()
|
||||
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
|
||||
const sp = line.indexOf(' ')
|
||||
|
||||
if (sp === -1) {
|
||||
continue
|
||||
}
|
||||
|
||||
const key = line.slice(0, sp).toLowerCase()
|
||||
const value = line.slice(sp + 1).trim()
|
||||
|
||||
if (key === 'hostname' && !out.hostname) {
|
||||
out.hostname = value
|
||||
} else if (key === 'user' && !out.user) {
|
||||
out.user = value
|
||||
} else if (key === 'port' && !out.port) {
|
||||
out.port = Number.parseInt(value, 10) || null
|
||||
} else if (key === 'identityfile' && !out.identityFile) {
|
||||
out.identityFile = value
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export { collectSshConfigHosts, parseSshConfigHosts, parseSshConfigIncludes, parseSshGOutput }
|
||||
@@ -1,866 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
baseSshOptions,
|
||||
buildControlArgs,
|
||||
buildExecArgs,
|
||||
buildInteractiveSshArgs,
|
||||
buildMasterArgs,
|
||||
classifySshError,
|
||||
controlSocketPath,
|
||||
createSshProbeConnection,
|
||||
forwardSpec,
|
||||
hostArgs,
|
||||
redactSecrets,
|
||||
runSsh,
|
||||
SSH_ERROR,
|
||||
SshConnection,
|
||||
sshErrorMessage,
|
||||
stopTunnelChild,
|
||||
target,
|
||||
validateSshTarget
|
||||
} from './ssh-connection'
|
||||
|
||||
test('redactSecrets scrubs the spawn-time session token env var', () => {
|
||||
const line = 'setsid env HERMES_DASHBOARD_SESSION_TOKEN=abc123deadbeef HERMES_DESKTOP=1 hermes dashboard'
|
||||
const out = redactSecrets(line)
|
||||
assert.ok(!out.includes('abc123deadbeef'))
|
||||
assert.match(out, /HERMES_DASHBOARD_SESSION_TOKEN=<redacted>/)
|
||||
// non-secret env vars are preserved
|
||||
assert.match(out, /HERMES_DESKTOP=1/)
|
||||
})
|
||||
|
||||
test('redactSecrets scrubs ?token= and ?ticket= URL params', () => {
|
||||
assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?token=supersecret'), /\?token=<redacted>/)
|
||||
assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?ticket=onetimeticket'), /\?ticket=<redacted>/)
|
||||
assert.match(redactSecrets('GET /x?a=1&token=zzz HTTP'), /&token=<redacted>/)
|
||||
assert.ok(!redactSecrets('?token=supersecret').includes('supersecret'))
|
||||
})
|
||||
|
||||
test('redactSecrets scrubs Authorization and X-Hermes-Session-Token headers', () => {
|
||||
assert.match(redactSecrets('Authorization: Bearer tok_9999'), /Authorization: Bearer <redacted>/)
|
||||
assert.ok(!redactSecrets('Authorization: Bearer tok_9999').includes('tok_9999'))
|
||||
assert.match(redactSecrets('X-Hermes-Session-Token: hdr_888'), /X-Hermes-Session-Token: ?<redacted>/)
|
||||
assert.ok(!redactSecrets('X-Hermes-Session-Token: hdr_888').includes('hdr_888'))
|
||||
})
|
||||
|
||||
test('redactSecrets handles null/undefined and non-secret text untouched', () => {
|
||||
assert.equal(redactSecrets(null), '')
|
||||
assert.equal(redactSecrets(undefined), '')
|
||||
assert.equal(redactSecrets('uname -s -m'), 'uname -s -m')
|
||||
})
|
||||
|
||||
test('controlSocketPath is stable, short, and host-distinct', () => {
|
||||
const a = controlSocketPath('me', 'box1', 22, '/tmp/d')
|
||||
const a2 = controlSocketPath('me', 'box1', 22, '/tmp/d')
|
||||
const b = controlSocketPath('me', 'box2', 22, '/tmp/d')
|
||||
assert.equal(a, a2, 'same triple → same socket (ControlMaster reuse)')
|
||||
assert.notEqual(a, b, 'different host → different socket')
|
||||
// 16 hex chars + .sock keeps the basename short for sun_path 104-byte limit
|
||||
assert.match(a, /\/[0-9a-f]{16}\.sock$/)
|
||||
})
|
||||
|
||||
test('controlSocketPath default base stays under sun_path even with the temp-listener suffix', () => {
|
||||
// OpenSSH binds a temporary listener at `<ControlPath>.<16 random chars>` (a
|
||||
// 17-byte suffix) while opening the master. The macOS regression was the
|
||||
// default base under os.tmpdir() (/var/folders/.../T/) pushing it over 104.
|
||||
const p = controlSocketPath('hermes', 'remote-build-server', 22) // no baseDir → default
|
||||
const worstCase = `${p}.0123456789abcdef` // mimic the .<16-char> temp suffix
|
||||
assert.ok(
|
||||
worstCase.length <= 104,
|
||||
`default control socket + temp suffix must fit sun_path (got ${worstCase.length}: ${worstCase})`
|
||||
)
|
||||
// And it must NOT live under the deeply-nested macOS per-user temp dir.
|
||||
assert.ok(!p.includes('/var/folders/'), 'default base must not be os.tmpdir() on macOS')
|
||||
})
|
||||
|
||||
test('baseSshOptions carries the house ControlMaster/BatchMode/accept-new policy', () => {
|
||||
const opts = baseSshOptions('/tmp/x.sock', 15000)
|
||||
const joined = opts.join(' ')
|
||||
assert.match(joined, /ControlPath=\/tmp\/x\.sock/)
|
||||
assert.match(joined, /ControlMaster=auto/)
|
||||
assert.match(joined, /ControlPersist=\d+/)
|
||||
assert.match(joined, /BatchMode=yes/)
|
||||
assert.match(joined, /StrictHostKeyChecking=accept-new/)
|
||||
assert.match(joined, /ExitOnForwardFailure=yes/)
|
||||
assert.match(joined, /ConnectTimeout=15/)
|
||||
assert.ok(!joined.includes('StrictHostKeyChecking=no'), 'never disables host-key checking')
|
||||
})
|
||||
|
||||
test('hostArgs adds -p only for non-default port and -i only with a key', () => {
|
||||
assert.deepEqual(hostArgs({ port: 22 }), [])
|
||||
assert.deepEqual(hostArgs({ port: 2222 }), ['-p', '2222'])
|
||||
assert.deepEqual(hostArgs({ port: 22, keyPath: '/k' }), ['-i', '/k'])
|
||||
assert.deepEqual(hostArgs({ port: 2200, keyPath: '/k' }), ['-p', '2200', '-i', '/k'])
|
||||
})
|
||||
|
||||
test('target builds user@host or bare host', () => {
|
||||
assert.equal(target('me', 'box'), 'me@box')
|
||||
assert.equal(target('', 'box'), 'box')
|
||||
})
|
||||
|
||||
test('buildExecArgs ends with host then the remote command', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||||
const args = buildExecArgs(conn, 'command -v hermes', 15000)
|
||||
assert.equal(args[args.length - 1], 'command -v hermes')
|
||||
assert.equal(args[args.length - 2], 'me@box')
|
||||
assert.ok(args.includes('BatchMode=yes'))
|
||||
})
|
||||
|
||||
test('buildControlArgs places -O <op> first and never appends a remote command', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 2222, keyPath: '/k', controlPath: '/tmp/x.sock' }
|
||||
const args = buildControlArgs(conn, 'forward', ['-L', forwardSpec(5000, 6000)], 15000)
|
||||
assert.equal(args[0], '-O')
|
||||
assert.equal(args[1], 'forward')
|
||||
assert.ok(args.includes('-L'))
|
||||
assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000'))
|
||||
assert.equal(args[args.length - 1], 'me@box')
|
||||
})
|
||||
|
||||
test('buildMasterArgs requests a backgrounded master (-M -N -f)', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||||
const args = buildMasterArgs(conn, 15000)
|
||||
assert.ok(args.includes('-M'))
|
||||
assert.ok(args.includes('-N'))
|
||||
assert.ok(args.includes('-f'))
|
||||
})
|
||||
|
||||
test('forwardSpec binds the local end to 127.0.0.1 only', () => {
|
||||
assert.equal(forwardSpec(5000, 6000), '127.0.0.1:5000:127.0.0.1:6000')
|
||||
assert.ok(forwardSpec(5000, 6000).startsWith('127.0.0.1:'))
|
||||
assert.ok(!forwardSpec(5000, 6000).startsWith('0.0.0.0'))
|
||||
})
|
||||
|
||||
test('buildInteractiveSshArgs requests a PTY, reuses the control master, execs a login shell', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||||
const args = buildInteractiveSshArgs(conn, '', 15000)
|
||||
assert.equal(args[0], '-tt', 'forces a PTY so the remote sees a real terminal')
|
||||
assert.ok(args.join(' ').includes('ControlPath=/tmp/x.sock'), 'reuses the existing master (no new auth)')
|
||||
assert.equal(args[args.length - 2], 'me@box')
|
||||
assert.equal(args[args.length - 1], 'exec "$SHELL" -l')
|
||||
})
|
||||
|
||||
test('buildInteractiveSshArgs cds into the remote cwd (best-effort) before the shell', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||||
const args = buildInteractiveSshArgs(conn, '/home/me/project', 15000)
|
||||
const remoteCmd = args[args.length - 1]
|
||||
assert.match(remoteCmd, /^cd '\/home\/me\/project' 2>\/dev\/null; exec "\$SHELL" -l$/)
|
||||
})
|
||||
|
||||
test('buildInteractiveSshArgs single-quotes a cwd with quotes safely', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||||
const args = buildInteractiveSshArgs(conn, "/tmp/a'b", 15000)
|
||||
// the embedded quote must be escaped, not break out of the quoting
|
||||
assert.ok(args[args.length - 1].startsWith("cd '/tmp/a'"))
|
||||
assert.ok(args[args.length - 1].includes('exec "$SHELL" -l'))
|
||||
})
|
||||
|
||||
test('classifySshError detects a changed host key (fail-closed)', () => {
|
||||
assert.equal(
|
||||
classifySshError('@@@@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @@@@'),
|
||||
SSH_ERROR.HOST_KEY_CHANGED
|
||||
)
|
||||
assert.equal(classifySshError('Host key verification failed.'), SSH_ERROR.HOST_KEY_CHANGED)
|
||||
assert.equal(classifySshError('Offending ECDSA key in /home/u/.ssh/known_hosts:5'), SSH_ERROR.HOST_KEY_CHANGED)
|
||||
})
|
||||
|
||||
test('classifySshError detects auth failure', () => {
|
||||
assert.equal(classifySshError('Permission denied (publickey).'), SSH_ERROR.AUTH_FAILED)
|
||||
assert.equal(classifySshError('Too many authentication failures'), SSH_ERROR.AUTH_FAILED)
|
||||
})
|
||||
|
||||
test('classifySshError detects unreachable', () => {
|
||||
assert.equal(classifySshError('ssh: Could not resolve hostname nope'), SSH_ERROR.UNREACHABLE)
|
||||
assert.equal(classifySshError('connect to host x port 22: Connection refused'), SSH_ERROR.UNREACHABLE)
|
||||
})
|
||||
|
||||
test('sshErrorMessage gives actionable guidance for auth and host-key-change', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22 }
|
||||
assert.match(sshErrorMessage(SSH_ERROR.AUTH_FAILED, conn, 'Permission denied'), /ssh-agent|ssh-add|IdentityFile/)
|
||||
assert.match(sshErrorMessage(SSH_ERROR.HOST_KEY_CHANGED, conn, 'CHANGED'), /ssh-keygen -R box/)
|
||||
})
|
||||
|
||||
// A fake child process that emits a scripted result on next tick.
|
||||
function fakeChild({ code = 0, stdout = '', stderr = '', errorEvent = null, hang = false }: any = {}) {
|
||||
const child: any = new EventEmitter()
|
||||
child.stdout = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
|
||||
child.kill = () => {
|
||||
child._killed = true
|
||||
}
|
||||
|
||||
if (hang) {
|
||||
return child // never emits close → drives the timeout path
|
||||
}
|
||||
|
||||
process.nextTick(() => {
|
||||
if (errorEvent) {
|
||||
child.emit('error', errorEvent)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (stdout) {
|
||||
child.stdout.emit('data', Buffer.from(stdout))
|
||||
}
|
||||
|
||||
if (stderr) {
|
||||
child.stderr.emit('data', Buffer.from(stderr))
|
||||
}
|
||||
|
||||
child.emit('close', code)
|
||||
})
|
||||
|
||||
return child
|
||||
}
|
||||
|
||||
// Build a spawnFn that returns scripted children per ssh invocation, recording
|
||||
// the args it was called with.
|
||||
function scriptedSpawn(scripts) {
|
||||
const calls: any[] = []
|
||||
let i = 0
|
||||
|
||||
const fn: any = (_cmd, args) => {
|
||||
calls.push(args)
|
||||
const script = typeof scripts === 'function' ? scripts(args, i) : scripts[Math.min(i, scripts.length - 1)]
|
||||
i += 1
|
||||
|
||||
return fakeChild(script || {})
|
||||
}
|
||||
|
||||
fn.calls = calls
|
||||
|
||||
return fn
|
||||
}
|
||||
|
||||
test('open() establishes the master when not already alive', async () => {
|
||||
// `-O check` fails first (not alive) → master opens (code 0). Track which
|
||||
// ssh ops ran rather than re-probing with the same always-failing check.
|
||||
const ops: string[] = []
|
||||
|
||||
const spawnFn = scriptedSpawn(args => {
|
||||
ops.push(args.includes('check') ? 'check' : args.includes('-M') ? 'master' : 'other')
|
||||
|
||||
if (args.includes('check')) {
|
||||
return { code: 255, stderr: 'no control path' }
|
||||
}
|
||||
|
||||
return { code: 0 }
|
||||
})
|
||||
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||||
await conn.open()
|
||||
assert.deepEqual(ops, ['check', 'master'], 'probes liveness first, then opens the master')
|
||||
})
|
||||
|
||||
test('open() is a no-op when the master is already alive and execs verify', async () => {
|
||||
const ops: string[] = []
|
||||
|
||||
const spawnFn = scriptedSpawn(args => {
|
||||
ops.push(args.includes('check') ? 'check' : args.includes('exit 0') ? 'verify' : 'master')
|
||||
|
||||
return { code: 0 } // check succeeds → alive; verify exec succeeds → trusted
|
||||
})
|
||||
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||||
await conn.open()
|
||||
assert.deepEqual(ops, ['check', 'verify'], 'alive master is exec-verified, then trusted without reopening')
|
||||
})
|
||||
|
||||
test('open() evicts a wedged master (check passes, exec hangs) and dials fresh', async () => {
|
||||
// The macOS mode-switch wedge: ControlPersist master answers -O check but
|
||||
// every exec through it hangs. open() must verify, evict (-O exit), and
|
||||
// establish a fresh master instead of trusting the corpse.
|
||||
const ops: string[] = []
|
||||
|
||||
const spawnFn = scriptedSpawn(args => {
|
||||
if (args.includes('check')) {
|
||||
ops.push('check')
|
||||
|
||||
return { code: 0 }
|
||||
}
|
||||
|
||||
if (args.includes('exit 0')) {
|
||||
ops.push('verify')
|
||||
|
||||
return { hang: true }
|
||||
}
|
||||
|
||||
if (args.includes('-O')) {
|
||||
ops.push('evict')
|
||||
|
||||
return { code: 0 }
|
||||
}
|
||||
|
||||
ops.push('master')
|
||||
|
||||
return { code: 0 }
|
||||
})
|
||||
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d', connectTimeoutMs: 50 })
|
||||
|
||||
await conn.open()
|
||||
assert.deepEqual(
|
||||
ops,
|
||||
['check', 'verify', 'evict', 'master'],
|
||||
'wedged master: verified, evicted, then a fresh master is dialed'
|
||||
)
|
||||
})
|
||||
|
||||
test('close() removes the control socket when -O exit fails', async () => {
|
||||
const dir = path.join(os.tmpdir(), `hermes-ssh-close-${process.pid}-${Date.now()}`)
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
||||
|
||||
const spawnFn = scriptedSpawn(args => {
|
||||
if (args.includes('check')) {
|
||||
return { code: 255 }
|
||||
} // not alive → open dials master
|
||||
|
||||
if (args.includes('-M')) {
|
||||
return { code: 0 }
|
||||
}
|
||||
|
||||
return { code: 255, stderr: 'mux: master gone' } // -O exit fails
|
||||
})
|
||||
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir })
|
||||
await conn.open()
|
||||
fs.writeFileSync(conn.controlPath, '') // simulate the lingering socket file
|
||||
await conn.close()
|
||||
assert.ok(!fs.existsSync(conn.controlPath), 'failed -O exit drops the socket so the next open dials fresh')
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('open() creates the control-socket directory if it does not exist', async () => {
|
||||
const dir = path.join(os.tmpdir(), `hermes-ssh-test-${process.pid}-${Date.now()}`)
|
||||
assert.ok(!fs.existsSync(dir), 'precondition: control dir absent')
|
||||
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir })
|
||||
|
||||
try {
|
||||
await conn.open()
|
||||
assert.ok(fs.existsSync(dir), 'open() created the control-socket directory before spawning ssh')
|
||||
} finally {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('open() surfaces a classified auth error', async () => {
|
||||
const spawnFn = scriptedSpawn(args => {
|
||||
if (args.includes('check')) {
|
||||
return { code: 255 }
|
||||
}
|
||||
|
||||
return { code: 255, stderr: 'Permission denied (publickey).' }
|
||||
})
|
||||
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||||
await assert.rejects(
|
||||
() => conn.open(),
|
||||
(err: any) => {
|
||||
assert.equal(err.kind, SSH_ERROR.AUTH_FAILED)
|
||||
assert.match(err.message, /ssh-agent|ssh-add/)
|
||||
|
||||
return true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('exec() returns stdout on success and rejects (classified) on failure', async () => {
|
||||
const okSpawn = scriptedSpawn([{ code: 0, stdout: 'Linux\n' }])
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: okSpawn, controlDir: '/tmp/d' })
|
||||
assert.equal((await conn.exec('uname -s')).trim(), 'Linux')
|
||||
|
||||
const failSpawn = scriptedSpawn([{ code: 1, stderr: 'ssh: Could not resolve hostname box' }])
|
||||
const conn2 = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: failSpawn, controlDir: '/tmp/d' })
|
||||
await assert.rejects(
|
||||
() => conn2.exec('uname -s'),
|
||||
(err: any) => {
|
||||
assert.equal(err.kind, SSH_ERROR.UNREACHABLE)
|
||||
|
||||
return true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('exec() treats a hung ssh as a timeout (half-open connection)', async () => {
|
||||
const spawnFn = scriptedSpawn([{ hang: true }])
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||||
await assert.rejects(
|
||||
() => conn.exec('uname -s', { timeoutMs: 30 }),
|
||||
(err: any) => {
|
||||
assert.equal(err.kind, SSH_ERROR.TIMEOUT)
|
||||
|
||||
return true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('forward() issues -O forward with a loopback-bound -L spec', async () => {
|
||||
const spawnFn = scriptedSpawn([{ code: 0 }])
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||||
await conn.forward(5000, 6000)
|
||||
const args = spawnFn.calls[0]
|
||||
assert.equal(args[0], '-O')
|
||||
assert.equal(args[1], 'forward')
|
||||
assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000'))
|
||||
})
|
||||
|
||||
test('lifecycle logging passes through redaction', async () => {
|
||||
const logs: string[] = []
|
||||
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
|
||||
|
||||
const conn = new SshConnection(
|
||||
{ host: 'box', user: 'me' },
|
||||
{ spawnFn, controlDir: '/tmp/d', rememberLog: l => logs.push(l) }
|
||||
)
|
||||
|
||||
await conn.open()
|
||||
|
||||
// none of the emitted log lines may carry a raw token-shaped secret
|
||||
for (const line of logs) {
|
||||
assert.ok(!/token=[^<]/.test(line))
|
||||
}
|
||||
|
||||
assert.ok(logs.some(l => l.includes('[ssh]')))
|
||||
})
|
||||
|
||||
test('no-mux: ssh args carry no ControlMaster/ControlPath options', async () => {
|
||||
const spawnFn = scriptedSpawn({ code: 0 })
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false })
|
||||
await conn.open()
|
||||
|
||||
for (const args of spawnFn.calls) {
|
||||
assert.ok(!args.some(a => /ControlMaster|ControlPath|ControlPersist/.test(a)), `mux option leaked: ${args}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('no-mux: open() verifies auth with a one-shot exec, no -M master', async () => {
|
||||
const spawnFn = scriptedSpawn({ code: 0 })
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false })
|
||||
await conn.open()
|
||||
assert.ok(!spawnFn.calls.some(args => args.includes('-M')), 'no master should be spawned')
|
||||
assert.ok(
|
||||
spawnFn.calls.some(args => args[args.length - 1] === 'exit 0'),
|
||||
'liveness/openness via one-shot exec'
|
||||
)
|
||||
})
|
||||
|
||||
test('SSH probe never creates or closes a ControlMaster', async () => {
|
||||
const spawnFn = scriptedSpawn({ code: 0 })
|
||||
const conn = createSshProbeConnection({ host: 'box', user: 'me' }, { spawnFn })
|
||||
await conn.open()
|
||||
await conn.close()
|
||||
const args = spawnFn.calls.flat()
|
||||
assert.ok(!args.includes('-M'))
|
||||
assert.ok(!args.includes('-O'))
|
||||
assert.ok(!args.some(value => /Control(?:Master|Path|Persist)/.test(value)))
|
||||
})
|
||||
|
||||
test('no-mux: open() classifies auth failure', async () => {
|
||||
const spawnFn = scriptedSpawn([{ code: 255, stderr: 'me@box: Permission denied (publickey).' }])
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false })
|
||||
await assert.rejects(conn.open(), (err: any) => err.kind === 'auth-failed')
|
||||
})
|
||||
|
||||
test('no-mux: forward spawns a persistent -N -L child; cancel + close kill it', async () => {
|
||||
// Real listener stands in for the tunnel's local end so waitForLocalPort sees it.
|
||||
const net = await import('node:net')
|
||||
const srv = net.createServer()
|
||||
await new Promise<void>(r => srv.listen(0, '127.0.0.1', () => r()))
|
||||
const localPort = (srv.address() as any).port
|
||||
const tunnels: any[] = []
|
||||
|
||||
const spawnFn: any = (_cmd, args) => {
|
||||
const child: any = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
child.exitCode = null
|
||||
|
||||
child.kill = () => {
|
||||
child._killed = true
|
||||
child.exitCode = 0
|
||||
process.nextTick(() => child.emit('exit', 0))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (args.includes('-N')) {
|
||||
tunnels.push({ args, child })
|
||||
process.nextTick(() =>
|
||||
child.stderr.emit('data', Buffer.from(`Local forwarding listening on 127.0.0.1 port ${localPort}.`))
|
||||
)
|
||||
} else {
|
||||
process.nextTick(() => child.emit('close', 0))
|
||||
}
|
||||
|
||||
if (!args.includes('-N')) {
|
||||
child.stdout = new EventEmitter()
|
||||
process.nextTick(() => child.emit('close', 0))
|
||||
}
|
||||
|
||||
return child
|
||||
}
|
||||
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false })
|
||||
await conn.forward(localPort, 9119)
|
||||
assert.equal(tunnels.length, 1, 'one persistent tunnel child')
|
||||
assert.ok(tunnels[0].args.includes('-L'), 'tunnel child carries -L spec')
|
||||
assert.ok(!tunnels[0].args.some(a => /ControlPath/.test(a)))
|
||||
|
||||
await conn.cancelForward(localPort, 9119)
|
||||
assert.ok(tunnels[0].child._killed, 'cancelForward kills the tunnel child')
|
||||
|
||||
conn._opened = true
|
||||
await conn.close() // no-mux close never runs ssh -O exit; must not throw
|
||||
srv.close()
|
||||
})
|
||||
|
||||
test('no-mux: forward fails fast when the tunnel child dies (bad spec/auth)', async () => {
|
||||
const spawnFn: any = (_cmd, args) => {
|
||||
const child: any = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
child.exitCode = null
|
||||
|
||||
child.kill = () => {}
|
||||
|
||||
if (args.includes('-N')) {
|
||||
process.nextTick(() => {
|
||||
child.stderr.emit('data', Buffer.from('Permission denied (publickey).'))
|
||||
child.exitCode = 255
|
||||
})
|
||||
}
|
||||
|
||||
return child
|
||||
}
|
||||
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false, forwardTimeoutMs: 2000 })
|
||||
await assert.rejects(conn.forward(1, 9119), (err: any) => err.kind === 'auth-failed')
|
||||
})
|
||||
|
||||
test('no-mux: an unrelated listener cannot mask a delayed bind failure', async () => {
|
||||
const net = await import('node:net')
|
||||
const srv = net.createServer()
|
||||
await new Promise<void>(resolve => srv.listen(0, '127.0.0.1', resolve))
|
||||
const localPort = (srv.address() as any).port
|
||||
|
||||
const spawnFn: any = (_cmd, args) => {
|
||||
const child: any = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
child.exitCode = null
|
||||
|
||||
child.kill = () => {}
|
||||
|
||||
if (args.includes('-N')) {
|
||||
setTimeout(() => {
|
||||
child.stderr.emit('data', Buffer.from(`bind [127.0.0.1]:${localPort}: Address already in use`))
|
||||
child.exitCode = 255
|
||||
child.emit('exit', 255)
|
||||
}, 20)
|
||||
}
|
||||
|
||||
return child
|
||||
}
|
||||
|
||||
const conn = new SshConnection({ host: 'box' }, { spawnFn, mux: false, forwardTimeoutMs: 1000 })
|
||||
await assert.rejects(conn.forward(localPort, 9119), /address already in use/i)
|
||||
srv.close()
|
||||
})
|
||||
|
||||
test('no-mux: tunnel death after readiness makes the connection unhealthy', async () => {
|
||||
const net = await import('node:net')
|
||||
const srv = net.createServer()
|
||||
await new Promise<void>(resolve => srv.listen(0, '127.0.0.1', resolve))
|
||||
const localPort = (srv.address() as any).port
|
||||
let tunnel
|
||||
|
||||
const spawnFn: any = (_cmd, args) => {
|
||||
const child: any = new EventEmitter()
|
||||
child.stdout = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
child.exitCode = null
|
||||
|
||||
child.kill = () => {}
|
||||
|
||||
if (args.includes('-N')) {
|
||||
tunnel = child
|
||||
process.nextTick(() =>
|
||||
child.stderr.emit('data', Buffer.from(`Local forwarding listening on 127.0.0.1 port ${localPort}.`))
|
||||
)
|
||||
} else {
|
||||
process.nextTick(() => child.emit('close', 0))
|
||||
}
|
||||
|
||||
return child
|
||||
}
|
||||
|
||||
const conn = new SshConnection({ host: 'box' }, { spawnFn, mux: false })
|
||||
await conn.open()
|
||||
await conn.forward(localPort, 9119)
|
||||
tunnel.emit('exit', 255)
|
||||
assert.equal(await conn.isAlive(), false)
|
||||
srv.close()
|
||||
})
|
||||
|
||||
test('validateSshTarget rejects a host starting with a dash (option injection)', () => {
|
||||
assert.throws(() => validateSshTarget('-oProxyCommand=evil', '', 22), /unsafe/i)
|
||||
assert.throws(() => validateSshTarget('--version', '', 22), /unsafe/i)
|
||||
})
|
||||
|
||||
test('validateSshTarget rejects control characters in host', () => {
|
||||
assert.throws(() => validateSshTarget('host\x00evil', '', 22), /unsafe/i)
|
||||
assert.throws(() => validateSshTarget('host\nnewline', '', 22), /unsafe/i)
|
||||
assert.throws(() => validateSshTarget('host\ttab', '', 22), /unsafe/i)
|
||||
})
|
||||
|
||||
test('validateSshTarget rejects control characters in user', () => {
|
||||
assert.throws(() => validateSshTarget('box', 'me\x00root', 22), /unsafe/i)
|
||||
assert.throws(() => validateSshTarget('box', '-oForward=yes', 22), /unsafe/i)
|
||||
})
|
||||
|
||||
test('validateSshTarget rejects ports outside 1-65535', () => {
|
||||
assert.throws(() => validateSshTarget('box', '', 0), /port/i)
|
||||
assert.throws(() => validateSshTarget('box', '', 65536), /port/i)
|
||||
assert.throws(() => validateSshTarget('box', '', -1), /port/i)
|
||||
assert.throws(() => validateSshTarget('box', '', NaN), /port/i)
|
||||
})
|
||||
|
||||
test('validateSshTarget accepts valid targets', () => {
|
||||
assert.doesNotThrow(() => validateSshTarget('my-host.example.com', 'alice', 22))
|
||||
assert.doesNotThrow(() => validateSshTarget('192.168.1.1', '', 2222))
|
||||
assert.doesNotThrow(() => validateSshTarget('::1', 'root', 22))
|
||||
})
|
||||
|
||||
test('SshConnection constructor rejects hostile host/user/port', () => {
|
||||
assert.throws(() => new SshConnection({ host: '-oProxyCommand=evil' }), /unsafe/i)
|
||||
assert.throws(() => new SshConnection({ host: 'box', user: '-oForward' }), /unsafe/i)
|
||||
assert.throws(() => new SshConnection({ host: 'box', port: 99999 }), /port/i)
|
||||
})
|
||||
|
||||
test('buildExecArgs inserts -- before the destination', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||||
const args = buildExecArgs(conn, 'uname -s', 15000)
|
||||
const ddIdx = args.indexOf('--')
|
||||
assert.ok(ddIdx >= 0, 'must contain --')
|
||||
assert.equal(args[ddIdx + 1], 'me@box', '-- immediately precedes the destination')
|
||||
assert.equal(args[ddIdx + 2], 'uname -s', 'remote command follows destination')
|
||||
})
|
||||
|
||||
test('buildMasterArgs inserts -- before the destination', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||||
const args = buildMasterArgs(conn, 15000)
|
||||
const ddIdx = args.indexOf('--')
|
||||
assert.ok(ddIdx >= 0, 'must contain --')
|
||||
assert.equal(args[ddIdx + 1], 'me@box')
|
||||
})
|
||||
|
||||
test('buildControlArgs inserts -- before the destination', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||||
const args = buildControlArgs(conn, 'check', [], 15000)
|
||||
const ddIdx = args.indexOf('--')
|
||||
assert.ok(ddIdx >= 0, 'must contain --')
|
||||
assert.equal(args[ddIdx + 1], 'me@box')
|
||||
})
|
||||
|
||||
test('buildInteractiveSshArgs inserts -- before the destination', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||||
const args = buildInteractiveSshArgs(conn, '', 15000)
|
||||
const ddIdx = args.indexOf('--')
|
||||
assert.ok(ddIdx >= 0, 'must contain --')
|
||||
assert.equal(args[ddIdx + 1], 'me@box')
|
||||
})
|
||||
|
||||
test('hostArgs rejects a keyPath with control characters', () => {
|
||||
assert.throws(() => hostArgs({ keyPath: '/tmp/key\x00inject' }), /unsafe/i)
|
||||
})
|
||||
|
||||
test('hostArgs rejects a keyPath starting with a dash', () => {
|
||||
assert.throws(() => hostArgs({ keyPath: '-oProxyCommand=evil' }), /unsafe/i)
|
||||
})
|
||||
|
||||
test('hostArgs accepts valid key paths', () => {
|
||||
assert.deepEqual(hostArgs({ keyPath: '/home/user/.ssh/id_ed25519' }), ['-i', '/home/user/.ssh/id_ed25519'])
|
||||
assert.deepEqual(hostArgs({ keyPath: '~/.ssh/id_rsa' }), ['-i', '~/.ssh/id_rsa'])
|
||||
})
|
||||
|
||||
test('runSsh delivers stdinData to the child and does not log it', async () => {
|
||||
let stdinWritten = ''
|
||||
|
||||
const spawnFn: any = (_cmd, _args, opts) => {
|
||||
const child: any = new EventEmitter()
|
||||
child.stdout = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
|
||||
child.kill = () => {}
|
||||
child.stdin = {
|
||||
end(data) {
|
||||
stdinWritten = String(data)
|
||||
}
|
||||
}
|
||||
assert.equal(opts.stdio[0], 'pipe', 'stdin must be pipe when stdinData is provided')
|
||||
process.nextTick(() => child.emit('close', 0))
|
||||
|
||||
return child
|
||||
}
|
||||
|
||||
await runSsh(['host', 'cat'], { timeoutMs: 5000, spawnFn, stdinData: 'secret-token-value' })
|
||||
assert.equal(stdinWritten, 'secret-token-value', 'stdinData must be written to child.stdin')
|
||||
})
|
||||
|
||||
test('open() rejects a control-dir that is a symlink', async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-test-'))
|
||||
const real = path.join(tmp, 'real')
|
||||
const link = path.join(tmp, 'link')
|
||||
fs.mkdirSync(real, { mode: 0o700 })
|
||||
fs.symlinkSync(real, link)
|
||||
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: link })
|
||||
await assert.rejects(conn.open(), /symlink|unsafe/i)
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('open() enforces 0700 on an existing control dir with lax permissions', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-test-'))
|
||||
const dir = path.join(tmp, 'ctrl')
|
||||
fs.mkdirSync(dir, { mode: 0o755 })
|
||||
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir })
|
||||
await conn.open()
|
||||
const stat = fs.statSync(dir)
|
||||
assert.equal(stat.mode & 0o777, 0o700, 'control dir must be tightened to 0700')
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('control socket identity separates installation scope and key identity', () => {
|
||||
const base = controlSocketPath('me', 'box', 22, '/tmp/d', {
|
||||
ownershipId: 'installation-a',
|
||||
scope: 'primary',
|
||||
keyPath: '/keys/id'
|
||||
})
|
||||
|
||||
assert.equal(
|
||||
base,
|
||||
controlSocketPath('me', 'box', 22, '/tmp/d', {
|
||||
ownershipId: 'installation-a',
|
||||
scope: 'primary',
|
||||
keyPath: '/keys/./id'
|
||||
})
|
||||
)
|
||||
assert.notEqual(
|
||||
base,
|
||||
controlSocketPath('me', 'box', 22, '/tmp/d', {
|
||||
ownershipId: 'installation-a',
|
||||
scope: 'worker',
|
||||
keyPath: '/keys/id'
|
||||
})
|
||||
)
|
||||
assert.notEqual(
|
||||
base,
|
||||
controlSocketPath('me', 'box', 22, '/tmp/d', {
|
||||
ownershipId: 'installation-b',
|
||||
scope: 'primary',
|
||||
keyPath: '/keys/id'
|
||||
})
|
||||
)
|
||||
assert.notEqual(
|
||||
base,
|
||||
controlSocketPath('me', 'box', 22, '/tmp/d', {
|
||||
ownershipId: 'installation-a',
|
||||
scope: 'primary',
|
||||
keyPath: '/keys/other'
|
||||
})
|
||||
)
|
||||
assert.notEqual(
|
||||
base,
|
||||
controlSocketPath('me', 'box', 22, '/tmp/d', {
|
||||
ownershipId: 'installation-a',
|
||||
scope: 'primary',
|
||||
keyPath: '/keys/id',
|
||||
effectiveConfigFingerprint: 'changed-config'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
test('closing one scope addresses only that scope control master', async () => {
|
||||
const firstSpawn = scriptedSpawn({ code: 0 })
|
||||
const secondSpawn = scriptedSpawn({ code: 0 })
|
||||
|
||||
const first = new SshConnection(
|
||||
{ host: 'box', user: 'me' },
|
||||
{
|
||||
spawnFn: firstSpawn,
|
||||
controlDir: '/tmp/d',
|
||||
ownershipId: 'installation',
|
||||
scope: 'first'
|
||||
}
|
||||
)
|
||||
|
||||
const second = new SshConnection(
|
||||
{ host: 'box', user: 'me' },
|
||||
{
|
||||
spawnFn: secondSpawn,
|
||||
controlDir: '/tmp/d',
|
||||
ownershipId: 'installation',
|
||||
scope: 'second'
|
||||
}
|
||||
)
|
||||
|
||||
first._opened = true
|
||||
second._opened = true
|
||||
await first.close()
|
||||
assert.notEqual(first.controlPath, second.controlPath)
|
||||
assert.ok(firstSpawn.calls[0].includes(`ControlPath=${first.controlPath}`))
|
||||
assert.ok(!firstSpawn.calls[0].includes(`ControlPath=${second.controlPath}`))
|
||||
assert.equal(second._opened, true)
|
||||
})
|
||||
|
||||
test('failed ControlMaster close disowns the master instead of retrying it', async () => {
|
||||
// Old contract kept _opened=true for a retry — which left wedged ControlPersist
|
||||
// masters trusted and reattachable (the macOS mode-switch livelock). New
|
||||
// contract: a master that refuses -O exit is disowned — socket dropped,
|
||||
// connection marked closed — so the next open dials fresh.
|
||||
const spawnFn = scriptedSpawn([{ code: 255, stderr: 'master refused exit' }])
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||||
conn._opened = true
|
||||
await conn.close()
|
||||
assert.equal(conn._opened, false)
|
||||
assert.equal(spawnFn.calls.length, 1)
|
||||
})
|
||||
|
||||
test('stopTunnelChild waits for process exit', async () => {
|
||||
const child: any = new EventEmitter()
|
||||
child.exitCode = null
|
||||
|
||||
child.kill = () => {
|
||||
process.nextTick(() => {
|
||||
child.exitCode = 0
|
||||
child.emit('exit', 0)
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
let stopped = false
|
||||
|
||||
const stopping = stopTunnelChild(child).then(() => {
|
||||
stopped = true
|
||||
})
|
||||
|
||||
assert.equal(stopped, false)
|
||||
await stopping
|
||||
assert.equal(stopped, true)
|
||||
})
|
||||
@@ -1,921 +0,0 @@
|
||||
/**
|
||||
* ssh-connection.ts
|
||||
*
|
||||
* Pure, electron-free OpenSSH ControlMaster connection manager for Desktop SSH
|
||||
* remote mode. Uses the system `ssh` client (not a JS SSH library) so it
|
||||
* inherits ~/.ssh/config, the agent, jump hosts (ProxyJump), and hardware keys
|
||||
* for free — the same rationale as tools/environments/ssh.py.
|
||||
*
|
||||
* No `import 'electron'` so it is unit-testable without Electron. main.ts
|
||||
* wires it into the electron-coupled lifecycle.
|
||||
*
|
||||
* Conventions mirrored from tools/environments/ssh.py:
|
||||
* - ControlMaster=auto + ControlPersist so one TCP/auth handshake is reused
|
||||
* across exec/forward operations.
|
||||
* - Hashed control-socket filename under a short tmpdir to stay under the
|
||||
* 104-byte sun_path limit macOS enforces on Unix domain sockets.
|
||||
* - BatchMode=yes for every programmatic invocation — a spawned ssh must
|
||||
* never hang on an interactive prompt (passphrase / 2FA). If auth needs
|
||||
* interactivity we fail fast and tell the user to load the key into their
|
||||
* agent.
|
||||
*
|
||||
* Host-key policy: StrictHostKeyChecking=accept-new (trust-on-first-use, log
|
||||
* the fingerprint), never `no`. A host-key *change* fails closed with the
|
||||
* verbatim OpenSSH error surfaced to the UI.
|
||||
*
|
||||
* Every operation is raced against a hard timeout. A half-open TCP connection
|
||||
* after laptop sleep can leave ssh hanging indefinitely rather than erroring;
|
||||
* timeout is treated as connection-dead so the caller does a full reconnect
|
||||
* rather than retrying in place.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import net from 'node:net'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000
|
||||
const DEFAULT_EXEC_TIMEOUT_MS = 20_000
|
||||
const DEFAULT_FORWARD_TIMEOUT_MS = 15_000
|
||||
const CONTROL_PERSIST_SECONDS = 300
|
||||
|
||||
// eslint-disable-next-line no-control-regex -- deliberately reject control chars in ssh targets
|
||||
const _CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/
|
||||
|
||||
function validateSshTarget(host, user, port) {
|
||||
if (!host || typeof host !== 'string') {
|
||||
throw new Error('Unsafe SSH target: host is required.')
|
||||
}
|
||||
|
||||
if (host.startsWith('-')) {
|
||||
throw new Error(`Unsafe SSH target: host must not start with a dash ("${host}").`)
|
||||
}
|
||||
|
||||
if (_CONTROL_CHAR_RE.test(host)) {
|
||||
throw new Error('Unsafe SSH target: host contains control characters.')
|
||||
}
|
||||
|
||||
if (user && _CONTROL_CHAR_RE.test(user)) {
|
||||
throw new Error('Unsafe SSH target: user contains control characters.')
|
||||
}
|
||||
|
||||
if (user && user.startsWith('-')) {
|
||||
throw new Error(`Unsafe SSH target: user must not start with a dash ("${user}").`)
|
||||
}
|
||||
|
||||
const p = Number(port)
|
||||
|
||||
if (!Number.isInteger(p) || p < 1 || p > 65535) {
|
||||
throw new Error(`Unsafe SSH port: ${port} (must be 1-65535).`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateKeyPath(keyPath) {
|
||||
if (!keyPath) {
|
||||
return
|
||||
}
|
||||
|
||||
if (_CONTROL_CHAR_RE.test(keyPath)) {
|
||||
throw new Error('Unsafe SSH key path: contains control characters.')
|
||||
}
|
||||
|
||||
if (keyPath.startsWith('-')) {
|
||||
throw new Error(`Unsafe SSH key path: must not start with a dash ("${keyPath}").`)
|
||||
}
|
||||
}
|
||||
|
||||
// Token / secret redaction
|
||||
|
||||
const _REDACTIONS: Array<[RegExp, string]> = [
|
||||
[/(HERMES_DASHBOARD_SESSION_TOKEN=)(\S+)/g, '$1<redacted>'],
|
||||
[/(X-Hermes-Session-Token["']?\s*[:=]\s*["']?)([^\s"'&]+)/gi, '$1<redacted>'],
|
||||
[/(Authorization["']?\s*:\s*Bearer\s+)(\S+)/gi, '$1<redacted>'],
|
||||
[/([?&](?:token|ticket)=)([^\s&"']+)/gi, '$1<redacted>']
|
||||
]
|
||||
|
||||
function redactSecrets(text) {
|
||||
let out = String(text == null ? '' : text)
|
||||
|
||||
for (const [re, repl] of _REDACTIONS) {
|
||||
out = out.replace(re, repl)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// Control-socket path
|
||||
|
||||
// Hash user@host:port to a short, stable, filesystem-safe socket id — stable
|
||||
// across reconnects so ControlMaster reuse works, short so the full path stays
|
||||
// under sun_path's 104-byte limit.
|
||||
//
|
||||
// CRITICAL (macOS): the base dir must be SHORT. os.tmpdir() on macOS is the
|
||||
// per-user `/var/folders/xx/yyyy…/T/` (~49 bytes), and OpenSSH binds a
|
||||
// TEMPORARY listener at `<ControlPath>.<16 random chars>` while establishing
|
||||
// the master — so a path that itself fits 104 still overflows at bind time. We
|
||||
// root under a short per-user base (`~/.hermes/desktop-ssh`) so even worst case
|
||||
// (~72 bytes on macOS) stays clear. Windows has no AF_UNIX sun_path limit.
|
||||
function controlSocketPath(user, host, port, baseDir?, identity: any = {}) {
|
||||
const dir = baseDir || defaultControlDir()
|
||||
const keyPathIdentity = path.normalize(String(identity.keyPath || ''))
|
||||
|
||||
const parts = [
|
||||
identity.ownershipId || '',
|
||||
identity.scope || '',
|
||||
user || '',
|
||||
host,
|
||||
Number(port),
|
||||
keyPathIdentity,
|
||||
identity.effectiveConfigFingerprint || ''
|
||||
]
|
||||
|
||||
const id = crypto.createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16)
|
||||
|
||||
return path.join(dir, `${id}.sock`)
|
||||
}
|
||||
|
||||
function defaultControlDir() {
|
||||
// POSIX: a SHORT, PER-USER base stays under the socket limit AND avoids a
|
||||
// world-shared /tmp dir (no symlink-hijack surface). Created 0700 in open().
|
||||
if (process.platform === 'win32') {
|
||||
return path.join(os.tmpdir(), 'hermes-desktop-ssh')
|
||||
}
|
||||
|
||||
return path.join(os.homedir(), '.hermes', 'desktop-ssh')
|
||||
}
|
||||
|
||||
// Command construction (pure — the unit tests exercise these directly)
|
||||
|
||||
// Mux (POSIX): ControlMaster options so exec/forward share one authenticated
|
||||
// connection. No-mux (Windows OpenSSH never implemented mux sockets): plain
|
||||
// per-invocation options — each ssh call authenticates on its own.
|
||||
function baseSshOptions(controlPath, connectTimeoutMs?) {
|
||||
const connectSecs = Math.max(1, Math.round((connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS) / 1000))
|
||||
|
||||
const mux = controlPath
|
||||
? [
|
||||
'-o',
|
||||
`ControlPath=${controlPath}`,
|
||||
'-o',
|
||||
'ControlMaster=auto',
|
||||
'-o',
|
||||
`ControlPersist=${CONTROL_PERSIST_SECONDS}`
|
||||
]
|
||||
: []
|
||||
|
||||
return [
|
||||
...mux,
|
||||
'-o',
|
||||
'BatchMode=yes',
|
||||
'-o',
|
||||
'StrictHostKeyChecking=accept-new',
|
||||
'-o',
|
||||
'ExitOnForwardFailure=yes',
|
||||
'-o',
|
||||
`ConnectTimeout=${connectSecs}`
|
||||
]
|
||||
}
|
||||
|
||||
// Non-default port and explicit identity file, shared by exec/master/forward.
|
||||
function hostArgs({ port, keyPath }: { port?: number | string; keyPath?: string } = {}) {
|
||||
const args: string[] = []
|
||||
|
||||
if (port && Number(port) !== 22) {
|
||||
args.push('-p', String(port))
|
||||
}
|
||||
|
||||
if (keyPath) {
|
||||
validateKeyPath(keyPath)
|
||||
args.push('-i', keyPath)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
function target(user, host) {
|
||||
return user ? `${user}@${host}` : host
|
||||
}
|
||||
|
||||
function buildExecArgs(conn, remoteCommand, connectTimeoutMs?) {
|
||||
return [
|
||||
...baseSshOptions(conn.controlPath, connectTimeoutMs),
|
||||
...hostArgs(conn),
|
||||
'--',
|
||||
target(conn.user, conn.host),
|
||||
remoteCommand
|
||||
]
|
||||
}
|
||||
|
||||
function buildControlArgs(conn, op, extra: string[] = [], connectTimeoutMs?) {
|
||||
return [
|
||||
'-O',
|
||||
op,
|
||||
...extra,
|
||||
...baseSshOptions(conn.controlPath, connectTimeoutMs),
|
||||
...hostArgs(conn),
|
||||
'--',
|
||||
target(conn.user, conn.host)
|
||||
]
|
||||
}
|
||||
|
||||
// Open the master explicitly: `-M -N -f` backgrounds ssh once the master is up,
|
||||
// so the spawn resolves when the connection is established (or fails fast under
|
||||
// BatchMode if auth is non-interactive-only).
|
||||
function buildMasterArgs(conn, connectTimeoutMs?) {
|
||||
return [
|
||||
'-M',
|
||||
'-N',
|
||||
'-f',
|
||||
...baseSshOptions(conn.controlPath, connectTimeoutMs),
|
||||
...hostArgs(conn),
|
||||
'--',
|
||||
target(conn.user, conn.host)
|
||||
]
|
||||
}
|
||||
|
||||
// Interactive `ssh -tt` for the INTERIM remote terminal (SSH mode only). Reuses
|
||||
// the existing ControlMaster socket so NO new auth handshake happens — the
|
||||
// master is already open, so this attaches instantly and never prompts.
|
||||
//
|
||||
// NOTE(remote-terminal): interim until the dashboard /api/terminal WebSocket
|
||||
// lands (specs/desktop-remote-terminal.md); delete this path then.
|
||||
function buildInteractiveSshArgs(conn, remoteCwd, connectTimeoutMs?, remoteCommand?) {
|
||||
const args = [
|
||||
'-tt',
|
||||
...baseSshOptions(conn.controlPath, connectTimeoutMs),
|
||||
...hostArgs(conn),
|
||||
'--',
|
||||
target(conn.user, conn.host)
|
||||
]
|
||||
|
||||
if (remoteCommand) {
|
||||
args.push(remoteCommand)
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
const cwd = String(remoteCwd || '').trim()
|
||||
|
||||
if (cwd) {
|
||||
const q = `'${cwd.replace(/'/g, `'\\''`)}'`
|
||||
args.push(`cd ${q} 2>/dev/null; exec "$SHELL" -l`)
|
||||
} else {
|
||||
args.push('exec "$SHELL" -l')
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
// Bind the local end to 127.0.0.1 ONLY — never 0.0.0.0 — so the tunnel does not
|
||||
// re-expose the remote dashboard to the client's LAN.
|
||||
function forwardSpec(localPort, remotePort, remoteHost = '127.0.0.1') {
|
||||
return `127.0.0.1:${localPort}:${remoteHost}:${remotePort}`
|
||||
}
|
||||
|
||||
// Error classification — distinct, actionable messages for the UI
|
||||
|
||||
const SSH_ERROR = {
|
||||
UNREACHABLE: 'unreachable',
|
||||
AUTH_FAILED: 'auth-failed',
|
||||
HOST_KEY_CHANGED: 'host-key-changed',
|
||||
TIMEOUT: 'timeout',
|
||||
UNKNOWN: 'unknown'
|
||||
}
|
||||
|
||||
// Order matters: the host-key-change banner also contains "WARNING"/"Offending",
|
||||
// so check it before generic auth.
|
||||
function classifySshError(stderr) {
|
||||
const text = String(stderr || '')
|
||||
|
||||
if (
|
||||
/REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed|Offending (?:key|ECDSA|RSA|ED25519)/i.test(
|
||||
text
|
||||
)
|
||||
) {
|
||||
return SSH_ERROR.HOST_KEY_CHANGED
|
||||
}
|
||||
|
||||
if (
|
||||
/Permission denied|Too many authentication failures|no matching host key|publickey|password|keyboard-interactive/i.test(
|
||||
text
|
||||
)
|
||||
) {
|
||||
return SSH_ERROR.AUTH_FAILED
|
||||
}
|
||||
|
||||
if (
|
||||
/Could not resolve hostname|Connection refused|Connection timed out|No route to host|Network is unreachable|Operation timed out|port \d+: Connection/i.test(
|
||||
text
|
||||
)
|
||||
) {
|
||||
return SSH_ERROR.UNREACHABLE
|
||||
}
|
||||
|
||||
return SSH_ERROR.UNKNOWN
|
||||
}
|
||||
|
||||
function sshErrorMessage(kind, conn, stderr?) {
|
||||
const host = target(conn.user, conn.host)
|
||||
|
||||
switch (kind) {
|
||||
case SSH_ERROR.HOST_KEY_CHANGED:
|
||||
return (
|
||||
`The host key for ${host} has CHANGED since you last connected. ` +
|
||||
`This could be a man-in-the-middle attack, or the server was reinstalled. ` +
|
||||
`SSH refused to connect. Verify the change is expected, then remove the old key ` +
|
||||
`with \`ssh-keygen -R ${conn.host}\` and reconnect.\n\n${String(stderr || '').trim()}`
|
||||
)
|
||||
|
||||
case SSH_ERROR.AUTH_FAILED:
|
||||
return (
|
||||
`SSH authentication to ${host} failed. Desktop runs ssh non-interactively ` +
|
||||
`(BatchMode), so a key requiring a passphrase or 2FA must be loaded into your ` +
|
||||
`ssh-agent first (e.g. \`ssh-add ~/.ssh/id_ed25519\`), or set an IdentityFile in ` +
|
||||
`~/.ssh/config. Original error: ${String(stderr || '').trim()}`
|
||||
)
|
||||
|
||||
case SSH_ERROR.UNREACHABLE:
|
||||
return `Could not reach ${host} over SSH. Check the host, port, and your network. Original error: ${String(stderr || '').trim()}`
|
||||
|
||||
case SSH_ERROR.TIMEOUT:
|
||||
return `SSH operation to ${host} timed out. The connection may be half-open (e.g. after sleep); reconnecting.`
|
||||
|
||||
default:
|
||||
return `SSH error connecting to ${host}: ${String(stderr || '').trim() || 'unknown failure'}`
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn helper — runs an ssh invocation, races it against a hard timeout
|
||||
|
||||
// Resolves { code, stdout, stderr }. On timeout the child is SIGKILLed and the
|
||||
// promise rejects with err.kind = TIMEOUT. `spawnFn` is injectable for tests.
|
||||
function runSsh(args, { timeoutMs, spawnFn = spawn, stdin = 'ignore', stdinData }: any = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const useStdinPipe = stdinData != null || stdin !== 'ignore'
|
||||
let child
|
||||
|
||||
try {
|
||||
child = spawnFn('ssh', args, { stdio: [useStdinPipe ? 'pipe' : 'ignore', 'pipe', 'pipe'] })
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (stdinData != null && child.stdin) {
|
||||
child.stdin.end(stdinData)
|
||||
}
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let settled = false
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
|
||||
try {
|
||||
child.kill('SIGKILL')
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
|
||||
const err: any = new Error(`ssh timed out after ${timeoutMs}ms`)
|
||||
err.kind = SSH_ERROR.TIMEOUT
|
||||
reject(err)
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout?.on('data', d => {
|
||||
stdout += d.toString()
|
||||
})
|
||||
child.stderr?.on('data', d => {
|
||||
stderr += d.toString()
|
||||
})
|
||||
child.on('error', error => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
})
|
||||
child.on('close', code => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve({ code, stdout, stderr })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function stopTunnelChild(child, timeoutMs = 5_000) {
|
||||
if (!child || child.exitCode != null || child.signalCode != null) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let settled = false
|
||||
|
||||
const finish = (error?: unknown) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
child.off?.('exit', onExit)
|
||||
child.off?.('error', onError)
|
||||
error ? reject(error) : resolve()
|
||||
}
|
||||
|
||||
const onExit = () => finish()
|
||||
const onError = error => finish(error)
|
||||
const timer = setTimeout(() => finish(new Error('SSH tunnel did not exit after termination.')), timeoutMs)
|
||||
child.once('exit', onExit)
|
||||
child.once('error', onError)
|
||||
|
||||
try {
|
||||
if (!child.kill()) {
|
||||
finish(new Error('SSH tunnel termination was refused.'))
|
||||
}
|
||||
} catch (error) {
|
||||
finish(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// SshConnection — the public manager
|
||||
|
||||
class SshConnection {
|
||||
host: string
|
||||
user: string
|
||||
port: number
|
||||
keyPath: string
|
||||
controlPath: string
|
||||
_spawnFn: any
|
||||
_log: (msg: string) => void
|
||||
_connectTimeoutMs: number
|
||||
_execTimeoutMs: number
|
||||
_forwardTimeoutMs: number
|
||||
_opened: boolean
|
||||
_mux: boolean
|
||||
_tunnels: Map<string, any>
|
||||
|
||||
constructor(cfg, opts: any = {}) {
|
||||
if (!cfg || !cfg.host) {
|
||||
throw new Error('SshConnection requires a host.')
|
||||
}
|
||||
|
||||
const port = cfg.port ? Number(cfg.port) : 22
|
||||
validateSshTarget(cfg.host, cfg.user || '', port)
|
||||
|
||||
if (cfg.keyPath) {
|
||||
validateKeyPath(cfg.keyPath)
|
||||
}
|
||||
|
||||
this.host = cfg.host
|
||||
this.user = cfg.user || ''
|
||||
this.port = port
|
||||
this.keyPath = cfg.keyPath || ''
|
||||
// Windows OpenSSH has no ControlMaster (mux sockets were never implemented
|
||||
// on Win32) — fall back to one ssh invocation per operation and a
|
||||
// persistent `ssh -N -L` child per tunnel. Empty controlPath routes the
|
||||
// pure builders onto their no-mux form.
|
||||
this._mux = opts.mux ?? process.platform !== 'win32'
|
||||
this.controlPath = this._mux
|
||||
? controlSocketPath(this.user, this.host, this.port, opts.controlDir, {
|
||||
keyPath: this.keyPath,
|
||||
ownershipId: opts.ownershipId,
|
||||
scope: opts.scope,
|
||||
effectiveConfigFingerprint: opts.effectiveConfigFingerprint
|
||||
})
|
||||
: ''
|
||||
this._tunnels = new Map()
|
||||
|
||||
this._spawnFn = opts.spawnFn || spawn
|
||||
|
||||
this._log = typeof opts.rememberLog === 'function' ? opts.rememberLog : () => {}
|
||||
this._connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
|
||||
this._execTimeoutMs = opts.execTimeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS
|
||||
this._forwardTimeoutMs = opts.forwardTimeoutMs ?? DEFAULT_FORWARD_TIMEOUT_MS
|
||||
this._opened = false
|
||||
}
|
||||
|
||||
// Lifecycle logging — ALWAYS through redaction.
|
||||
_logLine(msg) {
|
||||
this._log(redactSecrets(`[ssh] ${msg}`))
|
||||
}
|
||||
|
||||
_fail(stderrOrErr, fallbackKind = SSH_ERROR.UNKNOWN) {
|
||||
if (stderrOrErr && stderrOrErr.kind === SSH_ERROR.TIMEOUT) {
|
||||
const err: any = new Error(sshErrorMessage(SSH_ERROR.TIMEOUT, this))
|
||||
err.kind = SSH_ERROR.TIMEOUT
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
const stderr = typeof stderrOrErr === 'string' ? stderrOrErr : stderrOrErr?.message || ''
|
||||
const kind = stderr ? classifySshError(stderr) : fallbackKind
|
||||
const err: any = new Error(sshErrorMessage(kind, this, stderr))
|
||||
err.kind = kind
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Open the connection. Mux: start the persistent ControlMaster (idempotent —
|
||||
// a live master is a no-op). No-mux: there is no master; validate auth +
|
||||
// reachability with a one-shot `ssh true` so failures classify identically.
|
||||
async open() {
|
||||
if (await this.isAlive()) {
|
||||
// -O check passing is not proof the master works: a ControlPersist master
|
||||
// can survive a failed teardown with wedged channels (observed on macOS
|
||||
// after a mode switch — check succeeds, every exec times out). Verify with
|
||||
// a real exec before trusting it; on failure, evict and dial fresh.
|
||||
if (!this._mux || (await this._verifyMuxChannel())) {
|
||||
this._opened = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this._logLine('existing control master failed exec verification; evicting stale master')
|
||||
await this._evictStaleMaster()
|
||||
}
|
||||
|
||||
if (!this._mux) {
|
||||
this._logLine(`connecting (no-mux) to ${target(this.user, this.host)}:${this.port}`)
|
||||
let result
|
||||
|
||||
try {
|
||||
result = await runSsh(buildExecArgs(this, 'exit 0', this._connectTimeoutMs), {
|
||||
timeoutMs: this._connectTimeoutMs,
|
||||
spawnFn: this._spawnFn
|
||||
})
|
||||
} catch (error) {
|
||||
throw this._fail(error, SSH_ERROR.UNREACHABLE)
|
||||
}
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw this._fail(result.stderr, SSH_ERROR.UNREACHABLE)
|
||||
}
|
||||
|
||||
this._opened = true
|
||||
this._logLine('connection verified (no-mux; per-operation ssh)')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const controlDir = path.dirname(this.controlPath)
|
||||
|
||||
try {
|
||||
fs.mkdirSync(controlDir, { recursive: true, mode: 0o700 })
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
const st = fs.lstatSync(controlDir)
|
||||
|
||||
if (st.isSymbolicLink()) {
|
||||
throw new Error(`Unsafe SSH control dir: ${controlDir} is a symlink.`)
|
||||
}
|
||||
|
||||
if (!st.isDirectory()) {
|
||||
throw new Error(`Unsafe SSH control dir: ${controlDir} is not a directory.`)
|
||||
}
|
||||
|
||||
if (st.uid !== process.getuid!()) {
|
||||
throw new Error(`Unsafe SSH control dir: ${controlDir} is owned by uid ${st.uid}, not ${process.getuid!()}.`)
|
||||
}
|
||||
|
||||
if ((st.mode & 0o777) !== 0o700) {
|
||||
fs.chmodSync(controlDir, 0o700)
|
||||
}
|
||||
}
|
||||
|
||||
const args = buildMasterArgs(this, this._connectTimeoutMs)
|
||||
this._logLine(`opening control master to ${target(this.user, this.host)}:${this.port}`)
|
||||
let result
|
||||
|
||||
try {
|
||||
result = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })
|
||||
} catch (error) {
|
||||
throw this._fail(error, SSH_ERROR.UNREACHABLE)
|
||||
}
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw this._fail(result.stderr, SSH_ERROR.UNREACHABLE)
|
||||
}
|
||||
|
||||
this._opened = true
|
||||
this._logLine('control master established')
|
||||
}
|
||||
|
||||
// Liveness. Mux: `-O check` against the master socket. No-mux: a cheap
|
||||
// one-shot exec — "alive" means "we can still authenticate and run".
|
||||
async isAlive() {
|
||||
if ([...this._tunnels.values()].some(tunnel => tunnel.alive === false)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const args = this._mux
|
||||
? buildControlArgs(this, 'check', [], this._connectTimeoutMs)
|
||||
: buildExecArgs(this, 'exit 0', this._connectTimeoutMs)
|
||||
|
||||
try {
|
||||
const result: any = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })
|
||||
|
||||
return result.code === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// A real exec through the master (`exit 0` works under POSIX shells and
|
||||
// cmd.exe); a wedged mux hangs to the timeout.
|
||||
async _verifyMuxChannel() {
|
||||
try {
|
||||
const result: any = await runSsh(buildExecArgs(this, 'exit 0', this._connectTimeoutMs), {
|
||||
timeoutMs: this._connectTimeoutMs,
|
||||
spawnFn: this._spawnFn
|
||||
})
|
||||
|
||||
return result.code === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// -O exit (best-effort) then drop the socket so ControlMaster=auto cannot
|
||||
// re-attach to the corpse. (The orphaned master process is left to
|
||||
// ControlPersist; a wedged channel can pin it, but without its socket it is
|
||||
// inert.)
|
||||
async _evictStaleMaster() {
|
||||
try {
|
||||
await runSsh(buildControlArgs(this, 'exit', [], this._connectTimeoutMs), {
|
||||
timeoutMs: this._connectTimeoutMs,
|
||||
spawnFn: this._spawnFn
|
||||
})
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(this.controlPath)
|
||||
} catch (error: any) {
|
||||
if (error?.code !== 'ENOENT') {
|
||||
this._logLine(`could not remove stale control socket (${error.code}); a fresh master may not dial`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One-shot remote command over the control connection. Resolves stdout;
|
||||
// rejects with a classified error on non-zero exit or timeout.
|
||||
async exec(remoteCommand, { timeoutMs, stdinData }: any = {}) {
|
||||
const args = buildExecArgs(this, remoteCommand, this._connectTimeoutMs)
|
||||
let result
|
||||
|
||||
try {
|
||||
result = await runSsh(args, {
|
||||
timeoutMs: timeoutMs ?? this._execTimeoutMs,
|
||||
spawnFn: this._spawnFn,
|
||||
...(stdinData != null ? { stdinData } : {})
|
||||
})
|
||||
} catch (error) {
|
||||
throw this._fail(error)
|
||||
}
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw this._fail(result.stderr)
|
||||
}
|
||||
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
// Establish a local→remote forward. Mux: `-O forward` against the master.
|
||||
// No-mux: spawn a persistent `ssh -N -L` child that IS the tunnel; ready when
|
||||
// the local port accepts. The child dying = tunnel down (isAlive of the
|
||||
// backend catches it upstream).
|
||||
async forward(localPort, remotePort, remoteHost = '127.0.0.1') {
|
||||
const spec = forwardSpec(localPort, remotePort, remoteHost)
|
||||
this._logLine(`forwarding 127.0.0.1:${localPort} -> ${remoteHost}:${remotePort}`)
|
||||
|
||||
if (!this._mux) {
|
||||
const args = [
|
||||
...baseSshOptions('', this._connectTimeoutMs),
|
||||
...hostArgs(this),
|
||||
'-v',
|
||||
'-N',
|
||||
'-L',
|
||||
spec,
|
||||
'--',
|
||||
target(this.user, this.host)
|
||||
]
|
||||
|
||||
const child = this._spawnFn('ssh', args, { stdio: ['ignore', 'ignore', 'pipe'] })
|
||||
const tunnel = { child, alive: true }
|
||||
this._tunnels.set(spec, tunnel)
|
||||
let stderr = ''
|
||||
let readyConfirmed = false
|
||||
let readyResolve
|
||||
let readyReject
|
||||
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
readyResolve = resolve
|
||||
readyReject = reject
|
||||
})
|
||||
|
||||
const readyPattern = new RegExp(`Local forwarding listening on .* port ${localPort}\\b`)
|
||||
child.stderr?.on('data', d => {
|
||||
if (readyConfirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
stderr = `${stderr}${String(d)}`.slice(-16_384)
|
||||
|
||||
if (readyPattern.test(stderr)) {
|
||||
readyConfirmed = true
|
||||
readyResolve()
|
||||
}
|
||||
})
|
||||
child.on('error', error => {
|
||||
tunnel.alive = false
|
||||
readyReject(error)
|
||||
})
|
||||
child.on('exit', code => {
|
||||
tunnel.alive = false
|
||||
readyReject(new Error(`tunnel process exited with code ${code}`))
|
||||
})
|
||||
child.on('close', code => {
|
||||
tunnel.alive = false
|
||||
readyReject(new Error(`tunnel process closed with code ${code}`))
|
||||
})
|
||||
let readyTimeout
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
ready,
|
||||
new Promise((_, reject) => {
|
||||
readyTimeout = setTimeout(
|
||||
() => reject(new Error('tunnel did not confirm local forwarding')),
|
||||
this._forwardTimeoutMs
|
||||
)
|
||||
})
|
||||
])
|
||||
} catch (error: any) {
|
||||
try {
|
||||
await stopTunnelChild(child)
|
||||
this._tunnels.delete(spec)
|
||||
} catch (stopError) {
|
||||
throw this._fail(stopError, SSH_ERROR.UNKNOWN)
|
||||
}
|
||||
|
||||
throw this._fail(stderr || error, SSH_ERROR.UNKNOWN)
|
||||
} finally {
|
||||
clearTimeout(readyTimeout)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const args = buildControlArgs(this, 'forward', ['-L', spec], this._connectTimeoutMs)
|
||||
let result
|
||||
|
||||
try {
|
||||
result = await runSsh(args, { timeoutMs: this._forwardTimeoutMs, spawnFn: this._spawnFn })
|
||||
} catch (error) {
|
||||
throw this._fail(error)
|
||||
}
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw this._fail(result.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel a previously-established forward. Best-effort: a failure here is
|
||||
// logged but not thrown (close tears everything down anyway).
|
||||
async cancelForward(localPort, remotePort, remoteHost = '127.0.0.1') {
|
||||
const spec = forwardSpec(localPort, remotePort, remoteHost)
|
||||
|
||||
if (!this._mux) {
|
||||
const tunnel = this._tunnels.get(spec)
|
||||
|
||||
if (tunnel) {
|
||||
await stopTunnelChild(tunnel.child)
|
||||
this._tunnels.delete(spec)
|
||||
this._logLine(`cancelled forward 127.0.0.1:${localPort}`)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const args = buildControlArgs(this, 'cancel', ['-L', spec], this._connectTimeoutMs)
|
||||
|
||||
try {
|
||||
await runSsh(args, { timeoutMs: this._forwardTimeoutMs, spawnFn: this._spawnFn })
|
||||
this._logLine(`cancelled forward 127.0.0.1:${localPort}`)
|
||||
} catch (error: any) {
|
||||
this._logLine(`cancelForward failed (ignored): ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Tear down. Mux: exit the master (drops every forward with it). No-mux:
|
||||
// kill the tunnel children. Best-effort; never throws.
|
||||
async close() {
|
||||
if (!this._opened) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this._mux) {
|
||||
for (const [spec, tunnel] of this._tunnels) {
|
||||
await stopTunnelChild(tunnel.child)
|
||||
this._tunnels.delete(spec)
|
||||
}
|
||||
|
||||
this._opened = false
|
||||
this._logLine('connection closed (no-mux tunnels killed)')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const args = buildControlArgs(this, 'exit', [], this._connectTimeoutMs)
|
||||
|
||||
try {
|
||||
const result: any = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw this._fail(result.stderr)
|
||||
}
|
||||
|
||||
this._logLine('control master closed')
|
||||
} catch (error: any) {
|
||||
// A master that refuses -O exit is the wedge that poisons re-attach;
|
||||
// disown it. (Without its socket the orphan is inert; ControlPersist may
|
||||
// not reap it if a wedged channel never idles.)
|
||||
this._logLine(`close failed; removing control socket: ${error.message}`)
|
||||
|
||||
try {
|
||||
fs.unlinkSync(this.controlPath)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
this._opened = false
|
||||
}
|
||||
}
|
||||
|
||||
// Free local port for the tunnel's local end. Bind 127.0.0.1:0, read the
|
||||
// kernel-assigned port, release. The benign TOCTOU window (release → forward
|
||||
// grabs it) is caught upstream and retried with a fresh port.
|
||||
|
||||
function pickLocalPort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer()
|
||||
server.unref()
|
||||
server.on('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const { port } = server.address() as net.AddressInfo
|
||||
server.close(() => resolve(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function createSshProbeConnection(config, options: any = {}) {
|
||||
return new SshConnection(config, { ...options, mux: false })
|
||||
}
|
||||
|
||||
export {
|
||||
baseSshOptions,
|
||||
buildControlArgs,
|
||||
buildExecArgs,
|
||||
buildInteractiveSshArgs,
|
||||
buildMasterArgs,
|
||||
classifySshError,
|
||||
CONTROL_PERSIST_SECONDS,
|
||||
controlSocketPath,
|
||||
createSshProbeConnection,
|
||||
DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
DEFAULT_EXEC_TIMEOUT_MS,
|
||||
DEFAULT_FORWARD_TIMEOUT_MS,
|
||||
forwardSpec,
|
||||
hostArgs,
|
||||
pickLocalPort,
|
||||
redactSecrets,
|
||||
runSsh,
|
||||
SSH_ERROR,
|
||||
SshConnection,
|
||||
sshErrorMessage,
|
||||
stopTunnelChild,
|
||||
target,
|
||||
validateKeyPath,
|
||||
validateSshTarget
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
buildWindowsInteractiveCommand,
|
||||
detectRemotePlatform,
|
||||
encodedPowerShell,
|
||||
helperCommand,
|
||||
powerShellCommand,
|
||||
psLiteral,
|
||||
validLock
|
||||
} from './windows-remote-lifecycle'
|
||||
|
||||
const ownershipId = '0123456789abcdef0123456789abcdef'
|
||||
|
||||
function sshWith(exec) {
|
||||
return { exec }
|
||||
}
|
||||
|
||||
test('PowerShell transport uses UTF-16LE encoded commands and literal escaping', () => {
|
||||
assert.equal(Buffer.from(encodedPowerShell("'ok'"), 'base64').toString('utf16le'), "'ok'")
|
||||
assert.equal(psLiteral("a'b"), "'a''b'")
|
||||
assert.match(powerShellCommand('Write-Output ok'), /^powershell\.exe -NoProfile -NonInteractive .* -EncodedCommand /)
|
||||
})
|
||||
|
||||
test('platform detection preserves POSIX and falls back to Windows PowerShell', async () => {
|
||||
assert.deepEqual(await detectRemotePlatform(sshWith(async () => 'Linux\nx86_64\n')), { os: 'Linux', arch: 'x86_64' })
|
||||
const calls: string[] = []
|
||||
|
||||
const result = await detectRemotePlatform(
|
||||
sshWith(async command => {
|
||||
calls.push(command)
|
||||
|
||||
if (command.startsWith('uname ')) {
|
||||
throw new Error('PowerShell does not recognize uname')
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
os: 'Windows',
|
||||
arch: 'ARM64',
|
||||
hermesHome: 'C:\\h',
|
||||
hermesPath: 'C:\\h\\hermes.exe',
|
||||
python: 'C:\\h\\python.exe'
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
assert.equal(result.os, 'Windows')
|
||||
assert.match(calls[1], /EncodedCommand/)
|
||||
})
|
||||
|
||||
test('platform detection surfaces transport failures as themselves, not unsupported-platform', async () => {
|
||||
// A dead/unauthorized host is a connectivity verdict; only a host that answers
|
||||
// neither probe is an unsupported platform.
|
||||
const transportErr: any = new Error('SSH connection timed out')
|
||||
transportErr.kind = 'timeout'
|
||||
await assert.rejects(
|
||||
detectRemotePlatform(
|
||||
sshWith(async () => {
|
||||
throw transportErr
|
||||
})
|
||||
),
|
||||
(err: any) => err.kind === 'timeout'
|
||||
)
|
||||
// Probe genuinely failing on a reachable host still classifies unsupported,
|
||||
// and carries the probe detail for diagnosis.
|
||||
await assert.rejects(
|
||||
detectRemotePlatform(
|
||||
sshWith(async command => {
|
||||
if (command.startsWith('uname ')) {
|
||||
throw new Error('not recognized')
|
||||
}
|
||||
|
||||
throw new Error('Hermes is not installed on the remote Windows host.')
|
||||
})
|
||||
),
|
||||
(err: any) => err.kind === 'unsupported-platform' && /Hermes is not installed/.test(err.message)
|
||||
)
|
||||
})
|
||||
|
||||
test('helper command uses the fixed remote Python entry point and quotes path data', () => {
|
||||
const command = helperCommand({ python: "C:\\Program Files\\Hermes's\\python.exe" }, 'inspect', [
|
||||
'C:\\x y\\hermes.exe'
|
||||
])
|
||||
|
||||
const encoded = command.split(' ').pop()!
|
||||
const script = Buffer.from(encoded, 'base64').toString('utf16le')
|
||||
assert.match(script, /-m' 'hermes_cli\.windows_ssh_runtime' 'inspect'/)
|
||||
assert.match(script, /Hermes''s/)
|
||||
assert.match(script, /C:\\x y\\hermes\.exe/)
|
||||
})
|
||||
|
||||
test('Windows lock validation is scoped and exact', () => {
|
||||
const lock = {
|
||||
schemaVersion: 2,
|
||||
protocolVersion: 1,
|
||||
ownershipId,
|
||||
spawnNonce: '0123456789abcdef',
|
||||
pid: 10,
|
||||
creationTimeNs: '1784219690452757504',
|
||||
port: 1234,
|
||||
tokenFingerprint: 'a'.repeat(32),
|
||||
hermesPath: 'C:\\h\\hermes.exe',
|
||||
hermesHome: 'C:\\h'
|
||||
}
|
||||
|
||||
assert.equal(validLock(lock, ownershipId), true)
|
||||
assert.equal(validLock({ ...lock, ownershipId: 'b'.repeat(32) }, ownershipId), false)
|
||||
assert.equal(validLock({ ...lock, creationTimeNs: '0' }, ownershipId), false)
|
||||
// port 0 = spawn-in-progress record: valid ownership proof (cleanup can act
|
||||
// on it) but the reuse gate must reject it separately.
|
||||
assert.equal(validLock({ ...lock, port: 0 }, ownershipId), true)
|
||||
assert.equal(validLock({ ...lock, port: -1 }, ownershipId), false)
|
||||
})
|
||||
|
||||
test('Windows integrated terminal uses encoded PowerShell and preserves cwd as literal data', () => {
|
||||
const command = buildWindowsInteractiveCommand("C:\\Users\\O'Brien\\repo")
|
||||
const script = Buffer.from(command.split(' ').pop()!, 'base64').toString('utf16le')
|
||||
assert.match(script, /Set-Location -LiteralPath 'C:\\Users\\O''Brien\\repo'/)
|
||||
assert.match(script, /powershell\.exe -NoLogo/)
|
||||
})
|
||||
@@ -1,455 +0,0 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { redactSecrets, SSH_ERROR } from './ssh-connection'
|
||||
|
||||
const LOCKFILE_SCHEMA_VERSION = 2
|
||||
const PROTOCOL_VERSION = 1
|
||||
const READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\d+)/gm
|
||||
const READY_POLL_INTERVAL_MS = 750
|
||||
|
||||
function psLiteral(value) {
|
||||
return `'${String(value).replace(/'/g, "''")}'`
|
||||
}
|
||||
|
||||
function encodedPowerShell(script) {
|
||||
return Buffer.from(script, 'utf16le').toString('base64')
|
||||
}
|
||||
|
||||
function powerShellCommand(script) {
|
||||
return `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodedPowerShell(script)}`
|
||||
}
|
||||
|
||||
async function probeWindowsRemote(ssh, explicitHermesPath = '') {
|
||||
const explicit = psLiteral(explicitHermesPath)
|
||||
|
||||
const script = [
|
||||
'$ErrorActionPreference="Stop"',
|
||||
`$explicit=${explicit}`,
|
||||
'$hermesHome=$env:HERMES_HOME',
|
||||
'if(-not $hermesHome){$hermesHome=Join-Path $env:LOCALAPPDATA "hermes"}',
|
||||
'$candidates=@()',
|
||||
'if($explicit){$candidates+=$explicit}',
|
||||
'$cmd=Get-Command hermes.exe -ErrorAction SilentlyContinue',
|
||||
'if($cmd){$candidates+=$cmd.Source}',
|
||||
'$candidates+=(Join-Path $hermesHome "hermes-agent\\venv\\Scripts\\hermes.exe")',
|
||||
'$candidates+=(Join-Path $HOME "hermes-agent\\.venv\\Scripts\\hermes.exe")',
|
||||
'$hermes=$candidates|Where-Object{Test-Path -LiteralPath $_ -PathType Leaf}|Select-Object -First 1',
|
||||
'if(-not $hermes){throw "Hermes is not installed on the remote Windows host."}',
|
||||
'if($explicit -and $hermes -ne $explicit){throw "The configured Hermes path is not an executable file."}',
|
||||
'$python=Join-Path (Split-Path $hermes) "python.exe"',
|
||||
'if(-not (Test-Path -LiteralPath $python -PathType Leaf)){throw "The remote Hermes Python runtime was not found."}',
|
||||
'[ordered]@{os="Windows";arch=$env:PROCESSOR_ARCHITECTURE;hermesHome=$hermesHome;hermesPath=$hermes;python=$python}|ConvertTo-Json -Compress'
|
||||
].join(';')
|
||||
|
||||
return JSON.parse((await ssh.exec(powerShellCommand(script))).trim())
|
||||
}
|
||||
|
||||
const TRANSPORT_KINDS = new Set([
|
||||
SSH_ERROR.AUTH_FAILED,
|
||||
SSH_ERROR.HOST_KEY_CHANGED,
|
||||
SSH_ERROR.TIMEOUT,
|
||||
SSH_ERROR.UNREACHABLE
|
||||
])
|
||||
|
||||
async function detectRemotePlatform(ssh, explicitHermesPath = '') {
|
||||
try {
|
||||
const output = (await ssh.exec('uname -s; uname -m')).trim().split('\n')
|
||||
|
||||
if (output[0] === 'Linux' || output[0] === 'Darwin') {
|
||||
return { os: output[0], arch: output[1] || '' }
|
||||
}
|
||||
} catch (error: any) {
|
||||
// uname failing is the expected Windows fall-through; a TRANSPORT failure
|
||||
// (auth/host-key/timeout/unreachable) is not a platform verdict — surface it
|
||||
// as itself instead of letting the probe chain end in 'unsupported-platform'.
|
||||
if (TRANSPORT_KINDS.has(error?.kind)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await probeWindowsRemote(ssh, explicitHermesPath)
|
||||
} catch (cause: any) {
|
||||
if (TRANSPORT_KINDS.has(cause?.kind)) {
|
||||
throw cause
|
||||
}
|
||||
|
||||
// detail is remote-controlled output headed for the UI: redact + strip control chars.
|
||||
const detail = redactSecrets(String(cause?.message || cause || ''))
|
||||
// eslint-disable-next-line no-control-regex -- deliberately strip control chars from remote output
|
||||
.replace(/[\x00-\x1f\x7f]/g, ' ')
|
||||
.trim()
|
||||
|
||||
const error: any = new Error(
|
||||
`The remote operating system is not supported by Desktop SSH.${detail ? ` (probe: ${detail.slice(0, 300)})` : ''}`
|
||||
)
|
||||
|
||||
error.kind = 'unsupported-platform'
|
||||
error.cause = cause
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function helperCommand(runtime, operation, args = []) {
|
||||
const argv = [runtime.python, '-m', 'hermes_cli.windows_ssh_runtime', operation, ...args]
|
||||
|
||||
const script = [
|
||||
'$ErrorActionPreference="Stop"',
|
||||
`& ${argv.map(psLiteral).join(' ')}`,
|
||||
'if($LASTEXITCODE -ne 0){exit $LASTEXITCODE}'
|
||||
].join(';')
|
||||
|
||||
return powerShellCommand(script)
|
||||
}
|
||||
|
||||
async function helper(ssh, runtime, operation, args = [], stdinData?) {
|
||||
const output = await ssh.exec(helperCommand(runtime, operation, args), stdinData == null ? {} : { stdinData })
|
||||
|
||||
const lines = String(output || '')
|
||||
.replace(/^\uFEFF/, '')
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
|
||||
const parsed = JSON.parse(lines[lines.length - 1] || 'null')
|
||||
|
||||
if (parsed?.error) {
|
||||
throw new Error(parsed.error)
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function fingerprintToken(token) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(String(token || ''))
|
||||
.digest('hex')
|
||||
.slice(0, 32)
|
||||
}
|
||||
|
||||
function validLock(lock, ownershipId) {
|
||||
// port 0 = spawn-in-progress record (written before readiness); a valid
|
||||
// ownership proof for cleanup, but never reusable.
|
||||
return Boolean(
|
||||
lock &&
|
||||
lock.schemaVersion === LOCKFILE_SCHEMA_VERSION &&
|
||||
lock.protocolVersion === PROTOCOL_VERSION &&
|
||||
lock.ownershipId === ownershipId &&
|
||||
/^[0-9a-f]{16}$/.test(lock.spawnNonce || '') &&
|
||||
Number.isInteger(lock.pid) &&
|
||||
lock.pid > 0 &&
|
||||
/^[0-9]{10,20}$/.test(lock.creationTimeNs || '') &&
|
||||
Number.isInteger(lock.port) &&
|
||||
lock.port >= 0 &&
|
||||
lock.port <= 65535 &&
|
||||
/^[0-9a-f]{32}$/.test(lock.tokenFingerprint || '') &&
|
||||
typeof lock.hermesPath === 'string' &&
|
||||
typeof lock.hermesHome === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function assertCurrent(signal) {
|
||||
if (signal?.aborted) {
|
||||
const error: any = new Error('SSH bootstrap was cancelled.')
|
||||
error.kind = 'superseded'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function processState(ssh, runtime, lock) {
|
||||
return helper(ssh, runtime, 'process-state', [
|
||||
String(lock.pid),
|
||||
String(lock.creationTimeNs),
|
||||
lock.hermesPath,
|
||||
lock.spawnNonce
|
||||
])
|
||||
}
|
||||
|
||||
async function cleanupOwned(ssh, runtime, ownershipId, lock) {
|
||||
const attempt = async fn => {
|
||||
try {
|
||||
await fn()
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
if (lock) {
|
||||
const state = await processState(ssh, runtime, lock)
|
||||
|
||||
if (state.alive && state.owned) {
|
||||
// Deliberately not attempt()-wrapped: a thrown terminate must abort before
|
||||
// remove-lock, or a live backend is orphaned with no lock to reclaim it.
|
||||
await helper(ssh, runtime, 'terminate', [
|
||||
String(lock.pid),
|
||||
String(lock.creationTimeNs),
|
||||
lock.hermesPath,
|
||||
lock.spawnNonce
|
||||
])
|
||||
}
|
||||
|
||||
if (lock.spawnNonce) {
|
||||
await attempt(() => helper(ssh, runtime, 'remove-token', [ownershipId, lock.spawnNonce]))
|
||||
await attempt(() => helper(ssh, runtime, 'remove-log', [ownershipId, lock.spawnNonce]))
|
||||
}
|
||||
}
|
||||
|
||||
await attempt(() => helper(ssh, runtime, 'remove-lock', [ownershipId]))
|
||||
}
|
||||
|
||||
async function waitReady(ssh, runtime, ownershipId, lock, timeoutMs, signal) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
assertCurrent(signal)
|
||||
let state
|
||||
|
||||
try {
|
||||
state = await processState(ssh, runtime, lock)
|
||||
} catch {
|
||||
await new Promise(resolve => setTimeout(resolve, READY_POLL_INTERVAL_MS))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (!state.indeterminate && (!state.alive || !state.owned)) {
|
||||
let detail = ''
|
||||
|
||||
try {
|
||||
detail = (await helper(ssh, runtime, 'read-log', [ownershipId, lock.spawnNonce]))?.content || ''
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
const error: any = new Error(
|
||||
`Remote Windows backend exited before announcing its port. state=${JSON.stringify(state)} ${detail.slice(-2000)}`
|
||||
)
|
||||
|
||||
error.kind = 'spawn-failed'
|
||||
throw error
|
||||
}
|
||||
|
||||
let content = ''
|
||||
|
||||
try {
|
||||
content = (await helper(ssh, runtime, 'read-log', [ownershipId, lock.spawnNonce]))?.content || ''
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
let port
|
||||
|
||||
for (const match of content.matchAll(READY_RE)) {
|
||||
port = Number(match[1])
|
||||
}
|
||||
|
||||
if (port) {
|
||||
return port
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, READY_POLL_INTERVAL_MS))
|
||||
}
|
||||
|
||||
const error: any = new Error(`Timed out waiting for the remote Windows backend (${timeoutMs}ms).`)
|
||||
error.kind = 'ready-timeout'
|
||||
throw error
|
||||
}
|
||||
|
||||
async function connectWindowsRemote(deps) {
|
||||
const {
|
||||
ssh,
|
||||
ownershipId,
|
||||
profile = '',
|
||||
remoteHermesPath = '',
|
||||
reuseToken = '',
|
||||
signal,
|
||||
pickLocalPort,
|
||||
forward,
|
||||
cancelForward,
|
||||
waitForHermes,
|
||||
probeReuseProof,
|
||||
rememberLog = () => {},
|
||||
readyTimeoutMs = 45_000
|
||||
} = deps
|
||||
|
||||
assertCurrent(signal)
|
||||
const runtime = await probeWindowsRemote(ssh, remoteHermesPath)
|
||||
const inspection = await helper(ssh, runtime, 'inspect', [runtime.hermesPath])
|
||||
|
||||
if (!inspection.supported) {
|
||||
const error: any = new Error('Update Hermes on the remote Windows host before connecting with Desktop SSH.')
|
||||
error.kind = 'update-required'
|
||||
throw error
|
||||
}
|
||||
|
||||
runtime.hermesPath = inspection.path
|
||||
const hermesVersion = inspection.version || ''
|
||||
rememberLog(`[ssh-lifecycle] remote platform Windows/${runtime.arch}`)
|
||||
rememberLog(`[ssh-lifecycle] located hermes at ${runtime.hermesPath}`)
|
||||
|
||||
const lock = await helper(ssh, runtime, 'read-lock', [ownershipId])
|
||||
|
||||
if (validLock(lock, ownershipId)) {
|
||||
const state = await processState(ssh, runtime, lock)
|
||||
|
||||
if (state.indeterminate) {
|
||||
const error: any = new Error('Could not determine the state of the existing remote backend.')
|
||||
error.kind = 'transient-transport-error'
|
||||
throw error
|
||||
}
|
||||
|
||||
const reusable =
|
||||
state.alive &&
|
||||
state.owned &&
|
||||
lock.port > 0 &&
|
||||
Boolean(reuseToken) &&
|
||||
lock.tokenFingerprint === fingerprintToken(reuseToken) &&
|
||||
lock.hermesPath === runtime.hermesPath &&
|
||||
lock.hermesHome === runtime.hermesHome
|
||||
|
||||
if (reusable) {
|
||||
const localPort = await pickLocalPort()
|
||||
await forward(localPort, lock.port)
|
||||
|
||||
try {
|
||||
const baseUrl = `http://127.0.0.1:${localPort}`
|
||||
const classification = await probeReuseProof(baseUrl, reuseToken, lock.spawnNonce)
|
||||
|
||||
if (classification === 'authenticated-ok') {
|
||||
return {
|
||||
baseUrl,
|
||||
token: reuseToken,
|
||||
remotePort: lock.port,
|
||||
localPort,
|
||||
pid: lock.pid,
|
||||
reused: true,
|
||||
platform: { os: 'Windows', arch: runtime.arch },
|
||||
hermesPath: runtime.hermesPath,
|
||||
hermesVersion,
|
||||
ownershipId,
|
||||
spawnNonce: lock.spawnNonce,
|
||||
creationTimeNs: lock.creationTimeNs
|
||||
}
|
||||
}
|
||||
|
||||
if (classification !== 'authenticated-stale') {
|
||||
throw new Error('Invalid SSH reuse classification.')
|
||||
}
|
||||
|
||||
await cancelForward(localPort, lock.port)
|
||||
await cleanupOwned(ssh, runtime, ownershipId, lock)
|
||||
} catch (error) {
|
||||
await cancelForward(localPort, lock.port)
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
await cleanupOwned(ssh, runtime, ownershipId, lock)
|
||||
}
|
||||
} else if (lock) {
|
||||
await helper(ssh, runtime, 'remove-lock', [ownershipId])
|
||||
}
|
||||
|
||||
assertCurrent(signal)
|
||||
const token = crypto.randomBytes(32).toString('hex')
|
||||
const spawnNonce = crypto.randomBytes(8).toString('hex')
|
||||
await helper(ssh, runtime, 'upload-token', [ownershipId, spawnNonce], token)
|
||||
let spawned
|
||||
|
||||
try {
|
||||
spawned = await helper(
|
||||
ssh,
|
||||
runtime,
|
||||
'spawn',
|
||||
[],
|
||||
JSON.stringify({ ownershipId, spawnNonce, profile, hermesPath: runtime.hermesPath })
|
||||
)
|
||||
} catch (error) {
|
||||
await helper(ssh, runtime, 'remove-token', [ownershipId, spawnNonce])
|
||||
throw error
|
||||
}
|
||||
|
||||
const owned = {
|
||||
schemaVersion: LOCKFILE_SCHEMA_VERSION,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
ownershipId,
|
||||
spawnNonce,
|
||||
pid: spawned.pid,
|
||||
creationTimeNs: spawned.creationTimeNs,
|
||||
port: 0,
|
||||
profile,
|
||||
hermesPath: runtime.hermesPath,
|
||||
hermesHome: runtime.hermesHome,
|
||||
tokenFingerprint: fingerprintToken(token),
|
||||
startedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
let localPort = 0
|
||||
let remotePort = 0
|
||||
|
||||
try {
|
||||
// Write the ownership record IMMEDIATELY (port=0): if this attempt is
|
||||
// superseded before readiness and cleanup cannot reach the box, the next
|
||||
// connect still finds the lock and reaps the process by exact ownership.
|
||||
// Inside the try: if this write itself fails, the catch still kills the
|
||||
// just-spawned process via the in-memory record.
|
||||
await helper(ssh, runtime, 'write-lock', [ownershipId], JSON.stringify(owned))
|
||||
remotePort = await waitReady(ssh, runtime, ownershipId, owned, readyTimeoutMs, signal)
|
||||
localPort = await pickLocalPort()
|
||||
await forward(localPort, remotePort)
|
||||
const baseUrl = `http://127.0.0.1:${localPort}`
|
||||
await waitForHermes(baseUrl, token)
|
||||
assertCurrent(signal)
|
||||
await helper(ssh, runtime, 'write-lock', [ownershipId], JSON.stringify({ ...owned, port: remotePort }))
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
token,
|
||||
remotePort,
|
||||
localPort,
|
||||
pid: spawned.pid,
|
||||
reused: false,
|
||||
platform: { os: 'Windows', arch: runtime.arch },
|
||||
hermesPath: runtime.hermesPath,
|
||||
hermesVersion,
|
||||
ownershipId,
|
||||
spawnNonce,
|
||||
creationTimeNs: spawned.creationTimeNs
|
||||
}
|
||||
} catch (error) {
|
||||
if (localPort && remotePort) {
|
||||
await cancelForward(localPort, remotePort)
|
||||
}
|
||||
|
||||
await cleanupOwned(ssh, runtime, ownershipId, owned)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function buildWindowsInteractiveCommand(remoteCwd = '') {
|
||||
const cwd = String(remoteCwd || '').trim()
|
||||
const script = ['$ErrorActionPreference="Stop"']
|
||||
|
||||
if (cwd) {
|
||||
script.push(
|
||||
`if(Test-Path -LiteralPath ${psLiteral(cwd)} -PathType Container){Set-Location -LiteralPath ${psLiteral(cwd)}}`
|
||||
)
|
||||
}
|
||||
|
||||
script.push('$host.UI.RawUI.WindowTitle="Hermes SSH"', 'powershell.exe -NoLogo')
|
||||
|
||||
return powerShellCommand(script.join(';'))
|
||||
}
|
||||
|
||||
export {
|
||||
buildWindowsInteractiveCommand,
|
||||
connectWindowsRemote,
|
||||
detectRemotePlatform,
|
||||
encodedPowerShell,
|
||||
helper,
|
||||
helperCommand,
|
||||
powerShellCommand,
|
||||
probeWindowsRemote,
|
||||
psLiteral,
|
||||
validLock
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { pickLocalPort, SshConnection } from './ssh-connection'
|
||||
import { connectWindowsRemote } from './windows-remote-lifecycle'
|
||||
|
||||
// Live test against a real Windows host over SSH. Opt-in: set the env trio to
|
||||
// your test rig; skipped everywhere else (CI, other machines).
|
||||
// HERMES_WIN_SSH_HOST ssh alias/host of the Windows box
|
||||
// HERMES_WIN_SSH_USER remote user
|
||||
// HERMES_WIN_SSH_HERMES absolute path to the remote hermes.exe under test
|
||||
const liveHost = process.env.HERMES_WIN_SSH_HOST || ''
|
||||
const liveUser = process.env.HERMES_WIN_SSH_USER || ''
|
||||
const configuredHermes = process.env.HERMES_WIN_SSH_HERMES || ''
|
||||
const ownershipId = '89abcdef0123456789abcdef01234567'
|
||||
|
||||
function fetchJson(url, token, path) {
|
||||
return fetch(`${url}${path}`, { headers: { 'X-Hermes-Session-Token': token } }).then(async response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status}: ${await response.text()}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
})
|
||||
}
|
||||
|
||||
test.skipIf(!liveHost || !liveUser || !configuredHermes)(
|
||||
'live Windows remote lifecycle spawns, authenticates, reuses, and cleans exact ownership',
|
||||
async () => {
|
||||
const ssh = new SshConnection({ host: liveHost, user: liveUser, port: 22, keyPath: '' }, { mux: true })
|
||||
await ssh.open()
|
||||
|
||||
const common = {
|
||||
ssh,
|
||||
ownershipId,
|
||||
profile: '',
|
||||
remoteHermesPath: configuredHermes,
|
||||
pickLocalPort,
|
||||
forward: (local, remote) => ssh.forward(local, remote),
|
||||
cancelForward: (local, remote) => ssh.cancelForward(local, remote),
|
||||
waitForHermes: async (baseUrl, token) => {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
try {
|
||||
await fetchJson(baseUrl, token, '/api/status')
|
||||
|
||||
return
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
}
|
||||
|
||||
throw new Error('status timeout')
|
||||
},
|
||||
probeReuseProof: async (baseUrl, token, nonce) => {
|
||||
const body: any = await fetchJson(baseUrl, token, '/api/ssh/ownership')
|
||||
|
||||
return body.sshOwnerNonce === nonce ? 'authenticated-ok' : 'authenticated-stale'
|
||||
},
|
||||
rememberLog: () => {}
|
||||
}
|
||||
|
||||
let first
|
||||
let second
|
||||
|
||||
try {
|
||||
first = await connectWindowsRemote(common)
|
||||
assert.equal(first.platform.os, 'Windows')
|
||||
assert.equal(first.reused, false)
|
||||
const status: any = await fetchJson(first.baseUrl, first.token, '/api/status')
|
||||
assert.ok(status)
|
||||
await ssh.cancelForward(first.localPort, first.remotePort)
|
||||
second = await connectWindowsRemote({ ...common, reuseToken: first.token })
|
||||
assert.equal(second.reused, true)
|
||||
assert.equal(second.pid, first.pid)
|
||||
assert.equal(second.spawnNonce, first.spawnNonce)
|
||||
} finally {
|
||||
if (second) {
|
||||
await ssh.cancelForward(second.localPort, second.remotePort)
|
||||
}
|
||||
|
||||
const runtimeScript = `& '${configuredHermes.replace('hermes.exe', 'python.exe')}' -m hermes_cli.windows_ssh_runtime read-lock '${ownershipId}'`
|
||||
|
||||
const lock: any = JSON.parse(
|
||||
await ssh.exec(`powershell.exe -NoProfile -NonInteractive -Command "${runtimeScript}"`)
|
||||
)
|
||||
|
||||
if (lock) {
|
||||
const python = configuredHermes.replace('hermes.exe', 'python.exe')
|
||||
const terminate = `& '${python}' -m hermes_cli.windows_ssh_runtime terminate '${lock.pid}' '${lock.creationTimeNs}' '${lock.hermesPath}' '${lock.spawnNonce}'`
|
||||
await ssh.exec(`powershell.exe -NoProfile -NonInteractive -Command "${terminate}"`)
|
||||
await ssh.exec(
|
||||
`powershell.exe -NoProfile -NonInteractive -Command "& '${python}' -m hermes_cli.windows_ssh_runtime remove-lock '${ownershipId}'"`
|
||||
)
|
||||
await ssh.exec(
|
||||
`powershell.exe -NoProfile -NonInteractive -Command "& '${python}' -m hermes_cli.windows_ssh_runtime remove-log '${ownershipId}' '${lock.spawnNonce}'"`
|
||||
)
|
||||
}
|
||||
|
||||
await ssh.close()
|
||||
}
|
||||
},
|
||||
90_000
|
||||
)
|
||||
@@ -1,65 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID } from '@/store/layout'
|
||||
import {
|
||||
$filePreviewTabs,
|
||||
$previewTarget,
|
||||
clearSessionPreviewRegistry,
|
||||
type PreviewTarget,
|
||||
setCurrentSessionPreviewTarget
|
||||
} from '@/store/preview'
|
||||
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
|
||||
|
||||
import { closeActiveTab } from './close-tab'
|
||||
|
||||
function fileTarget(path: string): PreviewTarget {
|
||||
return {
|
||||
kind: 'file',
|
||||
label: path,
|
||||
path,
|
||||
previewKind: 'text',
|
||||
source: path,
|
||||
url: `file://${path}`
|
||||
}
|
||||
}
|
||||
|
||||
describe('closeActiveTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('document', { activeElement: null })
|
||||
$activeSessionId.set('session-1')
|
||||
$selectedStoredSessionId.set(null)
|
||||
window.localStorage.clear()
|
||||
clearSessionPreviewRegistry()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
$activeSessionId.set(null)
|
||||
$selectedStoredSessionId.set(null)
|
||||
clearSessionPreviewRegistry()
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('closes the active file preview tab (⌘W happy path)', () => {
|
||||
setCurrentSessionPreviewTarget(fileTarget('/work/notes.md'), 'manual')
|
||||
|
||||
expect($filePreviewTabs.get()).toHaveLength(1)
|
||||
expect($rightRailActiveTabId.get()).toBe('file:file:///work/notes.md')
|
||||
|
||||
expect(closeActiveTab()).toBe(true)
|
||||
expect($filePreviewTabs.get()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('closes the visible file tab when active selection is a ghost preview', () => {
|
||||
// Active tab id stuck on live-preview after that target was cleared, while
|
||||
// file tabs remain (UI falls back to tabs[0] until React syncs). ⌘W must
|
||||
// close the visible file tab instead of no-op'ing via closeWorkspaceTab().
|
||||
setCurrentSessionPreviewTarget(fileTarget('/work/notes.md'), 'manual')
|
||||
$previewTarget.set(null)
|
||||
$rightRailActiveTabId.set(RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
|
||||
expect($filePreviewTabs.get()).toHaveLength(1)
|
||||
expect(closeActiveTab()).toBe(true)
|
||||
expect($filePreviewTabs.get()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,12 @@
|
||||
import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
|
||||
import { closeWorkspaceTab } from '@/components/pane-shell/tree/store'
|
||||
import { isFocusWithin } from '@/lib/keybinds/combo'
|
||||
import { $filePreviewTabs, $previewTarget, closeActiveRightRailTab } from '@/store/preview'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '@/store/preview'
|
||||
|
||||
/**
|
||||
* ⌘W — close the tab of the context you're in, by precedence:
|
||||
* 1. a focused terminal → its active terminal tab,
|
||||
* 2. right-rail tabs (live preview and/or file peeks),
|
||||
* 2. an open preview → its active preview tab (unchanged from pre-tiling),
|
||||
* 3. the MAIN zone → its active tab (a session tile stacked into the workspace).
|
||||
* Returns false when nothing closes, so ⌘W is a no-op — it never closes the
|
||||
* window (a bare workspace stays put). Shared by the keyboard path (Win/Linux)
|
||||
@@ -19,13 +19,10 @@ export function closeActiveTab(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
// Prefer tab *presence* over the derived active file target. After the live
|
||||
// preview is cleared, `$rightRailActiveTabId` can stay on `preview` while
|
||||
// file tabs remain (the rail UI falls back to tabs[0]). Gating only on
|
||||
// `$filePreviewTarget` made ⌘W fall through to closeWorkspaceTab() and look
|
||||
// broken with a file tab still on screen.
|
||||
if ($previewTarget.get() || $filePreviewTabs.get().length > 0) {
|
||||
return closeActiveRightRailTab()
|
||||
if ($filePreviewTarget.get() || $previewTarget.get()) {
|
||||
closeActiveRightRailTab()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return closeWorkspaceTab()
|
||||
|
||||
@@ -18,10 +18,8 @@ import { RICH_INPUT_SLOT } from './rich-editor'
|
||||
export type ComposerTarget = 'edit' | 'main' | (string & {})
|
||||
export type ComposerInsertMode = 'block' | 'inline'
|
||||
|
||||
export interface FocusDetail {
|
||||
interface FocusDetail {
|
||||
target: ComposerTarget
|
||||
/** Append after focus (type-to-focus / soft `/`). */
|
||||
typeChar?: string
|
||||
}
|
||||
|
||||
interface InsertDetail {
|
||||
@@ -84,10 +82,8 @@ export const markActiveComposer = (target: ComposerTarget) => {
|
||||
* Used by broadcast listeners (voice, Esc-to-stop) to act on exactly one. */
|
||||
export const getActiveComposer = (): ComposerTarget => activeTarget
|
||||
|
||||
export const requestComposerFocus = (
|
||||
target: ComposerTarget | 'active' = 'active',
|
||||
{ typeChar }: { typeChar?: string } = {}
|
||||
) => dispatch<FocusDetail>(FOCUS_EVENT, { target: resolve(target), typeChar })
|
||||
export const requestComposerFocus = (target: ComposerTarget | 'active' = 'active') =>
|
||||
dispatch<FocusDetail>(FOCUS_EVENT, { target: resolve(target) })
|
||||
|
||||
export const requestComposerInsert = (
|
||||
text: string,
|
||||
@@ -102,8 +98,8 @@ export const requestComposerInsert = (
|
||||
dispatch<InsertDetail>(INSERT_EVENT, { mode, target: resolve(target), text: trimmed })
|
||||
}
|
||||
|
||||
export const onComposerFocusRequest = (handler: (detail: FocusDetail) => void) =>
|
||||
subscribe<FocusDetail>(FOCUS_EVENT, handler)
|
||||
export const onComposerFocusRequest = (handler: (target: ComposerTarget) => void) =>
|
||||
subscribe<FocusDetail>(FOCUS_EVENT, ({ target }) => handler(target))
|
||||
|
||||
export const onComposerInsertRequest = (handler: (detail: InsertDetail) => void) =>
|
||||
subscribe<InsertDetail>(INSERT_EVENT, handler)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user