mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-19 16:40:38 +08:00
Compare commits
1 Commits
bb/gui-mai
...
perf/fts-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc9783703b |
@@ -417,9 +417,9 @@ IMAGE_TOOLS_DEBUG=false
|
||||
# Default STT provider is "local" (faster-whisper) — runs on your machine, no API key needed.
|
||||
# Install with: pip install faster-whisper
|
||||
# Model downloads automatically on first use (~150 MB for "base").
|
||||
# To use cloud providers instead, set GROQ_API_KEY, VOICE_TOOLS_OPENAI_KEY, or ELEVENLABS_API_KEY above.
|
||||
# Provider priority: local > groq > openai > mistral > xai > elevenlabs
|
||||
# Configure in config.yaml: stt.provider: local | groq | openai | mistral | xai | elevenlabs
|
||||
# To use cloud providers instead, set GROQ_API_KEY or VOICE_TOOLS_OPENAI_KEY above.
|
||||
# Provider priority: local > groq > openai
|
||||
# Configure in config.yaml: stt.provider: local | groq | openai
|
||||
|
||||
# =============================================================================
|
||||
# STT ADVANCED OVERRIDES (optional)
|
||||
@@ -427,12 +427,10 @@ IMAGE_TOOLS_DEBUG=false
|
||||
# Override default STT models per provider (normally set via stt.model in config.yaml)
|
||||
# STT_GROQ_MODEL=whisper-large-v3-turbo
|
||||
# STT_OPENAI_MODEL=whisper-1
|
||||
# STT_ELEVENLABS_MODEL=scribe_v2
|
||||
|
||||
# Override STT provider endpoints (for proxies or self-hosted instances)
|
||||
# GROQ_BASE_URL=https://api.groq.com/openai/v1
|
||||
# STT_OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
# ELEVENLABS_STT_BASE_URL=https://api.elevenlabs.io/v1
|
||||
|
||||
# =============================================================================
|
||||
# MICROSOFT TEAMS INTEGRATION
|
||||
|
||||
22
.github/workflows/contributor-check.yml
vendored
22
.github/workflows/contributor-check.yml
vendored
@@ -3,9 +3,11 @@ name: Contributor Attribution Check
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
paths:
|
||||
# Only run when code files change (not docs-only PRs)
|
||||
- '*.py'
|
||||
- '**/*.py'
|
||||
- '.github/workflows/contributor-check.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -18,21 +20,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for git log
|
||||
|
||||
- name: Check if relevant files changed
|
||||
id: filter
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
CHANGED=$(git diff --name-only "$BASE"..."$HEAD" -- '*.py' '**/*.py' '.github/workflows/contributor-check.yml' || true)
|
||||
if [ -n "$CHANGED" ]; then
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No Python files changed, skipping attribution check."
|
||||
fi
|
||||
|
||||
- name: Check for unmapped contributor emails
|
||||
if: steps.filter.outputs.run == 'true'
|
||||
run: |
|
||||
# Get the merge base between this PR and main
|
||||
MERGE_BASE=$(git merge-base origin/main HEAD)
|
||||
|
||||
7
.github/workflows/deploy-site.yml
vendored
7
.github/workflows/deploy-site.yml
vendored
@@ -22,12 +22,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy-vercel:
|
||||
# Triggered automatically on release publish (production cuts) and
|
||||
# manually via `gh workflow run deploy-site.yml` when an out-of-band
|
||||
# main commit needs to ship live before the next release tag — e.g.
|
||||
# a skills-index PR that doesn't touch website/** paths and so
|
||||
# doesn't auto-deploy via the deploy-docs path.
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
if: github.event_name == 'release'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Vercel Deploy
|
||||
|
||||
342
.github/workflows/desktop-release.yml
vendored
342
.github/workflows/desktop-release.yml
vendored
@@ -1,342 +0,0 @@
|
||||
name: Desktop Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: Release channel to build
|
||||
required: true
|
||||
default: nightly
|
||||
type: choice
|
||||
options:
|
||||
- nightly
|
||||
- stable
|
||||
release_tag:
|
||||
description: "Required when channel=stable (example: v2026.5.5)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: desktop-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
channel: ${{ steps.meta.outputs.channel }}
|
||||
release_name: ${{ steps.meta.outputs.release_name }}
|
||||
release_tag: ${{ steps.meta.outputs.release_tag }}
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
is_stable: ${{ steps.meta.outputs.is_stable }}
|
||||
steps:
|
||||
- id: meta
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
INPUT_CHANNEL: ${{ github.event.inputs.channel }}
|
||||
INPUT_RELEASE_TAG: ${{ github.event.inputs.release_tag }}
|
||||
RELEASE_TAG_FROM_EVENT: ${{ github.event.release.tag_name }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
channel="nightly"
|
||||
release_tag="desktop-nightly"
|
||||
is_stable="false"
|
||||
|
||||
if [[ "$EVENT_NAME" == "release" ]]; then
|
||||
channel="stable"
|
||||
release_tag="$RELEASE_TAG_FROM_EVENT"
|
||||
is_stable="true"
|
||||
elif [[ "$EVENT_NAME" == "workflow_dispatch" && "$INPUT_CHANNEL" == "stable" ]]; then
|
||||
channel="stable"
|
||||
release_tag="$INPUT_RELEASE_TAG"
|
||||
is_stable="true"
|
||||
fi
|
||||
|
||||
if [[ "$channel" == "stable" ]]; then
|
||||
if [[ -z "$release_tag" ]]; then
|
||||
echo "Stable desktop releases require a release tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version="${release_tag#v}"
|
||||
release_name="Hermes Desktop ${release_tag}"
|
||||
else
|
||||
stamp="$(date -u +%Y%m%d)"
|
||||
short_sha="${GITHUB_SHA::7}"
|
||||
version="0.0.0-nightly.${stamp}.${short_sha}"
|
||||
release_name="Hermes Desktop Nightly ${stamp}-${short_sha}"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "channel=$channel"
|
||||
echo "release_name=$release_name"
|
||||
echo "release_tag=$release_tag"
|
||||
echo "version=$version"
|
||||
echo "is_stable=$is_stable"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
needs: prepare
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: mac
|
||||
runner: macos-latest
|
||||
build_args: --mac dmg zip
|
||||
- platform: win
|
||||
runner: windows-latest
|
||||
build_args: --win nsis msi
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
DESKTOP_CHANNEL: ${{ needs.prepare.outputs.channel }}
|
||||
DESKTOP_VERSION: ${{ needs.prepare.outputs.version }}
|
||||
MAC_CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
MAC_CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
|
||||
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Enforce signing gates for stable releases
|
||||
if: needs.prepare.outputs.is_stable == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
missing=()
|
||||
|
||||
if [[ "${{ matrix.platform }}" == "mac" ]]; then
|
||||
[[ -z "${MAC_CSC_LINK:-}" ]] && missing+=("CSC_LINK")
|
||||
[[ -z "${MAC_CSC_KEY_PASSWORD:-}" ]] && missing+=("CSC_KEY_PASSWORD")
|
||||
[[ -z "${APPLE_API_KEY:-}" ]] && missing+=("APPLE_API_KEY")
|
||||
[[ -z "${APPLE_API_KEY_ID:-}" ]] && missing+=("APPLE_API_KEY_ID")
|
||||
[[ -z "${APPLE_API_ISSUER:-}" ]] && missing+=("APPLE_API_ISSUER")
|
||||
else
|
||||
[[ -z "${WIN_CSC_LINK:-}" ]] && missing+=("WIN_CSC_LINK")
|
||||
[[ -z "${WIN_CSC_KEY_PASSWORD:-}" ]] && missing+=("WIN_CSC_KEY_PASSWORD")
|
||||
fi
|
||||
|
||||
if (( ${#missing[@]} > 0 )); then
|
||||
echo "::error::Stable desktop release missing required secrets: ${missing[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install workspace dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install TUI dependencies
|
||||
run: npm --prefix ui-tui ci
|
||||
|
||||
- name: Build bundled TUI payload
|
||||
run: npm --prefix ui-tui run build
|
||||
|
||||
- name: Build desktop renderer
|
||||
run: npm --prefix apps/desktop run build
|
||||
|
||||
- name: Map macOS signing credentials
|
||||
if: matrix.platform == 'mac'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
has_link=0
|
||||
has_pass=0
|
||||
[[ -n "${MAC_CSC_LINK:-}" ]] && has_link=1
|
||||
[[ -n "${MAC_CSC_KEY_PASSWORD:-}" ]] && has_pass=1
|
||||
|
||||
if [[ $has_link -eq 1 && $has_pass -eq 1 ]]; then
|
||||
echo "CSC_LINK=${MAC_CSC_LINK}" >> "$GITHUB_ENV"
|
||||
echo "CSC_KEY_PASSWORD=${MAC_CSC_KEY_PASSWORD}" >> "$GITHUB_ENV"
|
||||
elif [[ $has_link -eq 1 || $has_pass -eq 1 ]]; then
|
||||
echo "::error::macOS signing secrets are partially configured. Set both CSC_LINK and CSC_KEY_PASSWORD."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Map Windows signing credentials
|
||||
if: matrix.platform == 'win'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
has_link=0
|
||||
has_pass=0
|
||||
[[ -n "${WIN_CSC_LINK:-}" ]] && has_link=1
|
||||
[[ -n "${WIN_CSC_KEY_PASSWORD:-}" ]] && has_pass=1
|
||||
|
||||
if [[ $has_link -eq 1 && $has_pass -eq 1 ]]; then
|
||||
echo "CSC_LINK=${WIN_CSC_LINK}" >> "$GITHUB_ENV"
|
||||
echo "CSC_KEY_PASSWORD=${WIN_CSC_KEY_PASSWORD}" >> "$GITHUB_ENV"
|
||||
echo "CSC_FOR_PULL_REQUEST=true" >> "$GITHUB_ENV"
|
||||
elif [[ $has_link -eq 1 || $has_pass -eq 1 ]]; then
|
||||
echo "::error::Windows signing secrets are partially configured. Set both WIN_CSC_LINK and WIN_CSC_KEY_PASSWORD."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build desktop installers
|
||||
shell: bash
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=16384
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm --prefix apps/desktop run builder -- \
|
||||
${{ matrix.build_args }} \
|
||||
--publish never \
|
||||
--config.extraMetadata.version="${DESKTOP_VERSION}" \
|
||||
--config.extraMetadata.desktopChannel="${DESKTOP_CHANNEL}"
|
||||
|
||||
- name: Notarize and staple macOS DMG
|
||||
if: matrix.platform == 'mac' && needs.prepare.outputs.is_stable == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
dmg_path="$(ls apps/desktop/release/*.dmg | head -n 1)"
|
||||
node apps/desktop/scripts/notarize-artifact.cjs "$dmg_path"
|
||||
|
||||
- name: Validate macOS notarization and Gatekeeper trust
|
||||
if: matrix.platform == 'mac' && needs.prepare.outputs.is_stable == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
app_path="$(ls -d apps/desktop/release/mac*/Hermes.app | head -n 1)"
|
||||
dmg_path="$(ls apps/desktop/release/*.dmg | head -n 1)"
|
||||
xcrun stapler validate "$app_path"
|
||||
xcrun stapler validate "$dmg_path"
|
||||
spctl --assess --type execute --verbose=4 "$app_path"
|
||||
|
||||
- name: Generate desktop checksums
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node <<'EOF'
|
||||
const crypto = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const releaseDir = path.resolve('apps/desktop/release')
|
||||
const platform = process.env.PLATFORM
|
||||
const extensions = platform === 'mac' ? ['.dmg', '.zip'] : ['.exe', '.msi']
|
||||
const files = fs
|
||||
.readdirSync(releaseDir)
|
||||
.filter(name => extensions.some(ext => name.endsWith(ext)))
|
||||
.sort()
|
||||
|
||||
if (!files.length) {
|
||||
throw new Error(`No release artifacts were produced for ${platform}`)
|
||||
}
|
||||
|
||||
const lines = files.map(name => {
|
||||
const full = path.join(releaseDir, name)
|
||||
const hash = crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex')
|
||||
return `${hash} ${name}`
|
||||
})
|
||||
fs.writeFileSync(path.join(releaseDir, `SHA256SUMS-${platform}.txt`), `${lines.join('\n')}\n`)
|
||||
EOF
|
||||
env:
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
|
||||
- name: Upload packaged desktop artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: desktop-${{ matrix.platform }}
|
||||
path: |
|
||||
apps/desktop/release/*.dmg
|
||||
apps/desktop/release/*.zip
|
||||
apps/desktop/release/*.exe
|
||||
apps/desktop/release/*.msi
|
||||
apps/desktop/release/SHA256SUMS-${{ matrix.platform }}.txt
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
needs: [prepare, build]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CHANNEL: ${{ needs.prepare.outputs.channel }}
|
||||
RELEASE_NAME: ${{ needs.prepare.outputs.release_name }}
|
||||
RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: desktop-*
|
||||
merge-multiple: true
|
||||
path: dist/desktop
|
||||
|
||||
- name: Publish desktop assets to GitHub release
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s globstar nullglob
|
||||
|
||||
files=(
|
||||
dist/desktop/**/*.dmg
|
||||
dist/desktop/**/*.zip
|
||||
dist/desktop/**/*.exe
|
||||
dist/desktop/**/*.msi
|
||||
dist/desktop/**/SHA256SUMS-*.txt
|
||||
)
|
||||
|
||||
if (( ${#files[@]} == 0 )); then
|
||||
echo "No desktop artifacts were downloaded for publishing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$CHANNEL" == "nightly" ]]; then
|
||||
git tag -f "$RELEASE_TAG" "$GITHUB_SHA"
|
||||
git push origin "refs/tags/$RELEASE_TAG" --force
|
||||
|
||||
notes="Automated nightly desktop build from main. This prerelease is replaced on each new run."
|
||||
|
||||
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
while IFS= read -r asset_name; do
|
||||
gh release delete-asset "$RELEASE_TAG" "$asset_name" --yes
|
||||
done < <(gh release view "$RELEASE_TAG" --json assets -q '.assets[].name')
|
||||
|
||||
gh release edit "$RELEASE_TAG" \
|
||||
--title "$RELEASE_NAME" \
|
||||
--prerelease \
|
||||
--notes "$notes"
|
||||
else
|
||||
gh release create "$RELEASE_TAG" \
|
||||
--target "$GITHUB_SHA" \
|
||||
--title "$RELEASE_NAME" \
|
||||
--notes "$notes" \
|
||||
--prerelease
|
||||
fi
|
||||
else
|
||||
if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
notes="Automated desktop artifacts attached by desktop-release workflow."
|
||||
gh release create "$RELEASE_TAG" \
|
||||
--target "$GITHUB_SHA" \
|
||||
--title "$RELEASE_NAME" \
|
||||
--notes "$notes"
|
||||
fi
|
||||
fi
|
||||
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber
|
||||
24
.github/workflows/docker-publish.yml
vendored
24
.github/workflows/docker-publish.yml
vendored
@@ -196,26 +196,10 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
# Build once, load into the local daemon for smoke testing. PR arm64
|
||||
# builds deliberately avoid the gha cache: cold-cache arm64 builds can
|
||||
# outlive GitHub's short-lived Azure cache SAS token, then fail while
|
||||
# reading or writing cache blobs before the smoke test can run.
|
||||
- name: Build image (arm64, smoke test, uncached PR)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
load: true
|
||||
platforms: linux/arm64
|
||||
tags: ${{ env.IMAGE_NAME }}:test
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
|
||||
# Main/release builds still use the per-arch gha cache so the digest
|
||||
# push below can reuse layers from this smoke-test build.
|
||||
- name: Build image (arm64, smoke test, cached publish)
|
||||
if: github.event_name != 'pull_request'
|
||||
# Build once, load into the local daemon for smoke testing. Cached
|
||||
# to gha with a per-arch scope; the push step below reuses every
|
||||
# layer from this build.
|
||||
- name: Build image (arm64, smoke test)
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
|
||||
8
.github/workflows/nix-lockfile-fix.yml
vendored
8
.github/workflows/nix-lockfile-fix.yml
vendored
@@ -6,8 +6,8 @@ on:
|
||||
paths:
|
||||
- 'ui-tui/package-lock.json'
|
||||
- 'ui-tui/package.json'
|
||||
- 'apps/dashboard/package-lock.json'
|
||||
- 'apps/dashboard/package.json'
|
||||
- 'web/package-lock.json'
|
||||
- 'web/package.json'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
@@ -28,7 +28,7 @@ concurrency:
|
||||
jobs:
|
||||
# ── Auto-fix on main ───────────────────────────────────────────────
|
||||
# Fires when a push to main touches package.json or package-lock.json
|
||||
# in ui-tui/ or apps/dashboard/. Runs fix-lockfiles and pushes the hash
|
||||
# in ui-tui/ or web/. Runs fix-lockfiles and pushes the hash
|
||||
# update commit directly to main so Nix builds never stay broken.
|
||||
#
|
||||
# Safety invariants:
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
# run recompute from the correct package-lock state.
|
||||
pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \
|
||||
'ui-tui/package-lock.json' 'ui-tui/package.json' \
|
||||
'apps/dashboard/package-lock.json' 'apps/dashboard/package.json' || true)"
|
||||
'web/package-lock.json' 'web/package.json' || true)"
|
||||
if [ -n "$pkg_changed" ]; then
|
||||
echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute"
|
||||
exit 0
|
||||
|
||||
77
.github/workflows/supply-chain-audit.yml
vendored
77
.github/workflows/supply-chain-audit.yml
vendored
@@ -3,9 +3,15 @@ name: Supply Chain Audit
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
# No paths filter — the jobs must always run so required checks
|
||||
# report a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
paths:
|
||||
- '**/*.py'
|
||||
- '**/*.pth'
|
||||
- '**/setup.py'
|
||||
- '**/setup.cfg'
|
||||
- '**/sitecustomize.py'
|
||||
- '**/usercustomize.py'
|
||||
- '**/__init__.pth'
|
||||
- 'pyproject.toml'
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
@@ -21,44 +27,8 @@ permissions:
|
||||
# advisory-only workflow instead.
|
||||
|
||||
jobs:
|
||||
# ── Path filter (shared by both scan and dep-bounds) ───────────────
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
# True when any file the scanner cares about changed in this PR
|
||||
scan: ${{ steps.filter.outputs.scan }}
|
||||
# True when pyproject.toml changed in this PR
|
||||
deps: ${{ steps.filter.outputs.deps }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check for relevant file changes
|
||||
id: filter
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
SCAN_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \
|
||||
'*.py' '**/*.py' '*.pth' '**/*.pth' \
|
||||
'setup.py' 'setup.cfg' \
|
||||
'sitecustomize.py' 'usercustomize.py' '__init__.pth' \
|
||||
'pyproject.toml' || true)
|
||||
if [ -n "$SCAN_FILES" ]; then
|
||||
echo "scan=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "scan=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
DEPS_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- 'pyproject.toml' || true)
|
||||
if [ -n "$DEPS_FILES" ]; then
|
||||
echo "deps=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "deps=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
scan:
|
||||
name: Scan PR for critical supply chain risks
|
||||
needs: changes
|
||||
if: needs.changes.outputs.scan == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -177,24 +147,10 @@ jobs:
|
||||
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
|
||||
exit 1
|
||||
|
||||
# Gate: reports success when scan was skipped (no relevant files changed).
|
||||
# This ensures the required check always gets a status.
|
||||
scan-gate:
|
||||
name: Scan PR for critical supply chain risks
|
||||
needs: changes
|
||||
# always() so the gate still reports SUCCESS even if `changes` fails/is
|
||||
# skipped — without it, a failed dependency would leave the required
|
||||
# check unreported (i.e. "pending"), the exact failure mode this fixes.
|
||||
if: always() && needs.changes.outputs.scan != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No supply-chain-relevant files changed, skipping scan."
|
||||
|
||||
dep-bounds:
|
||||
name: Check PyPI dependency upper bounds
|
||||
needs: changes
|
||||
if: needs.changes.outputs.deps == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
if: contains(github.event.pull_request.changed_files_url, 'pyproject.toml') || true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -255,16 +211,3 @@ jobs:
|
||||
run: |
|
||||
echo "::error::PyPI dependencies without upper bounds detected. Add <next_major ceiling per CONTRIBUTING.md policy."
|
||||
exit 1
|
||||
|
||||
# Gate: reports success when dep-bounds was skipped (no pyproject.toml changed).
|
||||
# This ensures the required check always gets a status.
|
||||
dep-bounds-gate:
|
||||
name: Check PyPI dependency upper bounds
|
||||
needs: changes
|
||||
# always() so the gate still reports SUCCESS even if `changes` fails/is
|
||||
# skipped — without it, a failed dependency would leave the required
|
||||
# check unreported (i.e. "pending"), the exact failure mode this fixes.
|
||||
if: always() && needs.changes.outputs.deps != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No pyproject.toml changes, skipping dependency bounds check."
|
||||
|
||||
18
.gitignore
vendored
18
.gitignore
vendored
@@ -63,10 +63,6 @@ environments/benchmarks/evals/
|
||||
|
||||
# Web UI build output
|
||||
hermes_cli/web_dist/
|
||||
apps/desktop/build/
|
||||
apps/desktop/dist/
|
||||
apps/desktop/release/
|
||||
apps/desktop/*.tsbuildinfo
|
||||
|
||||
# Web UI assets — synced from @nous-research/ui at build time via
|
||||
# `npm run sync-assets` (see web/package.json).
|
||||
@@ -89,16 +85,6 @@ website/static/api/skills-index.json
|
||||
website/static/api/skills.json
|
||||
website/static/api/skills-meta.json
|
||||
models-dev-upstream/
|
||||
|
||||
# Local editor / agent tooling (machine-specific; keep in global config, not the repo)
|
||||
.codex/
|
||||
.cursor/
|
||||
.gemini/
|
||||
.zed/
|
||||
.mcp.json
|
||||
opencode.json
|
||||
config/mcporter.json
|
||||
|
||||
hermes_cli/tui_dist/*
|
||||
hermes_cli/scripts/
|
||||
docs/superpowers/*
|
||||
@@ -106,7 +92,3 @@ docs/superpowers/*
|
||||
# also created in-repo when an agent operates in this checkout). Plans, audit
|
||||
# logs, and per-session caches are never artifacts of the codebase.
|
||||
.hermes/
|
||||
|
||||
# Tool Search live-test harness output — non-deterministic model transcripts,
|
||||
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
|
||||
scripts/out/
|
||||
|
||||
25
AGENTS.md
25
AGENTS.md
@@ -2,8 +2,6 @@
|
||||
|
||||
Instructions for AI coding assistants and developers working on the hermes-agent codebase.
|
||||
|
||||
**Never give up on the right solution.**
|
||||
|
||||
## Development Environment
|
||||
|
||||
```bash
|
||||
@@ -68,29 +66,6 @@ hermes-agent/
|
||||
`gateway.log` when running the gateway. Profile-aware via `get_hermes_home()`.
|
||||
Browse with `hermes logs [--follow] [--level ...] [--session ...]`.
|
||||
|
||||
## TypeScript Style
|
||||
|
||||
Applies to TypeScript across Hermes: desktop, TUI, website, and future TS packages.
|
||||
|
||||
- Prefer small nanostores over component state when state is shared, reused, or read by distant UI.
|
||||
- Let each feature own its atoms. Chat state belongs near chat, shell state near shell, shared state in `src/store`.
|
||||
- Components that render from an atom should use `useStore`. Non-rendering actions should read with `$atom.get()`.
|
||||
- Do not pass state through three components when the leaf can subscribe to the atom.
|
||||
- Keep persistence beside the atom that owns it.
|
||||
- Keep route roots thin. They compose routes and shell; they should not become controllers.
|
||||
- No monolithic hooks. A hook should own one narrow job.
|
||||
- Prefer colocated action modules over hidden god hooks.
|
||||
- If a callback is pure side effect, use the terse void form:
|
||||
`onState={st => void setGatewayState(st)}`.
|
||||
- Async UI handlers should make intent explicit:
|
||||
`onClick={() => void save()}`.
|
||||
- Prefer interfaces for public props and shared object shapes. Avoid `type X = { ... }` for object props.
|
||||
- Extend React primitives for props: `React.ComponentProps<'button'>`, `React.ComponentProps<typeof Dialog>`, `Omit<...>`, `Pick<...>`.
|
||||
- Table-driven beats condition ladders when mapping ids, routes, or views.
|
||||
- `src/app` owns routes, pages, and page-specific components.
|
||||
- `src/store` owns shared atoms.
|
||||
- `src/lib` owns shared pure helpers.
|
||||
|
||||
## File Dependency Chain
|
||||
|
||||
```
|
||||
|
||||
@@ -43,7 +43,7 @@ Bundled skills (in `skills/`) ship with every Hermes install. They should be **b
|
||||
- Document handling, web research, common dev workflows, system administration
|
||||
- Used regularly by a wide range of people
|
||||
|
||||
If your skill is official and useful but not universally needed (e.g., a paid service integration, a heavyweight dependency), put it in **`optional-skills/`** — it ships with the repo but isn't activated by default. Users can discover it via `hermes skills browse` (labeled "official") and install it with `hermes skills install` (no third-party warning, built-in trust).
|
||||
If your skill is official and useful but not universally needed (e.g., a paid service integration, a heavyweight dependency), put it in **`optional-skills/`** — it ships with the repo but isn't activated by default. Users can discover it via `hermes skills browse` (labeled "official") and install it with `hermes skills install` (no third-party warning, builtin trust).
|
||||
|
||||
If your skill is specialized, community-contributed, or niche, it's better suited for a **Skills Hub** — upload it to a skills registry and share it in the [Nous Research Discord](https://discord.gg/NousResearch). Users can install it with `hermes skills install`.
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
||||
# hermes process, the dashboard, and per-profile gateways.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli xz-utils && \
|
||||
ca-certificates curl python3 python-is-python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli xz-utils && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---------- s6-overlay install ----------
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
graft skills
|
||||
graft optional-skills
|
||||
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
|
||||
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs
|
||||
# built from the sdist (e.g. Homebrew, downstream packagers). package-data
|
||||
# below covers the wheel; this covers the sdist. See #34034 / #28149.
|
||||
recursive-include plugins plugin.yaml plugin.yml
|
||||
global-exclude __pycache__
|
||||
global-exclude *.py[cod]
|
||||
|
||||
@@ -213,5 +213,3 @@ scripts/run_tests.sh
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
Built by [Nous Research](https://nousresearch.com).
|
||||
|
||||
phragg was here
|
||||
|
||||
@@ -3,73 +3,75 @@
|
||||
**Release Date:** May 16, 2026
|
||||
**Since v0.13.0:** 808 commits · 633 merged PRs · 1393 files changed · 165,061 insertions · 545 issues closed (12 P0, 50 P1) · 215 community contributors (including co-authors)
|
||||
|
||||
> The Foundation Release — Hermes Agent installs and runs anywhere now. Native Windows ships in early beta with a full PowerShell installer story, a `pip install hermes-agent` wheel lands on PyPI, lazy-deps reshape what `pip install hermes-agent` actually pulls down, the supply-chain checker scans every install/upgrade for unsafe versions, and a new OpenAI-compatible local proxy lets Codex / Aider / Cline talk to OAuth-only providers (Claude Pro, ChatGPT Pro, SuperGrok). The cold-start wave shaves ~19 seconds off `hermes` launch, browser-tool CDP calls run 180x faster, and `hermes tools` All-Platforms drops from 14s to under 1.5s. Two new messaging platforms (LINE and SimpleX Chat) and a Microsoft Graph foundation (Teams pipeline + webhook adapter) land alongside `/handoff` that finally transfers sessions live, `vision_analyze` passing pixels through to vision-capable models, `x_search` as a first-class tool, LSP semantic diagnostics on every `write_file` / `patch`, a unified pluggable `video_generate`, a `computer_use` cua-driver backend, cross-session 1-hour Claude prompt caching, a per-turn file-mutation verifier, plus 9 new optional skills. 50+ P1 closures, 12 P0 closures.
|
||||
> The Foundation Release — Hermes installs and runs anywhere, ships with the things you actually want to use, and stops shipping the things you don't. xAI Grok lands as a SuperGrok OAuth provider with grok-4.3 bumped to a 1M context window. A new OpenAI-compatible local proxy turns any OAuth-authed Hermes provider — Claude Pro, ChatGPT Pro, SuperGrok — into an endpoint that Codex / Aider / Cline / Continue can hit. `x_search` lands as a first-class X (Twitter) search tool with OAuth-or-API-key auth. The Microsoft Teams stack is wired end-to-end (Graph auth + webhook listener + pipeline runtime + outbound delivery). A debloating wave makes installs dramatically lighter — heavyweight backends now lazy-install on first use, the `[all]` extras drop everything covered by lazy-deps, and a tiered install falls back when a wheel rejects on your platform. `pip install hermes-agent` works from PyPI. The cold-start wave shaves ~19 seconds off `hermes` launch. Browser CDP calls are 180x faster. Two new messaging platforms (LINE + SimpleX Chat) bring the total to 22. Cross-session 1-hour Claude prompt caching, `/handoff` that actually transfers sessions live, native button UI for `clarify` on Telegram and Discord, Discord channel history backfill, LSP semantic diagnostics on every write, a unified pluggable `video_generate`, a `computer_use` cua-driver backend that finally works with non-Anthropic providers, clickable URLs in any terminal, Zed ACP Registry integration via `uvx`, native Windows beta, 9 new optional skills, OpenRouter Pareto Code router, huggingface/skills as a trusted default tap. 12 P0 + 50 P1 closures.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- **Native Windows support (early beta)** — full PowerShell installer, native subprocess/PTY paths, taskkill-based process management, MinGit auto-install, Microsoft Store python stub detection, foreground Ctrl+C preservation, taskkill+ps2 fallback, npm prefix handling, and ~40 follow-up Windows-only fixes across CLI / gateway / TUI / curator / tools. Hermes finally runs natively on `cmd.exe` and PowerShell, no WSL required. ([#21561](https://github.com/NousResearch/hermes-agent/pull/21561), [#22130](https://github.com/NousResearch/hermes-agent/pull/22130), [#22752](https://github.com/NousResearch/hermes-agent/pull/22752), [#26618](https://github.com/NousResearch/hermes-agent/pull/26618), and many more)
|
||||
- **xAI Grok via SuperGrok OAuth — and grok-4.3 jumps to a 1M context window** — If you pay for SuperGrok, you can now use Grok inside Hermes by signing in with your xAI account — no API key, no separate billing. The wire-through also bumps grok-4.3 to a 1M token context window, so you can drop whole codebases or research corpora into a single prompt. Includes proper handling for entitlement errors and an SSH-to-tunnel docs page for when you're SSH'd into a remote box and need to complete the OAuth flow. ([#26534](https://github.com/NousResearch/hermes-agent/pull/26534), [#26664](https://github.com/NousResearch/hermes-agent/pull/26664), [#26644](https://github.com/NousResearch/hermes-agent/pull/26644), [#26592](https://github.com/NousResearch/hermes-agent/pull/26592))
|
||||
|
||||
- **`pip install hermes-agent && hermes`** — Hermes Agent is now a real PyPI package. One command, no clone, no git, no shell installer. Wheel includes the Ink TUI bundle and shell launcher. (salvage of [#26350](https://github.com/NousResearch/hermes-agent/pull/26350)) ([#26593](https://github.com/NousResearch/hermes-agent/pull/26593))
|
||||
- **OpenAI-compatible local proxy for OAuth providers** — Run `hermes proxy` and you get a `http://localhost:port` endpoint that speaks the OpenAI API but is backed by whichever OAuth provider you're signed into — Claude Pro, ChatGPT Pro, SuperGrok. Now any tool that expects an OpenAI-compatible endpoint (Codex CLI, Aider, Cline, Continue, your custom scripts) just works with your existing subscription, no API key required. One subscription, every tool. ([#25969](https://github.com/NousResearch/hermes-agent/pull/25969))
|
||||
|
||||
- **Cold-start performance wave — ~19s off `hermes` launch** — skills cache, lazy Feishu import, no Nous HTTP at startup, plus PEP-562 lazy adapter imports (QQ, Yuanbao, Teams, Google Chat), deferred `fal_client` / `google-cloud` / `httpx` loads, models.dev disk-cache-first lookup, parallel doctor API checks, eager-skip plugin discovery on built-in subcommands, `hermes tools` All-Platforms drops from 14s to <1.5s, welcome banner skipped on `chat -q`. ([#22138](https://github.com/NousResearch/hermes-agent/pull/22138), [#22120](https://github.com/NousResearch/hermes-agent/pull/22120), [#22681](https://github.com/NousResearch/hermes-agent/pull/22681), [#22790](https://github.com/NousResearch/hermes-agent/pull/22790), [#22808](https://github.com/NousResearch/hermes-agent/pull/22808), [#22831](https://github.com/NousResearch/hermes-agent/pull/22831), [#22859](https://github.com/NousResearch/hermes-agent/pull/22859), [#22904](https://github.com/NousResearch/hermes-agent/pull/22904), [#22766](https://github.com/NousResearch/hermes-agent/pull/22766), [#25341](https://github.com/NousResearch/hermes-agent/pull/25341))
|
||||
- **`x_search` — first-class X (Twitter) search tool** — The agent can now search X directly without installing a skill or wiring up a custom integration. Search the timeline, find threads, surface specific posts — straight from the chat. Auth with either your X OAuth login or an API key, whichever you have. ([#26763](https://github.com/NousResearch/hermes-agent/pull/26763))
|
||||
|
||||
- **180x faster `browser_console` evaluations** — routed through the supervisor's persistent CDP WebSocket instead of spawning a fresh DevTools session per call. Real-world page interactions feel instant. ([#23226](https://github.com/NousResearch/hermes-agent/pull/23226))
|
||||
- **Microsoft Teams — end-to-end** — Hermes can now read messages from Teams and post back. The full Microsoft Graph stack lands together: auth + client foundation, a webhook listener that receives Teams events, a pipeline plugin runtime, and outbound delivery. Wire up the bot once, then chat to your agent from any Teams channel, DM, or group. (salvages of #21408–#21411) ([#21922](https://github.com/NousResearch/hermes-agent/pull/21922), [#21969](https://github.com/NousResearch/hermes-agent/pull/21969), [#22007](https://github.com/NousResearch/hermes-agent/pull/22007), [#22024](https://github.com/NousResearch/hermes-agent/pull/22024))
|
||||
|
||||
- **Supply-chain advisory checker + lazy-deps framework + tiered install fallback** — every `pip install` / `hermes update` scans dependencies against an advisory list, lazy-deps replace heavy import-time loads with first-use installs, and the installer falls back through extras tiers when a wheel rejects on the target platform. ([#24220](https://github.com/NousResearch/hermes-agent/pull/24220))
|
||||
- **Debloating wave — lighter installs, less you don't use** — A clean `pip install hermes-agent` used to pull down everything: every messaging adapter SDK, every image-gen SDK, every voice/TTS provider, whether you used them or not. Now those heavy backends (Slack / Matrix / Feishu / DingTalk adapters, hindsight client, codex app-server, Pixverse / Camofox / image-gen SDKs, voice/TTS providers) install automatically the first time you actually use them. The `[all]` extras drop everything covered by lazy-deps, the installer falls back through tiers when a wheel doesn't fit your platform, and a supply-chain advisory checker scans every install for unsafe versions. Faster installs, smaller disk footprint, fewer transitive vulnerabilities. ([#24220](https://github.com/NousResearch/hermes-agent/pull/24220), [#24515](https://github.com/NousResearch/hermes-agent/pull/24515), [#25014](https://github.com/NousResearch/hermes-agent/pull/25014), [#25038](https://github.com/NousResearch/hermes-agent/pull/25038), [#25766](https://github.com/NousResearch/hermes-agent/pull/25766), [#21818](https://github.com/NousResearch/hermes-agent/pull/21818))
|
||||
|
||||
- **OpenAI-compatible local proxy** — `hermes proxy` exposes any OAuth-authed provider (Claude Pro, ChatGPT Pro, SuperGrok) as an OpenAI-compatible endpoint that Codex / Aider / Cline / VS Code Continue can hit. Your subscription, your tools. ([#25969](https://github.com/NousResearch/hermes-agent/pull/25969))
|
||||
- **`pip install hermes-agent && hermes`** — Hermes Agent is now a real PyPI package. No more cloning the repo or running shell installers — one pip command and you're running. The wheel ships with the Ink TUI bundle and the shell launcher, so the full experience comes out of the box. (salvage of [#26350](https://github.com/NousResearch/hermes-agent/pull/26350)) ([#26593](https://github.com/NousResearch/hermes-agent/pull/26593), [#26148](https://github.com/NousResearch/hermes-agent/pull/26148))
|
||||
|
||||
- **Cross-session 1-hour Claude prompt cache** — Anthropic / OpenRouter / Nous Portal now share a 1h prefix cache across sessions for Claude models. Fast resume, fast `/new`, lower cost on repeat work. ([#23828](https://github.com/NousResearch/hermes-agent/pull/23828))
|
||||
- **Cross-session 1h Claude prompt cache** — When you use Claude through Anthropic, OpenRouter, or Nous Portal, the prompt prefix (system prompt, skills, memory) now caches for an hour across sessions. Start a `/new` session and the first response comes back faster and cheaper because the cache is still warm from your last session. Background memory review hits the cache too, so it's not paying full price every turn. ([#23828](https://github.com/NousResearch/hermes-agent/pull/23828), [#25434](https://github.com/NousResearch/hermes-agent/pull/25434), [#24778](https://github.com/NousResearch/hermes-agent/pull/24778))
|
||||
|
||||
- **Two new messaging platforms — LINE + SimpleX Chat** — LINE Messaging API lands as a first-class platform, SimpleX Chat salvages #2558 onto the modern adapter spec. Hermes is now on 22 platforms. ([#23197](https://github.com/NousResearch/hermes-agent/pull/23197), [#26232](https://github.com/NousResearch/hermes-agent/pull/26232))
|
||||
- **180x faster `browser_console` evaluations** — When the agent uses the browser tool to inspect a page or run JavaScript, those calls now share one persistent connection to Chrome instead of spinning up a new DevTools session every time. The difference is huge: things that used to take a couple of seconds per call return in milliseconds. Real-world page interactions feel instant. ([#23226](https://github.com/NousResearch/hermes-agent/pull/23226))
|
||||
|
||||
- **Microsoft Graph foundation — Teams pipeline + webhook adapter** — `msgraph` auth/client foundation, webhook listener platform, Teams pipeline plugin runtime, and Teams outbound delivery via the existing adapter — Hermes can now read and post to Teams. (salvages of #21408–#21411) ([#21922](https://github.com/NousResearch/hermes-agent/pull/21922), [#21969](https://github.com/NousResearch/hermes-agent/pull/21969), [#22007](https://github.com/NousResearch/hermes-agent/pull/22007), [#22024](https://github.com/NousResearch/hermes-agent/pull/22024))
|
||||
- **Cold-start performance wave — ~19 seconds off `hermes` launch** — Running `hermes` used to make you wait through a chunk of import overhead and network calls before you saw a prompt. Now the launch path is mostly deferred: heavy adapters only load when you use them, model catalogs come from disk cache first, doctor checks run in parallel, and `chat -q` skips the welcome banner entirely. The `hermes tools` All-Platforms screen alone dropped from 14 seconds to under 1.5 seconds. ([#22138](https://github.com/NousResearch/hermes-agent/pull/22138), [#22120](https://github.com/NousResearch/hermes-agent/pull/22120), [#22681](https://github.com/NousResearch/hermes-agent/pull/22681), [#22790](https://github.com/NousResearch/hermes-agent/pull/22790), [#22808](https://github.com/NousResearch/hermes-agent/pull/22808), [#22831](https://github.com/NousResearch/hermes-agent/pull/22831), [#22859](https://github.com/NousResearch/hermes-agent/pull/22859), [#22904](https://github.com/NousResearch/hermes-agent/pull/22904), [#22766](https://github.com/NousResearch/hermes-agent/pull/22766), [#25341](https://github.com/NousResearch/hermes-agent/pull/25341))
|
||||
|
||||
- **`/handoff` actually transfers the session live** — the agent's active session moves to a different model / persona / profile mid-conversation, with messages, tool history, and context preserved. ([#23395](https://github.com/NousResearch/hermes-agent/pull/23395))
|
||||
- **Two new messaging platforms — LINE + SimpleX Chat** — LINE is huge in Japan, Korea, and Taiwan, and now Hermes runs natively on the LINE Messaging API. SimpleX Chat is the privacy-focused decentralized messenger with no user IDs — also wired up as a first-class platform. That brings Hermes to 22 messaging platforms total, so wherever you and your team chat, the agent can be there. ([#23197](https://github.com/NousResearch/hermes-agent/pull/23197), [#26232](https://github.com/NousResearch/hermes-agent/pull/26232))
|
||||
|
||||
- **`x_search` — first-class X (Twitter) search tool** — gated tool with OAuth-or-API-key auth, no skill needed to query the timeline. ([#26763](https://github.com/NousResearch/hermes-agent/pull/26763))
|
||||
- **`/handoff` actually transfers the session live** — Switching models or personalities mid-conversation used to mean losing context or starting over. Now `/handoff` moves your active session — every message, every tool call, every piece of context — to the target model, persona, or profile, live, without dropping anything. Mid-debugging hand off from a fast model to a deep-reasoning one, or pass a session between profiles for different parts of a task. ([#23395](https://github.com/NousResearch/hermes-agent/pull/23395))
|
||||
|
||||
- **`vision_analyze` returns pixels to vision-capable models** — when the active model can see, `vision_analyze` now hands the image straight through instead of falling back to a text description. ([#22955](https://github.com/NousResearch/hermes-agent/pull/22955))
|
||||
- **Native button UI for `clarify` on Telegram and Discord** — When the agent uses the `clarify` tool to ask you a multiple-choice question, it now shows real platform-native buttons on Telegram and Discord instead of asking you to type back the option number. Tap the button, the agent gets your answer. Especially nice on mobile. ([#24199](https://github.com/NousResearch/hermes-agent/pull/24199), [#25485](https://github.com/NousResearch/hermes-agent/pull/25485))
|
||||
|
||||
- **LSP semantic diagnostics on every write** — `write_file` and `patch` now run real language-server diagnostics on the post-edit file (delta-only) and surface real errors before they ship downstream. ([#24168](https://github.com/NousResearch/hermes-agent/pull/24168), [#25978](https://github.com/NousResearch/hermes-agent/pull/25978))
|
||||
- **Discord channel history backfill (default on)** — When Hermes joins a Discord channel or thread for the first time, it now reads the recent message history so it knows what's been said before it responds. No more "what are we talking about?" — the agent has the context that's already on screen for everyone else. ([#25984](https://github.com/NousResearch/hermes-agent/pull/25984))
|
||||
|
||||
- **Per-turn file-mutation verifier footer** — after every turn that wrote files, the agent gets a verifier footer summarizing what actually changed on disk — catches silent overwrites and "wrote it but it didn't land" bugs. ([#24498](https://github.com/NousResearch/hermes-agent/pull/24498))
|
||||
- **`vision_analyze` returns pixels to vision-capable models** — When you point the agent at an image with `vision_analyze` and the active model can actually see (GPT-5, Claude, Gemini, Grok-vision), Hermes now passes the raw pixels straight to the model instead of converting them to a text description first. You get the model's actual visual reasoning instead of a degraded text-summary round-trip. ([#22955](https://github.com/NousResearch/hermes-agent/pull/22955))
|
||||
|
||||
- **Unified `video_generate` with pluggable provider backends** — single tool, any backend. Drop in a new video provider as a plugin, no core changes. ([#25126](https://github.com/NousResearch/hermes-agent/pull/25126))
|
||||
- **Per-turn file-mutation verifier footer** — After every turn that wrote or edited files, the agent now gets a short footer summarizing exactly what changed on disk — the file paths, the line counts, the actual delta. That means the agent catches its own mistakes when a write didn't land or got silently overwritten, instead of confidently telling you "I added the function" when the file wasn't actually saved. ([#24498](https://github.com/NousResearch/hermes-agent/pull/24498))
|
||||
|
||||
- **`computer_use` cua-driver backend** — proper focus-safe ops, non-Anthropic provider support, refresh on `hermes update`. Computer-use is no longer locked to a single SDK. (re-salvage of #16936) ([#21967](https://github.com/NousResearch/hermes-agent/pull/21967), [#24063](https://github.com/NousResearch/hermes-agent/pull/24063))
|
||||
- **LSP semantic diagnostics on every write** — When the agent uses `write_file` or `patch`, Hermes now runs a real language server against the edited file and surfaces any new errors back to the agent before the next turn. Type errors, undefined symbols, missing imports — caught immediately. Goes way beyond v0.13.0's basic Python/JSON/YAML/TOML linting because it's actual semantic analysis. ([#24168](https://github.com/NousResearch/hermes-agent/pull/24168), [#25978](https://github.com/NousResearch/hermes-agent/pull/25978))
|
||||
|
||||
- **xAI Grok OAuth provider — SuperGrok via subscription** — sign in with your xAI account, talk to Grok models from Hermes. ([#26534](https://github.com/NousResearch/hermes-agent/pull/26534))
|
||||
- **Unified `video_generate` with pluggable provider backends** — One tool, any video model. Hermes ships with the obvious backends already, but you can drop in a new video provider as a plugin without touching core. So when a new video model lands next month, it can be a one-file plugin instead of a fork. ([#25126](https://github.com/NousResearch/hermes-agent/pull/25126))
|
||||
|
||||
- **Clarify with buttons — native inline keyboards on Telegram + Discord** — the `clarify` tool renders multi-choice prompts as platform-native buttons instead of typed responses. ([#24199](https://github.com/NousResearch/hermes-agent/pull/24199), [#25485](https://github.com/NousResearch/hermes-agent/pull/25485))
|
||||
- **`computer_use` cua-driver backend — works with non-Anthropic models now** — Computer-use (the agent controlling your mouse and keyboard to drive GUI apps) used to be locked to Anthropic's SDK. The new cua-driver backend works with non-Anthropic providers too, has proper focus-safe operations, and refreshes itself on `hermes update`. Now any vision-capable model can drive your desktop. (re-salvage of #16936) ([#21967](https://github.com/NousResearch/hermes-agent/pull/21967), [#24063](https://github.com/NousResearch/hermes-agent/pull/24063))
|
||||
|
||||
- **Discord channel history backfill (default on)** — Hermes reads recent channel history when joining a thread so it actually knows what's been said. ([#25984](https://github.com/NousResearch/hermes-agent/pull/25984))
|
||||
- **Clickable URLs in any terminal** — Links in agent output are now real OSC8 hyperlinks with hover-highlight in any terminal that supports them. Click to open in your browser — no more copy-paste-trim of long URLs from the transcript. Just works in iTerm2, Kitty, Ghostty, modern Windows Terminal, etc. (@OutThisLife) ([#25071](https://github.com/NousResearch/hermes-agent/pull/25071), [#24013](https://github.com/NousResearch/hermes-agent/pull/24013))
|
||||
|
||||
- **Watchers skill — RSS / HTTP JSON / GitHub polling via cron `no_agent` mode** — skill recipes that wire change-detection sources directly into cron's script-only watchdog mode. ([#21881](https://github.com/NousResearch/hermes-agent/pull/21881))
|
||||
- **Zed ACP Registry — `uvx` install in one click** — Hermes is now listed in Zed's Agent Client Protocol registry, so Zed users can install it with one click. The install path uses `uvx` so there's no npm dependency. `hermes acp --setup-browser` bootstraps the browser tools for registry-driven installs. (salvage of [#25908](https://github.com/NousResearch/hermes-agent/pull/25908)) ([#26079](https://github.com/NousResearch/hermes-agent/pull/26079), [#26120](https://github.com/NousResearch/hermes-agent/pull/26120), [#26234](https://github.com/NousResearch/hermes-agent/pull/26234))
|
||||
|
||||
- **Zed ACP Registry integration + uvx distribution** — Hermes is in the Zed registry, installable via `uvx` (no npm). Plus `hermes acp --setup-browser` bootstraps browser tools for registry installs. (salvage of [#25908](https://github.com/NousResearch/hermes-agent/pull/25908)) ([#26079](https://github.com/NousResearch/hermes-agent/pull/26079), [#26120](https://github.com/NousResearch/hermes-agent/pull/26120), [#26234](https://github.com/NousResearch/hermes-agent/pull/26234))
|
||||
- **OpenRouter Pareto Code router with `min_coding_score` knob** — OpenRouter's "Pareto" router automatically picks the cheapest model that meets a minimum quality bar. The new `min_coding_score` config lets you set that bar for coding tasks specifically — Hermes routes to the most affordable model that's at least that good at code. Stop paying for top-tier models when a mid-tier one would do. ([#22838](https://github.com/NousResearch/hermes-agent/pull/22838))
|
||||
|
||||
- **OpenRouter Pareto Code router** — wire a new OpenRouter router with `min_coding_score` knob. Pick the cheapest model that meets your quality bar. ([#22838](https://github.com/NousResearch/hermes-agent/pull/22838))
|
||||
- **NovitaAI as a new model provider** — NovitaAI joins the provider lineup, giving you another option for open-source model hosting (Llama, Qwen, DeepSeek, etc.) with their pricing and rate limits. (salvage #7219) (@kshitijk4poor) ([#25507](https://github.com/NousResearch/hermes-agent/pull/25507))
|
||||
|
||||
- **Optional codex app-server runtime for OpenAI/Codex models** — drives the OpenAI Codex CLI under the hood for OpenAI/Codex paths, with session reuse, wedge retirement, and OAuth refresh classification. ([#24182](https://github.com/NousResearch/hermes-agent/pull/24182), [#25769](https://github.com/NousResearch/hermes-agent/pull/25769))
|
||||
- **Codex app-server runtime for OpenAI/Codex models** — An optional runtime that drives OpenAI's Codex CLI under the hood when you're using OpenAI or Codex paths. You get session reuse, automatic retirement of wedged sessions, and proper OAuth refresh classification — the kind of plumbing that makes long agentic runs not fall over. ([#24182](https://github.com/NousResearch/hermes-agent/pull/24182), [#25769](https://github.com/NousResearch/hermes-agent/pull/25769))
|
||||
|
||||
- **`hermes-skills/huggingface` as a trusted default tap** — community skills index from huggingface.co/skills is available by default in the Skills Hub. ([#26219](https://github.com/NousResearch/hermes-agent/pull/26219))
|
||||
- **`huggingface/skills` as a trusted default tap** — The community skills index hosted at huggingface.co/skills is now wired into the Skills Hub by default. So when somebody publishes a useful skill there, you can install it from your own `hermes skills` browser without any extra config. (closes #2549) ([#26219](https://github.com/NousResearch/hermes-agent/pull/26219))
|
||||
|
||||
- **9 new optional skills** — Hyperliquid (perp/spot trading via SDK + REST) (@kshitijk4poor & Hermes), Yahoo Finance market data, api-testing (REST/GraphQL debug), unified EVM multi-chain skill (folds #25291 + #2010 + base/), darwinian-evolver, osint-investigation (closes #355), pinggy-tunnel, watchers (RSS/HTTP/GitHub via cron), Notion overhaul for the Developer Platform (May 2026). ([#23582](https://github.com/NousResearch/hermes-agent/pull/23582), [#23583](https://github.com/NousResearch/hermes-agent/pull/23583), [#23590](https://github.com/NousResearch/hermes-agent/pull/23590), [#25299](https://github.com/NousResearch/hermes-agent/pull/25299), [#26760](https://github.com/NousResearch/hermes-agent/pull/26760), [#26729](https://github.com/NousResearch/hermes-agent/pull/26729), [#26765](https://github.com/NousResearch/hermes-agent/pull/26765), [#21881](https://github.com/NousResearch/hermes-agent/pull/21881), [#26612](https://github.com/NousResearch/hermes-agent/pull/26612))
|
||||
- **9 new optional skills** — Hyperliquid (perp + spot trading via the SDK and REST API), Yahoo Finance (live market data, fundamentals, historicals), api-testing (REST + GraphQL debug recipes), unified EVM multi-chain (one skill covers Ethereum + L2s + Base), darwinian-evolver (evolutionary prompt/skill tuning), osint-investigation (OSINT recipes for people / domains / orgs), pinggy-tunnel (expose local services to the public internet), watchers (polls RSS / HTTP JSON / GitHub via cron `no_agent` mode for change detection), and a full Notion overhaul for the May 2026 Developer Platform. ([#23582](https://github.com/NousResearch/hermes-agent/pull/23582), [#23583](https://github.com/NousResearch/hermes-agent/pull/23583), [#23590](https://github.com/NousResearch/hermes-agent/pull/23590), [#25299](https://github.com/NousResearch/hermes-agent/pull/25299), [#26760](https://github.com/NousResearch/hermes-agent/pull/26760), [#26729](https://github.com/NousResearch/hermes-agent/pull/26729), [#26765](https://github.com/NousResearch/hermes-agent/pull/26765), [#21881](https://github.com/NousResearch/hermes-agent/pull/21881), [#26612](https://github.com/NousResearch/hermes-agent/pull/26612))
|
||||
|
||||
- **API server exposes run approval events** — long-running runs surface approval requests over the API stream, no more silent stalls. (salvage of [#20311](https://github.com/NousResearch/hermes-agent/pull/20311)) ([#21899](https://github.com/NousResearch/hermes-agent/pull/21899))
|
||||
- **API server exposes run approval events** — If you're driving Hermes programmatically through the HTTP API, long-running runs no longer silently hang when the agent hits an approval-required command. The approval request now surfaces on the API stream so your client can prompt the user and reply — no more silent stalls. (salvage of [#20311](https://github.com/NousResearch/hermes-agent/pull/20311)) ([#21899](https://github.com/NousResearch/hermes-agent/pull/21899))
|
||||
|
||||
- **`/subgoal` — user-added criteria appended to active `/goal`** — layer extra success criteria onto a running goal loop. The judge sees them in the prompt, no behavior change when subgoals are empty. ([#25449](https://github.com/NousResearch/hermes-agent/pull/25449))
|
||||
- **Plugins can run any LLM call via `ctx.llm` + replace built-in tools via `tool_override`** — If you're writing a Hermes plugin, you now get first-class access to make LLM calls through the active provider and credentials — no manual client wiring. The new `tool_override` flag lets a plugin swap out a built-in tool with its own implementation cleanly. Plugin authors get the same model-routing and auth plumbing the core agent uses. (closes #11049) ([#23194](https://github.com/NousResearch/hermes-agent/pull/23194), [#26759](https://github.com/NousResearch/hermes-agent/pull/26759))
|
||||
|
||||
- **Plugins can run any LLM call via `ctx.llm`** — plugins get a first-class hook to make their own LLM requests through the active provider/credentials, no manual wiring. Plus `tool_override` flag for replacing built-in tools. ([#23194](https://github.com/NousResearch/hermes-agent/pull/23194), [#26759](https://github.com/NousResearch/hermes-agent/pull/26759))
|
||||
- **Brave Search (free tier) + DuckDuckGo (DDGS) as web-search providers** — Two new free web-search backends join Tavily, SearXNG, and Exa. Brave Search has a generous free tier; DDGS is the DuckDuckGo scraper that needs no key at all. Pick whichever fits your budget and rate-limit needs. ([#21337](https://github.com/NousResearch/hermes-agent/pull/21337))
|
||||
|
||||
- **Brave Search (free tier) + DuckDuckGo (DDGS) as web-search providers** — two new free search backends alongside Tavily / SearXNG / Exa. ([#21337](https://github.com/NousResearch/hermes-agent/pull/21337))
|
||||
- **Sudo brute-force block + 3 dangerous-command bypasses closed + tool-error sanitization** — The approval gate now blocks `sudo -S` brute-force attempts and classifies stdin-fed or askpass-stripped sudo invocations as DANGEROUS. Three known bypasses of dangerous-command detection are closed (inspired by Claude Code's command-detection work). And tool error strings are now sanitized before being re-injected into the model context, so a malicious file or remote service can't pass instructions to your agent through error output. ([#23736](https://github.com/NousResearch/hermes-agent/pull/23736), [#26829](https://github.com/NousResearch/hermes-agent/pull/26829), [#26823](https://github.com/NousResearch/hermes-agent/pull/26823))
|
||||
|
||||
- **Sudo brute-force block + sudo-stdin/askpass DANGEROUS classification** — closes the `sudo -S` brute-force avenue; approval gates classify stdin-fed and askpass-stripped sudo invocations as dangerous. (salvages of #22194 + #21128) ([#23736](https://github.com/NousResearch/hermes-agent/pull/23736))
|
||||
- **`/subgoal` — user-added criteria appended to an active `/goal`** — When you've got a `/goal` running (the persistent Ralph-loop goal where the agent keeps going until criteria are met), you can now use `/subgoal <text>` to layer extra success criteria onto it mid-run. The judge factors your new criteria into the done-or-keep-going decision without restarting the loop. ([#25449](https://github.com/NousResearch/hermes-agent/pull/25449))
|
||||
|
||||
- **Provider rename — Alibaba Cloud → Qwen Cloud, picker reorder** — matches what the world calls it. Existing config keys still work. ([#24835](https://github.com/NousResearch/hermes-agent/pull/24835))
|
||||
- **Provider rename — Alibaba Cloud → Qwen Cloud** — The Alibaba Cloud provider is renamed to Qwen Cloud in the picker and config to match what the rest of the world calls it. Existing config keys still work — no breaking changes — but the UI matches the actual brand now. ([#24835](https://github.com/NousResearch/hermes-agent/pull/24835))
|
||||
|
||||
- **Native Windows support (early beta)** — Hermes now runs natively on `cmd.exe` and PowerShell without WSL. A full PowerShell installer handles MinGit auto-install, Microsoft Store python stub detection, and the foreground Ctrl+C dance. There's still rough edges (this is the "early beta" stamp) — ~40 follow-up Windows-only fixes already landed in the window — but the basic loop works end-to-end on a clean Windows box. ([#21561](https://github.com/NousResearch/hermes-agent/pull/21561))
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
# Hermes Agent v0.15.1 (v2026.5.29)
|
||||
|
||||
**Release Date:** May 29, 2026
|
||||
**Since v0.15.0:** 28 commits · 21 merged PRs · hotfix release · 9 contributors
|
||||
|
||||
> **The Patch Release.** A same-day hotfix for v0.15.0. Headline fix: the dashboard infinite-reload loop that hit anyone running v0.15.0 in loopback mode (Docker, hosted Hermes, fresh installs). A handful of other v0.15.0 follow-ups go along for the ride — kanban worker SIGTERM, `/model` picker unification, `/yolo` session bypass, the full 19,932-entry skills.sh catalog, `.md` media delivery restoration, gateway probe-stepdown safety, web-URL redaction passthrough, kanban worker vision on referenced images, hindsight observation-default. Docker users get an explicit `--insecure` opt-in env var (no more bind-host inference), MCP server bare-command PATH resolution, and arm64 PR-build cache fixes.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- **Dashboard 401 reload loop fixed** — In loopback mode the dashboard's identity probe (`/api/auth/me`) returns 401 by design, but v0.15.0's stale-token reload guard treated every 401 as a rotated session token and full-page-reloaded to pick up a fresh one. Every successful sibling call cleared the one-shot reload guard, so the page reload-looped forever (Firefox: "Navigated to /sessions" storm; Chrome: React re-render storm). Fix adds an `allowUnauthorized` opt-out to `fetchJSON` that skips only the loopback stale-token reload — 401 still throws so `AuthWidget` swallows it, gated-mode `login_url` redirects are unaffected. Closes [#34206](https://github.com/NousResearch/hermes-agent/issues/34206), [#34202](https://github.com/NousResearch/hermes-agent/issues/34202). ([#30698](https://github.com/NousResearch/hermes-agent/pull/30698) — @austinpickett)
|
||||
|
||||
- **Docker dashboard `--insecure` is now an explicit env opt-in, never derived from bind host** — Previously the Docker entrypoint inferred `--insecure` when the dashboard bound to a non-loopback host. That conflated "I want LAN access" with "I want to disable the same-origin guard." The fix splits them: bind host is bind host, and disabling the dashboard's loopback auth requires an explicit `HERMES_DASHBOARD_INSECURE=1`. Existing setups that genuinely wanted insecure binding must now set the env var. ([#34188](https://github.com/NousResearch/hermes-agent/pull/34188), [#34204](https://github.com/NousResearch/hermes-agent/pull/34204) — @benbarclay)
|
||||
|
||||
- **MCP bare command resolution under Docker** — MCP servers configured with bare commands (`npx`, `npm`, `node`) now resolve against `/usr/local/bin` so they actually launch inside the Docker image where those binaries live. v0.15.0 left these failing silently in containers when the agent's effective PATH didn't include the Node toolchain location. ([#34186](https://github.com/NousResearch/hermes-agent/pull/34186) — @benbarclay)
|
||||
|
||||
- **Skills page sidebar / source pills restored** — A stale `useMemo` dependency in the new dashboard skills page collapsed the source pills and category sidebar to "All" only. Fixed; both surfaces now reflect the live catalog state. ([#34194](https://github.com/NousResearch/hermes-agent/pull/34194))
|
||||
|
||||
- **Kanban worker can be killed again** — `SIGTERM` on a kanban worker was being absorbed by an intermediate process and the worker stayed running. Closes [#28181](https://github.com/NousResearch/hermes-agent/issues/28181). ([#34045](https://github.com/NousResearch/hermes-agent/pull/34045))
|
||||
|
||||
- **Full skills.sh catalog (858 → 19,932 entries)** — The skills hub page was pulling a partial paginated catalog. The fetch now walks the sitemap, so all 19,932 skills.sh entries surface in the picker instead of just the first 858. ([#34025](https://github.com/NousResearch/hermes-agent/pull/34025))
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
### Dashboard / Web
|
||||
|
||||
- **`/api/auth/me` 401 no longer triggers reload loop** in loopback mode — ([#30698](https://github.com/NousResearch/hermes-agent/pull/30698) — @austinpickett)
|
||||
- **Skills page source pills + category sidebar restored** — stale `useMemo` dep ([#34194](https://github.com/NousResearch/hermes-agent/pull/34194))
|
||||
|
||||
### Docker
|
||||
|
||||
- **`--insecure` is now explicit opt-in via env var**, not derived from bind host ([#34188](https://github.com/NousResearch/hermes-agent/pull/34188) — @benbarclay)
|
||||
- **Dashboard test suite repaired** to match the insecure-opt-in fix ([#34204](https://github.com/NousResearch/hermes-agent/pull/34204) — @benbarclay)
|
||||
- **arm64 PR builds skip the GHA cache** to avoid cache-thrash on cross-arch builders ([#33704](https://github.com/NousResearch/hermes-agent/pull/33704) — @BROCCOLO1D)
|
||||
|
||||
### MCP
|
||||
|
||||
- **Bare `npx`/`npm`/`node` resolve against `/usr/local/bin`** for Docker compatibility ([#34186](https://github.com/NousResearch/hermes-agent/pull/34186) — @benbarclay)
|
||||
|
||||
### Kanban
|
||||
|
||||
- **Worker SIGTERM actually terminates the process** ([#34045](https://github.com/NousResearch/hermes-agent/pull/34045))
|
||||
- **Workers receive images referenced in task bodies** for vision-capable models ([#34210](https://github.com/NousResearch/hermes-agent/pull/34210))
|
||||
|
||||
### Gateway
|
||||
|
||||
- **`.md` files deliver again** — media-delivery validation defaults to denylist-only instead of an overly-narrow allowlist ([#34022](https://github.com/NousResearch/hermes-agent/pull/34022))
|
||||
- **Probe stepdown safety** — on a context-overflow without an explicit provider context limit, the agent no longer steps down to a smaller model based on an unknown ceiling (salvage of [#33673](https://github.com/NousResearch/hermes-agent/pull/33673)) ([#33826](https://github.com/NousResearch/hermes-agent/pull/33826))
|
||||
|
||||
### CLI
|
||||
|
||||
- **`/yolo` mid-session enables the per-session bypass** instead of just toggling the env var (which the running agent had already snapshotted) ([#33931](https://github.com/NousResearch/hermes-agent/pull/33931) — @kshitijk4poor)
|
||||
- **`/model` and `hermes model` show the same list**, plus disk cache for picker startup ([#33867](https://github.com/NousResearch/hermes-agent/pull/33867))
|
||||
|
||||
### Skills
|
||||
|
||||
- **Full skills.sh catalog via sitemap** — 858 → 19,932 entries ([#34025](https://github.com/NousResearch/hermes-agent/pull/34025))
|
||||
|
||||
### Redaction
|
||||
|
||||
- **Web URLs pass through unchanged** — the redactor was eating query parameters that looked credential-shaped ([#34029](https://github.com/NousResearch/hermes-agent/pull/34029))
|
||||
|
||||
---
|
||||
|
||||
## ✨ Small Features
|
||||
|
||||
- **Hindsight default narrowed to observation-only** for `recall_types` — tool path is also narrowed ([#34079](https://github.com/NousResearch/hermes-agent/pull/34079) — @nicoloboschi, follow-up [#34091](https://github.com/NousResearch/hermes-agent/pull/4df62d239e38bf8c212a595721c9c01e176f6c3a) — @kshitijk4poor)
|
||||
- **Memory providers receive completed-turn message context** — salvage of [#28065](https://github.com/NousResearch/hermes-agent/pull/28065) ([#34097](https://github.com/NousResearch/hermes-agent/pull/34097) — @kshitijk4poor, credit to @devwdave)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **`--no-supervise` / `HERMES_GATEWAY_NO_SUPERVISE` documented** in the reference docs (follow-up to [#33583](https://github.com/NousResearch/hermes-agent/pull/33583)) ([#33751](https://github.com/NousResearch/hermes-agent/pull/33751) — @r266-tech)
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Infrastructure
|
||||
|
||||
- **Vercel deploy workflow accepts `workflow_dispatch`** so docs deploys can be manually triggered ([#34081](https://github.com/NousResearch/hermes-agent/pull/34081))
|
||||
- **`@nous-research/ui` bumped to 0.18.2** (Nix `npmDepsHash` also updated to match) ([#34193](https://github.com/NousResearch/hermes-agent/pull/34193) follow-ups — @austinpickett)
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
### Core
|
||||
- @teknium1
|
||||
|
||||
### Community
|
||||
- @austinpickett — dashboard 401 reload-loop fix (the headline), `@nous-research/ui` bump, Nix `npmDepsHash` updates
|
||||
- @benbarclay — Docker `--insecure` opt-in, MCP bare-command resolution, dashboard test repair
|
||||
- @kshitijk4poor — `/yolo` session bypass, completed-turn memory context salvage, hindsight follow-up docs
|
||||
- @nicoloboschi — hindsight `recall_types` observation default
|
||||
- @BROCCOLO1D — arm64 PR build cache fix
|
||||
- @r266-tech — `--no-supervise` reference docs
|
||||
- @yangguangjin — probe stepdown safety (salvage of @yanghd's #33673)
|
||||
- @devwdave — completed-turn memory context (credited via salvage)
|
||||
- @andrewhosf — co-author
|
||||
|
||||
### Issue Reporters (the 401 loop)
|
||||
- @routesmith ([#34206](https://github.com/NousResearch/hermes-agent/issues/34206))
|
||||
- @beeaton ([#34202](https://github.com/NousResearch/hermes-agent/issues/34202))
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: [v2026.5.28...v2026.5.29](https://github.com/NousResearch/hermes-agent/compare/v2026.5.28...v2026.5.29)
|
||||
@@ -907,6 +907,72 @@ def _build_polished_completion_content(
|
||||
return [_text(text)]
|
||||
|
||||
|
||||
def _build_patch_mode_content(patch_text: str) -> List[Any]:
|
||||
"""Parse V4A patch mode input into ACP diff blocks when possible."""
|
||||
if not patch_text:
|
||||
return [acp.tool_content(acp.text_block(""))]
|
||||
|
||||
try:
|
||||
from tools.patch_parser import OperationType, parse_v4a_patch
|
||||
|
||||
operations, error = parse_v4a_patch(patch_text)
|
||||
if error or not operations:
|
||||
return [acp.tool_content(acp.text_block(patch_text))]
|
||||
|
||||
content: List[Any] = []
|
||||
for op in operations:
|
||||
if op.operation == OperationType.UPDATE:
|
||||
old_chunks: list[str] = []
|
||||
new_chunks: list[str] = []
|
||||
for hunk in op.hunks:
|
||||
old_lines = [line.content for line in hunk.lines if line.prefix in {" ", "-"}]
|
||||
new_lines = [line.content for line in hunk.lines if line.prefix in {" ", "+"}]
|
||||
if old_lines or new_lines:
|
||||
old_chunks.append("\n".join(old_lines))
|
||||
new_chunks.append("\n".join(new_lines))
|
||||
|
||||
old_text = "\n...\n".join(chunk for chunk in old_chunks if chunk)
|
||||
new_text = "\n...\n".join(chunk for chunk in new_chunks if chunk)
|
||||
if old_text or new_text:
|
||||
content.append(
|
||||
acp.tool_diff_content(
|
||||
path=op.file_path,
|
||||
old_text=old_text or None,
|
||||
new_text=new_text or "",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if op.operation == OperationType.ADD:
|
||||
added_lines = [line.content for hunk in op.hunks for line in hunk.lines if line.prefix == "+"]
|
||||
content.append(
|
||||
acp.tool_diff_content(
|
||||
path=op.file_path,
|
||||
new_text="\n".join(added_lines),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if op.operation == OperationType.DELETE:
|
||||
content.append(
|
||||
acp.tool_diff_content(
|
||||
path=op.file_path,
|
||||
old_text=f"Delete file: {op.file_path}",
|
||||
new_text="",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if op.operation == OperationType.MOVE:
|
||||
content.append(
|
||||
acp.tool_content(acp.text_block(f"Move file: {op.file_path} -> {op.new_path}"))
|
||||
)
|
||||
|
||||
return content or [acp.tool_content(acp.text_block(patch_text))]
|
||||
except Exception:
|
||||
return [acp.tool_content(acp.text_block(patch_text))]
|
||||
|
||||
|
||||
def _strip_diff_prefix(path: str) -> str:
|
||||
raw = str(path or "").strip()
|
||||
if raw.startswith(("a/", "b/")):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "hermes-agent",
|
||||
"name": "Hermes Agent",
|
||||
"version": "0.15.1",
|
||||
"version": "0.15.0",
|
||||
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
|
||||
"repository": "https://github.com/NousResearch/hermes-agent",
|
||||
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
|
||||
@@ -9,7 +9,7 @@
|
||||
"license": "MIT",
|
||||
"distribution": {
|
||||
"uvx": {
|
||||
"package": "hermes-agent[acp]==0.15.1",
|
||||
"package": "hermes-agent[acp]==0.15.0",
|
||||
"args": ["hermes-acp"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse, parse_qs, urlunparse
|
||||
|
||||
@@ -36,6 +37,7 @@ from agent.memory_manager import StreamingContextScrubber
|
||||
from agent.model_metadata import (
|
||||
MINIMUM_CONTEXT_LENGTH,
|
||||
fetch_model_metadata,
|
||||
get_model_context_length,
|
||||
is_local_endpoint,
|
||||
query_ollama_num_ctx,
|
||||
)
|
||||
@@ -50,6 +52,7 @@ from agent.tool_guardrails import (
|
||||
from hermes_cli.config import cfg_get
|
||||
from hermes_cli.timeouts import get_provider_request_timeout
|
||||
from hermes_constants import get_hermes_home
|
||||
from model_tools import check_toolset_requirements, get_tool_definitions
|
||||
from utils import base_url_host_matches
|
||||
|
||||
# Use the same logger name as run_agent so tests patching ``run_agent.logger``
|
||||
@@ -1198,18 +1201,6 @@ def init_agent(
|
||||
_agent_section = {}
|
||||
agent._tool_use_enforcement = _agent_section.get("tool_use_enforcement", "auto")
|
||||
|
||||
# Universal task-completion guidance toggle. Default True. Surfaced
|
||||
# as a separate flag from tool_use_enforcement because the guidance
|
||||
# applies to ALL models, not just the model families enforcement
|
||||
# targets.
|
||||
agent._task_completion_guidance = bool(_agent_section.get("task_completion_guidance", True))
|
||||
|
||||
# Local Python toolchain probe toggle. Default True. When False,
|
||||
# the probe is skipped entirely (no subprocess calls, no system-prompt
|
||||
# line). Useful for users on exotic setups where the probe heuristics
|
||||
# are noisy.
|
||||
agent._environment_probe = bool(_agent_section.get("environment_probe", True))
|
||||
|
||||
# App-level API retry count (wraps each model API call). Default 3,
|
||||
# overridable via agent.api_max_retries in config.yaml. See #11616.
|
||||
try:
|
||||
@@ -1471,6 +1462,7 @@ def init_agent(
|
||||
|
||||
# Reject models whose context window is below the minimum required
|
||||
# for reliable tool-calling workflows (64K tokens).
|
||||
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH
|
||||
_ctx = getattr(agent.context_compressor, "context_length", 0)
|
||||
if _ctx and _ctx < MINIMUM_CONTEXT_LENGTH:
|
||||
raise ValueError(
|
||||
|
||||
@@ -25,17 +25,24 @@ from __future__ import annotations
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from hermes_cli.timeouts import get_provider_request_timeout
|
||||
from agent.message_sanitization import (
|
||||
_repair_tool_call_arguments,
|
||||
_sanitize_surrogates,
|
||||
)
|
||||
from agent.tool_dispatch_helpers import _trajectory_normalize_msg, make_tool_result_message
|
||||
from agent.trajectory import convert_scratchpad_to_think
|
||||
from agent.credential_pool import STATUS_EXHAUSTED
|
||||
from agent.error_classifier import FailoverReason
|
||||
from agent.error_classifier import classify_api_error, FailoverReason
|
||||
from utils import base_url_host_matches, base_url_hostname, env_var_enabled, atomic_json_write
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1692,8 +1699,6 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
||||
session_id=agent.session_id or "",
|
||||
enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None,
|
||||
skip_pre_tool_call_hook=True,
|
||||
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
|
||||
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -894,6 +894,20 @@ def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
|
||||
def read_claude_managed_key() -> Optional[str]:
|
||||
"""Read Claude's native managed key from ~/.claude.json for diagnostics only."""
|
||||
claude_json = Path.home() / ".claude.json"
|
||||
if claude_json.exists():
|
||||
try:
|
||||
data = json.loads(claude_json.read_text(encoding="utf-8"))
|
||||
primary_key = data.get("primaryApiKey", "")
|
||||
if isinstance(primary_key, str) and primary_key.strip():
|
||||
return primary_key.strip()
|
||||
except (json.JSONDecodeError, OSError, IOError) as e:
|
||||
logger.debug("Failed to read ~/.claude.json: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def is_claude_code_token_valid(creds: Dict[str, Any]) -> bool:
|
||||
"""Check if Claude Code credentials have a non-expired access token."""
|
||||
import time
|
||||
@@ -1242,16 +1256,10 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
|
||||
print()
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import _can_open_graphical_browser as _can_open_gui
|
||||
webbrowser.open(auth_url)
|
||||
print(" (Browser opened automatically)")
|
||||
except Exception:
|
||||
_can_open_gui = lambda: True # noqa: E731 — degrade to prior behavior
|
||||
|
||||
if _can_open_gui():
|
||||
try:
|
||||
webbrowser.open(auth_url)
|
||||
print(" (Browser opened automatically)")
|
||||
except Exception:
|
||||
pass
|
||||
pass
|
||||
|
||||
print()
|
||||
print("After authorizing, you'll see a code. Paste it below.")
|
||||
|
||||
@@ -700,20 +700,12 @@ class _CodexCompletionsAdapter:
|
||||
# xAI's Responses endpoint rejects ``pattern`` and ``format`` JSON Schema
|
||||
# keywords (HTTP 400). Strip them here to match the parity guarantee that
|
||||
# chat_completion_helpers.py provides for the main-agent xAI path.
|
||||
#
|
||||
# Deep-copy before sanitizing — ``list(tools)`` is only a shallow
|
||||
# copy of the outer list, but the sanitizers mutate the inner
|
||||
# parameter dicts in place. Without a deep copy the caller's
|
||||
# tool registry permanently loses its slash-containing enum
|
||||
# constraints after the first auxiliary xAI call. See #27907.
|
||||
try:
|
||||
import copy as _copy
|
||||
from tools.schema_sanitizer import (
|
||||
strip_pattern_and_format,
|
||||
strip_slash_enum,
|
||||
)
|
||||
tools = _copy.deepcopy(list(tools))
|
||||
tools, _ = strip_pattern_and_format(tools)
|
||||
tools, _ = strip_pattern_and_format(list(tools))
|
||||
tools, _ = strip_slash_enum(tools)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
@@ -1243,23 +1235,8 @@ def _read_nous_auth() -> Optional[dict]:
|
||||
|
||||
|
||||
def _nous_api_key(provider: dict) -> str:
|
||||
"""Extract a usable Nous inference JWT from stored auth state."""
|
||||
from hermes_cli.auth import _nous_invoke_jwt_is_usable
|
||||
|
||||
for token_key, expiry_key in (
|
||||
("agent_key", "agent_key_expires_at"),
|
||||
("access_token", "expires_at"),
|
||||
):
|
||||
token = provider.get(token_key)
|
||||
if not isinstance(token, str) or not token.strip():
|
||||
continue
|
||||
if _nous_invoke_jwt_is_usable(
|
||||
token,
|
||||
scope=provider.get("scope"),
|
||||
expires_at=provider.get(expiry_key),
|
||||
):
|
||||
return token
|
||||
return ""
|
||||
"""Extract the Nous runtime credential from the compatibility field."""
|
||||
return provider.get("agent_key") or provider.get("access_token", "")
|
||||
|
||||
|
||||
def _nous_base_url() -> str:
|
||||
@@ -1271,16 +1248,25 @@ def _resolve_nous_runtime_api(*, force_refresh: bool = False) -> Optional[tuple[
|
||||
"""Return fresh Nous runtime credentials when available.
|
||||
|
||||
This mirrors the main agent's 401 recovery path and keeps auxiliary
|
||||
clients aligned with the singleton auth store + JWT refresh flow instead of
|
||||
clients aligned with the singleton auth store + JWT/mint flow instead of
|
||||
relying only on whatever raw tokens happen to be sitting in auth.json
|
||||
or the credential pool.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.auth import resolve_nous_runtime_credentials
|
||||
from hermes_cli.auth import (
|
||||
NOUS_INFERENCE_AUTH_MODE_AUTO,
|
||||
NOUS_INFERENCE_AUTH_MODE_LEGACY,
|
||||
resolve_nous_runtime_credentials,
|
||||
)
|
||||
|
||||
creds = resolve_nous_runtime_credentials(
|
||||
min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))),
|
||||
timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")),
|
||||
force_refresh=force_refresh,
|
||||
inference_auth_mode=(
|
||||
NOUS_INFERENCE_AUTH_MODE_LEGACY
|
||||
if force_refresh
|
||||
else NOUS_INFERENCE_AUTH_MODE_AUTO
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Auxiliary Nous runtime credential resolution failed: %s", exc)
|
||||
@@ -1564,9 +1550,13 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
_mark_provider_unhealthy("nous", ttl=60)
|
||||
return None, None
|
||||
if runtime is None and nous:
|
||||
# Runtime credential mint failed but stored Nous auth is still present.
|
||||
# Falls back to the raw stored token below; surface a debug line so
|
||||
# operators investigating expired/invalid sessions have a breadcrumb,
|
||||
# without blocking the fallback path the rest of this function relies on.
|
||||
logger.debug(
|
||||
"Auxiliary Nous: runtime JWT refresh failed; checking stored "
|
||||
"auth.json token."
|
||||
"Auxiliary Nous: runtime credential mint failed; falling back to "
|
||||
"stored auth.json token."
|
||||
)
|
||||
global auxiliary_is_nous
|
||||
auxiliary_is_nous = True
|
||||
@@ -1604,13 +1594,6 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
api_key, base_url = runtime
|
||||
else:
|
||||
api_key = _nous_api_key(nous or {})
|
||||
if not api_key:
|
||||
logger.warning(
|
||||
"Auxiliary Nous client unavailable: no usable inference JWT found "
|
||||
"(run: hermes auth add nous)."
|
||||
)
|
||||
_mark_provider_unhealthy("nous", ttl=60)
|
||||
return None, None
|
||||
base_url = str((nous or {}).get("inference_base_url") or _nous_base_url()).rstrip("/")
|
||||
return (
|
||||
OpenAI(
|
||||
@@ -2374,16 +2357,7 @@ def _is_auth_error(exc: Exception) -> bool:
|
||||
if status == 401:
|
||||
return True
|
||||
err_lower = str(exc).lower()
|
||||
if "error code: 401" in err_lower or "authenticationerror" in type(exc).__name__.lower():
|
||||
return True
|
||||
# xAI returns HTTP 403 with "unauthenticated:bad-credentials" when an OAuth2
|
||||
# access token has expired or is invalid — semantically a 401 auth failure,
|
||||
# even though the status code is 403 (PermissionDenied).
|
||||
if status == 403 and "bad-credentials" in err_lower:
|
||||
return True
|
||||
if "unauthenticated" in err_lower and "bad-credentials" in err_lower:
|
||||
return True
|
||||
return False
|
||||
return "error code: 401" in err_lower or "authenticationerror" in type(exc).__name__.lower()
|
||||
|
||||
|
||||
def _is_unsupported_parameter_error(exc: Exception, param: str) -> bool:
|
||||
@@ -2536,8 +2510,6 @@ def _recoverable_pool_provider(
|
||||
return "copilot"
|
||||
if base_url_host_matches(base, "api.kimi.com"):
|
||||
return "kimi-coding"
|
||||
if base_url_host_matches(base, "api.x.ai"):
|
||||
return "xai-oauth"
|
||||
# For api_key providers not in the hardcoded list (e.g. opencode-go), match
|
||||
# the client base URL against all registered api_key providers so that
|
||||
# credential-pool rotation works for any provider the user configured.
|
||||
@@ -2734,11 +2706,15 @@ def _refresh_provider_credentials(provider: str) -> bool:
|
||||
_evict_cached_clients(normalized)
|
||||
return True
|
||||
if normalized == "nous":
|
||||
from hermes_cli.auth import resolve_nous_runtime_credentials
|
||||
from hermes_cli.auth import (
|
||||
NOUS_INFERENCE_AUTH_MODE_LEGACY,
|
||||
resolve_nous_runtime_credentials,
|
||||
)
|
||||
|
||||
creds = resolve_nous_runtime_credentials(
|
||||
min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))),
|
||||
timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")),
|
||||
force_refresh=True,
|
||||
inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_LEGACY,
|
||||
)
|
||||
if not str(creds.get("api_key", "") or "").strip():
|
||||
return False
|
||||
@@ -2755,24 +2731,6 @@ def _refresh_provider_credentials(provider: str) -> bool:
|
||||
return False
|
||||
_evict_cached_clients(normalized)
|
||||
return True
|
||||
if normalized == "xai-oauth":
|
||||
# Preference: pool-level refresh (uses refresh_token from pool entry),
|
||||
# then fall back to singleton auth-store resolver.
|
||||
pool = load_pool(normalized)
|
||||
if pool and pool.has_credentials():
|
||||
# Ensure a current entry is selected before trying to refresh.
|
||||
pool.select()
|
||||
refreshed = pool.try_refresh_current()
|
||||
if refreshed is not None and str(getattr(refreshed, "runtime_api_key", "") or "").strip():
|
||||
_evict_cached_clients(normalized)
|
||||
return True
|
||||
from hermes_cli.auth import resolve_xai_oauth_runtime_credentials
|
||||
|
||||
creds = resolve_xai_oauth_runtime_credentials(force_refresh=True)
|
||||
if not str(creds.get("api_key", "") or "").strip():
|
||||
return False
|
||||
_evict_cached_clients(normalized)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.debug("Auxiliary provider credential refresh failed for %s: %s", normalized, exc)
|
||||
return False
|
||||
@@ -4725,23 +4683,24 @@ def _build_call_kwargs(
|
||||
kwargs["temperature"] = temperature
|
||||
|
||||
if max_tokens is not None:
|
||||
# We do NOT cap output by default. Most chat-completions providers treat
|
||||
# an omitted max_tokens as "use the model's max output", which is what we
|
||||
# want for auxiliary tasks (compression summaries, titles, vision, etc.) —
|
||||
# an explicit cap only risks truncating a summary or 400-ing on providers
|
||||
# that reject the parameter outright (e.g. GitHub Copilot / newer OpenAI
|
||||
# GPT-5 models require max_completion_tokens, not max_tokens; ZAI vision
|
||||
# models reject it entirely with error 1210). Omitting it sidesteps all of
|
||||
# those wire-format quirks at once.
|
||||
#
|
||||
# The one exception is the Anthropic Messages wire (MiniMax and any
|
||||
# ``/anthropic`` endpoint reached through the OpenAI SDK wrapper), where
|
||||
# max_tokens is a MANDATORY field — omitting it is a hard 400. Keep it only
|
||||
# there.
|
||||
_effective_base = base_url or (
|
||||
_current_custom_base_url() if provider == "custom" else ""
|
||||
# Codex adapter handles max_tokens internally; OpenRouter/Nous use max_tokens.
|
||||
# Direct OpenAI api.openai.com with newer models needs max_completion_tokens.
|
||||
# ZAI vision models (glm-4v-flash, glm-4v-plus, etc.) reject max_tokens with
|
||||
# error code 1210 ("API 调用参数有误") on multimodal requests — skip it.
|
||||
_model_lower = (model or "").lower()
|
||||
_skip_max_tokens = (
|
||||
provider == "zai"
|
||||
and ("4v" in _model_lower or "5v" in _model_lower or "-v" in _model_lower)
|
||||
)
|
||||
if _is_anthropic_compat_endpoint(provider, _effective_base):
|
||||
if _skip_max_tokens:
|
||||
pass # ZAI vision models do not accept max_tokens
|
||||
elif provider == "custom":
|
||||
custom_base = base_url or _current_custom_base_url()
|
||||
if base_url_hostname(custom_base) == "api.openai.com":
|
||||
kwargs["max_completion_tokens"] = max_tokens
|
||||
else:
|
||||
kwargs["max_tokens"] = max_tokens
|
||||
else:
|
||||
kwargs["max_tokens"] = max_tokens
|
||||
|
||||
if tools:
|
||||
|
||||
@@ -1167,6 +1167,18 @@ def _extract_provider_from_arn(arn: str) -> str:
|
||||
"""
|
||||
match = re.search(r"foundation-model/([^.]+)", arn)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def get_bedrock_model_ids(region: str) -> List[str]:
|
||||
"""Return a flat list of available Bedrock model IDs for the given region.
|
||||
|
||||
Convenience wrapper around ``discover_bedrock_models()`` for use in
|
||||
the model selection UI.
|
||||
"""
|
||||
models = discover_bedrock_models(region)
|
||||
return [m["id"] for m in models]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error classification — Bedrock-specific exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -186,6 +186,37 @@ def _resolve(configured: Optional[str]) -> Optional[BrowserProvider]:
|
||||
return None
|
||||
|
||||
|
||||
def get_active_browser_provider() -> Optional[BrowserProvider]:
|
||||
"""Resolve the currently-active cloud browser provider.
|
||||
|
||||
Reads ``browser.cloud_provider`` from config.yaml; falls back per the
|
||||
module docstring. Returns None for local mode or when no provider is
|
||||
available.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
|
||||
cfg = read_raw_config()
|
||||
browser_cfg = cfg.get("browser", {})
|
||||
except Exception as exc:
|
||||
logger.debug("Could not read browser config: %s", exc)
|
||||
browser_cfg = {}
|
||||
|
||||
configured: Optional[str] = None
|
||||
if isinstance(browser_cfg, dict) and "cloud_provider" in browser_cfg:
|
||||
try:
|
||||
from tools.tool_backend_helpers import normalize_browser_cloud_provider
|
||||
|
||||
configured = normalize_browser_cloud_provider(
|
||||
browser_cfg.get("cloud_provider")
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("normalize_browser_cloud_provider failed: %s", exc)
|
||||
configured = None
|
||||
|
||||
return _resolve(configured)
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Clear the registry. **Test-only.**"""
|
||||
with _lock:
|
||||
|
||||
@@ -15,23 +15,49 @@ sites unchanged. Symbols that tests patch on ``run_agent`` (e.g.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import contextvars
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse, parse_qs, urlunparse
|
||||
|
||||
from hermes_cli.timeouts import get_provider_request_timeout, get_provider_stale_timeout
|
||||
from hermes_constants import PARTIAL_STREAM_STUB_ID, FINISH_REASON_LENGTH
|
||||
from agent.error_classifier import FailoverReason
|
||||
from agent.error_classifier import classify_api_error, FailoverReason
|
||||
from agent.model_metadata import is_local_endpoint
|
||||
from agent.message_sanitization import (
|
||||
_sanitize_surrogates,
|
||||
_sanitize_messages_surrogates,
|
||||
_sanitize_structure_surrogates,
|
||||
_sanitize_messages_non_ascii,
|
||||
_sanitize_tools_non_ascii,
|
||||
_sanitize_structure_non_ascii,
|
||||
_strip_images_from_messages,
|
||||
_strip_non_ascii,
|
||||
_repair_tool_call_arguments,
|
||||
_escape_invalid_chars_in_json_strings,
|
||||
)
|
||||
from agent.tool_dispatch_helpers import (
|
||||
_is_multimodal_tool_result,
|
||||
_multimodal_text_summary,
|
||||
)
|
||||
from agent.retry_utils import jittered_backoff
|
||||
from agent.tool_guardrails import (
|
||||
ToolGuardrailDecision,
|
||||
append_toolguard_guidance,
|
||||
toolguard_synthetic_result,
|
||||
)
|
||||
from tools.terminal_tool import is_persistent_env
|
||||
from utils import base_url_host_matches, base_url_hostname
|
||||
@@ -149,6 +175,13 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
request_client_holder["owner_tid"] = threading.get_ident()
|
||||
return client
|
||||
|
||||
def _take_request_client():
|
||||
with request_client_lock:
|
||||
client = request_client_holder.get("client")
|
||||
request_client_holder["client"] = None
|
||||
request_client_holder["owner_tid"] = None
|
||||
return client
|
||||
|
||||
def _close_request_client_once(reason: str) -> None:
|
||||
# #29507: dispatch on the calling thread.
|
||||
#
|
||||
@@ -277,15 +310,8 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
else:
|
||||
_codex_idle_timeout_default = 12.0
|
||||
|
||||
# No-byte TTFB cutoff. The OpenAI SDK's own streaming read timeout is far
|
||||
# longer (openai 2.x DEFAULT_TIMEOUT.read = 600s), so a tight 12s default
|
||||
# killed subscription-backed Codex requests mid-prefill before the backend
|
||||
# had a chance to emit its first SSE event. Default to 120s — long enough to
|
||||
# clear normal backend admission / prompt prefill, short enough to still
|
||||
# reconnect promptly when the socket is genuinely wedged. Set
|
||||
# HERMES_CODEX_TTFB_TIMEOUT_SECONDS=0 to disable this watchdog entirely.
|
||||
_ttfb_enabled = _codex_watchdog_enabled
|
||||
_ttfb_timeout = _env_float("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", 120.0)
|
||||
_ttfb_timeout = _env_float("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", 12.0)
|
||||
if _ttfb_timeout <= 0:
|
||||
_ttfb_enabled = False
|
||||
elif _openai_codex_backend:
|
||||
@@ -307,7 +333,7 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
_ttfb_disable_above,
|
||||
)
|
||||
else:
|
||||
_ttfb_cap = _env_float("HERMES_CODEX_TTFB_MAX_SECONDS", 120.0)
|
||||
_ttfb_cap = _env_float("HERMES_CODEX_TTFB_MAX_SECONDS", 20.0)
|
||||
if _ttfb_cap > 0 and _ttfb_timeout > _ttfb_cap:
|
||||
logger.info(
|
||||
"Capping openai-codex no-byte TTFB timeout from %.0fs to %.0fs "
|
||||
@@ -588,23 +614,12 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
|
||||
# It also rejects ``enum`` values containing ``/`` (HuggingFace IDs
|
||||
# like ``Qwen/Qwen3.5-0.8B`` shipped by MCP servers) — same 400 with
|
||||
# the same opaque message; strip those enums too.
|
||||
#
|
||||
# Deep-copy ``tools_for_api`` before sanitizing: the sanitizers
|
||||
# mutate in place (documented contract on ``strip_slash_enum`` /
|
||||
# ``strip_pattern_and_format``), and ``tools_for_api`` is a direct
|
||||
# reference to ``agent.tools``. Without the copy, the first xAI
|
||||
# request permanently strips constraints from the shared per-agent
|
||||
# tool registry — every subsequent non-xAI call from the same
|
||||
# agent (auxiliary task routed to Anthropic, OpenRouter fallback,
|
||||
# main-model swap) sees the already-stripped schema. See #27907.
|
||||
if is_xai_responses:
|
||||
try:
|
||||
import copy as _copy
|
||||
from tools.schema_sanitizer import (
|
||||
strip_pattern_and_format,
|
||||
strip_slash_enum,
|
||||
)
|
||||
tools_for_api = _copy.deepcopy(tools_for_api)
|
||||
tools_for_api, _ = strip_pattern_and_format(tools_for_api)
|
||||
tools_for_api, _ = strip_slash_enum(tools_for_api)
|
||||
except Exception as exc:
|
||||
@@ -1621,6 +1636,13 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
request_client_holder["owner_tid"] = threading.get_ident()
|
||||
return client
|
||||
|
||||
def _take_request_client():
|
||||
with request_client_lock:
|
||||
client = request_client_holder.get("client")
|
||||
request_client_holder["client"] = None
|
||||
request_client_holder["owner_tid"] = None
|
||||
return client
|
||||
|
||||
def _close_request_client_once(reason: str) -> None:
|
||||
# See #29507 explanation in the non-streaming variant above. A
|
||||
# stranger thread (the interrupt-check / stale-stream detector loop)
|
||||
|
||||
@@ -980,48 +980,6 @@ def _extract_responses_reasoning_text(item: Any) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _format_responses_error(error_obj: Any, response_status: str) -> str:
|
||||
"""Build a human-readable error string from a Responses ``response.error`` payload.
|
||||
|
||||
The OpenAI Responses API carries failure details under ``response.error``
|
||||
on terminal ``response.failed`` events, in the shape
|
||||
``{"code": "rate_limit_exceeded", "message": "Slow down", "param": ...}``.
|
||||
Earlier code only surfaced ``message``, which left users staring at bare
|
||||
strings like ``"Slow down"`` while the failure mode (rate limit vs
|
||||
context-length vs internal_error vs model-overloaded) was hidden in
|
||||
``code``. We now prefix ``code`` when both are present so consumers can
|
||||
distinguish failure modes without parsing the bare message.
|
||||
|
||||
Falls back to ``code`` alone when ``message`` is empty, and to a stable
|
||||
default referencing the response status when no error payload is
|
||||
available at all. Adapted from anomalyco/opencode#28757.
|
||||
"""
|
||||
# Pull code and message from either dict or attribute-style payloads.
|
||||
code: Any = None
|
||||
message: Any = None
|
||||
if isinstance(error_obj, dict):
|
||||
code = error_obj.get("code")
|
||||
message = error_obj.get("message")
|
||||
elif error_obj is not None:
|
||||
code = getattr(error_obj, "code", None)
|
||||
message = getattr(error_obj, "message", None)
|
||||
|
||||
code_str = str(code).strip() if isinstance(code, str) else (str(code).strip() if code else "")
|
||||
message_str = str(message).strip() if isinstance(message, str) else (str(message).strip() if message else "")
|
||||
|
||||
if code_str and message_str:
|
||||
return f"{code_str}: {message_str}"
|
||||
if message_str:
|
||||
return message_str
|
||||
if code_str:
|
||||
return code_str
|
||||
if error_obj:
|
||||
# Last-resort: stringify whatever the provider sent so it's at least
|
||||
# visible in logs/UI rather than silently swallowed.
|
||||
return str(error_obj)
|
||||
return f"Responses API returned status '{response_status}'"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full response normalization
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1065,7 +1023,10 @@ def _normalize_codex_response(
|
||||
|
||||
if response_status in {"failed", "cancelled"}:
|
||||
error_obj = getattr(response, "error", None)
|
||||
error_msg = _format_responses_error(error_obj, response_status)
|
||||
if isinstance(error_obj, dict):
|
||||
error_msg = error_obj.get("message") or str(error_obj)
|
||||
else:
|
||||
error_msg = str(error_obj) if error_obj else f"Responses API returned status '{response_status}'"
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
content_parts: List[str] = []
|
||||
|
||||
@@ -16,6 +16,7 @@ compatibility.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
@@ -75,44 +75,6 @@ _IMAGE_TOKEN_ESTIMATE = 1600
|
||||
_IMAGE_CHAR_EQUIVALENT = _IMAGE_TOKEN_ESTIMATE * _CHARS_PER_TOKEN
|
||||
_SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
|
||||
|
||||
# Hard ceiling for the deterministic summary-failure handoff. The fallback is
|
||||
# only meant to preserve continuity anchors from the dropped window, not to
|
||||
# become another unbounded transcript copy after the LLM summarizer failed.
|
||||
_FALLBACK_SUMMARY_MAX_CHARS = 8_000
|
||||
_FALLBACK_TURN_MAX_CHARS = 700
|
||||
|
||||
|
||||
_PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+")
|
||||
|
||||
|
||||
def _dedupe_append(items: list[str], value: str, *, limit: int) -> None:
|
||||
value = value.strip()
|
||||
if value and value not in items and len(items) < limit:
|
||||
items.append(value)
|
||||
|
||||
|
||||
def _extract_tool_call_name_and_args(tool_call: Any) -> tuple[str, str]:
|
||||
"""Return a best-effort ``(name, arguments)`` pair for dict/object tool calls."""
|
||||
if isinstance(tool_call, dict):
|
||||
fn = tool_call.get("function") or {}
|
||||
return str(fn.get("name") or "unknown"), str(fn.get("arguments") or "")
|
||||
|
||||
fn = getattr(tool_call, "function", None)
|
||||
if fn is None:
|
||||
return "unknown", ""
|
||||
return str(getattr(fn, "name", None) or "unknown"), str(getattr(fn, "arguments", None) or "")
|
||||
|
||||
|
||||
def _extract_tool_call_id(tool_call: Any) -> str:
|
||||
if isinstance(tool_call, dict):
|
||||
return str(tool_call.get("id") or "")
|
||||
return str(getattr(tool_call, "id", "") or "")
|
||||
|
||||
|
||||
def _collect_path_mentions(text: str, relevant_files: list[str], *, limit: int = 12) -> None:
|
||||
for match in _PATH_MENTION_RE.findall(text):
|
||||
_dedupe_append(relevant_files, match.rstrip(".,:;"), limit=limit)
|
||||
|
||||
|
||||
def _content_length_for_budget(raw_content: Any) -> int:
|
||||
"""Return the effective char-length of a message's content for token budgeting.
|
||||
@@ -518,10 +480,6 @@ class ContextCompressor(ContextEngine):
|
||||
self._last_compression_savings_pct = 100.0
|
||||
self._ineffective_compression_count = 0
|
||||
self._summary_failure_cooldown_until = 0.0 # transient errors must not block a fresh session
|
||||
self.last_real_prompt_tokens = 0
|
||||
self.last_compression_rough_tokens = 0
|
||||
self.last_rough_tokens_when_real_prompt_fit = 0
|
||||
self.awaiting_real_usage_after_compression = False
|
||||
|
||||
def update_model(
|
||||
self,
|
||||
@@ -579,8 +537,8 @@ class ContextCompressor(ContextEngine):
|
||||
self.quiet_mode = quiet_mode
|
||||
# When True, summary-generation failure aborts compression entirely
|
||||
# (returns messages unchanged, sets _last_compress_aborted=True).
|
||||
# When False (default = historical behavior), insert a
|
||||
# deterministic "summary unavailable" handoff and drop the middle window.
|
||||
# When False (default = historical behavior), insert a static
|
||||
# "summary unavailable" placeholder and drop the middle window.
|
||||
self.abort_on_summary_failure = abort_on_summary_failure
|
||||
|
||||
self.context_length = get_model_context_length(
|
||||
@@ -619,10 +577,6 @@ class ContextCompressor(ContextEngine):
|
||||
|
||||
self.last_prompt_tokens = 0
|
||||
self.last_completion_tokens = 0
|
||||
self.last_real_prompt_tokens = 0
|
||||
self.last_compression_rough_tokens = 0
|
||||
self.last_rough_tokens_when_real_prompt_fit = 0
|
||||
self.awaiting_real_usage_after_compression = False
|
||||
|
||||
self.summary_model = summary_model_override or ""
|
||||
|
||||
@@ -656,44 +610,6 @@ class ContextCompressor(ContextEngine):
|
||||
self.last_prompt_tokens = usage.get("prompt_tokens", 0)
|
||||
self.last_completion_tokens = usage.get("completion_tokens", 0)
|
||||
self.last_total_tokens = usage.get("total_tokens", self.last_prompt_tokens + self.last_completion_tokens)
|
||||
if self.last_prompt_tokens > 0:
|
||||
self.last_real_prompt_tokens = self.last_prompt_tokens
|
||||
if self.last_prompt_tokens < self.threshold_tokens:
|
||||
if self.awaiting_real_usage_after_compression and self.last_compression_rough_tokens > 0:
|
||||
self.last_rough_tokens_when_real_prompt_fit = self.last_compression_rough_tokens
|
||||
else:
|
||||
self.last_rough_tokens_when_real_prompt_fit = 0
|
||||
self.awaiting_real_usage_after_compression = False
|
||||
|
||||
def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool:
|
||||
"""Return True when a high rough preflight estimate is known-noisy.
|
||||
|
||||
``estimate_request_tokens_rough(..., tools=...)`` intentionally
|
||||
overestimates schema-heavy requests so Hermes compresses before a
|
||||
provider rejects the payload. After a successful compressed API call,
|
||||
though, provider ``prompt_tokens`` are a better signal than repeating
|
||||
compaction from the same rough schema overhead. Defer only while the
|
||||
rough estimate has grown modestly since a request the provider proved
|
||||
fit under the threshold.
|
||||
"""
|
||||
if rough_tokens < self.threshold_tokens:
|
||||
return False
|
||||
if self.last_real_prompt_tokens <= 0:
|
||||
return False
|
||||
if self.last_real_prompt_tokens >= self.threshold_tokens:
|
||||
return False
|
||||
|
||||
baseline = self.last_rough_tokens_when_real_prompt_fit or self.last_compression_rough_tokens
|
||||
if baseline <= 0:
|
||||
return False
|
||||
|
||||
growth = max(0, rough_tokens - baseline)
|
||||
tolerated_growth = max(4096, int(self.threshold_tokens * 0.05))
|
||||
if growth > tolerated_growth:
|
||||
return False
|
||||
|
||||
self.last_rough_tokens_when_real_prompt_fit = max(baseline, rough_tokens)
|
||||
return True
|
||||
|
||||
def should_compress(self, prompt_tokens: int = None) -> bool:
|
||||
"""Check if context exceeds the compression threshold.
|
||||
@@ -968,195 +884,6 @@ class ContextCompressor(ContextEngine):
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
def _build_static_fallback_summary(
|
||||
self,
|
||||
turns_to_summarize: List[Dict[str, Any]],
|
||||
reason: str | None = None,
|
||||
) -> str:
|
||||
"""Build a deterministic handoff when the LLM summarizer is unavailable.
|
||||
|
||||
This is intentionally much less rich than an LLM-written summary, but it
|
||||
is still better than a bare "N messages were removed" marker. It keeps
|
||||
the most useful continuity anchors that can be extracted locally:
|
||||
recent user asks, assistant/tool actions, files/commands mentioned in
|
||||
tool calls, and any error text. The result uses the normal summary
|
||||
structure so downstream prompts can recover gracefully after a provider
|
||||
outage or summary-model failure.
|
||||
"""
|
||||
user_asks: list[str] = []
|
||||
assistant_actions: list[str] = []
|
||||
tool_actions: list[str] = []
|
||||
relevant_files: list[str] = []
|
||||
blockers: list[str] = []
|
||||
last_dropped_turns: list[str] = []
|
||||
|
||||
def _compact_fallback_turn(value: Any) -> str:
|
||||
text = redact_sensitive_text(_content_text_for_contains(value))
|
||||
text = re.sub(r"\bgh[pousr]_[A-Za-z0-9_]{8,}\b", "[REDACTED]", text)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
if len(text) > _FALLBACK_TURN_MAX_CHARS:
|
||||
text = text[: _FALLBACK_TURN_MAX_CHARS - 15].rstrip() + " ...[truncated]"
|
||||
return re.sub(r"\bgh[pousr]_[A-Za-z0-9_.-]+", "[REDACTED]", text)
|
||||
|
||||
def _remember_dropped_turn(label: str, text: str, *, limit: int = 8) -> None:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return
|
||||
last_dropped_turns.append(f"{label}: {text}")
|
||||
if len(last_dropped_turns) > limit:
|
||||
del last_dropped_turns[0]
|
||||
|
||||
def _collect_paths_from_jsonish(obj: Any) -> None:
|
||||
if isinstance(obj, dict):
|
||||
for key, val in obj.items():
|
||||
if key in {"path", "workdir", "file_path", "output_path"} and isinstance(val, str):
|
||||
_dedupe_append(relevant_files, val, limit=12)
|
||||
_collect_paths_from_jsonish(val)
|
||||
elif isinstance(obj, list):
|
||||
for val in obj:
|
||||
_collect_paths_from_jsonish(val)
|
||||
elif isinstance(obj, str):
|
||||
_collect_path_mentions(obj, relevant_files)
|
||||
|
||||
call_id_to_tool: dict[str, tuple[str, str]] = {}
|
||||
for msg in turns_to_summarize:
|
||||
if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
name, raw_args = _extract_tool_call_name_and_args(tc)
|
||||
args = redact_sensitive_text(raw_args)
|
||||
call_id = _extract_tool_call_id(tc)
|
||||
if call_id:
|
||||
call_id_to_tool[call_id] = (name, args)
|
||||
if args:
|
||||
try:
|
||||
parsed = json.loads(args)
|
||||
except Exception:
|
||||
parsed = args
|
||||
_collect_paths_from_jsonish(parsed)
|
||||
|
||||
for msg in turns_to_summarize:
|
||||
role = msg.get("role", "unknown")
|
||||
text = _compact_fallback_turn(msg.get("content"))
|
||||
_collect_path_mentions(text, relevant_files)
|
||||
|
||||
turn_text = text
|
||||
turn_tool_names: list[str] = []
|
||||
if role == "assistant" and msg.get("tool_calls"):
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
name, _args = _extract_tool_call_name_and_args(tc)
|
||||
turn_tool_names.append(name)
|
||||
if turn_tool_names:
|
||||
prefix = "tool calls: " + ", ".join(turn_tool_names[:6])
|
||||
turn_text = f"{prefix}; {turn_text}" if turn_text else prefix
|
||||
_remember_dropped_turn(str(role).upper(), turn_text)
|
||||
|
||||
if len(text) > 600:
|
||||
text = text[:420].rstrip() + " ... " + text[-160:].lstrip()
|
||||
|
||||
if role == "user" and text:
|
||||
user_asks.append(text)
|
||||
elif role == "assistant":
|
||||
tool_names: list[str] = []
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
name, _args = _extract_tool_call_name_and_args(tc)
|
||||
tool_names.append(name)
|
||||
if tool_names:
|
||||
assistant_actions.append(
|
||||
"Called tool(s): " + ", ".join(tool_names[:6])
|
||||
)
|
||||
elif text:
|
||||
assistant_actions.append(text)
|
||||
elif role == "tool":
|
||||
call_id = str(msg.get("tool_call_id") or "")
|
||||
tool_name, tool_args = call_id_to_tool.get(call_id, ("unknown", ""))
|
||||
tool_actions.append(
|
||||
_summarize_tool_result(tool_name, tool_args, text or "")
|
||||
)
|
||||
if re.search(
|
||||
r"\b(error|failed|exception|traceback|timeout|timed out|fatal)\b",
|
||||
text,
|
||||
re.I,
|
||||
):
|
||||
blockers.append(text[:500])
|
||||
|
||||
def _bullets(items: list[str], limit: int = 8) -> str:
|
||||
unique: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in items:
|
||||
item = item.strip()
|
||||
if not item or item in seen:
|
||||
continue
|
||||
seen.add(item)
|
||||
unique.append(item)
|
||||
if len(unique) >= limit:
|
||||
break
|
||||
return "\n".join(f"- {item}" for item in unique) if unique else "None."
|
||||
|
||||
completed: list[str] = []
|
||||
for idx, item in enumerate((assistant_actions + tool_actions)[:12], start=1):
|
||||
completed.append(f"{idx}. {item}")
|
||||
|
||||
active_task = (
|
||||
f"User asked: {user_asks[-1]!r}"
|
||||
if user_asks
|
||||
else "Unknown from deterministic fallback."
|
||||
)
|
||||
previous_summary_note = ""
|
||||
if self._previous_summary:
|
||||
previous_summary_note = (
|
||||
"\n\nPrevious compaction summary was present and should still be treated as "
|
||||
"background continuity context, but the latest LLM summary update failed."
|
||||
)
|
||||
|
||||
reason_text = f" Summary failure reason: {reason}." if reason else ""
|
||||
body = f"""## Active Task
|
||||
{active_task}
|
||||
|
||||
## Goal
|
||||
Recovered from a deterministic fallback because the LLM context summarizer was unavailable. Continue from the protected recent messages after this summary and use current file/system state for exact details.{previous_summary_note}
|
||||
|
||||
## Constraints & Preferences
|
||||
- This fallback was generated locally without an LLM summary call.
|
||||
- Secrets and credentials were redacted before preservation.
|
||||
- The summary may be incomplete; prefer verifying current files, git state, processes, and test results instead of assuming omitted details.
|
||||
|
||||
## Completed Actions
|
||||
{chr(10).join(completed) if completed else "None recoverable from compacted turns."}
|
||||
|
||||
## Active State
|
||||
Unknown from deterministic fallback. Inspect current repository/session state if needed.
|
||||
|
||||
## In Progress
|
||||
{active_task}
|
||||
|
||||
## Blocked
|
||||
{_bullets(blockers, limit=5)}
|
||||
|
||||
## Key Decisions
|
||||
None recoverable from deterministic fallback.
|
||||
|
||||
## Resolved Questions
|
||||
None recoverable from deterministic fallback.
|
||||
|
||||
## Pending User Asks
|
||||
{active_task}
|
||||
|
||||
## Relevant Files
|
||||
{_bullets(relevant_files, limit=12)}
|
||||
|
||||
## Remaining Work
|
||||
Continue from the most recent unfulfilled user ask and protected tail messages. Verify state with tools before making claims.
|
||||
|
||||
## Last Dropped Turns
|
||||
{_bullets(last_dropped_turns, limit=8)}
|
||||
|
||||
## Critical Context
|
||||
Summary generation was unavailable, so this is a best-effort deterministic fallback for {len(turns_to_summarize)} compacted message(s).{reason_text}"""
|
||||
summary = self._with_summary_prefix(redact_sensitive_text(body.strip()))
|
||||
if len(summary) > _FALLBACK_SUMMARY_MAX_CHARS:
|
||||
summary = summary[: _FALLBACK_SUMMARY_MAX_CHARS - 42].rstrip() + "\n...[fallback summary truncated]"
|
||||
return summary
|
||||
|
||||
def _fallback_to_main_for_compression(self, e: Exception, reason: str) -> None:
|
||||
"""Switch from a separate ``summary_model`` back to the main model.
|
||||
|
||||
@@ -1184,11 +911,7 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
|
||||
self.summary_model = "" # empty = use main model
|
||||
self._summary_failure_cooldown_until = 0.0 # no cooldown — retry immediately
|
||||
|
||||
def _generate_summary(
|
||||
self,
|
||||
turns_to_summarize: List[Dict[str, Any]],
|
||||
focus_topic: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topic: str = None) -> Optional[str]:
|
||||
"""Generate a structured summary of conversation turns.
|
||||
|
||||
Uses a structured template (Goal, Progress, Decisions, Resolved/Pending
|
||||
@@ -1885,9 +1608,9 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
# True → ABORT compression entirely. Return messages unchanged
|
||||
# and set _last_compress_aborted=True so callers can warn
|
||||
# the user and stop the auto-compress retry loop.
|
||||
# False → Fall through to the default fallback path below: insert
|
||||
# a deterministic "summary unavailable" handoff and drop
|
||||
# the middle window. Records _last_summary_fallback_used /
|
||||
# False → Fall through to the legacy fallback path below: insert
|
||||
# a static "summary unavailable" placeholder and drop the
|
||||
# middle window. Records _last_summary_fallback_used /
|
||||
# _last_summary_dropped_count for gateway hygiene to
|
||||
# surface a warning.
|
||||
# Default is False (historical behavior).
|
||||
@@ -1920,18 +1643,21 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
)
|
||||
compressed.append(msg)
|
||||
|
||||
# If LLM summary failed, insert a deterministic fallback so the model
|
||||
# gets at least locally recoverable continuity anchors instead of a
|
||||
# content-free "N messages were removed" marker.
|
||||
# Legacy fallback path: LLM summary failed and abort_on_summary_failure
|
||||
# is False (the default). Insert a static placeholder so the model
|
||||
# knows context was lost rather than silently dropping everything.
|
||||
if not summary:
|
||||
if not self.quiet_mode:
|
||||
logger.warning("Summary generation failed — inserting deterministic fallback context summary")
|
||||
logger.warning("Summary generation failed — inserting static fallback context marker")
|
||||
n_dropped = compress_end - compress_start
|
||||
self._last_summary_dropped_count = n_dropped
|
||||
self._last_summary_fallback_used = True
|
||||
summary = self._build_static_fallback_summary(
|
||||
turns_to_summarize,
|
||||
reason=self._last_summary_error,
|
||||
summary = (
|
||||
f"{SUMMARY_PREFIX}\n"
|
||||
f"Summary generation was unavailable. {n_dropped} message(s) were "
|
||||
f"removed to free context space but could not be summarized. The removed "
|
||||
f"messages contained earlier work in this session. Continue based on the "
|
||||
f"recent messages below and the current state of any files or resources."
|
||||
)
|
||||
|
||||
_merge_summary_into_tail = False
|
||||
|
||||
@@ -34,33 +34,13 @@ import tempfile
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Tuple
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from agent.model_metadata import estimate_request_tokens_rough
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _compression_lock_holder(agent: Any) -> str:
|
||||
"""Build a unique holder id for the lock: pid:tid:agent-instance:uuid.
|
||||
|
||||
The pid+tid prefix lets ops tell crashed/abandoned holders apart from
|
||||
live ones (expiry-based recovery uses the timestamp, but ``holder``
|
||||
is what shows up in diagnostics + log lines). The agent instance id
|
||||
and a per-acquire uuid disambiguate two co-resident agents on the
|
||||
same thread (background_review forks run on a worker thread, but
|
||||
on machines where compression itself dispatches to a thread pool
|
||||
we want each acquire to be unique).
|
||||
"""
|
||||
import threading
|
||||
return (
|
||||
f"pid={os.getpid()}"
|
||||
f":tid={threading.get_ident()}"
|
||||
f":agent={id(agent):x}"
|
||||
f":nonce={uuid.uuid4().hex[:8]}"
|
||||
)
|
||||
|
||||
|
||||
def check_compression_model_feasibility(agent: Any) -> None:
|
||||
"""Warn at session start if the auxiliary compression model's context
|
||||
window is smaller than the main model's compression threshold.
|
||||
@@ -325,103 +305,6 @@ def compress_context(
|
||||
"🗜️ Compacting context — summarizing earlier conversation so I can continue..."
|
||||
)
|
||||
|
||||
# ── Compression lock ────────────────────────────────────────────────
|
||||
# Atomic, state.db-backed lock per session_id. Without this, two
|
||||
# AIAgent instances that share the same session_id (most commonly the
|
||||
# parent-turn agent and its background-review fork — see
|
||||
# ``agent/background_review.py``: ``review_agent.session_id =
|
||||
# agent.session_id``) can each call compress() on overlapping
|
||||
# snapshots of the same conversation. Both succeed, both rotate
|
||||
# ``agent.session_id`` to a fresh id, both create child sessions in
|
||||
# state.db parented to the same old id. The gateway's SessionEntry
|
||||
# only catches one rotation, so the other child becomes an orphan
|
||||
# that silently accumulates writes — Damien's repro shape.
|
||||
#
|
||||
# Acquire keyed on the OLD session_id (the rotation target's parent),
|
||||
# because that's the id that competing paths see and read from
|
||||
# SessionEntry at the start of their own compression attempt.
|
||||
#
|
||||
# If we can't acquire the lock, another path is mid-compression on
|
||||
# this session. Aborting is correct: the messages are unchanged, the
|
||||
# other path's rotation will produce the canonical new session_id,
|
||||
# and our caller's auto-compress loop sees ``len(returned) == len(input)``
|
||||
# and stops retrying for this cycle. The session is NOT corrupted —
|
||||
# we just sit out this round and let the winner finish.
|
||||
_lock_db = getattr(agent, "_session_db", None)
|
||||
_lock_sid = agent.session_id or ""
|
||||
_lock_holder: Optional[str] = None
|
||||
# Probe whether the lock subsystem is actually available on this
|
||||
# SessionDB instance. A process running mismatched module versions
|
||||
# (e.g. ``conversation_compression.py`` reloaded after a pull but the
|
||||
# long-lived ``hermes_state.SessionDB`` class still bound to the
|
||||
# pre-#34351 version in memory) has the call site but not the method.
|
||||
# In that case ``try_acquire_compression_lock`` raises AttributeError —
|
||||
# NOT a ``sqlite3.Error`` — so the method's own fail-open guard never
|
||||
# runs and the exception propagates to the outer agent loop, which
|
||||
# prints the error and retries. Because compression never succeeds,
|
||||
# the token count never drops and the loop re-triggers compaction
|
||||
# forever (the "API call #47/#48/#49 ... has no attribute
|
||||
# try_acquire_compression_lock" spin). Fail OPEN here: if the lock
|
||||
# subsystem is missing or broken in any unexpected way, skip locking
|
||||
# and proceed with compression. Skipping the lock risks a rare
|
||||
# concurrent-compression session fork; an infinite no-progress loop
|
||||
# that never compresses at all is strictly worse.
|
||||
if _lock_db is not None and _lock_sid:
|
||||
_lock_holder = _compression_lock_holder(agent)
|
||||
try:
|
||||
_lock_acquired = _lock_db.try_acquire_compression_lock(
|
||||
_lock_sid, _lock_holder
|
||||
)
|
||||
except Exception as _lock_err:
|
||||
# Broken/absent lock subsystem (version skew, etc.). Log once
|
||||
# per session and proceed WITHOUT the lock rather than letting
|
||||
# the exception spin the outer loop.
|
||||
_lock_holder = None # we don't own anything to release
|
||||
if getattr(agent, "_last_compression_lock_error_sid", None) != _lock_sid:
|
||||
agent._last_compression_lock_error_sid = _lock_sid
|
||||
logger.warning(
|
||||
"compression lock subsystem unavailable for session=%s "
|
||||
"(%s: %s) — proceeding without lock. This usually means a "
|
||||
"stale in-memory module after an update; restart the "
|
||||
"process (or `hermes update`) to resync.",
|
||||
_lock_sid, type(_lock_err).__name__, _lock_err,
|
||||
)
|
||||
_lock_acquired = True # treat as acquired-but-unlocked; proceed
|
||||
if not _lock_acquired:
|
||||
try:
|
||||
existing = _lock_db.get_compression_lock_holder(_lock_sid)
|
||||
except Exception:
|
||||
existing = None
|
||||
logger.warning(
|
||||
"compression skipped: another path is compressing session=%s "
|
||||
"(holder=%s) — returning messages unchanged to avoid session fork",
|
||||
_lock_sid, existing,
|
||||
)
|
||||
_lock_holder = None # don't release a lock we don't own
|
||||
# Surface to the user once — quiet for downstream auto-compress loops
|
||||
if getattr(agent, "_last_compression_lock_warning_sid", None) != _lock_sid:
|
||||
agent._last_compression_lock_warning_sid = _lock_sid
|
||||
try:
|
||||
agent._emit_warning(
|
||||
"⚠ Skipping concurrent compression — another path "
|
||||
"is already compressing this session. Will retry "
|
||||
"after it finishes."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
_existing_sp = getattr(agent, "_cached_system_prompt", None)
|
||||
if not _existing_sp:
|
||||
_existing_sp = agent._build_system_prompt(system_message)
|
||||
return messages, _existing_sp
|
||||
|
||||
def _release_lock() -> None:
|
||||
"""Release the lock keyed on the OLD session_id (before rotation)."""
|
||||
if _lock_db is not None and _lock_sid and _lock_holder:
|
||||
try:
|
||||
_lock_db.release_compression_lock(_lock_sid, _lock_holder)
|
||||
except Exception as _rel_err:
|
||||
logger.debug("compression lock release failed: %s", _rel_err)
|
||||
|
||||
# Notify external memory provider before compression discards context
|
||||
if agent._memory_manager:
|
||||
try:
|
||||
@@ -435,11 +318,6 @@ def compress_context(
|
||||
# Plugin context engine with strict signature that doesn't accept
|
||||
# focus_topic / force — fall back to calling without them.
|
||||
compressed = agent.context_compressor.compress(messages, current_tokens=approx_tokens)
|
||||
except BaseException:
|
||||
# ANY exception during compress() must release the lock so the
|
||||
# session isn't permanently blocked from future compression.
|
||||
_release_lock()
|
||||
raise
|
||||
|
||||
# If compression aborted (aux LLM failed to produce a usable summary)
|
||||
# the compressor returns the input messages unchanged. Surface the
|
||||
@@ -458,7 +336,6 @@ def compress_context(
|
||||
_existing_sp = getattr(agent, "_cached_system_prompt", None)
|
||||
if not _existing_sp:
|
||||
_existing_sp = agent._build_system_prompt(system_message)
|
||||
_release_lock() # compression aborted — no rotation will happen
|
||||
return messages, _existing_sp
|
||||
|
||||
summary_error = getattr(agent.context_compressor, "_last_summary_error", None)
|
||||
@@ -603,12 +480,6 @@ def compress_context(
|
||||
agent.session_id or "none", _pre_msg_count, len(compressed),
|
||||
f"{_compressed_est:,}",
|
||||
)
|
||||
# Release the lock on the OLD session_id only AFTER rotation completed
|
||||
# and all post-rotation bookkeeping (memory manager, context engine,
|
||||
# file dedup) ran. A concurrent path that wakes up the moment we
|
||||
# release will see the NEW session_id in state.db / SessionEntry and
|
||||
# acquire on that — no race against our just-finished work.
|
||||
_release_lock()
|
||||
return compressed, new_system_prompt
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.anthropic_adapter import _is_oauth_token
|
||||
from agent.auxiliary_client import set_runtime_main
|
||||
from agent.codex_responses_adapter import _summarize_user_message_for_log
|
||||
from agent.display import KawaiiSpinner
|
||||
from agent.error_classifier import FailoverReason, classify_api_error
|
||||
@@ -51,13 +53,20 @@ from agent.model_metadata import (
|
||||
parse_available_output_tokens_from_error,
|
||||
save_context_length,
|
||||
)
|
||||
from agent.nous_rate_guard import (
|
||||
clear_nous_rate_limit,
|
||||
is_genuine_nous_rate_limit,
|
||||
nous_rate_limit_remaining,
|
||||
record_nous_rate_limit,
|
||||
)
|
||||
from agent.process_bootstrap import _install_safe_stdio
|
||||
from agent.prompt_caching import apply_anthropic_cache_control
|
||||
from agent.retry_utils import jittered_backoff
|
||||
from agent.trajectory import has_incomplete_scratchpad
|
||||
from agent.usage_pricing import estimate_usage_cost, normalize_usage
|
||||
from hermes_constants import PARTIAL_STREAM_STUB_ID
|
||||
from hermes_constants import display_hermes_home as _dhh_fn, PARTIAL_STREAM_STUB_ID
|
||||
from hermes_logging import set_session_context
|
||||
from tools.schema_sanitizer import strip_pattern_and_format
|
||||
from tools.skill_provenance import set_current_write_origin
|
||||
from utils import base_url_host_matches, env_var_enabled
|
||||
|
||||
@@ -203,13 +212,15 @@ def _print_billing_or_entitlement_guidance(
|
||||
def _try_refresh_nous_paid_entitlement_credentials(agent) -> bool:
|
||||
"""Refresh Nous runtime credentials after a fresh paid-entitlement check."""
|
||||
try:
|
||||
from hermes_cli.auth import NOUS_INFERENCE_AUTH_MODE_LEGACY
|
||||
from hermes_cli.nous_account import get_nous_portal_account_info
|
||||
|
||||
account_info = get_nous_portal_account_info(force_fresh=True)
|
||||
if account_info.paid_service_access is not True:
|
||||
return False
|
||||
return agent._try_refresh_nous_client_credentials(
|
||||
force=True,
|
||||
force=False,
|
||||
inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_LEGACY,
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
@@ -398,6 +409,7 @@ def run_conversation(
|
||||
|
||||
# Tag all log records on this thread with the session ID so
|
||||
# ``hermes logs --session <id>`` can filter a single conversation.
|
||||
from hermes_logging import set_session_context
|
||||
set_session_context(agent.session_id)
|
||||
|
||||
# Bind the skill write-origin ContextVar for this thread so tool
|
||||
@@ -406,6 +418,7 @@ def run_conversation(
|
||||
# a foreground user-directed turn. Set at the top of each call;
|
||||
# the review fork runs on its own thread with a fresh context,
|
||||
# so the foreground value here does not leak into it.
|
||||
from tools.skill_provenance import set_current_write_origin
|
||||
set_current_write_origin(getattr(agent, "_memory_write_origin", "assistant_tool"))
|
||||
|
||||
# If the previous turn activated fallback, restore the primary
|
||||
@@ -4301,54 +4314,36 @@ def run_conversation(
|
||||
)
|
||||
final_response = agent._handle_max_iterations(messages, api_call_count)
|
||||
|
||||
# If running as a kanban worker, signal the dispatcher that the
|
||||
# worker could not complete (rather than treating it as a
|
||||
# If running as a kanban worker, block the task so the dispatcher
|
||||
# knows the worker could not complete (rather than treating it as a
|
||||
# protocol violation). The agent loop strips tools before calling
|
||||
# _handle_max_iterations, so the model cannot call kanban_block
|
||||
# itself — we must do it on its behalf.
|
||||
#
|
||||
# We route through ``_record_task_failure(outcome="timed_out")``
|
||||
# rather than ``kanban_block`` so this counts toward the
|
||||
# ``consecutive_failures`` counter and the dispatcher's
|
||||
# ``failure_limit`` circuit breaker (#29747 gap 2). Without this,
|
||||
# a task whose worker keeps exhausting its budget would block
|
||||
# silently each run, get auto-promoted by the operator (or never
|
||||
# surface), and re-block in an endless loop with no signal.
|
||||
_kanban_task = os.environ.get("HERMES_KANBAN_TASK")
|
||||
if _kanban_task:
|
||||
try:
|
||||
from hermes_cli import kanban_db as _kb
|
||||
_conn = _kb.connect()
|
||||
try:
|
||||
_kb._record_task_failure(
|
||||
_conn,
|
||||
_kanban_task,
|
||||
error=(
|
||||
_ra().handle_function_call(
|
||||
"kanban_block",
|
||||
{
|
||||
"task_id": _kanban_task,
|
||||
"reason": (
|
||||
f"Iteration budget exhausted "
|
||||
f"({api_call_count}/{agent.max_iterations}) — "
|
||||
"task could not complete within the allowed "
|
||||
"iterations"
|
||||
),
|
||||
outcome="timed_out",
|
||||
release_claim=True,
|
||||
end_run=True,
|
||||
event_payload_extra={
|
||||
"budget_used": api_call_count,
|
||||
"budget_max": agent.max_iterations,
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
"recorded budget-exhausted failure for task %s (%d/%d)",
|
||||
_kanban_task, api_call_count, agent.max_iterations,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
},
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
logger.info(
|
||||
"kanban_block called for task %s after iteration "
|
||||
"exhaustion (%d/%d)",
|
||||
_kanban_task, api_call_count, agent.max_iterations,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to record budget-exhausted failure for task %s",
|
||||
"Failed to call kanban_block after iteration "
|
||||
"exhaustion for task %s",
|
||||
_kanban_task,
|
||||
exc_info=True,
|
||||
)
|
||||
@@ -4566,7 +4561,6 @@ def run_conversation(
|
||||
original_user_message=original_user_message,
|
||||
final_response=final_response,
|
||||
interrupted=interrupted,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
# Background memory/skill review — runs AFTER the response is delivered
|
||||
|
||||
@@ -14,7 +14,7 @@ from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from hermes_cli.config import load_env
|
||||
from hermes_cli.config import get_env_value, load_env
|
||||
from agent.credential_persistence import (
|
||||
is_borrowed_credential_source,
|
||||
sanitize_borrowed_credential_payload,
|
||||
@@ -22,6 +22,7 @@ from agent.credential_persistence import (
|
||||
import hermes_cli.auth as auth_mod
|
||||
from hermes_cli.auth import (
|
||||
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
|
||||
DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
|
||||
PROVIDER_REGISTRY,
|
||||
_auth_store_lock,
|
||||
_codex_access_token_is_expiring,
|
||||
@@ -54,38 +55,6 @@ def _load_config_safe() -> Optional[dict]:
|
||||
|
||||
STATUS_OK = "ok"
|
||||
STATUS_EXHAUSTED = "exhausted"
|
||||
# Terminal failure — the credential will never recover on its own. Used for
|
||||
# upstream-permanent OAuth states like ``token_invalidated`` / ``token_revoked``
|
||||
# where retrying after a TTL cooldown is guaranteed to fail. ``DEAD`` entries
|
||||
# are excluded from rotation unconditionally and only clear when an explicit
|
||||
# write-side sync (e.g. ``_save_codex_tokens`` after a fresh device-code
|
||||
# login) rewrites the tokens.
|
||||
STATUS_DEAD = "dead"
|
||||
|
||||
# OAuth error reasons that indicate the credential is permanently invalid
|
||||
# server-side and cannot be recovered by retry/refresh. Sourced from
|
||||
# OpenAI Codex Responses API, Anthropic, xAI, and Google OAuth spec.
|
||||
_TERMINAL_AUTH_REASONS = frozenset({
|
||||
"token_invalidated", # OpenAI Codex: "Your authentication token has been invalidated."
|
||||
"token_revoked", # OAuth 2.0 RFC 7009: token explicitly revoked
|
||||
"invalid_token", # RFC 6750: bearer token is malformed/expired/revoked
|
||||
"invalid_grant", # RFC 6749: refresh_token rejected during refresh
|
||||
"unauthorized_client", # RFC 6749: client no longer authorized
|
||||
"refresh_token_reused", # Single-use refresh token consumed by another process
|
||||
})
|
||||
|
||||
# How long a DEAD manual credential is preserved before being pruned.
|
||||
# Manual entries (``manual:*``) are independent credentials with no singleton
|
||||
# to re-seed from, so pruning them after a quiet window cleans up dead state
|
||||
# without losing recoverability — the user always has the option to re-add
|
||||
# via ``hermes auth add``.
|
||||
#
|
||||
# Singleton-seeded entries (``device_code``, ``loopback_pkce``, ``claude_code``)
|
||||
# are NOT pruned because ``_seed_from_singletons`` would just re-create them
|
||||
# on the next ``load_pool()`` with the same stale singleton tokens, defeating
|
||||
# the cleanup. They remain in the pool marked DEAD until an explicit re-auth
|
||||
# write-side sync (``_save_codex_tokens`` etc.) clears the status.
|
||||
DEAD_MANUAL_PRUNE_TTL_SECONDS = 24 * 60 * 60 # 24 hours
|
||||
|
||||
AUTH_TYPE_OAUTH = "oauth"
|
||||
AUTH_TYPE_API_KEY = "api_key"
|
||||
@@ -202,22 +171,8 @@ class PooledCredential:
|
||||
def runtime_api_key(self) -> str:
|
||||
if self.provider == "nous":
|
||||
# Nous stores the runtime inference credential in agent_key for
|
||||
# compatibility. It must be a NAS invoke JWT.
|
||||
for token, expires_at in (
|
||||
(self.agent_key, self.agent_key_expires_at),
|
||||
(self.access_token, self.expires_at),
|
||||
):
|
||||
if (
|
||||
isinstance(token, str)
|
||||
and token.strip()
|
||||
and auth_mod._nous_invoke_jwt_is_usable(
|
||||
token,
|
||||
scope=getattr(self, "scope", None),
|
||||
expires_at=expires_at,
|
||||
)
|
||||
):
|
||||
return token.strip()
|
||||
return ""
|
||||
# compatibility. It may be a NAS invoke JWT or legacy opaque key.
|
||||
return str(self.agent_key or self.access_token or "")
|
||||
return str(self.access_token or "")
|
||||
|
||||
@property
|
||||
@@ -483,29 +438,6 @@ class CredentialPool:
|
||||
[entry.to_dict() for entry in self._entries],
|
||||
)
|
||||
|
||||
def _is_terminal_auth_failure(
|
||||
self,
|
||||
status_code: Optional[int],
|
||||
normalized_error: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Detect upstream-permanent OAuth failures that won't recover on TTL.
|
||||
|
||||
Only fires for 401 responses whose error code/reason matches a known
|
||||
terminal OAuth state (token_invalidated, token_revoked, invalid_grant,
|
||||
etc.). Distinguishes permanent failures from transient ones like
|
||||
token_expired (refreshable) or generic 401 without a specific reason
|
||||
(could be a server-side glitch worth retrying).
|
||||
|
||||
Returns False for non-401 status codes — 429 rate limits and 402
|
||||
billing failures are transient by nature and should keep TTL semantics.
|
||||
"""
|
||||
if status_code != 401:
|
||||
return False
|
||||
reason = normalized_error.get("reason")
|
||||
if not isinstance(reason, str):
|
||||
return False
|
||||
return reason.strip().lower() in _TERMINAL_AUTH_REASONS
|
||||
|
||||
def _mark_exhausted(
|
||||
self,
|
||||
entry: PooledCredential,
|
||||
@@ -513,20 +445,9 @@ class CredentialPool:
|
||||
error_context: Optional[Dict[str, Any]] = None,
|
||||
) -> PooledCredential:
|
||||
normalized_error = _normalize_error_context(error_context)
|
||||
# Permanent OAuth failures (token_invalidated, token_revoked, etc.)
|
||||
# transition to STATUS_DEAD instead of STATUS_EXHAUSTED. Without this,
|
||||
# a revoked credential gets a 1-hour TTL cooldown and then re-enters
|
||||
# rotation, failing immediately every hour until the user manually
|
||||
# removes it (issue #32849). DEAD entries are excluded from rotation
|
||||
# unconditionally and only clear via an explicit re-auth write-side
|
||||
# sync (``_save_codex_tokens`` after a fresh device-code login).
|
||||
if self._is_terminal_auth_failure(status_code, normalized_error):
|
||||
terminal_status = STATUS_DEAD
|
||||
else:
|
||||
terminal_status = STATUS_EXHAUSTED
|
||||
updated = replace(
|
||||
entry,
|
||||
last_status=terminal_status,
|
||||
last_status=STATUS_EXHAUSTED,
|
||||
last_status_at=time.time(),
|
||||
last_error_code=status_code,
|
||||
last_error_reason=normalized_error.get("reason"),
|
||||
@@ -931,7 +852,12 @@ class CredentialPool:
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
auth_mod.resolve_nous_runtime_credentials(
|
||||
force_refresh=force,
|
||||
min_key_ttl_seconds=DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
|
||||
inference_auth_mode=(
|
||||
auth_mod.NOUS_INFERENCE_AUTH_MODE_LEGACY
|
||||
if force
|
||||
else auth_mod.NOUS_INFERENCE_AUTH_MODE_AUTO
|
||||
),
|
||||
)
|
||||
updated = self._sync_nous_entry_from_auth_store(entry)
|
||||
else:
|
||||
@@ -1213,7 +1139,7 @@ class CredentialPool:
|
||||
auth_mod.XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
|
||||
)
|
||||
if self.provider == "nous":
|
||||
# Nous refresh can require network access and should happen when
|
||||
# Nous refresh/mint can require network access and should happen when
|
||||
# runtime credentials are actually resolved, not merely when the pool
|
||||
# is enumerated for listing, migration, or selection.
|
||||
return False
|
||||
@@ -1232,14 +1158,13 @@ class CredentialPool:
|
||||
"""
|
||||
now = time.time()
|
||||
cleared_any = False
|
||||
entries_to_prune: List[str] = []
|
||||
available: List[PooledCredential] = []
|
||||
for entry in self._entries:
|
||||
# For anthropic claude_code entries, sync from the credentials file
|
||||
# before any status/refresh checks. This picks up tokens refreshed
|
||||
# by other processes (Claude Code CLI, other Hermes profiles).
|
||||
if (self.provider == "anthropic" and entry.source == "claude_code"
|
||||
and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}):
|
||||
and entry.last_status == STATUS_EXHAUSTED):
|
||||
synced = self._sync_anthropic_entry_from_credentials_file(entry)
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
@@ -1250,7 +1175,7 @@ class CredentialPool:
|
||||
# exhausted status stale.
|
||||
if (self.provider == "nous"
|
||||
and entry.source == "device_code"
|
||||
and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}):
|
||||
and entry.last_status == STATUS_EXHAUSTED):
|
||||
synced = self._sync_nous_entry_from_auth_store(entry)
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
@@ -1262,7 +1187,7 @@ class CredentialPool:
|
||||
# future for ChatGPT weekly windows).
|
||||
if (self.provider == "openai-codex"
|
||||
and entry.source == "device_code"
|
||||
and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}):
|
||||
and entry.last_status == STATUS_EXHAUSTED):
|
||||
synced = self._sync_codex_entry_from_auth_store(entry)
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
@@ -1273,41 +1198,11 @@ class CredentialPool:
|
||||
# xAI Grok OAuth login) has since rotated in auth.json.
|
||||
if (self.provider == "xai-oauth"
|
||||
and entry.source == "loopback_pkce"
|
||||
and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}):
|
||||
and entry.last_status == STATUS_EXHAUSTED):
|
||||
synced = self._sync_xai_oauth_entry_from_auth_store(entry)
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
cleared_any = True
|
||||
if entry.last_status == STATUS_DEAD:
|
||||
# Manual DEAD credentials get pruned after a 24h quiet window
|
||||
# so the pool doesn't accumulate dead entries forever. The
|
||||
# user can always re-add via ``hermes auth add``. Singleton-
|
||||
# seeded DEAD entries are kept so the audit trail (label,
|
||||
# last_error_reason, timestamps) stays visible — pruning them
|
||||
# would just be undone by ``_seed_from_singletons`` on the
|
||||
# next load anyway.
|
||||
if _is_manual_source(entry.source):
|
||||
dead_at = entry.last_status_at or 0
|
||||
if dead_at and now - dead_at > DEAD_MANUAL_PRUNE_TTL_SECONDS:
|
||||
_label = entry.label or entry.id[:8]
|
||||
logger.warning(
|
||||
"credential pool: pruning DEAD manual entry %s "
|
||||
"(reason=%s, age=%.1fh) — re-add via `hermes auth add %s`",
|
||||
_label,
|
||||
entry.last_error_reason or "unknown",
|
||||
(now - dead_at) / 3600.0,
|
||||
self.provider,
|
||||
)
|
||||
# Mark for removal after the loop completes; we can't
|
||||
# mutate self._entries while iterating.
|
||||
entries_to_prune.append(entry.id)
|
||||
cleared_any = True
|
||||
# Permanently failed credentials never re-enter rotation via
|
||||
# TTL. They only clear when a write-side re-auth sync rewrites
|
||||
# the tokens (e.g. ``_save_codex_tokens`` after a fresh
|
||||
# device-code login). The auth.json-sync paths below handle
|
||||
# the re-auth case for OAuth singletons.
|
||||
continue
|
||||
if entry.last_status == STATUS_EXHAUSTED:
|
||||
exhausted_until = _exhausted_until(entry)
|
||||
if exhausted_until is not None and now < exhausted_until:
|
||||
@@ -1331,9 +1226,6 @@ class CredentialPool:
|
||||
continue
|
||||
entry = refreshed
|
||||
available.append(entry)
|
||||
if entries_to_prune:
|
||||
pruned_ids = set(entries_to_prune)
|
||||
self._entries = [e for e in self._entries if e.id not in pruned_ids]
|
||||
if cleared_any:
|
||||
self._persist()
|
||||
return available
|
||||
@@ -1401,22 +1293,11 @@ class CredentialPool:
|
||||
if entry is None:
|
||||
return None
|
||||
_label = entry.label or entry.id[:8]
|
||||
self._mark_exhausted(entry, status_code, error_context)
|
||||
# Re-read the updated entry to log the correct terminal state.
|
||||
updated_entry = next(
|
||||
(e for e in self._entries if e.id == entry.id), entry,
|
||||
logger.info(
|
||||
"credential pool: marking %s exhausted (status=%s), rotating",
|
||||
_label, status_code,
|
||||
)
|
||||
if updated_entry.last_status == STATUS_DEAD:
|
||||
logger.warning(
|
||||
"credential pool: marking %s DEAD (status=%s, reason=%s) — "
|
||||
"permanently failed, will NOT re-enter rotation until re-auth",
|
||||
_label, status_code, updated_entry.last_error_reason or "unknown",
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"credential pool: marking %s exhausted (status=%s), rotating",
|
||||
_label, status_code,
|
||||
)
|
||||
self._mark_exhausted(entry, status_code, error_context)
|
||||
self._current_id = None
|
||||
next_entry = self._select_unlocked()
|
||||
if next_entry:
|
||||
@@ -1756,9 +1637,9 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
"inference_base_url": state.get("inference_base_url"),
|
||||
"agent_key": state.get("agent_key"),
|
||||
"agent_key_expires_at": state.get("agent_key_expires_at"),
|
||||
# Carry the refresh timestamps into the pool so
|
||||
# Carry the mint/refresh timestamps into the pool so
|
||||
# freshness-sensitive consumers (self-heal hooks, pool
|
||||
# pruning by age) can distinguish just-refreshed credentials
|
||||
# pruning by age) can distinguish just-minted credentials
|
||||
# from stale ones. Without these, fresh device_code
|
||||
# entries get obtained_at=None and look older than they
|
||||
# are (#15099).
|
||||
|
||||
@@ -39,9 +39,12 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
@@ -249,10 +249,6 @@ def get_read_block_error(path: str) -> Optional[str]:
|
||||
".env",
|
||||
"webhook_subscriptions.json",
|
||||
os.path.join("auth", "google_oauth.json"),
|
||||
# Bitwarden Secrets Manager disk cache: stores plaintext secret values
|
||||
# to avoid re-fetching across back-to-back CLI invocations. The file
|
||||
# was introduced by #31968 but not added to this guard.
|
||||
os.path.join("cache", "bws_cache.json"),
|
||||
)
|
||||
for hd in hermes_dirs:
|
||||
for name in credential_file_names:
|
||||
|
||||
@@ -31,6 +31,7 @@ import json
|
||||
import logging
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@@ -899,15 +899,7 @@ def start_oauth_flow(
|
||||
try:
|
||||
import webbrowser
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import (
|
||||
_can_open_graphical_browser as _can_open_gui,
|
||||
)
|
||||
except Exception:
|
||||
_can_open_gui = lambda: True # noqa: E731
|
||||
|
||||
if _can_open_gui():
|
||||
webbrowser.open(auth_url, new=1, autoraise=True)
|
||||
webbrowser.open(auth_url, new=1, autoraise=True)
|
||||
except Exception as exc:
|
||||
logger.debug("webbrowser.open failed: %s", exc)
|
||||
|
||||
|
||||
@@ -37,8 +37,6 @@ from __future__ import annotations
|
||||
import base64
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
@@ -48,102 +46,6 @@ logger = logging.getLogger(__name__)
|
||||
_VALID_MODES = frozenset({"auto", "native", "text"})
|
||||
|
||||
|
||||
# Image extensions used by extract_image_refs(). Kept tight on purpose — we
|
||||
# only auto-attach things the model can actually see. Documents/archives are
|
||||
# excluded because the gateway's broader extract_local_files() also routes
|
||||
# them differently (send_document), and we don't want to attach a PDF as a
|
||||
# vision part.
|
||||
_IMAGE_EXTS = (
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif", ".heic",
|
||||
)
|
||||
_IMAGE_EXT_PATTERN = "|".join(e.lstrip(".") for e in _IMAGE_EXTS)
|
||||
|
||||
# Absolute / home-relative local image path. Matches the same shape gateway's
|
||||
# extract_local_files() uses: anchors to ``~/`` or ``/``, ignores matches inside
|
||||
# URLs (the ``(?<![/:\w.])`` lookbehind), and case-insensitive on the extension.
|
||||
_LOCAL_IMAGE_PATH_RE = re.compile(
|
||||
r"(?<![/:\w.])(?:~/|/)(?:[\w.\-]+/)*[\w.\-]+\.(?:" + _IMAGE_EXT_PATTERN + r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# http(s) URL ending in an image extension (optionally followed by a
|
||||
# query string). Case-insensitive on the extension. Strict ``http(s)://``
|
||||
# scheme so we don't accidentally grab ``file://`` URLs or other shapes.
|
||||
_IMAGE_URL_RE = re.compile(
|
||||
r"https?://[^\s<>\"']+?\.(?:" + _IMAGE_EXT_PATTERN + r")(?:\?[^\s<>\"']*)?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def extract_image_refs(text: str) -> Tuple[List[str], List[str]]:
|
||||
"""Scan free-form text for image references the model should see.
|
||||
|
||||
Returns ``(local_paths, urls)``:
|
||||
|
||||
* ``local_paths`` — absolute (``/``) or home-relative (``~/``) paths
|
||||
whose suffix is an image extension AND whose expanded form exists
|
||||
on disk as a file. Order-preserving, deduplicated.
|
||||
* ``urls`` — ``http(s)://…`` URLs whose path ends in an image
|
||||
extension (a ``?query`` is allowed after the extension).
|
||||
Order-preserving, deduplicated.
|
||||
|
||||
Matches inside fenced code blocks (``` ``` ```) and inline backticks
|
||||
(`` `…` ``) are skipped so that snippets pasted into a task body for
|
||||
reference aren't mistaken for live attachments. This mirrors the
|
||||
behaviour of ``gateway.platforms.base.BaseAdapter.extract_local_files``.
|
||||
|
||||
Local paths are validated against the filesystem; URLs are not
|
||||
(the provider fetches them at request time).
|
||||
"""
|
||||
if not isinstance(text, str) or not text:
|
||||
return [], []
|
||||
|
||||
# Build spans covered by fenced code blocks and inline code so we can
|
||||
# ignore references the author embedded purely as example text.
|
||||
code_spans: list[tuple[int, int]] = []
|
||||
for m in re.finditer(r"```[^\n]*\n.*?```", text, re.DOTALL):
|
||||
code_spans.append((m.start(), m.end()))
|
||||
for m in re.finditer(r"`[^`\n]+`", text):
|
||||
code_spans.append((m.start(), m.end()))
|
||||
|
||||
def _in_code(pos: int) -> bool:
|
||||
return any(s <= pos < e for s, e in code_spans)
|
||||
|
||||
local_paths: list[str] = []
|
||||
seen_paths: set[str] = set()
|
||||
for match in _LOCAL_IMAGE_PATH_RE.finditer(text):
|
||||
if _in_code(match.start()):
|
||||
continue
|
||||
raw = match.group(0)
|
||||
expanded = os.path.expanduser(raw)
|
||||
try:
|
||||
if not os.path.isfile(expanded):
|
||||
continue
|
||||
except OSError:
|
||||
# ENAMETOOLONG / EINVAL on pathological inputs — skip rather than crash.
|
||||
continue
|
||||
if expanded in seen_paths:
|
||||
continue
|
||||
seen_paths.add(expanded)
|
||||
local_paths.append(expanded)
|
||||
|
||||
urls: list[str] = []
|
||||
seen_urls: set[str] = set()
|
||||
for match in _IMAGE_URL_RE.finditer(text):
|
||||
if _in_code(match.start()):
|
||||
continue
|
||||
url = match.group(0)
|
||||
# Strip trailing punctuation that's almost certainly prose, not part
|
||||
# of the URL (e.g. "see https://x.com/a.png." or "/a.png)").
|
||||
url = url.rstrip(".,;:!?)]>")
|
||||
if url in seen_urls:
|
||||
continue
|
||||
seen_urls.add(url)
|
||||
urls.append(url)
|
||||
|
||||
return local_paths, urls
|
||||
|
||||
|
||||
# Strict YAML/JSON boolean coercion for capability overrides.
|
||||
#
|
||||
# ``bool("false")`` is True in Python because non-empty strings are truthy, so
|
||||
@@ -418,29 +320,20 @@ def _file_to_data_url(path: Path) -> Optional[str]:
|
||||
def build_native_content_parts(
|
||||
user_text: str,
|
||||
image_paths: List[str],
|
||||
image_urls: Optional[List[str]] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], List[str]]:
|
||||
"""Build an OpenAI-style ``content`` list for a user turn.
|
||||
|
||||
Shape:
|
||||
[{"type": "text", "text": "...\\n\\n[Image attached at: /local/path]"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/a.png"}},
|
||||
...]
|
||||
|
||||
Local paths are read from disk and embedded as base64 ``data:`` URLs.
|
||||
Remote URLs (``http(s)://``) are passed through verbatim — the provider
|
||||
fetches them server-side. The model still sees the pixels either way.
|
||||
|
||||
For each successfully attached image, a hint is appended to the text
|
||||
part:
|
||||
|
||||
* local path → ``[Image attached at: <path>]``
|
||||
* URL → ``[Image attached: <url>]``
|
||||
|
||||
The hint gives the model a string handle so MCP/skill tools that take
|
||||
an image path or URL argument can be invoked on the same image without
|
||||
an extra round-trip. This parallels the text-mode hint produced by
|
||||
The local path of each successfully attached image is appended to the
|
||||
text part as ``[Image attached at: <path>]``. The model still sees the
|
||||
pixels via the ``image_url`` part (full native vision); the path note
|
||||
just gives it a string handle so MCP/skill tools that take an image
|
||||
path or URL argument can be invoked on the same image without an
|
||||
extra round-trip. This parallels the text-mode hint produced by
|
||||
``Runner._enrich_message_with_vision`` (``vision_analyze using image_url:
|
||||
<path>``) so behaviour is consistent across both image input modes.
|
||||
|
||||
@@ -449,14 +342,12 @@ def build_native_content_parts(
|
||||
ceiling), the agent's retry loop transparently shrinks and retries
|
||||
once — see ``run_agent._try_shrink_image_parts_in_messages``.
|
||||
|
||||
Returns (content_parts, skipped). Skipped entries are local paths
|
||||
that couldn't be read from disk; URLs are never skipped (they're
|
||||
not validated here).
|
||||
Returns (content_parts, skipped_paths). Skipped paths are files that
|
||||
couldn't be read from disk and are NOT advertised in the path hints.
|
||||
"""
|
||||
skipped: List[str] = []
|
||||
image_parts: List[Dict[str, Any]] = []
|
||||
attached_paths: List[str] = []
|
||||
attached_urls: List[str] = []
|
||||
|
||||
for raw_path in image_paths:
|
||||
p = Path(raw_path)
|
||||
@@ -473,26 +364,16 @@ def build_native_content_parts(
|
||||
})
|
||||
attached_paths.append(str(raw_path))
|
||||
|
||||
for url in image_urls or []:
|
||||
url = (url or "").strip()
|
||||
if not url:
|
||||
continue
|
||||
image_parts.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": url},
|
||||
})
|
||||
attached_urls.append(url)
|
||||
|
||||
text = (user_text or "").strip()
|
||||
|
||||
# If at least one image attached, build a single text part that combines
|
||||
# the user's caption (or a neutral default) with one hint per image.
|
||||
if attached_paths or attached_urls:
|
||||
# the user's caption (or a neutral default) with one path hint per image.
|
||||
if attached_paths:
|
||||
base_text = text or "What do you see in this image?"
|
||||
hint_lines: List[str] = []
|
||||
hint_lines.extend(f"[Image attached at: {p}]" for p in attached_paths)
|
||||
hint_lines.extend(f"[Image attached: {u}]" for u in attached_urls)
|
||||
combined_text = f"{base_text}\n\n" + "\n".join(hint_lines)
|
||||
path_hints = "\n".join(
|
||||
f"[Image attached at: {p}]" for p in attached_paths
|
||||
)
|
||||
combined_text = f"{base_text}\n\n{path_hints}"
|
||||
parts: List[Dict[str, Any]] = [{"type": "text", "text": combined_text}]
|
||||
parts.extend(image_parts)
|
||||
return parts, skipped
|
||||
@@ -507,5 +388,4 @@ def build_native_content_parts(
|
||||
__all__ = [
|
||||
"decide_image_input_mode",
|
||||
"build_native_content_parts",
|
||||
"extract_image_refs",
|
||||
]
|
||||
|
||||
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def register_subparser(subparsers: argparse._SubParsersAction) -> None:
|
||||
@@ -248,6 +249,7 @@ def _cmd_restart() -> int:
|
||||
|
||||
def _cmd_which(server_id: str) -> int:
|
||||
from agent.lsp.install import INSTALL_RECIPES, hermes_lsp_bin_dir
|
||||
import os
|
||||
import shutil as _shutil
|
||||
|
||||
recipe = INSTALL_RECIPES.get(server_id)
|
||||
|
||||
@@ -39,20 +39,25 @@ import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import Future as ConcurrentFuture
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from agent.lsp import eventlog
|
||||
from agent.lsp.client import (
|
||||
DIAGNOSTICS_DOCUMENT_WAIT,
|
||||
LSPClient,
|
||||
file_uri,
|
||||
)
|
||||
from agent.lsp.servers import (
|
||||
ServerContext,
|
||||
ServerDef,
|
||||
SpawnSpec,
|
||||
find_server_for_file,
|
||||
language_id_for,
|
||||
)
|
||||
from agent.lsp.workspace import (
|
||||
clear_cache,
|
||||
is_inside_workspace,
|
||||
resolve_workspace_for_file,
|
||||
)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from agent.lsp.workspace import nearest_root
|
||||
from agent.lsp.workspace import nearest_root, normalize_path
|
||||
|
||||
logger = logging.getLogger("agent.lsp.servers")
|
||||
|
||||
|
||||
@@ -368,42 +368,11 @@ class MemoryManager:
|
||||
|
||||
# -- Sync ----------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _provider_sync_accepts_messages(provider: MemoryProvider) -> bool:
|
||||
"""Return whether sync_turn accepts a messages keyword."""
|
||||
try:
|
||||
signature = inspect.signature(provider.sync_turn)
|
||||
except (TypeError, ValueError):
|
||||
return True
|
||||
params = list(signature.parameters.values())
|
||||
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params):
|
||||
return True
|
||||
return "messages" in signature.parameters
|
||||
|
||||
def sync_all(
|
||||
self,
|
||||
user_content: str,
|
||||
assistant_content: str,
|
||||
*,
|
||||
session_id: str = "",
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
def sync_all(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
|
||||
"""Sync a completed turn to all providers."""
|
||||
for provider in self._providers:
|
||||
try:
|
||||
if messages is not None and self._provider_sync_accepts_messages(provider):
|
||||
provider.sync_turn(
|
||||
user_content,
|
||||
assistant_content,
|
||||
session_id=session_id,
|
||||
messages=messages,
|
||||
)
|
||||
else:
|
||||
provider.sync_turn(
|
||||
user_content,
|
||||
assistant_content,
|
||||
session_id=session_id,
|
||||
)
|
||||
provider.sync_turn(user_content, assistant_content, session_id=session_id)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Memory provider '%s' sync_turn failed: %s",
|
||||
|
||||
@@ -112,22 +112,11 @@ class MemoryProvider(ABC):
|
||||
that do background prefetching should override this.
|
||||
"""
|
||||
|
||||
def sync_turn(
|
||||
self,
|
||||
user_content: str,
|
||||
assistant_content: str,
|
||||
*,
|
||||
session_id: str = "",
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
|
||||
"""Persist a completed turn to the backend.
|
||||
|
||||
Called after each turn. Should be non-blocking — queue for
|
||||
background processing if the backend has latency.
|
||||
|
||||
``messages`` is the OpenAI-style conversation message list as of the
|
||||
completed turn, including any assistant tool calls and tool results.
|
||||
Providers that do not need raw turn context can ignore it.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -15,6 +15,18 @@ 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. ``enum`` arrays on scalar-typed nodes may not contain ``null`` or empty
|
||||
strings. Strip those entries (drop the enum entirely if it becomes empty).
|
||||
4. ``$ref`` nodes may not carry sibling keywords. Moonshot expands the
|
||||
reference before validation and then rejects the node if sibling keys
|
||||
like ``description`` remain on the same node as ``$ref``. Strip every
|
||||
sibling from ``$ref`` nodes so only ``{"$ref": "..."}`` survives.
|
||||
(Ported from anomalyco/opencode#24730.)
|
||||
5. ``items`` may not be a tuple-style array (``items: [schemaA, schemaB]``
|
||||
for positional element schemas). Moonshot's schema engine requires a
|
||||
single object schema applied to every array element. Collapse tuple
|
||||
``items`` to the first element schema (or ``{}`` if the tuple is empty).
|
||||
(Ported from anomalyco/opencode#24730.)
|
||||
|
||||
The ``#/definitions/...`` → ``#/$defs/...`` rewrite for draft-07 refs is
|
||||
handled separately in ``tools/mcp_tool._normalize_mcp_input_schema`` so it
|
||||
@@ -66,6 +78,16 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
|
||||
}
|
||||
elif key in _SCHEMA_LIST_KEYS and isinstance(value, list):
|
||||
repaired[key] = [_repair_schema(v, is_schema=True) for v in value]
|
||||
elif key == "items" and isinstance(value, list):
|
||||
# Rule 5: tuple-style ``items`` arrays (positional element
|
||||
# schemas) are not accepted by Moonshot. Collapse to the
|
||||
# first element schema if present, else to ``{}``. This
|
||||
# matches opencode's behaviour for moonshotai / kimi models.
|
||||
first = value[0] if value else {}
|
||||
if isinstance(first, dict):
|
||||
repaired[key] = _repair_schema(first, is_schema=True)
|
||||
else:
|
||||
repaired[key] = first
|
||||
elif key in _SCHEMA_NODE_KEYS:
|
||||
# items / not / additionalProperties: single nested schema.
|
||||
# additionalProperties can also be a bool — leave those alone.
|
||||
@@ -130,6 +152,15 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
|
||||
else:
|
||||
repaired.pop("enum")
|
||||
|
||||
# Rule 4: $ref nodes must not have sibling keywords. Moonshot expands
|
||||
# the reference before validation and then rejects the node if siblings
|
||||
# like ``description`` / ``type`` / ``default`` appear alongside $ref.
|
||||
# The referenced definition still carries its own description on the
|
||||
# target node, which Moonshot accepts.
|
||||
# (Ported from anomalyco/opencode#24730.)
|
||||
if "$ref" in repaired:
|
||||
return {"$ref": repaired["$ref"]}
|
||||
|
||||
return repaired
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ assemble pieces, then combines them with memory and ephemeral prompts.
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
@@ -235,11 +236,6 @@ KANBAN_GUIDANCE = (
|
||||
"- Do not shell out to `hermes kanban <verb>` for board operations. Use "
|
||||
"the `kanban_*` tools — they work across all terminal backends.\n"
|
||||
"- Do not complete a task you didn't actually finish. Block it.\n"
|
||||
"- Do not call `clarify` to ask questions. You are running headless — "
|
||||
"there is no live user to answer. The call will time out and the task "
|
||||
"will sit silently in `running` with no signal to the operator. Instead: "
|
||||
"`kanban_comment` the context, then `kanban_block(reason=...)` so the "
|
||||
"task surfaces on the board as needing input.\n"
|
||||
"- Do not assign follow-up work to yourself. Assign it to the right "
|
||||
"specialist profile.\n"
|
||||
"- Do not call `delegate_task` as a board substitute. `delegate_task` is "
|
||||
@@ -266,37 +262,6 @@ TOOL_USE_ENFORCEMENT_GUIDANCE = (
|
||||
# Add new patterns here when a model family needs explicit steering.
|
||||
TOOL_USE_ENFORCEMENT_MODELS = ("gpt", "codex", "gemini", "gemma", "grok", "glm", "qwen", "deepseek")
|
||||
|
||||
# Universal "finish the job" guidance — applied to ALL models, not gated
|
||||
# by model family. Addresses two cross-model failure modes:
|
||||
# 1. Stopping after a stub: writing a tiny file or running one command
|
||||
# and then ending the turn with a description of the plan instead
|
||||
# of the finished artifact. (Observed on Opus during a real
|
||||
# Sarasota real-estate build task: 3 API calls, 85-byte file,
|
||||
# one terminal command, finish_reason=stop.)
|
||||
# 2. Fabricating output when a real path is blocked. When `pip` or a
|
||||
# tool fails, some models will synthesize plausible-looking results
|
||||
# (fake addresses, fake JSON, fake numbers) instead of reporting
|
||||
# the blocker. (Observed on DeepSeek v4-flash on the same task:
|
||||
# pushed through PEP-668 wall, then returned fabricated listings.)
|
||||
#
|
||||
# Short on purpose. This block is shipped to every user, every session,
|
||||
# in the cached system prompt — token cost is paid once at install and
|
||||
# then amortised across all sessions via prefix caching. Keep it tight.
|
||||
TASK_COMPLETION_GUIDANCE = (
|
||||
"# Finishing the job\n"
|
||||
"When the user asks you to build, run, or verify something, the deliverable is "
|
||||
"a working artifact backed by real tool output — not a description of one. "
|
||||
"Do not stop after writing a stub, a plan, or a single command. Keep working "
|
||||
"until you have actually exercised the code or produced the requested result, "
|
||||
"then report what real execution returned.\n"
|
||||
"If a tool, install, or network call fails and blocks the real path, say so "
|
||||
"directly and try an alternative (different package manager, different "
|
||||
"approach, ask the user). NEVER substitute plausible-looking fabricated "
|
||||
"output (made-up data, invented file contents, synthesised API responses) "
|
||||
"for results you couldn't actually produce. Reporting a blocker honestly "
|
||||
"is always better than inventing a result."
|
||||
)
|
||||
|
||||
# OpenAI GPT/Codex-specific execution guidance. Addresses known failure modes
|
||||
# where GPT models abandon work on partial results, skip prerequisite lookups,
|
||||
# hallucinate instead of using tools, and declare "done" without verification.
|
||||
@@ -848,27 +813,6 @@ def build_environment_hints() -> str:
|
||||
|
||||
if is_wsl():
|
||||
hints.append(WSL_ENVIRONMENT_HINT)
|
||||
|
||||
# Embedder-supplied environment description. Lets a host that wraps Hermes
|
||||
# (e.g. a sandbox runner / managed platform) explain the environment the
|
||||
# agent is running in — proxy, credential handling, mount layout — without
|
||||
# forking the identity slot (SOUL.md). Read once at prompt-build time, so
|
||||
# it's part of the stable, cache-safe system prompt. The env var is the
|
||||
# build-time/embedder mechanism (set in a container ENV); config.yaml
|
||||
# ``agent.environment_hint`` is the user-facing surface. Env var wins.
|
||||
extra = (os.getenv("HERMES_ENVIRONMENT_HINT") or "").strip()
|
||||
if not extra:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
extra = str(
|
||||
(load_config().get("agent", {}) or {}).get("environment_hint", "")
|
||||
).strip()
|
||||
except Exception as e:
|
||||
logger.debug("Could not read agent.environment_hint from config: %s", e)
|
||||
if extra:
|
||||
hints.append(extra)
|
||||
|
||||
return "\n\n".join(hints)
|
||||
|
||||
|
||||
|
||||
@@ -331,7 +331,7 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F
|
||||
"""Apply all redaction patterns to a block of text.
|
||||
|
||||
Safe to call on any string -- non-matching text passes through unchanged.
|
||||
Enabled by default. Disable via security.redact_secrets: false in config.yaml.
|
||||
Disabled by default — enable via security.redact_secrets: true in config.yaml.
|
||||
Set force=True for safety boundaries that must never return raw secrets
|
||||
regardless of the user's global logging redaction preference.
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import platform
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
|
||||
@@ -37,7 +37,6 @@ from agent.prompt_builder import (
|
||||
PLATFORM_HINTS,
|
||||
SESSION_SEARCH_GUIDANCE,
|
||||
SKILLS_GUIDANCE,
|
||||
TASK_COMPLETION_GUIDANCE,
|
||||
TOOL_USE_ENFORCEMENT_GUIDANCE,
|
||||
TOOL_USE_ENFORCEMENT_MODELS,
|
||||
)
|
||||
@@ -101,15 +100,6 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
# Pointer to the hermes-agent skill + docs for user questions about Hermes itself.
|
||||
stable_parts.append(HERMES_AGENT_HELP_GUIDANCE)
|
||||
|
||||
# Universal task-completion / no-fabrication guidance. Applied to ALL
|
||||
# models regardless of tool_use_enforcement gating — the failure modes
|
||||
# this targets (stopping after a stub; fabricating output when a real
|
||||
# path is blocked) are not model-family specific. Gated only by
|
||||
# config.yaml ``agent.task_completion_guidance`` (default True) so
|
||||
# users who want a leaner prompt can turn it off.
|
||||
if getattr(agent, "_task_completion_guidance", True) and agent.valid_tool_names:
|
||||
stable_parts.append(TASK_COMPLETION_GUIDANCE)
|
||||
|
||||
# Tool-aware behavioral guidance: only inject when the tools are loaded
|
||||
tool_guidance = []
|
||||
if "memory" in agent.valid_tool_names:
|
||||
@@ -215,23 +205,6 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
if _env_hints:
|
||||
stable_parts.append(_env_hints)
|
||||
|
||||
# Local Python toolchain probe — names python/pip/uv/PEP-668 state when
|
||||
# something is non-default so the model can pick the right install
|
||||
# strategy without discovering by failure. Emits a single line; emits
|
||||
# NOTHING when the environment is clean (no token cost). Skipped
|
||||
# entirely for remote terminal backends (the host's Python state is
|
||||
# irrelevant when tools run inside docker/modal/ssh). Gated by
|
||||
# config.yaml ``agent.environment_probe`` (default True).
|
||||
if getattr(agent, "_environment_probe", True):
|
||||
try:
|
||||
from tools.env_probe import get_environment_probe_line
|
||||
_probe_line = get_environment_probe_line()
|
||||
if _probe_line:
|
||||
stable_parts.append(_probe_line)
|
||||
except Exception:
|
||||
# Probe failure must never block prompt build.
|
||||
pass
|
||||
|
||||
# Active-profile hint — names the Hermes profile the agent is running
|
||||
# under so it doesn't conflate ~/.hermes/skills/ (default profile) with
|
||||
# ~/.hermes/profiles/<active>/skills/ (this profile's). Deterministic
|
||||
|
||||
@@ -13,13 +13,14 @@ extracted functions reach back through the ``run_agent`` module via
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.display import (
|
||||
KawaiiSpinner,
|
||||
@@ -37,9 +38,12 @@ from agent.tool_dispatch_helpers import (
|
||||
make_tool_result_message,
|
||||
)
|
||||
from tools.terminal_tool import (
|
||||
_get_approval_callback,
|
||||
_get_sudo_password_callback,
|
||||
set_approval_callback as _set_approval_callback,
|
||||
set_sudo_password_callback as _set_sudo_password_callback,
|
||||
get_active_env,
|
||||
)
|
||||
from tools.thread_context import propagate_context_to_thread
|
||||
from tools.tool_result_storage import (
|
||||
maybe_persist_tool_result,
|
||||
enforce_turn_budget,
|
||||
@@ -58,55 +62,6 @@ def _ra():
|
||||
return run_agent
|
||||
|
||||
|
||||
def _tool_search_scoped_names(agent) -> frozenset:
|
||||
"""Return the deferrable tool names the session may invoke via tool_call.
|
||||
|
||||
The Tool Search unwrap dispatches the underlying tool directly, bypassing
|
||||
the bridge branch (and its scope check) in
|
||||
``model_tools.handle_function_call``. To keep a restricted-toolset session
|
||||
(subagent, kanban worker, curated gateway session) from reaching tools it
|
||||
was never granted, the unwrap validates the underlying name against this
|
||||
set: the deferrable subset of the session's own enabled/disabled toolset
|
||||
scope.
|
||||
|
||||
Result is cached on the agent and refreshed when the tool registry's
|
||||
generation changes (e.g. an MCP server reconnects), so the common case is
|
||||
a dict lookup, not a full tool-defs rebuild on every tool call.
|
||||
"""
|
||||
try:
|
||||
import model_tools
|
||||
from tools import tool_search as _ts
|
||||
from tools.registry import registry as _registry
|
||||
except Exception:
|
||||
return frozenset()
|
||||
|
||||
enabled = getattr(agent, "enabled_toolsets", None)
|
||||
disabled = getattr(agent, "disabled_toolsets", None)
|
||||
cache_key = (
|
||||
getattr(_registry, "_generation", 0),
|
||||
frozenset(enabled) if enabled is not None else None,
|
||||
frozenset(disabled) if disabled is not None else None,
|
||||
)
|
||||
cached = getattr(agent, "_tool_search_scope_cache", None)
|
||||
if cached is not None and cached[0] == cache_key:
|
||||
return cached[1]
|
||||
try:
|
||||
scoped_defs = model_tools.get_tool_definitions(
|
||||
enabled_toolsets=enabled,
|
||||
disabled_toolsets=disabled,
|
||||
quiet_mode=True,
|
||||
skip_tool_search_assembly=True,
|
||||
) or []
|
||||
names = _ts.scoped_deferrable_names(scoped_defs)
|
||||
except Exception:
|
||||
names = frozenset()
|
||||
try:
|
||||
agent._tool_search_scope_cache = (cache_key, names)
|
||||
except Exception:
|
||||
pass
|
||||
return names
|
||||
|
||||
|
||||
def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None:
|
||||
"""Execute multiple tool calls concurrently using a thread pool.
|
||||
|
||||
@@ -145,41 +100,6 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
if not isinstance(function_args, dict):
|
||||
function_args = {}
|
||||
|
||||
# ── Tool Search unwrap ────────────────────────────────────────
|
||||
# When the model invokes the tool_call bridge, peel it open so
|
||||
# every downstream check (checkpointing, guardrails, plugin
|
||||
# pre-tool-call hooks, the display/activity feed, the post-call
|
||||
# callback) sees the underlying tool — not the bridge. This is
|
||||
# the OpenClaw lesson: hooks must observe the real tool name.
|
||||
#
|
||||
# The original tool_call entry on ``tool_call.function`` is left
|
||||
# untouched so the conversation transcript and the matching
|
||||
# tool_call_id are preserved exactly as the model emitted them.
|
||||
#
|
||||
# Scope gate: the unwrap dispatches the underlying tool directly
|
||||
# (bypassing the bridge branch in handle_function_call and its
|
||||
# scope check), so we enforce session toolset scope HERE. A tool
|
||||
# the session was not granted is rejected before any checkpoint,
|
||||
# hook, or dispatch fires.
|
||||
_ts_scope_block = None
|
||||
try:
|
||||
from tools import tool_search as _ts
|
||||
if function_name == _ts.TOOL_CALL_NAME:
|
||||
_underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args)
|
||||
if not _err and _underlying:
|
||||
if _underlying in _tool_search_scoped_names(agent):
|
||||
function_name = _underlying
|
||||
function_args = _underlying_args
|
||||
else:
|
||||
_ts_scope_block = json.dumps({
|
||||
"error": (
|
||||
f"'{_underlying}' is not available in this session. "
|
||||
"Use tool_search to find tools you can call."
|
||||
),
|
||||
}, ensure_ascii=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Checkpoint for file-mutating tools
|
||||
if function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled:
|
||||
try:
|
||||
@@ -204,25 +124,21 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
|
||||
block_result = None
|
||||
blocked_by_guardrail = False
|
||||
if _ts_scope_block is not None:
|
||||
# Out-of-scope tool_call: reject before hooks/guardrails/dispatch.
|
||||
block_result = _ts_scope_block
|
||||
else:
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
block_message = get_pre_tool_call_block_message(
|
||||
function_name, function_args, task_id=effective_task_id or "",
|
||||
)
|
||||
except Exception:
|
||||
block_message = None
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
block_message = get_pre_tool_call_block_message(
|
||||
function_name, function_args, task_id=effective_task_id or "",
|
||||
)
|
||||
except Exception:
|
||||
block_message = None
|
||||
|
||||
if block_message is not None:
|
||||
block_result = json.dumps({"error": block_message}, ensure_ascii=False)
|
||||
else:
|
||||
guardrail_decision = agent._tool_guardrails.before_call(function_name, function_args)
|
||||
if not guardrail_decision.allows_execution:
|
||||
block_result = agent._guardrail_block_result(guardrail_decision)
|
||||
blocked_by_guardrail = True
|
||||
if block_message is not None:
|
||||
block_result = json.dumps({"error": block_message}, ensure_ascii=False)
|
||||
else:
|
||||
guardrail_decision = agent._tool_guardrails.before_call(function_name, function_args)
|
||||
if not guardrail_decision.allows_execution:
|
||||
block_result = agent._guardrail_block_result(guardrail_decision)
|
||||
blocked_by_guardrail = True
|
||||
|
||||
parsed_calls.append((tool_call, function_name, function_args, block_result, blocked_by_guardrail))
|
||||
|
||||
@@ -270,6 +186,14 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
agent._current_tool = tool_names_str
|
||||
agent._touch_activity(f"executing {num_tools} tools concurrently: {tool_names_str}")
|
||||
|
||||
# Capture CLI callbacks from the agent thread so worker threads can
|
||||
# register them locally. Without this, _get_approval_callback() in
|
||||
# terminal_tool returns None in ThreadPoolExecutor workers, causing
|
||||
# the dangerous-command prompt to fall back to input() — which
|
||||
# deadlocks against prompt_toolkit's raw terminal mode (#13617).
|
||||
_parent_approval_cb = _get_approval_callback()
|
||||
_parent_sudo_cb = _get_sudo_password_callback()
|
||||
|
||||
def _run_tool(index, tool_call, function_name, function_args):
|
||||
"""Worker function executed in a thread."""
|
||||
# Register this worker tid so the agent can fan out an interrupt
|
||||
@@ -296,9 +220,18 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
set_activity_callback(agent._touch_activity)
|
||||
except Exception:
|
||||
pass
|
||||
# Approval/sudo callbacks (thread-local) and the agent turn's
|
||||
# ContextVars are propagated by propagate_context_to_thread() at the
|
||||
# submit site below (GHSA-qg5c-hvr5-hjgr, #13617).
|
||||
# Propagate approval/sudo callbacks to this worker thread.
|
||||
# Mirrors cli.py run_agent() pattern (GHSA-qg5c-hvr5-hjgr).
|
||||
if _parent_approval_cb is not None:
|
||||
try:
|
||||
_set_approval_callback(_parent_approval_cb)
|
||||
except Exception:
|
||||
pass
|
||||
if _parent_sudo_cb is not None:
|
||||
try:
|
||||
_set_sudo_password_callback(_parent_sudo_cb)
|
||||
except Exception:
|
||||
pass
|
||||
start = time.time()
|
||||
try:
|
||||
result = agent._invoke_tool(
|
||||
@@ -328,6 +261,13 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
_ra()._set_interrupt(False, _worker_tid)
|
||||
except Exception:
|
||||
pass
|
||||
# Clear thread-local callbacks so a recycled worker thread
|
||||
# doesn't hold stale references to a disposed CLI instance.
|
||||
try:
|
||||
_set_approval_callback(None)
|
||||
_set_sudo_password_callback(None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Start spinner for CLI mode (skip when TUI handles tool progress)
|
||||
spinner = None
|
||||
@@ -347,12 +287,9 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
max_workers = min(len(runnable_calls), _MAX_TOOL_WORKERS)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
for i, tc, name, args in runnable_calls:
|
||||
# Propagate the agent turn's ContextVars (e.g.
|
||||
# _approval_session_key) AND thread-local approval/sudo
|
||||
# callbacks into the worker thread; clears callbacks on exit.
|
||||
f = executor.submit(
|
||||
propagate_context_to_thread(_run_tool), i, tc, name, args
|
||||
)
|
||||
# Propagate ContextVars (e.g. _approval_session_key); mirrors asyncio.to_thread.
|
||||
ctx = contextvars.copy_context()
|
||||
f = executor.submit(ctx.run, _run_tool, i, tc, name, args)
|
||||
futures.append(f)
|
||||
|
||||
# Wait for all to complete with periodic heartbeats so the
|
||||
@@ -560,38 +497,15 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
if not isinstance(function_args, dict):
|
||||
function_args = {}
|
||||
|
||||
# Tool Search unwrap — see execute_tool_calls_concurrent for full
|
||||
# rationale, including the scope gate (the unwrap dispatches the
|
||||
# underlying tool directly, so session toolset scope is enforced here).
|
||||
_ts_scope_block: Optional[str] = None
|
||||
try:
|
||||
from tools import tool_search as _ts
|
||||
if function_name == _ts.TOOL_CALL_NAME:
|
||||
_underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args)
|
||||
if not _err and _underlying:
|
||||
if _underlying in _tool_search_scoped_names(agent):
|
||||
function_name = _underlying
|
||||
function_args = _underlying_args
|
||||
else:
|
||||
_ts_scope_block = (
|
||||
f"'{_underlying}' is not available in this session. "
|
||||
"Use tool_search to find tools you can call."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check plugin hooks for a block directive before executing.
|
||||
_block_msg: Optional[str] = None
|
||||
if _ts_scope_block is not None:
|
||||
_block_msg = _ts_scope_block
|
||||
else:
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
_block_msg = get_pre_tool_call_block_message(
|
||||
function_name, function_args, task_id=effective_task_id or "",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
_block_msg = get_pre_tool_call_block_message(
|
||||
function_name, function_args, task_id=effective_task_id or "",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_guardrail_block_decision: ToolGuardrailDecision | None = None
|
||||
if _block_msg is None:
|
||||
@@ -838,8 +752,6 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
session_id=agent.session_id or "",
|
||||
enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None,
|
||||
skip_pre_tool_call_hook=True,
|
||||
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
|
||||
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
|
||||
)
|
||||
_spinner_result = function_result
|
||||
except Exception as tool_error:
|
||||
@@ -860,8 +772,6 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
session_id=agent.session_id or "",
|
||||
enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None,
|
||||
skip_pre_tool_call_hook=True,
|
||||
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
|
||||
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
|
||||
)
|
||||
except Exception as tool_error:
|
||||
function_result = f"Error executing tool '{function_name}': {tool_error}"
|
||||
|
||||
@@ -10,7 +10,7 @@ reasoning configuration, temperature handling, and extra_body assembly.
|
||||
"""
|
||||
|
||||
import copy
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.lmstudio_reasoning import resolve_lmstudio_effort
|
||||
from agent.moonshot_schema import is_moonshot_model, sanitize_moonshot_tools
|
||||
@@ -476,17 +476,13 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
ephemeral = params.get("ephemeral_max_output_tokens")
|
||||
user_max = params.get("max_tokens")
|
||||
anthropic_max = params.get("anthropic_max_output")
|
||||
# Per-model default cap — profiles override get_max_tokens() when
|
||||
# they front several backends with different completion-token limits
|
||||
# (e.g. opencode-go: mimo-v2.5-pro = 131072).
|
||||
profile_max = profile.get_max_tokens(model)
|
||||
|
||||
if ephemeral is not None and max_tokens_fn:
|
||||
api_kwargs.update(max_tokens_fn(ephemeral))
|
||||
elif user_max is not None and max_tokens_fn:
|
||||
api_kwargs.update(max_tokens_fn(user_max))
|
||||
elif profile_max and max_tokens_fn:
|
||||
api_kwargs.update(max_tokens_fn(profile_max))
|
||||
elif profile.default_max_tokens and max_tokens_fn:
|
||||
api_kwargs.update(max_tokens_fn(profile.default_max_tokens))
|
||||
elif anthropic_max is not None:
|
||||
api_kwargs["max_tokens"] = anthropic_max
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
# Default minimum codex version we test against. The PR sets this from the
|
||||
# `codex --version` parsed at install time; bumping is a one-line change here.
|
||||
|
||||
@@ -31,7 +31,6 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from agent.codex_responses_adapter import _format_responses_error
|
||||
from agent.redact import redact_sensitive_text
|
||||
from agent.transports.codex_app_server import (
|
||||
CodexAppServerClient,
|
||||
@@ -582,7 +581,7 @@ class CodexAppServerSession:
|
||||
(note.get("params") or {}).get("turn") or {}
|
||||
).get("error")
|
||||
if err_obj:
|
||||
err_msg = _format_responses_error(err_obj, str(turn_status))
|
||||
err_msg = err_obj.get("message") or str(err_obj)
|
||||
# If the turn failed for an auth/refresh reason,
|
||||
# rewrite the error into a re-auth hint AND mark
|
||||
# the session for retirement.
|
||||
|
||||
40
apps/bootstrap-installer/.gitignore
vendored
40
apps/bootstrap-installer/.gitignore
vendored
@@ -1,40 +0,0 @@
|
||||
# Rust / Cargo
|
||||
/src-tauri/target/
|
||||
/src-tauri/Cargo.lock
|
||||
|
||||
# Vite / build output
|
||||
/dist/
|
||||
/dist-ssr/
|
||||
*.local
|
||||
|
||||
# TypeScript build info + tsc emit (we don't ship .js for the
|
||||
# vite.config.ts; Vite reads it directly via ts-node-style loader).
|
||||
*.tsbuildinfo
|
||||
vite.config.d.ts
|
||||
vite.config.js
|
||||
|
||||
# Tauri generated artifacts (regenerated on each build)
|
||||
/src-tauri/gen/schemas/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Internal placeholder (re-create if needed)
|
||||
.tauri-note
|
||||
@@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hermes Setup</title>
|
||||
</head>
|
||||
<body class="h-full antialiased">
|
||||
<div id="root" class="h-full"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"name": "@hermes/bootstrap-installer",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"description": "Hermes Setup — signed installer that drives scripts/install.ps1 with a polished native UI.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 5175",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"tauri:build:debug": "tauri build --debug"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nous-research/ui": "0.16.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
"@tauri-apps/plugin-opener": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"@vscode/codicons": "^0.0.45",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"katex": "^0.16.45",
|
||||
"lucide-react": "^0.577.0",
|
||||
"nanostores": "^1.3.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-shimmer": "^0.4.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
[package]
|
||||
name = "hermes-bootstrap"
|
||||
version = "0.0.1"
|
||||
description = "Hermes Setup — signed installer that drives scripts/install.ps1"
|
||||
authors = ["Nous Research <info@nousresearch.com>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.77"
|
||||
|
||||
# Rename the output binary so the distributed artifact is literally
|
||||
# `Hermes-Setup.exe` on disk — not `hermes-bootstrap.exe`. Grandma sees
|
||||
# what we hand her, period. Tauri honors [[bin]] over [package].name
|
||||
# for the produced executable name.
|
||||
[[bin]]
|
||||
name = "Hermes-Setup"
|
||||
path = "src/main.rs"
|
||||
|
||||
# The library target name MUST match the `withGlobalTauri` binding name that
|
||||
# tauri.conf.json's `app.windows[].label` references. We don't ship a separate
|
||||
# lib for now; everything is in src/.
|
||||
[lib]
|
||||
name = "hermes_bootstrap_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
# Tauri runtime + plugins
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
|
||||
# Async + IO
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
futures = "0.3"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# HTTP — rustls so we don't need OpenSSL on the build box
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
|
||||
|
||||
# Logging — emitted to a file under HERMES_HOME/logs/ and (optionally) the
|
||||
# webview console via Tauri's event channel.
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
tracing-appender = "0.2"
|
||||
|
||||
# Paths + utils
|
||||
dirs = "5"
|
||||
which = "6"
|
||||
anyhow = "1"
|
||||
thiserror = "1"
|
||||
once_cell = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
# Process control on Windows (CREATE_NO_WINDOW etc.)
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Console",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[profile.release]
|
||||
# A 5-10MB signed installer is the goal. LTO + size-opt + single codegen unit.
|
||||
panic = "abort"
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
strip = true
|
||||
@@ -1,150 +0,0 @@
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
// -----------------------------------------------------------------
|
||||
// Bake the install.ps1 pin into the binary at compile time.
|
||||
//
|
||||
// BUILD_PIN_COMMIT and BUILD_PIN_BRANCH are read by bootstrap.rs's
|
||||
// `option_env!()` macro to default the install-script reference.
|
||||
// Precedence (matches install.ps1's own arg precedence): commit > branch.
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. Env var override at build time (HERMES_BUILD_PIN_COMMIT, etc.).
|
||||
// Useful for CI builds that want to pin to a tagged release SHA
|
||||
// rather than whatever the checkout's HEAD happens to be.
|
||||
// 2. `git rev-parse HEAD` + `git rev-parse --abbrev-ref HEAD` against
|
||||
// the repo this build.rs lives in. Default for `cargo tauri build`
|
||||
// from a dev machine — pins the produced .exe to your current
|
||||
// checkout state.
|
||||
// 3. Last-resort fallback: hardcoded `main` branch, no commit. The
|
||||
// installer will fetch HEAD-of-main at runtime. Used when the
|
||||
// build is happening outside a git checkout (e.g. cargo install
|
||||
// from a packaged crate, unlikely for this binary but defensive).
|
||||
//
|
||||
// Build script reruns on git HEAD change so a new commit triggers
|
||||
// a rebuild without `cargo clean`.
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
let commit = resolve_commit_pin();
|
||||
let branch = resolve_branch_pin();
|
||||
|
||||
if let Some(c) = &commit {
|
||||
println!("cargo:rustc-env=BUILD_PIN_COMMIT={c}");
|
||||
println!("cargo:warning=hermes-bootstrap: pinning to commit {}", short(c));
|
||||
}
|
||||
if let Some(b) = &branch {
|
||||
println!("cargo:rustc-env=BUILD_PIN_BRANCH={b}");
|
||||
println!("cargo:warning=hermes-bootstrap: pinning to branch {b}");
|
||||
}
|
||||
if commit.is_none() && branch.is_none() {
|
||||
// Fail loudly rather than silently produce a binary that errors
|
||||
// at runtime with "no install-script pin supplied". A build that
|
||||
// can't resolve a pin almost certainly indicates a misconfigured
|
||||
// build environment.
|
||||
println!(
|
||||
"cargo:warning=hermes-bootstrap: no pin resolved at build time; binary will fail at runtime without HERMES_SETUP_DEV_REPO_ROOT or runtime args"
|
||||
);
|
||||
}
|
||||
|
||||
// Rerun build.rs when HEAD moves so successive builds pick up new
|
||||
// commits without needing `cargo clean`. .git/HEAD changes on every
|
||||
// commit / branch switch / rebase.
|
||||
let git_dir = locate_git_dir();
|
||||
if let Some(gd) = &git_dir {
|
||||
println!("cargo:rerun-if-changed={}/HEAD", gd.display());
|
||||
// .git/HEAD often points at a ref (e.g. `ref: refs/heads/bb/gui`);
|
||||
// also watch the ref itself so a new commit on the same branch
|
||||
// re-triggers.
|
||||
if let Ok(head) = std::fs::read_to_string(gd.join("HEAD")) {
|
||||
if let Some(rest) = head.trim().strip_prefix("ref: ") {
|
||||
println!("cargo:rerun-if-changed={}/{}", gd.display(), rest);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("cargo:rerun-if-env-changed=HERMES_BUILD_PIN_COMMIT");
|
||||
println!("cargo:rerun-if-env-changed=HERMES_BUILD_PIN_BRANCH");
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Tauri windows manifest. See hermes-setup.manifest for rationale —
|
||||
// declares level="asInvoker" so Windows's installer-detection
|
||||
// heuristic doesn't refuse to launch us without UAC elevation.
|
||||
// -----------------------------------------------------------------
|
||||
#[cfg(target_os = "windows")]
|
||||
let attrs = {
|
||||
let manifest = include_str!("hermes-setup.manifest");
|
||||
let win = tauri_build::WindowsAttributes::new().app_manifest(manifest);
|
||||
tauri_build::Attributes::new().windows_attributes(win)
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let attrs = tauri_build::Attributes::new();
|
||||
|
||||
tauri_build::try_build(attrs).expect("failed to run tauri-build");
|
||||
}
|
||||
|
||||
fn resolve_commit_pin() -> Option<String> {
|
||||
if let Ok(v) = std::env::var("HERMES_BUILD_PIN_COMMIT") {
|
||||
if !v.trim().is_empty() {
|
||||
return Some(v.trim().to_string());
|
||||
}
|
||||
}
|
||||
let out = Command::new("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_branch_pin() -> Option<String> {
|
||||
if let Ok(v) = std::env::var("HERMES_BUILD_PIN_BRANCH") {
|
||||
if !v.trim().is_empty() {
|
||||
return Some(v.trim().to_string());
|
||||
}
|
||||
}
|
||||
let out = Command::new("git")
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
|
||||
// "HEAD" is what you get on a detached checkout — no meaningful branch
|
||||
// to pin to. The commit pin still applies; just don't emit a branch.
|
||||
if s.is_empty() || s == "HEAD" {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
}
|
||||
}
|
||||
|
||||
fn locate_git_dir() -> Option<std::path::PathBuf> {
|
||||
let out = Command::new("git")
|
||||
.args(["rev-parse", "--git-dir"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(std::path::PathBuf::from(s))
|
||||
}
|
||||
|
||||
fn short(commit: &str) -> &str {
|
||||
if commit.len() >= 12 {
|
||||
&commit[..12]
|
||||
} else {
|
||||
commit
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2/capability",
|
||||
"identifier": "default",
|
||||
"description": "Capabilities required by Hermes Setup. Narrowly scoped: we don't write user files outside HERMES_HOME, we don't read arbitrary paths, and the only external network call goes through reqwest (Rust side, not exposed to the webview).",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-minimize",
|
||||
"core:event:default",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"process:default",
|
||||
"shell:default"
|
||||
]
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<!--
|
||||
Hermes Setup application manifest.
|
||||
|
||||
The TL;DR: tell Windows we are NOT an installer in the classic "needs
|
||||
UAC elevation" sense, despite the product name. We provision into
|
||||
%LOCALAPPDATA%\hermes which is user-scoped and never touch HKLM or
|
||||
Program Files. install.ps1 runs as a child process and elevates
|
||||
itself only if a future stage explicitly needs HKLM access.
|
||||
|
||||
Without this manifest, the "Hermes Setup" productName embedded in
|
||||
the binary's resource trips Windows's installer-detection heuristic
|
||||
(https://learn.microsoft.com/en-us/windows/security/identity-protection/
|
||||
user-account-control/how-user-account-control-works#installer-detection)
|
||||
and CreateProcess fails with ERROR_ELEVATION_REQUIRED (740) when the
|
||||
user double-clicks. asInvoker disables that.
|
||||
-->
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<assemblyIdentity
|
||||
version="0.0.1.0"
|
||||
processorArchitecture="*"
|
||||
name="NousResearch.Hermes.Setup"
|
||||
type="win32"
|
||||
/>
|
||||
<description>Hermes Setup</description>
|
||||
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
|
||||
<!-- Tell Windows we know about all supported OSes (10 + 11) so it
|
||||
doesn't shim us into Vista-compat mode. -->
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10 / 11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
<!-- Windows 8.1 -->
|
||||
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
|
||||
<!-- Windows 8 -->
|
||||
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
|
||||
<!-- Windows 7 -->
|
||||
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
|
||||
<!-- Windows Vista -->
|
||||
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}"/>
|
||||
</application>
|
||||
</compatibility>
|
||||
|
||||
<!-- Per-monitor v2 DPI awareness so the installer doesn't go blurry
|
||||
on high-DPI displays when dragged between monitors. -->
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
|
||||
<!-- Use the modern common controls (v6 themes). Without this, our
|
||||
file picker / shell dialogs fall back to 1990s-era visuals. -->
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity
|
||||
type="win32"
|
||||
name="Microsoft.Windows.Common-Controls"
|
||||
version="6.0.0.0"
|
||||
processorArchitecture="*"
|
||||
publicKeyToken="6595b64144ccf1df"
|
||||
language="*"
|
||||
/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
</assembly>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 674 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 674 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 674 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 78 KiB |
@@ -1,712 +0,0 @@
|
||||
//! Bootstrap orchestration.
|
||||
//!
|
||||
//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.cjs`.
|
||||
//! Drives install.ps1 / install.sh stage-by-stage, emits progress events
|
||||
//! over the Tauri `bootstrap` channel, writes a forensic log to
|
||||
//! HERMES_HOME/logs/bootstrap-<timestamp>.log.
|
||||
//!
|
||||
//! Lifecycle:
|
||||
//! 1. `start_bootstrap` (Tauri command) → spawns the worker task.
|
||||
//! 2. Worker resolves install script (dev/cache/download).
|
||||
//! 3. Worker calls `install.ps1 -Manifest` → emits `manifest` event.
|
||||
//! 4. Worker iterates stages, calling `install.ps1 -Stage NAME -NonInteractive -Json`.
|
||||
//! 5. On success → `complete`. On any stage failure → `failed`. On cancel → `failed`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
||||
use crate::events::{BootstrapEvent, Manifest, StageState};
|
||||
use crate::install_script::{self, Pin, ScriptKind, ScriptSource};
|
||||
use crate::powershell::{self, StreamSink};
|
||||
use crate::AppState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public Tauri commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Frontend → Rust: kick off the install.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct StartBootstrapArgs {
|
||||
/// Optional override for the commit pin. Defaults to the build-time
|
||||
/// pin baked in via `BUILD_PIN_COMMIT`.
|
||||
pub commit: Option<String>,
|
||||
/// Optional override for the branch pin. Defaults to `BUILD_PIN_BRANCH`.
|
||||
pub branch: Option<String>,
|
||||
/// Include Stage-Desktop (build apps/desktop) in the manifest. The
|
||||
/// signed bootstrap installer passes true; the deprecated Electron-side
|
||||
/// bootstrap-runner passes false to avoid building-while-running.
|
||||
#[serde(default = "default_true")]
|
||||
pub include_desktop: bool,
|
||||
/// Optional override for HERMES_HOME. Tests use this; production
|
||||
/// almost always falls back to the OS default.
|
||||
pub hermes_home: Option<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BootstrapStatus {
|
||||
pub running: bool,
|
||||
pub completed: bool,
|
||||
pub install_root: Option<String>,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
/// Handle stored in AppState while a bootstrap run is in flight. Carries
|
||||
/// the cancellation channel and the most recent terminal status so the
|
||||
/// frontend can re-query after a window refresh.
|
||||
pub struct BootstrapHandle {
|
||||
pub cancel_tx: mpsc::Sender<()>,
|
||||
pub started_at: Instant,
|
||||
pub status: BootstrapStatus,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_bootstrap(
|
||||
app: AppHandle,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
args: StartBootstrapArgs,
|
||||
) -> Result<(), String> {
|
||||
let mut guard = state.bootstrap.lock().await;
|
||||
if let Some(h) = guard.as_ref() {
|
||||
if h.status.running {
|
||||
return Err("Bootstrap is already running".into());
|
||||
}
|
||||
}
|
||||
|
||||
let (cancel_tx, cancel_rx) = mpsc::channel::<()>(1);
|
||||
let handle = BootstrapHandle {
|
||||
cancel_tx,
|
||||
started_at: Instant::now(),
|
||||
status: BootstrapStatus {
|
||||
running: true,
|
||||
completed: false,
|
||||
install_root: None,
|
||||
last_error: None,
|
||||
},
|
||||
};
|
||||
*guard = Some(handle);
|
||||
drop(guard);
|
||||
|
||||
let app_for_task = app.clone();
|
||||
let state_for_task = state.inner().clone();
|
||||
let args_for_task = args;
|
||||
let cancel_rx = Arc::new(Mutex::new(Some(cancel_rx)));
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = run_bootstrap(app_for_task.clone(), args_for_task, cancel_rx).await;
|
||||
|
||||
// Reflect terminal state into AppState so get_bootstrap_status()
|
||||
// can serve it after the task exits.
|
||||
let mut guard = state_for_task.bootstrap.lock().await;
|
||||
if let Some(h) = guard.as_mut() {
|
||||
h.status.running = false;
|
||||
match &result {
|
||||
Ok(install_root) => {
|
||||
h.status.completed = true;
|
||||
h.status.install_root = Some(install_root.clone());
|
||||
h.status.last_error = None;
|
||||
}
|
||||
Err(err) => {
|
||||
h.status.completed = false;
|
||||
h.status.last_error = Some(err.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_bootstrap(state: State<'_, Arc<AppState>>) -> Result<(), String> {
|
||||
let guard = state.bootstrap.lock().await;
|
||||
if let Some(h) = guard.as_ref() {
|
||||
let _ = h.cancel_tx.try_send(());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_bootstrap_status(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
) -> Result<BootstrapStatus, String> {
|
||||
let guard = state.bootstrap.lock().await;
|
||||
Ok(match guard.as_ref() {
|
||||
Some(h) => BootstrapStatus {
|
||||
running: h.status.running,
|
||||
completed: h.status.completed,
|
||||
install_root: h.status.install_root.clone(),
|
||||
last_error: h.status.last_error.clone(),
|
||||
},
|
||||
None => BootstrapStatus {
|
||||
running: false,
|
||||
completed: false,
|
||||
install_root: None,
|
||||
last_error: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn the locally-built Hermes desktop binary, then close the installer
|
||||
/// window. Caller resolves the binary path from `install_root`.
|
||||
///
|
||||
/// Returns Err with a human-readable message if the binary doesn't exist
|
||||
/// (e.g. when Stage-Desktop was skipped) so the frontend can present
|
||||
/// actionable failure UI rather than silently doing nothing.
|
||||
#[tauri::command]
|
||||
pub async fn launch_hermes_desktop(
|
||||
app: AppHandle,
|
||||
install_root: String,
|
||||
) -> Result<(), String> {
|
||||
let install_root = PathBuf::from(install_root);
|
||||
let exe_path = resolve_hermes_desktop_exe(&install_root).ok_or_else(|| {
|
||||
format!(
|
||||
"Couldn't find a built Hermes desktop at {}. The desktop build step \
|
||||
may have been skipped or failed. Run `hermes desktop` from a \
|
||||
terminal to build and launch it.",
|
||||
install_root.join("apps").join("desktop").join("release").display()
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!(?exe_path, "launching Hermes desktop");
|
||||
|
||||
// Detach from us — the installer is about to exit.
|
||||
let mut cmd = tokio::process::Command::new(&exe_path);
|
||||
cmd.current_dir(exe_path.parent().unwrap_or(&install_root));
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// DETACHED_PROCESS = 0x00000008
|
||||
cmd.creation_flags(0x0000_0008);
|
||||
}
|
||||
|
||||
cmd.spawn().map_err(|e| {
|
||||
format!(
|
||||
"failed to launch {}: {e}",
|
||||
exe_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
// Give Windows ~150ms to actually start the new process before we exit.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||
|
||||
// Exit the installer cleanly. Tauri's process plugin gives us the
|
||||
// right hook regardless of platform.
|
||||
app.exit(0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walks the well-known electron-builder unpacked-app paths under
|
||||
/// `install_root`. Mirrors the resolver in `cmd_gui` (apps/desktop/release/
|
||||
/// <os>-unpacked/<exe>).
|
||||
fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Option<PathBuf> {
|
||||
let release_dir = install_root.join("apps").join("desktop").join("release");
|
||||
let candidates: &[(&str, &str)] = if cfg!(target_os = "windows") {
|
||||
&[
|
||||
("win-unpacked", "Hermes.exe"),
|
||||
("win-arm64-unpacked", "Hermes.exe"),
|
||||
]
|
||||
} else if cfg!(target_os = "macos") {
|
||||
&[
|
||||
("mac/Hermes.app/Contents/MacOS", "Hermes"),
|
||||
("mac-arm64/Hermes.app/Contents/MacOS", "Hermes"),
|
||||
]
|
||||
} else {
|
||||
&[("linux-unpacked", "hermes")]
|
||||
};
|
||||
for (subdir, exe) in candidates {
|
||||
let p = release_dir.join(subdir).join(exe);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bootstrap implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn run_bootstrap(
|
||||
app: AppHandle,
|
||||
args: StartBootstrapArgs,
|
||||
cancel_rx_holder: Arc<Mutex<Option<mpsc::Receiver<()>>>>,
|
||||
) -> Result<String> {
|
||||
let kind = ScriptKind::for_current_os();
|
||||
|
||||
let pin = Pin {
|
||||
commit: args.commit.or_else(|| option_env_string("BUILD_PIN_COMMIT")),
|
||||
branch: args.branch.or_else(|| option_env_string("BUILD_PIN_BRANCH")),
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
?pin,
|
||||
kind = ?kind,
|
||||
include_desktop = args.include_desktop,
|
||||
"bootstrap starting"
|
||||
);
|
||||
|
||||
let app_for_log = app.clone();
|
||||
let emit_log = move |line: &str| {
|
||||
emit_event(
|
||||
&app_for_log,
|
||||
BootstrapEvent::Log {
|
||||
stage: None,
|
||||
line: line.to_string(),
|
||||
},
|
||||
);
|
||||
// Bump to info-level so the line shows in bootstrap-installer.log
|
||||
// under the default INFO filter. Previously this was debug! which
|
||||
// got dropped on the floor, leaving us blind whenever install.ps1
|
||||
// failed — the log only had the "bootstrap starting" banner.
|
||||
tracing::info!(target: "bootstrap.log", "{line}");
|
||||
};
|
||||
|
||||
// 1. Resolve install.ps1
|
||||
let script = install_script::resolve(kind, &pin, &emit_log)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let msg = format!("resolve install script failed: {e:#}");
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: None,
|
||||
error: msg.clone(),
|
||||
},
|
||||
);
|
||||
anyhow!(msg)
|
||||
})?;
|
||||
|
||||
let source_note = match &script.source {
|
||||
ScriptSource::DevCheckout => "dev checkout",
|
||||
ScriptSource::Bundled => "bundled",
|
||||
ScriptSource::Cached => "cached",
|
||||
ScriptSource::Downloaded => "downloaded",
|
||||
};
|
||||
emit_log(&format!(
|
||||
"[bootstrap] script {} via {}",
|
||||
script.path.display(),
|
||||
source_note
|
||||
));
|
||||
|
||||
// 2. Fetch manifest
|
||||
//
|
||||
// -IncludeDesktop MUST be passed to the manifest call too — install.ps1
|
||||
// gates the desktop stage inclusion on this flag, so without it here
|
||||
// the manifest comes back missing the desktop stage and we never run
|
||||
// it. The per-stage call below also passes -IncludeDesktop to keep
|
||||
// the contracts identical.
|
||||
let manifest_args = build_pin_args(&script);
|
||||
let mut manifest_args_full = vec!["-Manifest".to_string()];
|
||||
manifest_args_full.extend(manifest_args.clone());
|
||||
if args.include_desktop {
|
||||
manifest_args_full.push("-IncludeDesktop".to_string());
|
||||
}
|
||||
|
||||
let manifest_result = run_install_script(
|
||||
&app,
|
||||
&script.path,
|
||||
&manifest_args_full,
|
||||
args.hermes_home.as_deref(),
|
||||
None,
|
||||
Some("__manifest__".to_string()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if manifest_result.exit_code != Some(0) {
|
||||
let err = format!(
|
||||
"install.ps1 -Manifest failed: exit {:?}\n{}",
|
||||
manifest_result.exit_code,
|
||||
manifest_result.stderr.trim()
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: None,
|
||||
error: err.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(err));
|
||||
}
|
||||
|
||||
let manifest: Manifest = powershell::parse_manifest(&manifest_result.stdout).ok_or_else(|| {
|
||||
let err = format!(
|
||||
"install.ps1 -Manifest produced no parseable JSON payload\n{}",
|
||||
truncate(&manifest_result.stdout, 4000)
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: None,
|
||||
error: err.clone(),
|
||||
},
|
||||
);
|
||||
anyhow!(err)
|
||||
})?;
|
||||
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Manifest {
|
||||
stages: manifest.stages.clone(),
|
||||
protocol_version: manifest.protocol_version,
|
||||
},
|
||||
);
|
||||
|
||||
// 3. Iterate stages.
|
||||
for stage in &manifest.stages {
|
||||
// Skip Stage-Desktop unless explicitly requested. install.ps1 may
|
||||
// or may not include it in the manifest depending on the flag we
|
||||
// pass, but if it slipped in, gate client-side too.
|
||||
if !args.include_desktop && stage.name.eq_ignore_ascii_case("desktop") {
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Skipped,
|
||||
duration_ms: Some(0),
|
||||
result: None,
|
||||
error: Some("skipped by include_desktop=false".into()),
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if cancellation_signalled(&cancel_rx_holder).await {
|
||||
let err = "bootstrap cancelled by user".to_string();
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: Some(stage.name.clone()),
|
||||
error: err.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(err));
|
||||
}
|
||||
|
||||
let started = Instant::now();
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Running,
|
||||
duration_ms: None,
|
||||
result: None,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
|
||||
let mut stage_args = vec![
|
||||
"-Stage".to_string(),
|
||||
stage.name.clone(),
|
||||
"-NonInteractive".to_string(),
|
||||
"-Json".to_string(),
|
||||
];
|
||||
stage_args.extend(manifest_args.clone());
|
||||
if args.include_desktop {
|
||||
stage_args.push("-IncludeDesktop".to_string());
|
||||
}
|
||||
|
||||
// Each stage gets its own cancel receiver because tokio::select!
|
||||
// in run_script consumes it. Take/return through the Arc<Mutex>.
|
||||
let local_cancel_rx = cancel_rx_holder.lock().await.take();
|
||||
|
||||
let stage_result = run_install_script(
|
||||
&app,
|
||||
&script.path,
|
||||
&stage_args,
|
||||
args.hermes_home.as_deref(),
|
||||
local_cancel_rx,
|
||||
Some(stage.name.clone()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let duration_ms = started.elapsed().as_millis() as u64;
|
||||
|
||||
if stage_result.killed {
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Failed,
|
||||
duration_ms: Some(duration_ms),
|
||||
result: None,
|
||||
error: Some("cancelled by user".into()),
|
||||
},
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: Some(stage.name.clone()),
|
||||
error: "cancelled by user".into(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!("cancelled by user"));
|
||||
}
|
||||
|
||||
let result_frame = powershell::parse_stage_result(&stage_result.stdout);
|
||||
|
||||
match result_frame {
|
||||
None => {
|
||||
let err = format!(
|
||||
"install.ps1 -Stage {} produced no JSON result frame (exit={:?})",
|
||||
stage.name, stage_result.exit_code
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Failed,
|
||||
duration_ms: Some(duration_ms),
|
||||
result: None,
|
||||
error: Some(err.clone()),
|
||||
},
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: Some(stage.name.clone()),
|
||||
error: err.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(err));
|
||||
}
|
||||
Some(frame) if frame.ok && frame.skipped => {
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Skipped,
|
||||
duration_ms: Some(duration_ms),
|
||||
result: Some(frame),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
Some(frame) if frame.ok => {
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Succeeded,
|
||||
duration_ms: Some(duration_ms),
|
||||
result: Some(frame),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
Some(frame) => {
|
||||
let err = frame
|
||||
.reason
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("exit code {:?}", stage_result.exit_code));
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Failed,
|
||||
duration_ms: Some(duration_ms),
|
||||
result: Some(frame),
|
||||
error: Some(err.clone()),
|
||||
},
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: Some(stage.name.clone()),
|
||||
error: err.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Resolve install_root. install.ps1 doesn't (yet) report this back
|
||||
// explicitly; we infer it from $HermesHome which Stage-Repository clones
|
||||
// the repo INTO at $HermesHome\hermes-agent. Mirrors hermes_constants.
|
||||
let hermes_home = args
|
||||
.hermes_home
|
||||
.clone()
|
||||
.unwrap_or_else(|| crate::paths::hermes_home().to_string_lossy().into_owned());
|
||||
let install_root = PathBuf::from(&hermes_home).join("hermes-agent");
|
||||
|
||||
// Copy ourselves to HERMES_HOME/hermes-setup.exe so the desktop app can
|
||||
// re-invoke us with `--update` and shortcuts have a stable target. This is
|
||||
// a one-shot install concern; an `--update` re-invocation no-ops because
|
||||
// we're already running from that path. Best-effort — a failure here must
|
||||
// not fail an otherwise-successful install.
|
||||
if let Err(err) = crate::paths::copy_self_to_hermes_home() {
|
||||
tracing::warn!(?err, "failed to copy installer into HERMES_HOME (non-fatal)");
|
||||
emit_log(&format!(
|
||||
"[bootstrap] warning: could not stage updater binary: {err}"
|
||||
));
|
||||
}
|
||||
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Complete {
|
||||
install_root: install_root.to_string_lossy().into_owned(),
|
||||
marker: Some(serde_json::json!({
|
||||
"pinnedCommit": pin.commit,
|
||||
"pinnedBranch": pin.branch,
|
||||
})),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(install_root.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
async fn cancellation_signalled(holder: &Arc<Mutex<Option<mpsc::Receiver<()>>>>) -> bool {
|
||||
let mut guard = holder.lock().await;
|
||||
if let Some(rx) = guard.as_mut() {
|
||||
rx.try_recv().is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_install_script(
|
||||
app: &AppHandle,
|
||||
script_path: &std::path::Path,
|
||||
args: &[String],
|
||||
hermes_home_override: Option<&str>,
|
||||
cancel_rx: Option<mpsc::Receiver<()>>,
|
||||
stage_name: Option<String>,
|
||||
) -> Result<powershell::ScriptResult> {
|
||||
let app_for_stdout = app.clone();
|
||||
let stage_for_stdout = stage_name.clone();
|
||||
let app_for_stderr = app.clone();
|
||||
let stage_for_stderr = stage_name.clone();
|
||||
let stage_for_stdout_log = stage_name.clone();
|
||||
let stage_for_stderr_log = stage_name.clone();
|
||||
|
||||
let sink = StreamSink {
|
||||
on_stdout_line: Box::new(move |line: &str| {
|
||||
emit_event(
|
||||
&app_for_stdout,
|
||||
BootstrapEvent::Log {
|
||||
stage: stage_for_stdout.clone(),
|
||||
line: line.to_string(),
|
||||
},
|
||||
);
|
||||
// Tee to the rolling installer log so we have a persistent
|
||||
// record of every install.ps1 line. Without this, the only
|
||||
// log evidence of a failure was the Tauri event stream —
|
||||
// which gets discarded the moment the failure route mounts.
|
||||
match &stage_for_stdout_log {
|
||||
Some(name) => {
|
||||
tracing::info!(target: "bootstrap.log", stage = %name, "{line}")
|
||||
}
|
||||
None => tracing::info!(target: "bootstrap.log", "{line}"),
|
||||
}
|
||||
}),
|
||||
on_stderr_line: Box::new(move |line: &str| {
|
||||
emit_event(
|
||||
&app_for_stderr,
|
||||
BootstrapEvent::Log {
|
||||
stage: stage_for_stderr.clone(),
|
||||
line: format!("stderr: {line}"),
|
||||
},
|
||||
);
|
||||
// stderr-level lines get warn! so they're visually distinct
|
||||
// when scrolling through the log later.
|
||||
match &stage_for_stderr_log {
|
||||
Some(name) => {
|
||||
tracing::warn!(target: "bootstrap.log", stage = %name, "stderr: {line}")
|
||||
}
|
||||
None => tracing::warn!(target: "bootstrap.log", "stderr: {line}"),
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
powershell::run_script(script_path, args, sink, hermes_home_override, cancel_rx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(?e, "install script invocation failed");
|
||||
anyhow!("install script invocation failed: {e:#}")
|
||||
})
|
||||
}
|
||||
|
||||
fn build_pin_args(script: &install_script::ResolvedScript) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(c) = &script.commit {
|
||||
out.push("-Commit".to_string());
|
||||
out.push(c.clone());
|
||||
}
|
||||
if let Some(b) = &script.branch {
|
||||
out.push("-Branch".to_string());
|
||||
out.push(b.clone());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn emit_event(app: &AppHandle, event: BootstrapEvent) {
|
||||
// Tee important state transitions to the rolling installer log so
|
||||
// bootstrap-installer.log isn't just "starting" + final summary.
|
||||
// Log lines (the noisy stuff) handle their own tracing in
|
||||
// run_install_script's sink; here we cover the lifecycle frames.
|
||||
match &event {
|
||||
BootstrapEvent::Manifest { stages, .. } => {
|
||||
tracing::info!(
|
||||
stage_count = stages.len(),
|
||||
names = ?stages.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
|
||||
"manifest received"
|
||||
);
|
||||
}
|
||||
BootstrapEvent::Stage {
|
||||
name,
|
||||
state,
|
||||
duration_ms,
|
||||
error,
|
||||
..
|
||||
} => {
|
||||
tracing::info!(
|
||||
stage = %name,
|
||||
?state,
|
||||
duration_ms = ?duration_ms,
|
||||
error = ?error,
|
||||
"stage transition"
|
||||
);
|
||||
}
|
||||
BootstrapEvent::Complete { install_root, .. } => {
|
||||
tracing::info!(install_root = %install_root, "bootstrap complete");
|
||||
}
|
||||
BootstrapEvent::Failed { stage, error } => {
|
||||
tracing::error!(stage = ?stage, error = %error, "bootstrap FAILED");
|
||||
}
|
||||
BootstrapEvent::Log { .. } => {
|
||||
// Log lines are teed via the sink callbacks in
|
||||
// run_install_script — don't double-emit here.
|
||||
}
|
||||
}
|
||||
if let Err(e) = app.emit(BootstrapEvent::CHANNEL, &event) {
|
||||
tracing::warn!(?e, "failed to emit bootstrap event");
|
||||
}
|
||||
}
|
||||
|
||||
fn option_env_string(key: &str) -> Option<String> {
|
||||
// option_env! only accepts literals, so we hardcode the known keys.
|
||||
let val = match key {
|
||||
"BUILD_PIN_COMMIT" => option_env!("BUILD_PIN_COMMIT"),
|
||||
"BUILD_PIN_BRANCH" => option_env!("BUILD_PIN_BRANCH"),
|
||||
_ => None,
|
||||
};
|
||||
val.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}...", &s[..max])
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
//! Event types streamed from Rust → React.
|
||||
//!
|
||||
//! These mirror `apps/desktop/electron/bootstrap-runner.cjs`'s event shape
|
||||
//! 1:1 so the React installer code can be roughly identical to the Electron
|
||||
//! install-overlay we'll replace.
|
||||
//!
|
||||
//! The Tauri event channel name is `"bootstrap"` for all of these — the
|
||||
//! `type` discriminator on each payload is how the frontend routes.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Stage definition as reported by `install.ps1 -Manifest`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StageInfo {
|
||||
pub name: String,
|
||||
pub title: String,
|
||||
pub category: String,
|
||||
/// `needs_user_input=true` stages run with -NonInteractive and emit
|
||||
/// skipped=true; the post-install wizard takes over for those.
|
||||
#[serde(rename = "needs_user_input", alias = "needsUserInput")]
|
||||
pub needs_user_input: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Manifest {
|
||||
pub stages: Vec<StageInfo>,
|
||||
#[serde(rename = "protocol_version", alias = "protocolVersion", default)]
|
||||
pub protocol_version: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StageResultPayload {
|
||||
pub stage: String,
|
||||
pub ok: bool,
|
||||
#[serde(default)]
|
||||
pub skipped: bool,
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
/// install.ps1 may attach stage-specific structured data here.
|
||||
#[serde(default)]
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Run-state for a single stage as we transition through it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum StageState {
|
||||
Running,
|
||||
Succeeded,
|
||||
Skipped,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// The single event channel `bootstrap` emits these. `type` discriminates.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum BootstrapEvent {
|
||||
/// Sent once at the start with the full stage list.
|
||||
Manifest {
|
||||
stages: Vec<StageInfo>,
|
||||
#[serde(rename = "protocolVersion")]
|
||||
protocol_version: Option<u32>,
|
||||
},
|
||||
/// Stage state transition. `result` populated only on terminal states.
|
||||
Stage {
|
||||
name: String,
|
||||
state: StageState,
|
||||
#[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")]
|
||||
duration_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<StageResultPayload>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
},
|
||||
/// Raw stdout/stderr line from install.ps1 (or our wrapper).
|
||||
Log {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stage: Option<String>,
|
||||
line: String,
|
||||
},
|
||||
/// Sent once when all stages complete successfully.
|
||||
Complete {
|
||||
#[serde(rename = "installRoot")]
|
||||
install_root: String,
|
||||
marker: Option<serde_json::Value>,
|
||||
},
|
||||
/// Sent once if the run aborts.
|
||||
Failed {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stage: Option<String>,
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl BootstrapEvent {
|
||||
/// Tauri event name. Single channel for all bootstrap events; the
|
||||
/// `type` tag tells the renderer how to interpret the payload.
|
||||
pub const CHANNEL: &'static str = "bootstrap";
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
//! Resolves and downloads `scripts/install.ps1` (and `install.sh`).
|
||||
//!
|
||||
//! Resolution order:
|
||||
//! 1. Dev shortcut: a sibling repo checkout via $HERMES_SETUP_DEV_REPO_ROOT
|
||||
//! env var. Lets devs iterate without re-publishing the script.
|
||||
//! 2. Bundled fallback: if the installer was bundled with a script (e.g.
|
||||
//! tauri's `resource` mechanism), serve from there. Not used today.
|
||||
//! 3. Network: download from GitHub raw at a pinned commit or branch.
|
||||
//! Commit pins are immutable; branch pins are HEAD-tracking.
|
||||
//!
|
||||
//! Mirrors `apps/desktop/electron/bootstrap-runner.cjs`'s `resolveInstallScript`,
|
||||
//! but the dev-checkout resolution is driven by an env var rather than the
|
||||
//! Electron app's APP_ROOT/../.. trick, because Hermes-Setup.exe is meant
|
||||
//! to live OUTSIDE any repo checkout.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::paths;
|
||||
|
||||
/// Identity of the install.ps1 we'll execute. Used by both the manifest
|
||||
/// fetch and the per-stage runs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedScript {
|
||||
pub path: PathBuf,
|
||||
pub source: ScriptSource,
|
||||
/// Commit pin (40-char SHA) if known. install.ps1's `-Commit` arg is
|
||||
/// what makes the repo stage clone the exact tested SHA.
|
||||
pub commit: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ScriptSource {
|
||||
DevCheckout,
|
||||
Bundled,
|
||||
Cached,
|
||||
Downloaded,
|
||||
}
|
||||
|
||||
/// What flavor of script (Windows .ps1 vs Unix .sh).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ScriptKind {
|
||||
Ps1,
|
||||
Sh,
|
||||
}
|
||||
|
||||
impl ScriptKind {
|
||||
pub fn for_current_os() -> Self {
|
||||
if cfg!(target_os = "windows") {
|
||||
Self::Ps1
|
||||
} else {
|
||||
Self::Sh
|
||||
}
|
||||
}
|
||||
|
||||
fn filename(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Ps1 => "install.ps1",
|
||||
Self::Sh => "install.sh",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a string looks like a git SHA (7+ hex chars). Mirrors
|
||||
/// `STAMP_COMMIT_RE` from bootstrap-runner.cjs.
|
||||
fn is_valid_commit(s: &str) -> bool {
|
||||
let len = s.len();
|
||||
(7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Resolves the install script to use for this run.
|
||||
///
|
||||
/// `pin` is the commit-or-branch from either Hermes-Setup's build-time
|
||||
/// constant (compiled into the installer) or a runtime override.
|
||||
pub async fn resolve(
|
||||
kind: ScriptKind,
|
||||
pin: &Pin,
|
||||
emit_log: &impl Fn(&str),
|
||||
) -> Result<ResolvedScript> {
|
||||
// 1. Dev shortcut.
|
||||
if let Ok(repo_root) = std::env::var("HERMES_SETUP_DEV_REPO_ROOT") {
|
||||
let candidate = PathBuf::from(repo_root).join("scripts").join(kind.filename());
|
||||
if candidate.exists() {
|
||||
emit_log(&format!(
|
||||
"[bootstrap] dev mode — using local {} at {}",
|
||||
kind.filename(),
|
||||
candidate.display()
|
||||
));
|
||||
return Ok(ResolvedScript {
|
||||
path: candidate,
|
||||
source: ScriptSource::DevCheckout,
|
||||
commit: pin.commit.clone(),
|
||||
branch: pin.branch.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. (Not implemented) bundled fallback.
|
||||
|
||||
// 3. Network. Pin must be a real commit or a branch ref.
|
||||
let commit_or_ref = match (&pin.commit, &pin.branch) {
|
||||
(Some(c), _) if is_valid_commit(c) => c.clone(),
|
||||
(_, Some(b)) if !b.trim().is_empty() => b.clone(),
|
||||
(Some(other), _) => {
|
||||
return Err(anyhow!(
|
||||
"install script pin commit `{other}` is not a valid git SHA"
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
return Err(anyhow!(
|
||||
"no install-script pin supplied — installer cannot resolve a script source"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let cached = cached_path(kind, &commit_or_ref);
|
||||
if cached.exists() {
|
||||
emit_log(&format!(
|
||||
"[bootstrap] using cached {} for {}",
|
||||
kind.filename(),
|
||||
truncate_ref(&commit_or_ref)
|
||||
));
|
||||
return Ok(ResolvedScript {
|
||||
path: cached,
|
||||
source: ScriptSource::Cached,
|
||||
commit: pin.commit.clone(),
|
||||
branch: pin.branch.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
emit_log(&format!(
|
||||
"[bootstrap] downloading {} for {} from GitHub",
|
||||
kind.filename(),
|
||||
truncate_ref(&commit_or_ref)
|
||||
));
|
||||
|
||||
download(kind, &commit_or_ref, &cached).await?;
|
||||
|
||||
emit_log(&format!("[bootstrap] cached to {}", cached.display()));
|
||||
|
||||
Ok(ResolvedScript {
|
||||
path: cached,
|
||||
source: ScriptSource::Downloaded,
|
||||
commit: pin.commit.clone(),
|
||||
branch: pin.branch.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Pin {
|
||||
pub commit: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
fn cached_path(kind: ScriptKind, commit_or_ref: &str) -> PathBuf {
|
||||
let safe = sanitize_ref(commit_or_ref);
|
||||
let filename = match kind {
|
||||
ScriptKind::Ps1 => format!("install-{safe}.ps1"),
|
||||
ScriptKind::Sh => format!("install-{safe}.sh"),
|
||||
};
|
||||
paths::bootstrap_cache_dir().join(filename)
|
||||
}
|
||||
|
||||
/// Replace anything that's not [A-Za-z0-9._-] with `_`. Branch refs can
|
||||
/// contain `/`, dots, etc.; we want a flat filename.
|
||||
fn sanitize_ref(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn truncate_ref(s: &str) -> &str {
|
||||
if is_valid_commit(s) && s.len() >= 12 {
|
||||
&s[..12]
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads to `dest_path` via reqwest with rustls. Atomically renames
|
||||
/// `dest_path.tmp` → `dest_path` so partial writes don't poison the cache.
|
||||
async fn download(kind: ScriptKind, commit_or_ref: &str, dest_path: &Path) -> Result<()> {
|
||||
let url = format!(
|
||||
"https://raw.githubusercontent.com/NousResearch/hermes-agent/{}/scripts/{}",
|
||||
commit_or_ref,
|
||||
kind.filename()
|
||||
);
|
||||
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!("creating bootstrap-cache parent dir {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
|
||||
let tmp_path = dest_path.with_extension({
|
||||
let ext = dest_path
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("tmp");
|
||||
format!("{ext}.tmp")
|
||||
});
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.get(&url)
|
||||
.header("User-Agent", "hermes-setup/0.0.1")
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("GET {url}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Failed to download {}: HTTP {} from {}",
|
||||
kind.filename(),
|
||||
response.status(),
|
||||
url
|
||||
));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.with_context(|| format!("reading body of {url}"))?;
|
||||
|
||||
let mut file = tokio::fs::File::create(&tmp_path)
|
||||
.await
|
||||
.with_context(|| format!("creating temp file {}", tmp_path.display()))?;
|
||||
file.write_all(&bytes)
|
||||
.await
|
||||
.with_context(|| format!("writing temp file {}", tmp_path.display()))?;
|
||||
file.flush().await.context("flushing temp file")?;
|
||||
drop(file);
|
||||
|
||||
tokio::fs::rename(&tmp_path, dest_path)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"renaming {} → {}",
|
||||
tmp_path.display(),
|
||||
dest_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_valid_commit_accepts_short_and_full_shas() {
|
||||
assert!(is_valid_commit("02d26981d3d4ad50e142399b8476f59ad5953ff0"));
|
||||
assert!(is_valid_commit("02d2698"));
|
||||
assert!(!is_valid_commit("02d269"));
|
||||
assert!(!is_valid_commit("not-a-sha"));
|
||||
assert!(!is_valid_commit(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_ref_replaces_slashes() {
|
||||
assert_eq!(sanitize_ref("bb/gui"), "bb_gui");
|
||||
assert_eq!(sanitize_ref("main"), "main");
|
||||
assert_eq!(sanitize_ref("release/1.2.3"), "release_1.2.3");
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
//! Hermes Setup — Tauri entrypoint.
|
||||
//!
|
||||
//! Spawns a single window pointed at the React frontend (apps/bootstrap-installer/src/).
|
||||
//! All install-time work lives in `bootstrap.rs` and is invoked through the Tauri
|
||||
//! commands registered at the bottom of `run()`.
|
||||
//!
|
||||
//! The Windows-subsystem strip lives on the binary crate (src/main.rs), not
|
||||
//! here — a crate-level attribute on a lib doesn't propagate to the linker
|
||||
//! flags of the executable that consumes it.
|
||||
|
||||
mod bootstrap;
|
||||
mod events;
|
||||
mod install_script;
|
||||
mod powershell;
|
||||
mod paths;
|
||||
mod update;
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// How the installer was invoked. Resolved once from the process args in
|
||||
/// `run()` and exposed to the frontend via `get_mode` so it can route to the
|
||||
/// install flow (first-run onboarding) or the update flow (driven by the
|
||||
/// desktop app handing off via `Hermes-Setup.exe --update`).
|
||||
///
|
||||
/// Bare launch (double-click, first-run) => Install.
|
||||
/// `--update` (spawned by the desktop's "Update" button) => Update.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AppMode {
|
||||
Install,
|
||||
Update,
|
||||
}
|
||||
|
||||
impl AppMode {
|
||||
/// Resolve the mode from an argument iterator. Anything containing the
|
||||
/// `--update` flag selects Update; otherwise Install. Kept arg-iterator
|
||||
/// generic (not reading `std::env` directly) so it's unit-testable.
|
||||
pub fn from_args<I, S>(args: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
for a in args {
|
||||
if a.as_ref() == "--update" {
|
||||
return AppMode::Update;
|
||||
}
|
||||
}
|
||||
AppMode::Install
|
||||
}
|
||||
}
|
||||
|
||||
/// Process-wide install state, shared across Tauri commands.
|
||||
///
|
||||
/// The bootstrap is a one-shot, single-tenant process — we only need one
|
||||
/// of these per window. `Arc<Mutex<...>>` lets command handlers grab it
|
||||
/// without lifetime gymnastics.
|
||||
pub struct AppState {
|
||||
pub bootstrap: Mutex<Option<bootstrap::BootstrapHandle>>,
|
||||
/// How this process was launched (install vs update). Immutable for the
|
||||
/// lifetime of the process; read by the `get_mode` command.
|
||||
pub mode: AppMode,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn new(mode: AppMode) -> Self {
|
||||
Self {
|
||||
bootstrap: Mutex::new(None),
|
||||
mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Frontend → Rust: which flow should the UI render?
|
||||
#[tauri::command]
|
||||
fn get_mode(state: tauri::State<'_, Arc<AppState>>) -> AppMode {
|
||||
state.mode
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// Tracing → bootstrap-installer.log under HERMES_HOME/logs/ so install
|
||||
// failures leave a trail for support. Console output also goes here in
|
||||
// debug builds.
|
||||
let _guard = paths::init_logging();
|
||||
|
||||
let mode = AppMode::from_args(std::env::args().skip(1));
|
||||
tracing::info!(?mode, "Hermes Setup starting");
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(Arc::new(AppState::new(mode)))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Mode (install vs update)
|
||||
get_mode,
|
||||
// Bootstrap lifecycle
|
||||
bootstrap::start_bootstrap,
|
||||
bootstrap::cancel_bootstrap,
|
||||
bootstrap::get_bootstrap_status,
|
||||
// Update lifecycle
|
||||
update::start_update,
|
||||
// Hand-off
|
||||
bootstrap::launch_hermes_desktop,
|
||||
// Diagnostics
|
||||
paths::get_log_path,
|
||||
paths::get_hermes_home,
|
||||
paths::open_log_dir,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Hermes Setup");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AppMode;
|
||||
|
||||
#[test]
|
||||
fn bare_args_are_install() {
|
||||
assert_eq!(AppMode::from_args(Vec::<String>::new()), AppMode::Install);
|
||||
assert_eq!(AppMode::from_args(["--foo", "bar"]), AppMode::Install);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_flag_selects_update() {
|
||||
assert_eq!(AppMode::from_args(["--update"]), AppMode::Update);
|
||||
assert_eq!(
|
||||
AppMode::from_args(["--something", "--update", "--else"]),
|
||||
AppMode::Update
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
// Hermes Setup — process entrypoint. All logic lives in lib.rs so it can
|
||||
// be unit-tested as a library; this file just calls into it.
|
||||
//
|
||||
// The windows_subsystem attribute MUST live here on the binary crate
|
||||
// (not lib.rs) — placing it on the lib was the bug that left a stray
|
||||
// cmd window behind Hermes-Setup.exe on release builds.
|
||||
//
|
||||
// `windows_subsystem = "windows"` strips the console allocation that
|
||||
// the default `windows_subsystem = "console"` would do, so double-clicking
|
||||
// the .exe gives you ONLY the Tauri window.
|
||||
//
|
||||
// debug_assertions guard: dev builds keep the console so tracing output
|
||||
// is visible during `cargo tauri dev`.
|
||||
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
hermes_bootstrap_lib::run()
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
//! Filesystem paths + logging setup.
|
||||
//!
|
||||
//! Mirrors `hermes_constants.get_hermes_home()` from the Python CLI:
|
||||
//! Windows: %LOCALAPPDATA%\hermes
|
||||
//! macOS: ~/Library/Application Support/hermes
|
||||
//! Linux: ~/.hermes (XDG override via $HERMES_HOME)
|
||||
//!
|
||||
//! IMPORTANT: this must match exactly. Drift here means install.ps1
|
||||
//! writes to one place and the installer reads from another, breaking
|
||||
//! the bootstrap-complete check.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
|
||||
/// Returns the canonical Hermes home directory, respecting $HERMES_HOME if set.
|
||||
pub fn hermes_home() -> PathBuf {
|
||||
if let Ok(override_path) = std::env::var("HERMES_HOME") {
|
||||
if !override_path.trim().is_empty() {
|
||||
return PathBuf::from(override_path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// %LOCALAPPDATA%\hermes — matches scripts/install.ps1's $HermesHome.
|
||||
if let Some(local_app_data) = dirs::data_local_dir() {
|
||||
return local_app_data.join("hermes");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// ~/Library/Application Support/hermes
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join("Library/Application Support/hermes");
|
||||
}
|
||||
}
|
||||
|
||||
// Linux + fallback: ~/.hermes
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(".hermes");
|
||||
}
|
||||
|
||||
// Last resort — current dir, almost certainly wrong but at least
|
||||
// doesn't panic.
|
||||
PathBuf::from(".hermes")
|
||||
}
|
||||
|
||||
pub fn log_dir() -> PathBuf {
|
||||
hermes_home().join("logs")
|
||||
}
|
||||
|
||||
pub fn log_path() -> PathBuf {
|
||||
log_dir().join("bootstrap-installer.log")
|
||||
}
|
||||
|
||||
pub fn bootstrap_cache_dir() -> PathBuf {
|
||||
hermes_home().join("bootstrap-cache")
|
||||
}
|
||||
|
||||
/// Stable location the installer copies itself to after a successful install.
|
||||
/// The desktop app re-invokes this with `--update`, and the start-menu /
|
||||
/// desktop shortcuts can point users back to it. Lives directly under
|
||||
/// HERMES_HOME so it survives repo checkout deletion (unlike anything under
|
||||
/// hermes-agent/).
|
||||
///
|
||||
/// On Windows this is `%LOCALAPPDATA%\hermes\hermes-setup.exe`; on other
|
||||
/// platforms the extension differs but the directory is the same.
|
||||
pub fn installer_dest() -> PathBuf {
|
||||
let name = if cfg!(target_os = "windows") {
|
||||
"hermes-setup.exe"
|
||||
} else {
|
||||
"hermes-setup"
|
||||
};
|
||||
hermes_home().join(name)
|
||||
}
|
||||
|
||||
/// Copy the currently-running installer binary to `installer_dest()` so it's
|
||||
/// available for future `--update` runs and shortcut launches.
|
||||
///
|
||||
/// No-ops (returns Ok) when the running exe is ALREADY the destination — which
|
||||
/// is exactly the case during an `--update` run (the desktop launched us FROM
|
||||
/// that path), where copying onto ourselves would be a Windows sharing
|
||||
/// violation. Best-effort: a failure here must not fail the install, so the
|
||||
/// caller logs and continues.
|
||||
pub fn copy_self_to_hermes_home() -> std::io::Result<()> {
|
||||
let src = std::env::current_exe()?;
|
||||
let dest = installer_dest();
|
||||
|
||||
// Skip if we're already running from the destination (update re-invocation
|
||||
// or a prior copy). canonicalize both so symlinks / 8.3 short paths / case
|
||||
// differences don't trick us into a self-copy.
|
||||
let same = match (src.canonicalize(), dest.canonicalize()) {
|
||||
(Ok(a), Ok(b)) => a == b,
|
||||
_ => src == dest,
|
||||
};
|
||||
if same {
|
||||
tracing::info!(?dest, "installer already at destination; skipping self-copy");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::copy(&src, &dest)?;
|
||||
tracing::info!(?src, ?dest, "copied installer to HERMES_HOME");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where install.ps1 writes the bootstrap-complete marker (existence-only file
|
||||
/// the Electron app also checks). Per main.cjs:
|
||||
/// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete')
|
||||
/// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so
|
||||
/// this is a probe helper, not a definitive path.
|
||||
pub fn likely_bootstrap_marker(install_root: &Path) -> PathBuf {
|
||||
install_root.join(".hermes-bootstrap-complete")
|
||||
}
|
||||
|
||||
/// Initializes tracing to bootstrap-installer.log under HERMES_HOME/logs/.
|
||||
/// Returns a guard that flushes the appender on drop — keep it alive for
|
||||
/// the lifetime of the process.
|
||||
pub fn init_logging() -> Option<WorkerGuard> {
|
||||
let dir = log_dir();
|
||||
if let Err(err) = std::fs::create_dir_all(&dir) {
|
||||
// No log dir → log to stderr only. Don't panic; the installer
|
||||
// should still be usable on an exotic filesystem.
|
||||
eprintln!("[hermes-setup] could not create log dir {dir:?}: {err}");
|
||||
return None;
|
||||
}
|
||||
|
||||
let file_appender = tracing_appender::rolling::never(&dir, "bootstrap-installer.log");
|
||||
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
||||
|
||||
let env_filter = tracing_subscriber::EnvFilter::try_from_env("HERMES_BOOTSTRAP_LOG")
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(env_filter)
|
||||
.with_writer(non_blocking)
|
||||
.with_ansi(false)
|
||||
.with_target(true)
|
||||
.init();
|
||||
|
||||
Some(guard)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_log_path() -> String {
|
||||
log_path().to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_hermes_home() -> String {
|
||||
hermes_home().to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_log_dir(app: tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
let path = log_dir();
|
||||
app.opener()
|
||||
.open_path(path.to_string_lossy(), None::<&str>)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
//! Drives PowerShell (Windows) or bash (Unix) for install.ps1 / install.sh.
|
||||
//!
|
||||
//! Port of `spawnPowerShell` from bootstrap-runner.cjs, with the same
|
||||
//! line-buffered stdout/stderr streaming + cancellation semantics.
|
||||
//!
|
||||
//! On Windows we pass `-NoProfile -ExecutionPolicy Bypass -File <script>`.
|
||||
//! On Unix we shell out to `bash <script>` since install.sh expects bash.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Hooks the caller installs to receive output.
|
||||
pub struct StreamSink {
|
||||
pub on_stdout_line: Box<dyn Fn(&str) + Send + Sync>,
|
||||
pub on_stderr_line: Box<dyn Fn(&str) + Send + Sync>,
|
||||
}
|
||||
|
||||
/// Outcome of a script invocation. Mirrors bootstrap-runner.cjs's
|
||||
/// `{stdout, stderr, code, signal, killed}` shape.
|
||||
#[derive(Debug)]
|
||||
pub struct ScriptResult {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub exit_code: Option<i32>,
|
||||
pub killed: bool,
|
||||
}
|
||||
|
||||
/// Cancellation signal — `cancel_tx.send(()).await` aborts the running script.
|
||||
pub type CancelRx = mpsc::Receiver<()>;
|
||||
|
||||
/// Spawns install.ps1 / install.sh with the given args and streams output.
|
||||
///
|
||||
/// `hermes_home_override` propagates to the child as $HERMES_HOME so the
|
||||
/// install script writes to the same directory the installer is reading from.
|
||||
pub async fn run_script(
|
||||
script_path: &Path,
|
||||
args: &[String],
|
||||
sink: StreamSink,
|
||||
hermes_home_override: Option<&str>,
|
||||
mut cancel_rx: Option<CancelRx>,
|
||||
) -> Result<ScriptResult> {
|
||||
let mut cmd = build_command(script_path, args);
|
||||
|
||||
if let Some(home) = hermes_home_override {
|
||||
cmd.env("HERMES_HOME", home);
|
||||
}
|
||||
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
// On Windows, avoid spawning a flashing cmd window when we're hosted
|
||||
// inside a GUI process. Tauri's main window is already created, so
|
||||
// the side-effect console for the child is unwanted.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// CREATE_NO_WINDOW = 0x08000000
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
|
||||
let mut child: Child = cmd
|
||||
.spawn()
|
||||
.with_context(|| format!("spawning {}", script_path.display()))?;
|
||||
|
||||
let stdout = child.stdout.take().expect("stdout was piped");
|
||||
let stderr = child.stderr.take().expect("stderr was piped");
|
||||
|
||||
let mut stdout_reader = BufReader::new(stdout).lines();
|
||||
let mut stderr_reader = BufReader::new(stderr).lines();
|
||||
|
||||
let mut combined_stdout = String::new();
|
||||
let mut combined_stderr = String::new();
|
||||
let mut killed = false;
|
||||
|
||||
// Loop: poll stdout, stderr, cancel, and child exit concurrently.
|
||||
loop {
|
||||
tokio::select! {
|
||||
line = stdout_reader.next_line() => {
|
||||
match line {
|
||||
Ok(Some(l)) => {
|
||||
(sink.on_stdout_line)(&l);
|
||||
combined_stdout.push_str(&l);
|
||||
combined_stdout.push('\n');
|
||||
}
|
||||
Ok(None) => {
|
||||
// EOF on stdout — wait for stderr + exit.
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("stdout read error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
line = stderr_reader.next_line() => {
|
||||
match line {
|
||||
Ok(Some(l)) => {
|
||||
(sink.on_stderr_line)(&l);
|
||||
combined_stderr.push_str(&l);
|
||||
combined_stderr.push('\n');
|
||||
}
|
||||
Ok(None) => {
|
||||
// stderr EOF — keep draining stdout.
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("stderr read error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = recv_cancel(&mut cancel_rx) => {
|
||||
tracing::warn!("cancellation received — killing child");
|
||||
killed = true;
|
||||
// best-effort kill; don't propagate errors
|
||||
let _ = child.start_kill();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drain remaining lines after the loop exited.
|
||||
while let Ok(Some(l)) = stdout_reader.next_line().await {
|
||||
(sink.on_stdout_line)(&l);
|
||||
combined_stdout.push_str(&l);
|
||||
combined_stdout.push('\n');
|
||||
}
|
||||
while let Ok(Some(l)) = stderr_reader.next_line().await {
|
||||
(sink.on_stderr_line)(&l);
|
||||
combined_stderr.push_str(&l);
|
||||
combined_stderr.push('\n');
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.context("waiting for install script to exit")?;
|
||||
|
||||
Ok(ScriptResult {
|
||||
stdout: combined_stdout,
|
||||
stderr: combined_stderr,
|
||||
exit_code: status.code(),
|
||||
killed,
|
||||
})
|
||||
}
|
||||
|
||||
async fn recv_cancel(rx: &mut Option<CancelRx>) {
|
||||
match rx {
|
||||
Some(r) => {
|
||||
let _ = r.recv().await;
|
||||
}
|
||||
None => std::future::pending::<()>().await,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn build_command(script_path: &Path, args: &[String]) -> Command {
|
||||
// We want PowerShell 5.1 / 7. install.ps1 uses 5.1-safe syntax everywhere.
|
||||
// Prefer `powershell.exe` (5.1 baseline, present on every Windows since 7)
|
||||
// over `pwsh.exe` (7+, may not be present).
|
||||
let mut cmd = Command::new("powershell.exe");
|
||||
cmd.arg("-NoProfile");
|
||||
cmd.arg("-ExecutionPolicy").arg("Bypass");
|
||||
cmd.arg("-File").arg(script_path);
|
||||
for a in args {
|
||||
cmd.arg(a);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn build_command(script_path: &Path, args: &[String]) -> Command {
|
||||
// install.sh expects bash. /bin/bash is fine on macOS (Apple still
|
||||
// ships an old 3.2 bash; install.sh is written to that baseline).
|
||||
let mut cmd = Command::new("bash");
|
||||
cmd.arg(script_path);
|
||||
for a in args {
|
||||
cmd.arg(a);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Parses the LAST line of stdout that looks like a JSON object matching
|
||||
/// the install.ps1 stage-result contract: `{ok: bool, stage: string, ...}`.
|
||||
///
|
||||
/// Mirrors `parseStageResult` from bootstrap-runner.cjs. install.ps1 may
|
||||
/// print info/banner lines before the result frame; we scan from the end.
|
||||
pub fn parse_stage_result(stdout: &str) -> Option<crate::events::StageResultPayload> {
|
||||
for line in stdout.lines().rev() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
if value.get("ok").and_then(|v| v.as_bool()).is_some()
|
||||
&& value.get("stage").and_then(|v| v.as_str()).is_some()
|
||||
{
|
||||
if let Ok(parsed) =
|
||||
serde_json::from_value::<crate::events::StageResultPayload>(value)
|
||||
{
|
||||
return Some(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Same logic but for the `-Manifest` payload (the LAST line with a `stages`
|
||||
/// array). Returns the parsed manifest.
|
||||
pub fn parse_manifest(stdout: &str) -> Option<crate::events::Manifest> {
|
||||
for line in stdout.lines().rev() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
if value.get("stages").and_then(|v| v.as_array()).is_some() {
|
||||
if let Ok(parsed) = serde_json::from_value::<crate::events::Manifest>(value) {
|
||||
return Some(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_stage_result_picks_last_json_line() {
|
||||
let stdout = r#"
|
||||
[bootstrap] some info
|
||||
{"ok": false, "stage": "venv", "reason": "bad python"}
|
||||
{"ok": true, "stage": "venv"}
|
||||
final non-json banner
|
||||
"#;
|
||||
let result = parse_stage_result(stdout).unwrap();
|
||||
assert_eq!(result.stage, "venv");
|
||||
assert!(result.ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_manifest_finds_stages_array() {
|
||||
let stdout = r#"
|
||||
info line
|
||||
{"stages": [{"name": "uv", "title": "uv", "category": "prereqs", "needs_user_input": false}], "protocol_version": 1}
|
||||
"#;
|
||||
let m = parse_manifest(stdout).unwrap();
|
||||
assert_eq!(m.stages.len(), 1);
|
||||
assert_eq!(m.stages[0].name, "uv");
|
||||
assert_eq!(m.protocol_version, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_returns_none_when_no_match() {
|
||||
assert!(parse_stage_result("just banner\n").is_none());
|
||||
assert!(parse_manifest("just banner\n").is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,462 +0,0 @@
|
||||
//! Update orchestration.
|
||||
//!
|
||||
//! Driven when the installer is launched as `Hermes-Setup.exe --update` (see
|
||||
//! `AppMode` in lib.rs). The desktop app hands off to us — it exits, then we:
|
||||
//!
|
||||
//! 1. wait for the old Hermes desktop process to fully exit (so the venv
|
||||
//! shim is free; otherwise `hermes update` aborts with exit code 2),
|
||||
//! 2. run `hermes update --yes --gateway` (Python/repo update; this does NOT
|
||||
//! rebuild apps/desktop by design — see cmd_update in hermes_cli/main.py),
|
||||
//! 3. run `hermes desktop --build-only` (the rebuild step update skips),
|
||||
//! 4. launch the freshly-built desktop (reuses bootstrap::launch logic).
|
||||
//!
|
||||
//! We reuse the `BootstrapEvent` channel + the existing progress UI by
|
||||
//! emitting a synthetic two-stage manifest ("update", "rebuild"). To the
|
||||
//! frontend an update looks like a short bootstrap.
|
||||
//!
|
||||
//! Cross-platform note: `hermes update` already handles macOS/Linux (git/pip).
|
||||
//! The only OS-specific bits here are the venv shim path (resolve_hermes) and
|
||||
//! the no-window creation flag — both already cfg-gated. Keep new logic
|
||||
//! OS-agnostic so the mac/linux port stays "fill in the paths".
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::events::{BootstrapEvent, StageInfo, StageState};
|
||||
|
||||
/// `hermes update` exit code meaning "another hermes process is holding the
|
||||
/// venv shim open / dirty precondition" — see _cmd_update_impl in
|
||||
/// hermes_cli/main.py (sys.exit(2)). We surface a targeted message for this.
|
||||
const UPDATE_EXIT_CONCURRENT: i32 = 2;
|
||||
|
||||
/// How long to wait for the old desktop process to release the venv shim
|
||||
/// before giving up and letting `hermes update`'s own guard decide.
|
||||
const DESKTOP_EXIT_WAIT: Duration = Duration::from_secs(20);
|
||||
const DESKTOP_EXIT_POLL: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Frontend → Rust: kick off the update flow. Mirrors `start_bootstrap`'s
|
||||
/// fire-and-forget shape; progress arrives on the `bootstrap` event channel.
|
||||
#[tauri::command]
|
||||
pub async fn start_update(app: AppHandle) -> Result<(), String> {
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = run_update(app.clone()).await {
|
||||
// run_update already emits a Failed event on the paths that matter;
|
||||
// this catches anything that escaped. Emit defensively.
|
||||
emit(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: None,
|
||||
error: format!("{err:#}"),
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_update(app: AppHandle) -> Result<()> {
|
||||
let hermes_home = crate::paths::hermes_home();
|
||||
let install_root = hermes_home.join("hermes-agent");
|
||||
|
||||
let hermes = resolve_hermes(&install_root).ok_or_else(|| {
|
||||
let msg = format!(
|
||||
"Could not find the hermes CLI under {}. Is Hermes installed? \
|
||||
Re-run the installer to repair the install.",
|
||||
install_root.display()
|
||||
);
|
||||
emit(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: None,
|
||||
error: msg.clone(),
|
||||
},
|
||||
);
|
||||
anyhow!(msg)
|
||||
})?;
|
||||
|
||||
// Synthetic manifest so the existing progress UI renders our two stages.
|
||||
emit(
|
||||
&app,
|
||||
BootstrapEvent::Manifest {
|
||||
stages: vec![
|
||||
stage_info("update", "Updating Hermes"),
|
||||
stage_info("rebuild", "Rebuilding the desktop app"),
|
||||
],
|
||||
protocol_version: None,
|
||||
},
|
||||
);
|
||||
|
||||
// ---- pre-step: wait for the old desktop to die -----------------------
|
||||
// The desktop exec'd us then called app.exit(), but process teardown is
|
||||
// async on Windows. If it still holds the venv shim, `hermes update`
|
||||
// aborts with exit 2. Give it a bounded window to clear.
|
||||
wait_for_venv_free(&install_root, &app).await;
|
||||
|
||||
// ---- stage 1: hermes update -----------------------------------------
|
||||
// Pass --branch so `hermes update` targets the branch this installer was
|
||||
// built/pinned against (BUILD_PIN_BRANCH), NOT its built-in default of
|
||||
// `main`. The install was a detached-HEAD checkout of a specific commit;
|
||||
// without --branch, `hermes update` switches the checkout to `main` (a
|
||||
// divergent branch that may not even have the desktop CLI command), then
|
||||
// reports "already up to date" against the wrong branch. The desktop
|
||||
// detected the update against this same branch, so we must update against
|
||||
// it too.
|
||||
let pin_branch = option_env_string("BUILD_PIN_BRANCH");
|
||||
let mut update_args: Vec<&str> = vec!["update", "--yes", "--gateway"];
|
||||
if let Some(b) = pin_branch.as_deref() {
|
||||
update_args.push("--branch");
|
||||
update_args.push(b);
|
||||
}
|
||||
|
||||
emit_stage(&app, "update", StageState::Running, None, None);
|
||||
let started = Instant::now();
|
||||
let update = run_streamed(
|
||||
&app,
|
||||
&hermes,
|
||||
&update_args,
|
||||
&install_root,
|
||||
Some("update"),
|
||||
)
|
||||
.await?;
|
||||
let update_ms = started.elapsed().as_millis() as u64;
|
||||
|
||||
match update.exit_code {
|
||||
Some(0) => {
|
||||
emit_stage(&app, "update", StageState::Succeeded, Some(update_ms), None);
|
||||
}
|
||||
Some(code) if code == UPDATE_EXIT_CONCURRENT => {
|
||||
let msg = "Hermes is still running. Close all Hermes windows and try \
|
||||
the update again."
|
||||
.to_string();
|
||||
emit_stage(
|
||||
&app,
|
||||
"update",
|
||||
StageState::Failed,
|
||||
Some(update_ms),
|
||||
Some(msg.clone()),
|
||||
);
|
||||
emit(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: Some("update".into()),
|
||||
error: msg.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(msg));
|
||||
}
|
||||
other => {
|
||||
let msg = format!(
|
||||
"hermes update failed (exit {:?}). See {} for details.",
|
||||
other,
|
||||
crate::paths::hermes_home()
|
||||
.join("logs")
|
||||
.join("update.log")
|
||||
.display()
|
||||
);
|
||||
emit_stage(
|
||||
&app,
|
||||
"update",
|
||||
StageState::Failed,
|
||||
Some(update_ms),
|
||||
Some(msg.clone()),
|
||||
);
|
||||
emit(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: Some("update".into()),
|
||||
error: msg.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(msg));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- stage 2: hermes desktop --build-only ----------------------------
|
||||
// `hermes update` deliberately does NOT build apps/desktop (it installs
|
||||
// repo-root deps with --workspaces=false). This is the rebuild it skips.
|
||||
emit_stage(&app, "rebuild", StageState::Running, None, None);
|
||||
let started = Instant::now();
|
||||
let rebuild = run_streamed(
|
||||
&app,
|
||||
&hermes,
|
||||
&["desktop", "--build-only"],
|
||||
&install_root,
|
||||
Some("rebuild"),
|
||||
)
|
||||
.await?;
|
||||
let rebuild_ms = started.elapsed().as_millis() as u64;
|
||||
|
||||
if rebuild.exit_code != Some(0) {
|
||||
let msg = format!(
|
||||
"Rebuilding the desktop app failed (exit {:?}). The update was \
|
||||
applied but the app could not be rebuilt; run `hermes desktop` \
|
||||
from a terminal to see the error.",
|
||||
rebuild.exit_code
|
||||
);
|
||||
emit_stage(
|
||||
&app,
|
||||
"rebuild",
|
||||
StageState::Failed,
|
||||
Some(rebuild_ms),
|
||||
Some(msg.clone()),
|
||||
);
|
||||
emit(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: Some("rebuild".into()),
|
||||
error: msg.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(msg));
|
||||
}
|
||||
emit_stage(&app, "rebuild", StageState::Succeeded, Some(rebuild_ms), None);
|
||||
|
||||
// ---- done: signal complete, then launch the fresh desktop ------------
|
||||
emit(
|
||||
&app,
|
||||
BootstrapEvent::Complete {
|
||||
install_root: install_root.to_string_lossy().into_owned(),
|
||||
marker: None,
|
||||
},
|
||||
);
|
||||
|
||||
// Reuse the same detached-launch + app.exit(0) used post-install.
|
||||
if let Err(err) =
|
||||
crate::bootstrap::launch_hermes_desktop(app.clone(), install_root.to_string_lossy().into_owned())
|
||||
.await
|
||||
{
|
||||
// Launch failed: don't hard-fail the update (it succeeded); surface a
|
||||
// log line so the success screen can still tell the user to launch
|
||||
// manually.
|
||||
emit_log(
|
||||
&app,
|
||||
None,
|
||||
&format!("[update] could not auto-launch desktop: {err}. Launch Hermes manually."),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll until the venv shim is no longer locked (Windows) or a bounded timeout
|
||||
/// elapses. On non-Windows this is a short fixed grace since file locking
|
||||
/// isn't the failure mode there.
|
||||
async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
|
||||
let shim = venv_hermes(install_root);
|
||||
let deadline = Instant::now() + DESKTOP_EXIT_WAIT;
|
||||
|
||||
emit_log(app, Some("update"), "[update] waiting for Hermes to exit…");
|
||||
|
||||
loop {
|
||||
if !is_locked(&shim) {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
emit_log(
|
||||
app,
|
||||
Some("update"),
|
||||
"[update] timed out waiting for Hermes to exit; proceeding anyway",
|
||||
);
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(DESKTOP_EXIT_POLL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort lock probe: try to open the file for read+write. On Windows an
|
||||
/// exclusively-held running .exe refuses the open with a sharing violation.
|
||||
/// On Unix this almost always succeeds (no mandatory locking), which is fine —
|
||||
/// the venv-shim contention is a Windows-only problem.
|
||||
fn is_locked(path: &Path) -> bool {
|
||||
if !path.exists() {
|
||||
return false;
|
||||
}
|
||||
match std::fs::OpenOptions::new().read(true).write(true).open(path) {
|
||||
Ok(_) => false,
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn `hermes <args>` from `cwd`, stream stdout/stderr as Log events on the
|
||||
/// bootstrap channel, and return the exit code. Mirrors powershell::run_script
|
||||
/// but for an arbitrary command (no install.ps1 -File wrapping).
|
||||
async fn run_streamed(
|
||||
app: &AppHandle,
|
||||
program: &Path,
|
||||
args: &[&str],
|
||||
cwd: &Path,
|
||||
stage: Option<&str>,
|
||||
) -> Result<CmdResult> {
|
||||
let mut cmd = Command::new(program);
|
||||
cmd.args(args)
|
||||
.current_dir(cwd)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// CREATE_NO_WINDOW = 0x08000000 — no flashing console behind the GUI.
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| anyhow!("spawning {} {:?}: {e}", program.display(), args))?;
|
||||
|
||||
let stdout = child.stdout.take().expect("stdout piped");
|
||||
let stderr = child.stderr.take().expect("stderr piped");
|
||||
let mut out = BufReader::new(stdout).lines();
|
||||
let mut err = BufReader::new(stderr).lines();
|
||||
|
||||
let stage_owned = stage.map(|s| s.to_string());
|
||||
loop {
|
||||
tokio::select! {
|
||||
line = out.next_line() => match line {
|
||||
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), &l),
|
||||
Ok(None) => break,
|
||||
Err(e) => { tracing::warn!("stdout read error: {e}"); break; }
|
||||
},
|
||||
line = err.next_line() => match line {
|
||||
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), &format!("stderr: {l}")),
|
||||
Ok(None) => {}
|
||||
Err(e) => { tracing::warn!("stderr read error: {e}"); }
|
||||
},
|
||||
}
|
||||
}
|
||||
while let Ok(Some(l)) = out.next_line().await {
|
||||
emit_log(app, stage_owned.as_deref(), &l);
|
||||
}
|
||||
while let Ok(Some(l)) = err.next_line().await {
|
||||
emit_log(app, stage_owned.as_deref(), &format!("stderr: {l}"));
|
||||
}
|
||||
|
||||
let status = child.wait().await.map_err(|e| anyhow!("waiting for child: {e}"))?;
|
||||
Ok(CmdResult {
|
||||
exit_code: status.code(),
|
||||
})
|
||||
}
|
||||
|
||||
struct CmdResult {
|
||||
exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
/// Path to the venv hermes shim under an install root, regardless of existence.
|
||||
fn venv_hermes(install_root: &Path) -> PathBuf {
|
||||
if cfg!(target_os = "windows") {
|
||||
install_root.join("venv").join("Scripts").join("hermes.exe")
|
||||
} else {
|
||||
install_root.join("venv").join("bin").join("hermes")
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the hermes CLI to drive. Prefer the venv shim in the install we
|
||||
/// just updated; fall back to `hermes` on PATH.
|
||||
fn resolve_hermes(install_root: &Path) -> Option<PathBuf> {
|
||||
let shim = venv_hermes(install_root);
|
||||
if shim.exists() {
|
||||
return Some(shim);
|
||||
}
|
||||
// PATH fallback. which-style probe via env, kept dependency-free.
|
||||
let exe = if cfg!(target_os = "windows") { "hermes.exe" } else { "hermes" };
|
||||
if let Ok(path) = std::env::var("PATH") {
|
||||
let sep = if cfg!(target_os = "windows") { ';' } else { ':' };
|
||||
for dir in path.split(sep) {
|
||||
let cand = Path::new(dir).join(exe);
|
||||
if cand.exists() {
|
||||
return Some(cand);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event helpers — keep emit shape identical to bootstrap.rs so the UI is reused
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn stage_info(name: &str, title: &str) -> StageInfo {
|
||||
StageInfo {
|
||||
name: name.to_string(),
|
||||
title: title.to_string(),
|
||||
category: "update".to_string(),
|
||||
needs_user_input: false,
|
||||
}
|
||||
}
|
||||
|
||||
// option_env! only accepts string literals, so the build-time pins are read
|
||||
// by their literal names here. Mirrors bootstrap.rs's helper of the same name
|
||||
// (kept local rather than shared because option_env! can't be parameterized).
|
||||
fn option_env_string(key: &str) -> Option<String> {
|
||||
let val = match key {
|
||||
"BUILD_PIN_COMMIT" => option_env!("BUILD_PIN_COMMIT"),
|
||||
"BUILD_PIN_BRANCH" => option_env!("BUILD_PIN_BRANCH"),
|
||||
_ => None,
|
||||
};
|
||||
val.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn emit(app: &AppHandle, event: BootstrapEvent) {
|
||||
if let Err(e) = app.emit(BootstrapEvent::CHANNEL, &event) {
|
||||
tracing::warn!(?e, "failed to emit update event");
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_stage(
|
||||
app: &AppHandle,
|
||||
name: &str,
|
||||
state: StageState,
|
||||
duration_ms: Option<u64>,
|
||||
error: Option<String>,
|
||||
) {
|
||||
tracing::info!(stage = %name, ?state, ?duration_ms, ?error, "update stage");
|
||||
emit(
|
||||
app,
|
||||
BootstrapEvent::Stage {
|
||||
name: name.to_string(),
|
||||
state,
|
||||
duration_ms,
|
||||
result: None,
|
||||
error,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_log(app: &AppHandle, stage: Option<&str>, line: &str) {
|
||||
match stage {
|
||||
Some(s) => tracing::info!(target: "bootstrap.log", stage = %s, "{line}"),
|
||||
None => tracing::info!(target: "bootstrap.log", "{line}"),
|
||||
}
|
||||
emit(
|
||||
app,
|
||||
BootstrapEvent::Log {
|
||||
stage: stage.map(|s| s.to_string()),
|
||||
line: line.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn venv_hermes_is_under_install_root() {
|
||||
let root = Path::new("/x/hermes-agent");
|
||||
let shim = venv_hermes(root);
|
||||
assert!(shim.starts_with(root));
|
||||
assert!(shim.to_string_lossy().contains("venv"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_is_not_locked() {
|
||||
assert!(!is_locked(Path::new("/nonexistent/does/not/exist/xyz")));
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Hermes Setup",
|
||||
"version": "0.0.1",
|
||||
"identifier": "com.nousresearch.hermes.setup",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://127.0.0.1:5175",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Hermes Setup",
|
||||
"width": 880,
|
||||
"height": 620,
|
||||
"minWidth": 720,
|
||||
"minHeight": 520,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost"
|
||||
},
|
||||
"withGlobalTauri": false
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"category": "DeveloperTool",
|
||||
"shortDescription": "Hermes Setup",
|
||||
"longDescription": "Installs Hermes Agent on your machine. Drives scripts/install.ps1 (Windows) and scripts/install.sh (macOS/Linux).",
|
||||
"publisher": "Nous Research",
|
||||
"copyright": "Copyright © 2026 Nous Research",
|
||||
"targets": [
|
||||
"app",
|
||||
"dmg",
|
||||
"appimage"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"windows": {
|
||||
"webviewInstallMode": {
|
||||
"type": "embedBootstrapper"
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "11.0",
|
||||
"hardenedRuntime": true
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect } from 'react'
|
||||
import { $route, $bootstrap, initialize } from './store'
|
||||
import Welcome from './routes/welcome'
|
||||
import Progress from './routes/progress'
|
||||
import Success from './routes/success'
|
||||
import Failure from './routes/failure'
|
||||
|
||||
/*
|
||||
* App shell — Hermes Setup.
|
||||
*
|
||||
* No header chrome (the OS title bar already says "Hermes Setup"; an
|
||||
* in-window repeat of the H mark + words was redundant slop).
|
||||
*
|
||||
* Route state lives in a single $route atom — 4 screens, no react-router.
|
||||
*/
|
||||
export default function App() {
|
||||
const route = useStore($route)
|
||||
const bootstrap = useStore($bootstrap)
|
||||
|
||||
useEffect(() => {
|
||||
void initialize()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full flex-col overflow-hidden bg-background text-foreground">
|
||||
<main className="relative z-10 flex flex-1 flex-col overflow-hidden">
|
||||
{route === 'welcome' && <Welcome />}
|
||||
{route === 'progress' && <Progress bootstrap={bootstrap} />}
|
||||
{route === 'success' && <Success />}
|
||||
{route === 'failure' && <Failure bootstrap={bootstrap} />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { Slot } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
/*
|
||||
* Button — copied verbatim from apps/desktop/src/components/ui/button.tsx.
|
||||
*
|
||||
* We import the desktop's local shadcn-style Button rather than
|
||||
* @nous-research/ui's <Button>, because the DS Button uses bg-midground /
|
||||
* text-background-base utilities that resolve to the DS's hardcoded
|
||||
* gold/brown brand defaults (#ffac02 / #170d02) unless overridden in
|
||||
* runtime. The desktop never sets those vars; it routes through its
|
||||
* own --dt-* token chain via shadcn classes like bg-primary. We do
|
||||
* the same so visuals match exactly.
|
||||
*/
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 decoration-current/20 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-xs':
|
||||
"size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
interface ButtonProps
|
||||
extends React.ComponentProps<'button'>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
export function Button({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const Comp = asChild ? Slot.Root : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
data-size={size}
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { buttonVariants }
|
||||
@@ -1,12 +0,0 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
/*
|
||||
* cn — Tailwind-aware class merger. Same util the desktop and dashboard
|
||||
* use. clsx handles conditional classes; twMerge resolves utility
|
||||
* conflicts so `cn('px-2', condition && 'px-4')` ends up with px-4 only,
|
||||
* not both.
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './app.tsx'
|
||||
import './styles.css'
|
||||
|
||||
// Default to LIGHT mode — matches the Hermes desktop's default. The
|
||||
// desktop's runtime theme system can switch to .dark later, but our
|
||||
// installer ships in light mode only since we don't carry the theme
|
||||
// provider machinery.
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -1,77 +0,0 @@
|
||||
import { type CSSProperties } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Button } from '../components/button'
|
||||
import {
|
||||
$logPath,
|
||||
openLogDir,
|
||||
startInstall,
|
||||
type BootstrapStateModel
|
||||
} from '../store'
|
||||
import { RefreshCw, FileText } from 'lucide-react'
|
||||
|
||||
interface FailureProps {
|
||||
bootstrap: BootstrapStateModel
|
||||
}
|
||||
|
||||
/*
|
||||
* Failure screen. Same hero treatment as Welcome/Success — the wordmark
|
||||
* carries the brand, so we keep it across every terminal state.
|
||||
*
|
||||
* The actual error message lives below in muted text. Two clear
|
||||
* affordances: Retry (primary) and Open log folder (secondary).
|
||||
*/
|
||||
export default function Failure({ bootstrap }: FailureProps) {
|
||||
const logPath = useStore($logPath)
|
||||
|
||||
return (
|
||||
<div className="hermes-fade-in flex h-full flex-col items-center justify-center gap-6 px-12 py-10">
|
||||
<div className="w-full max-w-2xl min-w-0 text-center">
|
||||
<p
|
||||
className="fit-text mx-auto mb-4 w-full font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-destructive mix-blend-plus-lighter dark:text-destructive/90"
|
||||
style={
|
||||
{
|
||||
'--fit-text-line-height': '0.9',
|
||||
'--fit-text-max': '5rem',
|
||||
'--fit-text-min': '2.25rem'
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<span>Install didn’t finish</span>
|
||||
</span>
|
||||
<span aria-hidden="true">Install didn’t finish</span>
|
||||
</p>
|
||||
|
||||
<p className="m-0 mx-auto max-w-xl text-center text-sm leading-normal tracking-tight text-muted-foreground">
|
||||
{bootstrap.error ?? 'Something went wrong during installation.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={() => void startInstall()}
|
||||
size="lg"
|
||||
className="inline-flex items-center gap-2 px-6"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
Retry install
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => void openLogDir()}
|
||||
className="inline-flex items-center gap-2"
|
||||
>
|
||||
<FileText size={16} />
|
||||
Open log folder
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{logPath && (
|
||||
<p className="max-w-lg text-center text-xs text-muted-foreground/70">
|
||||
Log: <code className="font-mono">{logPath}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Button } from '../components/button'
|
||||
import {
|
||||
cancelInstall,
|
||||
$progress,
|
||||
type BootstrapStateModel,
|
||||
type StageState
|
||||
} from '../store'
|
||||
import { Check, X, ChevronRight, FileText, Loader2 } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface ProgressProps {
|
||||
bootstrap: BootstrapStateModel
|
||||
}
|
||||
|
||||
/*
|
||||
* Progress screen — drives a stage list + collapsible log panel. Uses
|
||||
* the DS <Progress> for the top bar so its motion + ring match the rest
|
||||
* of the product.
|
||||
*/
|
||||
export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
||||
const progress = useStore($progress)
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
const logEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (showLogs && logEndRef.current) {
|
||||
logEndRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [bootstrap.logs.length, showLogs])
|
||||
|
||||
const currentStage =
|
||||
bootstrap.currentStage != null
|
||||
? bootstrap.stages[bootstrap.currentStage]
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="hermes-fade-in flex h-full flex-col">
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<div className="mb-3 flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
{bootstrap.status === 'running' && (
|
||||
<Loader2 size={12} className="animate-spin text-primary" />
|
||||
)}
|
||||
<span>
|
||||
{bootstrap.status === 'running'
|
||||
? currentStage
|
||||
? currentStage.info.title
|
||||
: 'Preparing\u2026'
|
||||
: bootstrap.status === 'completed'
|
||||
? 'Done'
|
||||
: 'Installing'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{progress.done} of {progress.total} steps
|
||||
</div>
|
||||
</div>
|
||||
{/* Top progress bar — plain HTML, derived from --primary so it
|
||||
tracks the theme accent. */}
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300 ease-out"
|
||||
style={{ width: `${Math.max(2, progress.fraction * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<ol className="space-y-1">
|
||||
{bootstrap.stageOrder.map((name) => {
|
||||
const rec = bootstrap.stages[name]
|
||||
if (!rec) return null
|
||||
return (
|
||||
<li
|
||||
key={name}
|
||||
className={clsx(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors',
|
||||
rec.state === 'running' && 'bg-card text-foreground',
|
||||
rec.state === 'succeeded' && 'text-foreground/80',
|
||||
rec.state === 'skipped' && 'text-muted-foreground',
|
||||
rec.state === 'failed' &&
|
||||
'bg-destructive/10 text-destructive',
|
||||
!rec.state && 'text-muted-foreground/60'
|
||||
)}
|
||||
>
|
||||
<StateIcon state={rec.state ?? null} />
|
||||
<span className="flex-1 truncate">{rec.info.title}</span>
|
||||
{rec.durationMs != null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDuration(rec.durationMs)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{showLogs && (
|
||||
<div className="flex w-1/2 flex-col border-l border-border bg-card/40">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2">
|
||||
<div className="text-xs font-medium text-foreground/80">
|
||||
Live output
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{bootstrap.logs.length} lines
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2 font-mono text-[11px] leading-relaxed">
|
||||
{bootstrap.logs.map((entry, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={clsx(
|
||||
'whitespace-pre-wrap',
|
||||
entry.line.startsWith('stderr:')
|
||||
? 'text-destructive'
|
||||
: 'text-foreground/70'
|
||||
)}
|
||||
>
|
||||
{entry.line}
|
||||
</div>
|
||||
))}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between border-t border-border px-6 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLogs((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<FileText size={14} />
|
||||
{showLogs ? 'Hide details' : 'Show details'}
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={clsx(
|
||||
'transition-transform',
|
||||
showLogs && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{bootstrap.status === 'running' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void cancelInstall()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StateIcon({ state }: { state: StageState | null }) {
|
||||
if (state === 'running') {
|
||||
return <Loader2 size={14} className="animate-spin text-primary" />
|
||||
}
|
||||
if (state === 'succeeded') {
|
||||
return <Check size={14} className="text-emerald-400" />
|
||||
}
|
||||
if (state === 'skipped') {
|
||||
return <ChevronRight size={14} className="text-muted-foreground/70" />
|
||||
}
|
||||
if (state === 'failed') {
|
||||
return <X size={14} className="text-destructive" />
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="h-[6px] w-[6px] rounded-full bg-muted-foreground/40"
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
||||
const m = Math.floor(ms / 60000)
|
||||
const s = Math.round((ms % 60000) / 1000)
|
||||
return `${m}m ${s}s`
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { type CSSProperties } from 'react'
|
||||
import { Button } from '../components/button'
|
||||
import { launchHermesDesktop } from '../store'
|
||||
import { Rocket, AlertCircle } from 'lucide-react'
|
||||
|
||||
/*
|
||||
* Success screen. HERMES AGENT wordmark stays as the visual anchor
|
||||
* (same Collapse Bold treatment as Welcome + the desktop chat intro),
|
||||
* with a status line below.
|
||||
*
|
||||
* Launching the desktop can fail (e.g. Stage-Desktop was skipped and
|
||||
* Hermes.exe doesn't exist). We catch the Tauri error and surface it
|
||||
* inline rather than silently doing nothing — the previous version
|
||||
* had `onClick={() => void launchHermesDesktop()}` which swallowed
|
||||
* the rejection and left the user staring at an unresponsive button.
|
||||
*/
|
||||
export default function Success() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [launching, setLaunching] = useState(false)
|
||||
|
||||
async function handleLaunch() {
|
||||
setError(null)
|
||||
setLaunching(true)
|
||||
try {
|
||||
await launchHermesDesktop()
|
||||
// On success the installer exits — control never returns here.
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setError(msg)
|
||||
setLaunching(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hermes-fade-in flex h-full flex-col items-center justify-center gap-8 px-12 py-10">
|
||||
<div className="w-full max-w-2xl min-w-0 text-center">
|
||||
<p
|
||||
className="fit-text mx-auto mb-4 w-full font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
|
||||
style={
|
||||
{
|
||||
'--fit-text-line-height': '0.9',
|
||||
'--fit-text-max': '5rem',
|
||||
'--fit-text-min': '2.25rem'
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<span>Hermes is ready</span>
|
||||
</span>
|
||||
<span aria-hidden="true">Hermes is ready</span>
|
||||
</p>
|
||||
|
||||
<p className="m-0 text-center text-base leading-normal tracking-tight text-muted-foreground">
|
||||
You can launch from here, or any time from your terminal with{' '}
|
||||
<code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-sm">
|
||||
hermes desktop
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => void handleLaunch()}
|
||||
size="lg"
|
||||
disabled={launching}
|
||||
className="inline-flex items-center gap-2 px-6"
|
||||
>
|
||||
<Rocket size={18} />
|
||||
{launching ? 'Launching…' : 'Launch Hermes'}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex max-w-2xl items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||
>
|
||||
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">Couldn’t launch the desktop app</div>
|
||||
<div className="mt-1 text-destructive/80">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { type CSSProperties } from 'react'
|
||||
import { Button } from '../components/button'
|
||||
import { startInstall } from '../store'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
/*
|
||||
* Welcome screen.
|
||||
*
|
||||
* Mirrors the desktop's chat intro (apps/desktop/src/components/chat/intro.tsx):
|
||||
* - HERMES AGENT wordmark rendered in Collapse Bold, uppercase, tracked
|
||||
* - mix-blend-plus-lighter so the type "glows" on the canvas
|
||||
* - fit-text utility so the wordmark sizes itself to the column
|
||||
*
|
||||
* No install-path footer. The default install location is correct for
|
||||
* 99% of users; the rest will use the CLI installer with a -HermesHome
|
||||
* flag. Showing %LOCALAPPDATA% to grandma is developer-brain.
|
||||
*/
|
||||
export default function Welcome() {
|
||||
return (
|
||||
<div className="hermes-fade-in flex h-full flex-col items-center justify-center gap-10 px-12 py-10">
|
||||
{/* Hero — same recipe the desktop's chat/intro.tsx uses */}
|
||||
<div className="w-full max-w-2xl min-w-0 text-center">
|
||||
<p
|
||||
className="fit-text mx-auto mb-4 w-full font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
|
||||
style={
|
||||
{
|
||||
'--fit-text-line-height': '0.9',
|
||||
'--fit-text-max': '6rem',
|
||||
'--fit-text-min': '2.5rem'
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<span>HERMES AGENT</span>
|
||||
</span>
|
||||
<span aria-hidden="true">HERMES AGENT</span>
|
||||
</p>
|
||||
|
||||
<p className="m-0 text-center text-base leading-normal tracking-tight text-muted-foreground">
|
||||
The agent that grows with you. We’ll set things up in the
|
||||
background — takes a few minutes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => void startInstall()}
|
||||
size="lg"
|
||||
className="group inline-flex items-center gap-2 px-6"
|
||||
>
|
||||
Install Hermes
|
||||
<ArrowRight
|
||||
size={18}
|
||||
className="transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
import { atom, computed } from 'nanostores'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
/*
|
||||
* Bootstrap state store — single source of truth for installer screens.
|
||||
*
|
||||
* Lives in nanostores per the project's TypeScript guidelines (apps/desktop
|
||||
* AGENTS.md): "Prefer small nanostores over component state when state is
|
||||
* shared, reused, or read by distant UI."
|
||||
*
|
||||
* One channel from Rust ('bootstrap' event), discriminated by payload.type.
|
||||
* We translate those events into typed atom updates here so the rest of
|
||||
* the app only deals with React-friendly state.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types — mirror src-tauri/src/events.rs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StageInfo {
|
||||
name: string
|
||||
title: string
|
||||
category: string
|
||||
needs_user_input: boolean
|
||||
}
|
||||
|
||||
export type StageState = 'running' | 'succeeded' | 'skipped' | 'failed'
|
||||
|
||||
export interface StageRecord {
|
||||
info: StageInfo
|
||||
state: StageState | null
|
||||
durationMs?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface BootstrapStateModel {
|
||||
status: 'idle' | 'running' | 'completed' | 'failed'
|
||||
protocolVersion: number | null
|
||||
stages: Record<string, StageRecord>
|
||||
stageOrder: string[]
|
||||
currentStage: string | null
|
||||
installRoot: string | null
|
||||
error: string | null
|
||||
logs: Array<{ stage?: string; line: string }>
|
||||
}
|
||||
|
||||
const INITIAL: BootstrapStateModel = {
|
||||
status: 'idle',
|
||||
protocolVersion: null,
|
||||
stages: {},
|
||||
stageOrder: [],
|
||||
currentStage: null,
|
||||
installRoot: null,
|
||||
error: null,
|
||||
logs: []
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Route = 'welcome' | 'progress' | 'success' | 'failure'
|
||||
|
||||
/// How the installer was launched, mirrored from src-tauri AppMode.
|
||||
/// 'install' = first-run onboarding (bare launch). 'update' = driven by the
|
||||
/// desktop app handing off via `Hermes-Setup.exe --update`.
|
||||
export type AppMode = 'install' | 'update'
|
||||
|
||||
export const $route = atom<Route>('welcome')
|
||||
export const $mode = atom<AppMode>('install')
|
||||
export const $bootstrap = atom<BootstrapStateModel>(INITIAL)
|
||||
export const $logPath = atom<string | null>(null)
|
||||
export const $hermesHome = atom<string | null>(null)
|
||||
|
||||
export const $progress = computed($bootstrap, (b) => {
|
||||
const total = b.stageOrder.length
|
||||
if (total === 0) return { done: 0, total: 0, fraction: 0 }
|
||||
let done = 0
|
||||
for (const name of b.stageOrder) {
|
||||
const s = b.stages[name]?.state
|
||||
if (s === 'succeeded' || s === 'skipped' || s === 'failed') done += 1
|
||||
}
|
||||
return { done, total, fraction: done / total }
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri event subscription
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface BootstrapManifestEvent {
|
||||
type: 'manifest'
|
||||
stages: StageInfo[]
|
||||
protocolVersion: number | null
|
||||
}
|
||||
|
||||
interface BootstrapStageEvent {
|
||||
type: 'stage'
|
||||
name: string
|
||||
state: StageState
|
||||
durationMs?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface BootstrapLogEvent {
|
||||
type: 'log'
|
||||
stage?: string
|
||||
line: string
|
||||
}
|
||||
|
||||
interface BootstrapCompleteEvent {
|
||||
type: 'complete'
|
||||
installRoot: string
|
||||
marker: unknown
|
||||
}
|
||||
|
||||
interface BootstrapFailedEvent {
|
||||
type: 'failed'
|
||||
stage?: string
|
||||
error: string
|
||||
}
|
||||
|
||||
type BootstrapEvent =
|
||||
| BootstrapManifestEvent
|
||||
| BootstrapStageEvent
|
||||
| BootstrapLogEvent
|
||||
| BootstrapCompleteEvent
|
||||
| BootstrapFailedEvent
|
||||
|
||||
let unlisten: UnlistenFn | null = null
|
||||
|
||||
export async function initialize(): Promise<void> {
|
||||
if (unlisten) return
|
||||
|
||||
// Pull static info on mount for the diagnostics footer.
|
||||
try {
|
||||
const [logPath, hermesHome, mode] = await Promise.all([
|
||||
invoke<string>('get_log_path'),
|
||||
invoke<string>('get_hermes_home'),
|
||||
invoke<AppMode>('get_mode')
|
||||
])
|
||||
$logPath.set(logPath)
|
||||
$hermesHome.set(hermesHome)
|
||||
$mode.set(mode)
|
||||
} catch (err) {
|
||||
console.warn('failed to fetch installer paths', err)
|
||||
}
|
||||
|
||||
unlisten = await listen<BootstrapEvent>('bootstrap', (event) => {
|
||||
const payload = event.payload
|
||||
const cur = $bootstrap.get()
|
||||
switch (payload.type) {
|
||||
case 'manifest': {
|
||||
const stages: Record<string, StageRecord> = {}
|
||||
const order: string[] = []
|
||||
for (const s of payload.stages) {
|
||||
stages[s.name] = { info: s, state: null }
|
||||
order.push(s.name)
|
||||
}
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
status: 'running',
|
||||
protocolVersion: payload.protocolVersion,
|
||||
stages,
|
||||
stageOrder: order,
|
||||
currentStage: null,
|
||||
installRoot: null,
|
||||
error: null,
|
||||
logs: []
|
||||
})
|
||||
$route.set('progress')
|
||||
break
|
||||
}
|
||||
case 'stage': {
|
||||
const existing = cur.stages[payload.name]
|
||||
if (!existing) {
|
||||
console.warn('stage event for unknown stage', payload.name)
|
||||
break
|
||||
}
|
||||
const next: StageRecord = {
|
||||
...existing,
|
||||
state: payload.state,
|
||||
durationMs: payload.durationMs,
|
||||
error: payload.error
|
||||
}
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
stages: { ...cur.stages, [payload.name]: next },
|
||||
currentStage:
|
||||
payload.state === 'running' ? payload.name : cur.currentStage
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'log': {
|
||||
const logs = [...cur.logs, { stage: payload.stage, line: payload.line }]
|
||||
// Keep the rolling buffer bounded so the UI doesn't get OOM'd
|
||||
// during a long install (playwright chromium download is ~10k lines).
|
||||
const trimmed = logs.length > 2000 ? logs.slice(-2000) : logs
|
||||
$bootstrap.set({ ...cur, logs: trimmed })
|
||||
break
|
||||
}
|
||||
case 'complete':
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
status: 'completed',
|
||||
installRoot: payload.installRoot,
|
||||
currentStage: null
|
||||
})
|
||||
// Install: show the "launch Hermes" success screen. Update: this is a
|
||||
// hand-off — the installer relaunches the desktop and exits within a
|
||||
// few hundred ms, so routing to success just flashes that screen
|
||||
// before the window closes. Stay on progress until we exit.
|
||||
if ($mode.get() !== 'update') {
|
||||
$route.set('success')
|
||||
}
|
||||
break
|
||||
case 'failed':
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
status: 'failed',
|
||||
error: payload.error,
|
||||
currentStage: null
|
||||
})
|
||||
$route.set('failure')
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
// Update mode is a hand-off, not a user-initiated flow: the desktop already
|
||||
// exited and re-launched us as `--update`. Kick the update immediately so
|
||||
// the user lands on progress, not a redundant "click to update" screen.
|
||||
if ($mode.get() === 'update') {
|
||||
void startUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function startInstall(opts?: { branch?: string }): Promise<void> {
|
||||
// Reset before kicking off so a retry from the failure screen clears
|
||||
// the previous run's state.
|
||||
$bootstrap.set(INITIAL)
|
||||
$route.set('progress')
|
||||
await invoke('start_bootstrap', {
|
||||
args: {
|
||||
commit: null,
|
||||
branch: opts?.branch ?? null,
|
||||
include_desktop: true,
|
||||
hermes_home: null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function startUpdate(): Promise<void> {
|
||||
// Update is driven by the desktop handing off (Hermes-Setup.exe --update);
|
||||
// there's no welcome click. Reset + jump straight to progress, then let the
|
||||
// Rust side stream the synthetic update manifest.
|
||||
$bootstrap.set(INITIAL)
|
||||
$route.set('progress')
|
||||
await invoke('start_update')
|
||||
}
|
||||
|
||||
export async function cancelInstall(): Promise<void> {
|
||||
await invoke('cancel_bootstrap')
|
||||
}
|
||||
|
||||
export async function launchHermesDesktop(): Promise<void> {
|
||||
const installRoot = $bootstrap.get().installRoot
|
||||
if (!installRoot) throw new Error('no install root')
|
||||
await invoke('launch_hermes_desktop', { installRoot })
|
||||
}
|
||||
|
||||
export async function openLogDir(): Promise<void> {
|
||||
await invoke('open_log_dir')
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/*
|
||||
* Hermes Setup — defer entirely to the desktop's styles.css.
|
||||
*
|
||||
* Rather than re-implement the Hermes design system (and inevitably drift
|
||||
* from it), we import apps/desktop/src/styles.css wholesale. The desktop
|
||||
* is the canonical source of truth for fonts, color tokens, button chrome,
|
||||
* scrollbars, layout utilities, and animations. Any change to the
|
||||
* Hermes look propagates here automatically with no copy-paste maintenance.
|
||||
*
|
||||
* Path resolution caveats:
|
||||
* - Tailwind v4's `@import` resolves relative to this file. The desktop's
|
||||
* `@source '../../../node_modules/...'` declarations therefore re-resolve
|
||||
* against apps/bootstrap-installer/src/. Since both apps live two levels
|
||||
* deep under the same repo root, `../../../node_modules` lands in the
|
||||
* same place. (Verify if either app ever moves.)
|
||||
* - The desktop's `@font-face url('../../../node_modules/...')` references
|
||||
* are baked into the *imported* stylesheet; CSS resolves url()s relative
|
||||
* to the file that contains them, so they continue to point at the
|
||||
* correct node_modules path even from here.
|
||||
*
|
||||
* Forced light mode: the desktop ships with a runtime theme switcher
|
||||
* (ThemeProvider + applyTheme) that can flip to dark via document.documentElement.
|
||||
* The installer has no UI for theme switching, so we stay on the desktop's
|
||||
* default light surface (Nous-blue accent on near-white chrome).
|
||||
*/
|
||||
@import '../../desktop/src/styles.css';
|
||||
|
||||
/* Installer-only additions: a fade-in animation and a warm radial glow
|
||||
for the welcome screen. Everything else inherits from the desktop. */
|
||||
@keyframes hermes-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hermes-fade-in {
|
||||
animation: hermes-fade-in 0.45s ease-out both;
|
||||
}
|
||||
|
||||
.hermes-glow {
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
color-mix(in srgb, var(--ui-warm) 18%, transparent) 0%,
|
||||
transparent 60%
|
||||
);
|
||||
}
|
||||
1
apps/bootstrap-installer/src/vite-env.d.ts
vendored
1
apps/bootstrap-installer/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"esModuleInterop": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'node:path'
|
||||
|
||||
// Hermes Setup — Tauri-targeted Vite config.
|
||||
//
|
||||
// Port 5175 keeps us out of the way of:
|
||||
// web (vite default 5173)
|
||||
// apps/desktop dev (5174 per its package.json)
|
||||
//
|
||||
// `clearScreen: false` is the Tauri convention — they spawn vite as a child
|
||||
// process and want our errors to stay visible.
|
||||
|
||||
const host = process.env.TAURI_DEV_HOST
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 5175,
|
||||
strictPort: true,
|
||||
host: host || '127.0.0.1',
|
||||
hmr: host
|
||||
? {
|
||||
protocol: 'ws',
|
||||
host,
|
||||
port: 5176
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// Don't watch the Rust side — tauri-cli handles it.
|
||||
ignored: ['**/src-tauri/**']
|
||||
}
|
||||
},
|
||||
build: {
|
||||
target: 'esnext',
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true
|
||||
}
|
||||
})
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"arrowParens": "avoid",
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "auto",
|
||||
"printWidth": 120,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"useTabs": false
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
# Hermes Desktop
|
||||
|
||||
Native Electron shell for Hermes. It packages the desktop renderer, a bundled Hermes source payload, and installer targets for macOS and Windows. Note: this doc needs updating.
|
||||
|
||||
## Setup
|
||||
|
||||
Install workspace dependencies from the repo root so `apps/desktop`, `web`, and `apps/shared` stay linked:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
For Python, you have two options:
|
||||
|
||||
**Option A — let the desktop provision it for you (recommended for first-time setup):** just run `npm run dev`. On first launch the desktop creates a venv at `HERMES_HOME/hermes-agent/venv` and runs `pip install -e .` against the resolved Hermes source automatically. Requires Python 3.11+ on `PATH`.
|
||||
|
||||
**Option B — share an existing CLI install:** if you already ran `scripts/install.ps1` / `scripts/install.sh`, that's the same layout the desktop uses. The desktop reuses your existing venv and editable install — no extra steps. See [Runtime Bootstrap](#runtime-bootstrap) below for details.
|
||||
|
||||
If you're hacking on Hermes from a clone outside `HERMES_HOME/hermes-agent`, point the desktop at it explicitly:
|
||||
|
||||
```bash
|
||||
HERMES_DESKTOP_HERMES_ROOT=/path/to/your/clone npm run dev
|
||||
```
|
||||
|
||||
### Runtime prerequisites
|
||||
|
||||
Hermes Desktop needs:
|
||||
|
||||
- **Python 3.11+** — for the agent runtime, dashboard backend, and tool execution. (required)
|
||||
- **Git for Windows** (Windows only) — provides Git Bash, which Hermes' terminal tool calls directly. Linux and macOS already ship a system bash. (required)
|
||||
- **ripgrep** — used by Hermes' `search_files` tool for fast `.gitignore`-aware file/content search. Recommended on all platforms; Hermes falls back to `grep`/`find` if missing (works but slower and noisier).
|
||||
|
||||
The packaged Windows installer (`Hermes-*.exe`) detects all three at install time. Required items missing are auto-installed via `winget install -e --id Python.Python.3.11 --scope user` and `winget install -e --id Git.Git`. The recommended ripgrep is offered as `winget install -e --id BurntSushi.ripgrep.MSVC --scope user`. If `winget` isn't available the installer shows manual download URLs and lets you continue. The MSI installer (`Hermes-*.msi`) doesn't run the prereq page — enterprise deploys are expected to handle prereqs out-of-band.
|
||||
|
||||
For dev (`npm run dev`) the Python and Git Bash checks happen at first launch via the Electron bootstrapper, which throws a clear error if either prereq is missing. Manual install commands you can run yourself:
|
||||
|
||||
```powershell
|
||||
winget install -e --id Python.Python.3.11 --scope user
|
||||
winget install -e --id Git.Git
|
||||
winget install -e --id BurntSushi.ripgrep.MSVC --scope user
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd apps/desktop
|
||||
npm run dev
|
||||
```
|
||||
|
||||
`npm run dev` starts Vite on `127.0.0.1:5174`, launches Electron, and lets Electron boot the Hermes backend (`hermes dashboard --no-open --tui`) on an open port in `9120-9199`. This path is for UI iteration and may still show Electron/dev identities in OS prompts.
|
||||
|
||||
Useful overrides:
|
||||
|
||||
```bash
|
||||
HERMES_DESKTOP_HERMES_ROOT=/path/to/hermes-agent npm run dev
|
||||
HERMES_DESKTOP_PYTHON=/path/to/python npm run dev
|
||||
HERMES_DESKTOP_CWD=/path/to/project npm run dev
|
||||
HERMES_DESKTOP_IGNORE_EXISTING=1 npm run dev
|
||||
HERMES_HOME=/tmp/throwaway-hermes-home npm run dev
|
||||
HERMES_DESKTOP_BOOT_FAKE=1 npm run dev
|
||||
HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=900 npm run dev
|
||||
```
|
||||
|
||||
`HERMES_DESKTOP_IGNORE_EXISTING=1` skips any `hermes` CLI already on `PATH`, which is useful when testing the factory-image bootstrap path.
|
||||
|
||||
`HERMES_HOME` overrides the install root (default: `%LOCALAPPDATA%\hermes` on Windows, `~/.hermes` elsewhere) — handy for sandboxed dev runs that shouldn't touch your real config.
|
||||
|
||||
`HERMES_DESKTOP_BOOT_FAKE=1` adds deterministic per-phase delays to desktop startup so you can validate the startup overlay and progress bar. For convenience, `npm run dev:fake-boot` enables fake mode with defaults.
|
||||
|
||||
On a fresh Hermes profile, Desktop shows a first-run setup overlay after boot. The overlay saves the minimum required provider credential (for example `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, or `OPENAI_API_KEY`) to the active Hermes `.env`, reloads the backend env, and then lets the user continue without opening Settings manually.
|
||||
|
||||
## Dashboard Dev
|
||||
|
||||
Run the Python dashboard backend with embedded chat enabled:
|
||||
|
||||
```bash
|
||||
hermes dashboard --tui --no-open
|
||||
```
|
||||
|
||||
For dashboard HMR, start Vite in another terminal:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open the Vite URL. The dev server proxies `/api`, `/api/pty`, and plugin assets to `http://127.0.0.1:9119` and fetches the live dashboard HTML so the ephemeral session token matches the running backend.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run pack # unpacked app at release/mac-<arch>/Hermes.app
|
||||
npm run dist:mac # macOS DMG + zip
|
||||
npm run dist:mac:dmg # DMG only
|
||||
npm run dist:mac:zip # zip only
|
||||
npm run dist:win # NSIS + MSI
|
||||
```
|
||||
|
||||
Before packaging, the desktop app no longer bundles a copy of the Hermes Agent Python source. Instead, the packaged Electron app will fetch and install Hermes Agent at first launch via `scripts/install.ps1`'s stage protocol (Windows) — see the bootstrap flow documented in `electron/main.cjs`. macOS and Linux packaged builds are temporarily non-functional until `install.sh` gains the same stage protocol; dev workflows on all three platforms continue to work since they resolve a sibling source checkout.
|
||||
|
||||
## Automated Releases
|
||||
|
||||
Desktop installers are published by [`.github/workflows/desktop-release.yml`](../../.github/workflows/desktop-release.yml) with two channels:
|
||||
|
||||
- **Stable:** runs on published GitHub releases and uploads signed artifacts to that release tag.
|
||||
- **Nightly:** runs on `main` pushes and updates the rolling `desktop-nightly` prerelease.
|
||||
|
||||
The workflow injects a channel-aware desktop version at build time:
|
||||
|
||||
- stable: derived from the release tag (for example `v2026.5.5` -> `2026.5.5`)
|
||||
- nightly: `0.0.0-nightly.YYYYMMDD.<sha>`
|
||||
|
||||
Artifact names include channel, platform, and architecture:
|
||||
|
||||
```text
|
||||
Hermes-<version>-<channel>-<platform>-<arch>.<ext>
|
||||
```
|
||||
|
||||
Each run also publishes `SHA256SUMS-<platform>.txt` so installers can be verified.
|
||||
|
||||
### Stable release gates
|
||||
|
||||
Stable builds fail fast if signing credentials are missing:
|
||||
|
||||
- macOS signing + notarization: `CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_API_KEY`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`
|
||||
- Windows signing: `WIN_CSC_LINK`, `WIN_CSC_KEY_PASSWORD`
|
||||
|
||||
Stable macOS builds also validate stapling and Gatekeeper assessment in CI before upload.
|
||||
|
||||
## Icons
|
||||
|
||||
Desktop icons live in `assets/`:
|
||||
|
||||
- `assets/icon.icns`
|
||||
- `assets/icon.ico`
|
||||
- `assets/icon.png`
|
||||
|
||||
The builder config points at `assets/icon`. Replace these files directly if the app icon changes.
|
||||
|
||||
## Testing Install Paths
|
||||
|
||||
Use the package-local test scripts from this directory:
|
||||
|
||||
```bash
|
||||
npm run test:desktop:all
|
||||
npm run test:desktop:existing
|
||||
npm run test:desktop:fresh
|
||||
npm run test:desktop:dmg
|
||||
npm run test:desktop:platforms
|
||||
```
|
||||
|
||||
`test:desktop:existing` builds the packaged app and opens it normally. It should use an existing `hermes` CLI if one is on `PATH`, preserving the user’s real `~/.hermes` config.
|
||||
|
||||
`test:desktop:fresh` builds the packaged app and launches it in a throwaway fresh-install sandbox. It sets `HERMES_DESKTOP_IGNORE_EXISTING=1`, points Electron `userData` at a temp dir, points `HERMES_HOME` at a temp dir, and launches through the factory-image bootstrap path without touching your real desktop runtime or `~/.hermes`.
|
||||
|
||||
`test:desktop:dmg` builds and opens the DMG.
|
||||
|
||||
`test:desktop:platforms` runs platform bootstrap-path assertions, including:
|
||||
- existing-CLI vs factory-image runtime path selection semantics
|
||||
- WSL2 protection against Windows `.exe/.cmd/.bat/.ps1` overrides
|
||||
- platform-specific runtime import checks (`winpty` vs `ptyprocess`)
|
||||
|
||||
For fast reruns without rebuilding:
|
||||
|
||||
```bash
|
||||
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:fresh
|
||||
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:existing
|
||||
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:dmg
|
||||
```
|
||||
|
||||
## Installing Locally
|
||||
|
||||
```bash
|
||||
npm run dist:mac:dmg
|
||||
open release/Hermes-0.0.0-arm64.dmg
|
||||
```
|
||||
|
||||
Drag `Hermes` to Applications. If testing repeated installs, replace the existing app.
|
||||
|
||||
## Runtime Bootstrap
|
||||
|
||||
Hermes Desktop shares its install layout with the CLI installers (`scripts/install.ps1`, `scripts/install.sh`) so a desktop-only user and a CLI-only user end up with the same files in the same places.
|
||||
|
||||
### Where things live
|
||||
|
||||
```text
|
||||
HERMES_HOME/ # %LOCALAPPDATA%\hermes (Windows)
|
||||
# ~/.hermes (macOS / Linux)
|
||||
├── hermes-agent/ # ACTIVE_HERMES_ROOT — git checkout
|
||||
│ ├── .git/ # canonical install is always a git checkout
|
||||
│ ├── hermes_cli/, agent/, ... # Python source
|
||||
│ ├── pyproject.toml # source of truth for deps
|
||||
│ ├── venv/ # virtualenv (Scripts\python.exe on Windows,
|
||||
│ │ # bin/python elsewhere)
|
||||
│ └── .hermes-bootstrap-complete # marker: first-launch install.ps1 succeeded
|
||||
├── git/ # PortableGit (Windows; installed by install.ps1)
|
||||
├── config.yaml # user config
|
||||
├── .env # API keys
|
||||
└── logs/
|
||||
├── desktop.log # Electron-side boot log
|
||||
├── agent.log
|
||||
├── errors.log
|
||||
└── gateway.log
|
||||
```
|
||||
|
||||
The packaged installer ships only the Electron app — Hermes Agent itself is fetched and installed at first launch by running `scripts/install.ps1` (Windows) against the git ref baked into the .exe at build time (see `apps/desktop/scripts/write-build-stamp.cjs`).
|
||||
|
||||
### Resolution order
|
||||
|
||||
The desktop resolves a Hermes backend in this order:
|
||||
|
||||
1. `HERMES_DESKTOP_HERMES_ROOT` — explicit dev override.
|
||||
2. Repo source root — only when running `npm run dev` from a checkout. Takes precedence over `HERMES_HOME/hermes-agent` so devs always run their local edits.
|
||||
3. `HERMES_HOME/hermes-agent` if the `.hermes-bootstrap-complete` marker is present. The marker attests that install.ps1 succeeded and the user finished initial configuration; we trust the install and skip the bootstrap flow on every launch after the first.
|
||||
4. Existing `hermes` CLI on PATH (skipped when `HERMES_DESKTOP_IGNORE_EXISTING=1`).
|
||||
5. Pip-installed `hermes_cli` module via system Python.
|
||||
6. None of the above → bootstrap-needed sentinel. The desktop's first-launch wizard runs `scripts/install.ps1` stages, then writes the marker on success.
|
||||
|
||||
### First-launch flow on a packaged install
|
||||
|
||||
1. `resolveHermesBackend()` returns `kind: 'bootstrap-needed'`.
|
||||
2. The renderer shows the install overlay; main fetches `scripts/install.ps1` from GitHub at the pinned commit (from `install-stamp.json`).
|
||||
3. Main drives `install.ps1 -Manifest` to get the stage list, then iterates `install.ps1 -Stage <name> -NonInteractive -Json` with live progress events to the renderer.
|
||||
4. On all stages succeeding, main writes `.hermes-bootstrap-complete` with `{ schemaVersion, pinnedCommit, pinnedBranch, completedAt, desktopVersion }`.
|
||||
5. Renderer hands off to the existing onboarding overlay (API key / model / persona).
|
||||
6. Subsequent launches see the marker and skip everything in steps 1-5.
|
||||
|
||||
### Updates
|
||||
|
||||
Once bootstrapped, the install is a real git checkout. Updates flow through the in-app update path (`applyUpdates()` → `git fetch && git pull --ff-only` against the configured branch) or `hermes update` from the CLI. Both check `pyproject.toml` drift and re-run `pip install -e .` only when needed.
|
||||
|
||||
A user who installed via `scripts/install.ps1` directly (so `HERMES_HOME/hermes-agent/.git` exists but no `.hermes-bootstrap-complete` marker) is detected via resolver step 4 (their `hermes` CLI on PATH) and the desktop reuses their install without re-running the bootstrap.
|
||||
|
||||
## Debugging
|
||||
|
||||
Desktop boot logs are written to:
|
||||
|
||||
```text
|
||||
HERMES_HOME/logs/desktop.log # %LOCALAPPDATA%\hermes\logs\desktop.log on Windows
|
||||
# ~/.hermes/logs/desktop.log on macOS / Linux
|
||||
```
|
||||
|
||||
If the UI reports `Desktop boot failed`, check that log first. It includes the backend command output and recent Python traceback context.
|
||||
|
||||
To force a fresh first-launch bootstrap (rare — useful for development / dogfooding the install flow):
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
rm "$HOME/.hermes/hermes-agent/.hermes-bootstrap-complete"
|
||||
|
||||
# Windows (PowerShell)
|
||||
Remove-Item "$env:LOCALAPPDATA\hermes\hermes-agent\.hermes-bootstrap-complete"
|
||||
```
|
||||
|
||||
For a full reset of just the Python venv (rare — usually only needed if the venv is broken):
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
rm -rf "$HOME/.hermes/hermes-agent/venv"
|
||||
|
||||
# Windows (PowerShell)
|
||||
Remove-Item -Recurse -Force "$env:LOCALAPPDATA\hermes\hermes-agent\venv"
|
||||
```
|
||||
|
||||
To reset stale macOS microphone permission prompts:
|
||||
|
||||
```bash
|
||||
tccutil reset Microphone com.github.Electron
|
||||
tccutil reset Microphone com.nousresearch.hermes
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
Run before handing off installer changes:
|
||||
|
||||
```bash
|
||||
npm run fix
|
||||
npm run type-check
|
||||
npm run lint
|
||||
npm run test:desktop:all
|
||||
```
|
||||
|
||||
Current lint may report existing warnings, but it should exit with no errors.
|
||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 78 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 674 KiB |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* backend-probes.cjs
|
||||
*
|
||||
* Cheap "does this candidate backend actually work" checks used by
|
||||
* resolveHermesBackend (main.cjs). The resolver walks a ladder of
|
||||
* candidates -- bootstrap marker, `hermes` on PATH, system Python with
|
||||
* hermes_cli installed -- and historically returned the first candidate
|
||||
* whose binary existed on disk. That assumption breaks when a user has
|
||||
* a pre-installed Python 3.11-3.13 (so findSystemPython() returns a
|
||||
* path) but no hermes_cli in its site-packages: the resolver hands back
|
||||
* a backend the spawn step can't actually run, and the user gets a
|
||||
* dead-on-arrival "ModuleNotFoundError: No module named 'hermes_cli'"
|
||||
* instead of the first-launch installer.
|
||||
*
|
||||
* These probes give the resolver a way to verify a candidate before
|
||||
* trusting it. Failure (non-zero exit, exception, timeout) means "skip
|
||||
* this rung, try the next one"; success means "spawn this for real."
|
||||
* Falling off the bottom of the ladder lands on the bootstrap-needed
|
||||
* sentinel, which is exactly what we want when nothing pre-existing
|
||||
* actually works.
|
||||
*
|
||||
* Both probes are deliberately fast and forgiving:
|
||||
* - 5s timeout (a hung interpreter beats forever, but we still give
|
||||
* slow disks / cold caches room to breathe)
|
||||
* - stdio ignored (we only care about exit code; stdout/stderr are
|
||||
* not surfaced to the user, just to recentHermesLog for forensics
|
||||
* via the caller's catch block if it chooses)
|
||||
* - any throw -> false (never propagate -- resolver wants a boolean)
|
||||
*
|
||||
* Kept in a standalone cjs module so it can be unit-tested with
|
||||
* `node --test` without dragging in the electron runtime (same pattern
|
||||
* as bootstrap-platform.cjs and hardening.cjs).
|
||||
*/
|
||||
|
||||
const { execFileSync } = require('node:child_process')
|
||||
|
||||
const PROBE_TIMEOUT_MS = 5000
|
||||
|
||||
/**
|
||||
* Return true iff `python -c "import hermes_cli"` exits 0.
|
||||
*
|
||||
* Used to gate the "fallback to system Python with hermes_cli installed"
|
||||
* rung of resolveHermesBackend. Without this, a system Python 3.11-3.13
|
||||
* registered in PEP 514 makes findSystemPython() succeed regardless of
|
||||
* whether hermes_cli has actually been pip-installed into its
|
||||
* site-packages -- and the resolver returns a backend that immediately
|
||||
* dies on spawn.
|
||||
*
|
||||
* @param {string} pythonPath - Absolute path to a python.exe / python.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function canImportHermesCli(pythonPath) {
|
||||
if (!pythonPath) return false
|
||||
try {
|
||||
execFileSync(pythonPath, ['-c', 'import hermes_cli'], {
|
||||
stdio: 'ignore',
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
windowsHide: true
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true iff `<hermesCommand> --version` exits 0.
|
||||
*
|
||||
* Used to gate the "existing `hermes` on PATH" rung. Without this, a
|
||||
* stale hermes.cmd shim left behind by an uninstalled pip install (or
|
||||
* a half-built venv whose `hermes` entry-point points at a deleted
|
||||
* Python) survives findOnPath() and gets selected as the backend.
|
||||
*
|
||||
* We intentionally avoid invoking the command with the dashboard args
|
||||
* here -- `--version` is the cheapest "is this binary alive" smoke
|
||||
* test that every hermes_cli entry-point has supported since 0.1.
|
||||
*
|
||||
* @param {string} hermesCommand - Resolved absolute path to a hermes
|
||||
* executable (or an interpreter+script wrapper).
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.shell] - Whether to run through a shell. For
|
||||
* .cmd/.bat shims on Windows execFileSync needs shell:true to find
|
||||
* the cmd interpreter; mirrors the same flag isCommandScript() drives
|
||||
* in resolveHermesBackend.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function verifyHermesCli(hermesCommand, opts = {}) {
|
||||
if (!hermesCommand) return false
|
||||
try {
|
||||
execFileSync(hermesCommand, ['--version'], {
|
||||
stdio: 'ignore',
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
shell: Boolean(opts.shell),
|
||||
windowsHide: true
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
canImportHermesCli,
|
||||
verifyHermesCli,
|
||||
PROBE_TIMEOUT_MS
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* Tests for electron/backend-probes.cjs.
|
||||
*
|
||||
* Run with: node --test electron/backend-probes.test.cjs
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
|
||||
// Resolve the host's own Node binary -- guaranteed to be on disk and
|
||||
// runnable. We use it as both a stand-in for "a python that doesn't
|
||||
// have hermes_cli" (since `node -c "import hermes_cli"` will exit
|
||||
// non-zero) and as a way to script verifyHermesCli's success path
|
||||
// (a tiny script we write to disk that exits 0 on --version).
|
||||
const NODE_BIN = process.execPath
|
||||
|
||||
test('canImportHermesCli returns false when path is falsy', () => {
|
||||
assert.equal(canImportHermesCli(''), false)
|
||||
assert.equal(canImportHermesCli(null), false)
|
||||
assert.equal(canImportHermesCli(undefined), false)
|
||||
})
|
||||
|
||||
test('canImportHermesCli returns false when interpreter cannot run -c', () => {
|
||||
// node IS an interpreter, but `node -c "import hermes_cli"` is a
|
||||
// SyntaxError -- different exit reason from a real Python's
|
||||
// ModuleNotFoundError, but the predicate is "exit 0 or not" and
|
||||
// both land on "not", which is exactly what we want for the
|
||||
// resolver fall-through.
|
||||
assert.equal(canImportHermesCli(NODE_BIN), false)
|
||||
})
|
||||
|
||||
test('canImportHermesCli returns false when binary does not exist', () => {
|
||||
const ghost = path.join(os.tmpdir(), 'hermes-probes-ghost-' + Date.now() + '.exe')
|
||||
assert.equal(canImportHermesCli(ghost), false)
|
||||
})
|
||||
|
||||
test('verifyHermesCli returns false when command is falsy', () => {
|
||||
assert.equal(verifyHermesCli(''), false)
|
||||
assert.equal(verifyHermesCli(null), false)
|
||||
assert.equal(verifyHermesCli(undefined), false)
|
||||
})
|
||||
|
||||
test('verifyHermesCli returns false when binary does not exist', () => {
|
||||
const ghost = path.join(os.tmpdir(), 'hermes-probes-ghost-' + Date.now() + '.exe')
|
||||
assert.equal(verifyHermesCli(ghost), false)
|
||||
})
|
||||
|
||||
test('verifyHermesCli returns true when --version exits 0', () => {
|
||||
// Write a tiny script that exits 0 regardless of args, then invoke
|
||||
// it through node. This stands in for a working hermes binary --
|
||||
// verifyHermesCli only cares about the exit code.
|
||||
const scriptPath = path.join(os.tmpdir(), `hermes-probes-ok-${Date.now()}-${process.pid}.cjs`)
|
||||
fs.writeFileSync(scriptPath, 'process.exit(0)\n')
|
||||
try {
|
||||
// Use node as the launcher and our script as the "command". Pass
|
||||
// shell:false (default) -- node is a real binary, no shim.
|
||||
// execFileSync passes ['--version'] as args, which node ignores
|
||||
// gracefully (well, it prints its version and exits 0, which is
|
||||
// perfect -- exit code 0 is the only signal we read).
|
||||
assert.equal(verifyHermesCli(NODE_BIN), true)
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(scriptPath)
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
|
||||
test('verifyHermesCli swallows timeouts (does not throw)', () => {
|
||||
// We can't easily provoke a real 5s hang in CI without slowing the
|
||||
// suite, but we CAN confirm that an invocation that DOES throw
|
||||
// (because the binary is missing) returns false rather than
|
||||
// propagating. Same code path the timeout case takes.
|
||||
assert.equal(verifyHermesCli('/definitely/not/a/real/binary/anywhere'), false)
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
const fs = require('node:fs')
|
||||
|
||||
function isWslEnvironment(env = process.env, platform = process.platform, kernelRelease = null) {
|
||||
if (platform !== 'linux') return false
|
||||
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true
|
||||
|
||||
try {
|
||||
const release = kernelRelease ?? fs.readFileSync('/proc/sys/kernel/osrelease', 'utf8')
|
||||
return /microsoft|wsl/i.test(release)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isWindowsBinaryPathInWsl(filePath, options = {}) {
|
||||
const isWsl = options.isWsl ?? isWslEnvironment(options.env, options.platform)
|
||||
if (!isWsl) return false
|
||||
|
||||
const normalized = String(filePath || '')
|
||||
.replace(/\\/g, '/')
|
||||
.toLowerCase()
|
||||
|
||||
return (
|
||||
normalized.endsWith('.exe') ||
|
||||
normalized.endsWith('.cmd') ||
|
||||
normalized.endsWith('.bat') ||
|
||||
normalized.endsWith('.ps1')
|
||||
)
|
||||
}
|
||||
|
||||
function bundledRuntimeImportCheck(platform = process.platform) {
|
||||
return platform === 'win32' ? 'import fastapi, uvicorn, winpty' : 'import fastapi, uvicorn, ptyprocess'
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
bundledRuntimeImportCheck,
|
||||
isWindowsBinaryPathInWsl,
|
||||
isWslEnvironment
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
|
||||
const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
|
||||
test('isWslEnvironment detects WSL2 env vars on linux', () => {
|
||||
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
|
||||
assert.equal(isWslEnvironment({ WSL_INTEROP: '/run/WSL/123_interop' }, 'linux'), true)
|
||||
assert.equal(isWslEnvironment({}, 'linux', '6.6.87.2-microsoft-standard-WSL2'), true)
|
||||
assert.equal(isWslEnvironment({}, 'linux', '6.6.87-generic'), false)
|
||||
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'darwin'), false)
|
||||
})
|
||||
|
||||
test('isWindowsBinaryPathInWsl blocks Windows binary types on WSL', () => {
|
||||
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.exe', { isWsl: true }), true)
|
||||
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.cmd', { isWsl: true }), true)
|
||||
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.bat', { isWsl: true }), true)
|
||||
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/install.ps1', { isWsl: true }), true)
|
||||
assert.equal(isWindowsBinaryPathInWsl('/usr/local/bin/hermes', { isWsl: true }), false)
|
||||
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.exe', { isWsl: false }), false)
|
||||
})
|
||||
|
||||
test('bundledRuntimeImportCheck selects platform-specific import checks', () => {
|
||||
assert.equal(bundledRuntimeImportCheck('win32'), 'import fastapi, uvicorn, winpty')
|
||||
assert.equal(bundledRuntimeImportCheck('darwin'), 'import fastapi, uvicorn, ptyprocess')
|
||||
assert.equal(bundledRuntimeImportCheck('linux'), 'import fastapi, uvicorn, ptyprocess')
|
||||
})
|
||||
|
||||
test('packaged electron entrypoints do not require unpackaged npm modules', () => {
|
||||
const electronDir = __dirname
|
||||
const entrypoints = ['main.cjs', 'preload.cjs', 'bootstrap-platform.cjs']
|
||||
// - electron: provided by the electron runtime, always resolvable in packaged builds.
|
||||
// - node-pty: hoisted by workspace dedup AND shipped via extraResources to
|
||||
// resources/native-deps/node-pty (see scripts/stage-native-deps.cjs). main.cjs
|
||||
// has a try/catch fallback at line ~38 that resolves the staged copy when the
|
||||
// bare require fails in the packaged asar, so the bare require itself is by
|
||||
// design rather than an oversight.
|
||||
const allowedBareRequires = new Set(['electron', 'node-pty'])
|
||||
const requirePattern = /require\(['"]([^'"]+)['"]\)/g
|
||||
|
||||
for (const entrypoint of entrypoints) {
|
||||
const source = fs.readFileSync(path.join(electronDir, entrypoint), 'utf8')
|
||||
const bareRequires = Array.from(source.matchAll(requirePattern))
|
||||
.map(match => match[1])
|
||||
.filter(specifier => !specifier.startsWith('node:'))
|
||||
.filter(specifier => !specifier.startsWith('.'))
|
||||
.filter(specifier => !allowedBareRequires.has(specifier))
|
||||
|
||||
assert.deepEqual(bareRequires, [], `${entrypoint} has unpackaged runtime requires`)
|
||||
}
|
||||
})
|
||||
@@ -1,466 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* bootstrap-runner.cjs
|
||||
*
|
||||
* Drives apps/desktop's first-launch install of Hermes Agent by spawning
|
||||
* scripts/install.ps1 stage-by-stage and streaming progress events back to
|
||||
* the renderer.
|
||||
*
|
||||
* Wired from electron/main.cjs:
|
||||
* const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
* const result = await runBootstrap({
|
||||
* installStamp, // INSTALL_STAMP from main.cjs (may be null in dev)
|
||||
* activeRoot, // ACTIVE_HERMES_ROOT
|
||||
* sourceRepoRoot, // SOURCE_REPO_ROOT (for dev install.ps1 lookup)
|
||||
* hermesHome, // HERMES_HOME
|
||||
* logRoot, // HERMES_HOME/logs
|
||||
* emit: ev => {...} // event sink (sender.send or similar)
|
||||
* })
|
||||
*
|
||||
* Emits events with shape:
|
||||
* { type: 'manifest', stages: [{name, title, category, needs_user_input}, ...] }
|
||||
* { type: 'stage', name, state: 'running'|'succeeded'|'skipped'|'failed',
|
||||
* json?, durationMs?, error? }
|
||||
* { type: 'log', stage?, line } // raw line from install.ps1
|
||||
* { type: 'complete', marker: <written marker payload> }
|
||||
* { type: 'failed', stage?, error } // bootstrap aborted
|
||||
*
|
||||
* Resolves with the same shape as the final 'complete' or 'failed' event so
|
||||
* callers can await either way.
|
||||
*
|
||||
* NOT implemented yet (deferred to Phase 1E / 1F):
|
||||
* - User-facing retry / cancel from the renderer (event channels exist;
|
||||
* no UI consumes them yet)
|
||||
* - macOS / Linux install.sh equivalent
|
||||
*/
|
||||
|
||||
const fs = require('node:fs')
|
||||
const fsp = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
const https = require('node:https')
|
||||
const { spawn } = require('node:child_process')
|
||||
|
||||
const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
|
||||
|
||||
// Stages flagged needs_user_input=true in the manifest are skipped by the
|
||||
// runner (passed -NonInteractive to install.ps1, which the install script
|
||||
// itself handles by emitting skipped=true frames). The renderer / 1E onboarding
|
||||
// overlay takes over for those concerns (API keys, model, persona, gateway).
|
||||
// We let install.ps1's own -NonInteractive logic drive this rather than
|
||||
// filtering client-side -- single source of truth.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// install.ps1 source resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveLocalInstallScript(sourceRepoRoot) {
|
||||
if (!sourceRepoRoot) return null
|
||||
const candidate = path.join(sourceRepoRoot, 'scripts', 'install.ps1')
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.R_OK)
|
||||
return candidate
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function bootstrapCacheDir(hermesHome) {
|
||||
return path.join(hermesHome, 'bootstrap-cache')
|
||||
}
|
||||
|
||||
function cachedScriptPath(hermesHome, commit) {
|
||||
return path.join(bootstrapCacheDir(hermesHome), `install-${commit}.ps1`)
|
||||
}
|
||||
|
||||
function downloadInstallScript(commit, destPath) {
|
||||
// Fetch from GitHub raw at the pinned commit. The raw URL with a SHA
|
||||
// is immutable (unlike a branch ref), so we don't need integrity
|
||||
// verification beyond "did the file we wrote pass a syntax probe."
|
||||
const url = `https://raw.githubusercontent.com/NousResearch/hermes-agent/${commit}/scripts/install.ps1`
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
||||
const tmpPath = destPath + '.tmp'
|
||||
const out = fs.createWriteStream(tmpPath)
|
||||
https
|
||||
.get(url, res => {
|
||||
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||
// GitHub raw shouldn't redirect for a SHA URL, but follow once
|
||||
// defensively.
|
||||
out.close()
|
||||
fs.unlinkSync(tmpPath)
|
||||
https
|
||||
.get(res.headers.location, res2 => {
|
||||
if (res2.statusCode !== 200) {
|
||||
reject(new Error(`Failed to download install.ps1: HTTP ${res2.statusCode} from redirect ${res.headers.location}`))
|
||||
return
|
||||
}
|
||||
const out2 = fs.createWriteStream(tmpPath)
|
||||
res2.pipe(out2)
|
||||
out2.on('finish', () => {
|
||||
out2.close()
|
||||
fs.renameSync(tmpPath, destPath)
|
||||
resolve(destPath)
|
||||
})
|
||||
out2.on('error', reject)
|
||||
})
|
||||
.on('error', reject)
|
||||
return
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
out.close()
|
||||
try {
|
||||
fs.unlinkSync(tmpPath)
|
||||
} catch {}
|
||||
reject(new Error(`Failed to download install.ps1: HTTP ${res.statusCode} from ${url}`))
|
||||
return
|
||||
}
|
||||
res.pipe(out)
|
||||
out.on('finish', () => {
|
||||
out.close()
|
||||
fs.renameSync(tmpPath, destPath)
|
||||
resolve(destPath)
|
||||
})
|
||||
out.on('error', err => {
|
||||
try {
|
||||
fs.unlinkSync(tmpPath)
|
||||
} catch {}
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
.on('error', err => {
|
||||
try {
|
||||
fs.unlinkSync(tmpPath)
|
||||
} catch {}
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit }) {
|
||||
// 1. Dev shortcut: prefer a local checkout's install.ps1 so we can iterate
|
||||
// without pushing. SOURCE_REPO_ROOT comes from main.cjs (path.resolve
|
||||
// of APP_ROOT/../..).
|
||||
const localScript = resolveLocalInstallScript(sourceRepoRoot)
|
||||
if (localScript) {
|
||||
emit({ type: 'log', line: `[bootstrap] using local install.ps1 at ${localScript}` })
|
||||
return { path: localScript, source: 'local' }
|
||||
}
|
||||
|
||||
// 2. Packaged path: download from GitHub at the pinned commit (1B's stamp).
|
||||
if (!installStamp || !installStamp.commit || !STAMP_COMMIT_RE.test(installStamp.commit)) {
|
||||
throw new Error(
|
||||
'Cannot resolve install.ps1: no SOURCE_REPO_ROOT and no install stamp. ' +
|
||||
'This packaged build was produced without a valid build-time stamp.'
|
||||
)
|
||||
}
|
||||
|
||||
const cached = cachedScriptPath(hermesHome, installStamp.commit)
|
||||
try {
|
||||
await fsp.access(cached, fs.constants.R_OK)
|
||||
emit({ type: 'log', line: `[bootstrap] using cached install.ps1 for ${installStamp.commit.slice(0, 12)}` })
|
||||
return { path: cached, source: 'cache', commit: installStamp.commit }
|
||||
} catch {
|
||||
// not cached; download
|
||||
}
|
||||
|
||||
emit({ type: 'log', line: `[bootstrap] fetching install.ps1 for ${installStamp.commit.slice(0, 12)} from GitHub` })
|
||||
await downloadInstallScript(installStamp.commit, cached)
|
||||
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
|
||||
return { path: cached, source: 'download', commit: installStamp.commit }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// powershell wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, hermesHome } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ps = process.platform === 'win32' ? 'powershell.exe' : 'pwsh'
|
||||
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
|
||||
|
||||
const child = spawn(ps, fullArgs, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
// Pass HERMES_HOME through so install.ps1 respects the caller's
|
||||
// choice rather than re-computing the default.
|
||||
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
|
||||
}
|
||||
})
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let killed = false
|
||||
|
||||
const onAbort = () => {
|
||||
killed = true
|
||||
try {
|
||||
child.kill('SIGTERM')
|
||||
} catch {}
|
||||
}
|
||||
if (abortSignal) {
|
||||
if (abortSignal.aborted) {
|
||||
onAbort()
|
||||
} else {
|
||||
abortSignal.addEventListener('abort', onAbort, { once: true })
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
|
||||
// Stream stdout line-by-line so the renderer sees progress in real time.
|
||||
let stdoutBuf = ''
|
||||
child.stdout.on('data', chunk => {
|
||||
stdout += chunk
|
||||
stdoutBuf += chunk
|
||||
let nl
|
||||
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
|
||||
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
|
||||
stdoutBuf = stdoutBuf.slice(nl + 1)
|
||||
if (line) emit && emit({ type: 'log', stage: stageName, line })
|
||||
}
|
||||
})
|
||||
|
||||
let stderrBuf = ''
|
||||
child.stderr.on('data', chunk => {
|
||||
stderr += chunk
|
||||
stderrBuf += chunk
|
||||
let nl
|
||||
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
|
||||
const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
|
||||
stderrBuf = stderrBuf.slice(nl + 1)
|
||||
if (line) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${line}` })
|
||||
}
|
||||
})
|
||||
|
||||
child.on('error', err => {
|
||||
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
|
||||
reject(err)
|
||||
})
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
|
||||
// Flush any trailing bytes
|
||||
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf })
|
||||
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${stderrBuf}` })
|
||||
resolve({ stdout, stderr, code, signal, killed })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manifest + stage dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Build the install.ps1 pin args (-Commit / -Branch) from the install-stamp
|
||||
// so the repository stage clones the exact SHA the .exe was tested with
|
||||
// instead of falling back to install.ps1's default ($Branch = "main").
|
||||
function buildPinArgs(installStamp) {
|
||||
const args = []
|
||||
if (installStamp && installStamp.commit) {
|
||||
args.push('-Commit', installStamp.commit)
|
||||
}
|
||||
if (installStamp && installStamp.branch) {
|
||||
args.push('-Branch', installStamp.branch)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
async function fetchManifest({ scriptPath, emit, hermesHome, installStamp }) {
|
||||
const pinArgs = buildPinArgs(installStamp)
|
||||
const result = await spawnPowerShell(scriptPath, ['-Manifest', ...pinArgs], {
|
||||
emit,
|
||||
stageName: '__manifest__',
|
||||
hermesHome
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`install.ps1 -Manifest failed: exit ${result.code}\n${result.stderr || result.stdout}`)
|
||||
}
|
||||
// The manifest is the LAST JSON line on stdout (install.ps1 may print
|
||||
// banner / info lines first depending on Console.OutputEncoding effects).
|
||||
// Find the last line that parses as JSON with a `stages` field.
|
||||
const lines = result.stdout.split(/\r?\n/).filter(Boolean)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const parsed = JSON.parse(lines[i])
|
||||
if (parsed && Array.isArray(parsed.stages)) {
|
||||
return parsed
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
throw new Error(`install.ps1 -Manifest produced no parseable JSON payload\n${result.stdout}`)
|
||||
}
|
||||
|
||||
// Parse the JSON result frame from a stage run. The protocol guarantees
|
||||
// exactly one JSON line per stage in -Json or -Stage mode (post #27224 fix
|
||||
// for the double-emit bug we addressed in the install.ps1 PR).
|
||||
function parseStageResult(stdout) {
|
||||
const lines = stdout.split(/\r?\n/).filter(Boolean)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const parsed = JSON.parse(lines[i])
|
||||
if (parsed && typeof parsed.ok === 'boolean' && typeof parsed.stage === 'string') {
|
||||
return parsed
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function runStage({ scriptPath, stage, emit, hermesHome, abortSignal, installStamp }) {
|
||||
const startedAt = Date.now()
|
||||
emit({ type: 'stage', name: stage.name, state: 'running' })
|
||||
|
||||
const pinArgs = buildPinArgs(installStamp)
|
||||
const result = await spawnPowerShell(
|
||||
scriptPath,
|
||||
['-Stage', stage.name, '-NonInteractive', '-Json', ...pinArgs],
|
||||
{ emit, stageName: stage.name, abortSignal, hermesHome }
|
||||
)
|
||||
|
||||
const durationMs = Date.now() - startedAt
|
||||
|
||||
if (result.killed) {
|
||||
const ev = { type: 'stage', name: stage.name, state: 'failed', durationMs, error: 'cancelled by user' }
|
||||
emit(ev)
|
||||
return ev
|
||||
}
|
||||
|
||||
const json = parseStageResult(result.stdout)
|
||||
|
||||
if (!json) {
|
||||
const ev = {
|
||||
type: 'stage',
|
||||
name: stage.name,
|
||||
state: 'failed',
|
||||
durationMs,
|
||||
error: `install.ps1 -Stage ${stage.name} produced no JSON result frame (exit=${result.code})`,
|
||||
json: null
|
||||
}
|
||||
emit(ev)
|
||||
return ev
|
||||
}
|
||||
|
||||
if (json.ok && json.skipped) {
|
||||
const ev = { type: 'stage', name: stage.name, state: 'skipped', durationMs, json }
|
||||
emit(ev)
|
||||
return ev
|
||||
}
|
||||
if (json.ok) {
|
||||
const ev = { type: 'stage', name: stage.name, state: 'succeeded', durationMs, json }
|
||||
emit(ev)
|
||||
return ev
|
||||
}
|
||||
const ev = { type: 'stage', name: stage.name, state: 'failed', durationMs, json, error: json.reason || `exit code ${result.code}` }
|
||||
emit(ev)
|
||||
return ev
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-run log file
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function openRunLog(logRoot) {
|
||||
fs.mkdirSync(logRoot, { recursive: true })
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const logPath = path.join(logRoot, `bootstrap-${ts}.log`)
|
||||
const stream = fs.createWriteStream(logPath, { flags: 'a' })
|
||||
return { path: logPath, stream }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public entrypoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function runBootstrap(opts) {
|
||||
const {
|
||||
installStamp,
|
||||
activeRoot,
|
||||
sourceRepoRoot,
|
||||
hermesHome,
|
||||
logRoot,
|
||||
onEvent,
|
||||
abortSignal,
|
||||
writeMarker // callback to write the bootstrap-complete marker; main.cjs provides
|
||||
} = opts
|
||||
|
||||
const runLog = openRunLog(logRoot || path.join(hermesHome, 'logs'))
|
||||
|
||||
// Tee every event to the runLog AND the caller's onEvent. This gives us a
|
||||
// forensic trail per bootstrap run AND lets the renderer subscribe live.
|
||||
const emit = ev => {
|
||||
try {
|
||||
runLog.stream.write(JSON.stringify(ev) + '\n')
|
||||
} catch {}
|
||||
try {
|
||||
if (typeof onEvent === 'function') onEvent(ev)
|
||||
} catch (err) {
|
||||
// Don't let a subscriber bug crash the bootstrap
|
||||
runLog.stream.write(`emit error: ${err && err.message}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
emit({
|
||||
type: 'log',
|
||||
line:
|
||||
`[bootstrap] starting at ${new Date().toISOString()}; ` +
|
||||
`activeRoot=${activeRoot}; ` +
|
||||
`stamp=${installStamp ? installStamp.commit.slice(0, 12) : '<none>'}; ` +
|
||||
`runLog=${runLog.path}`
|
||||
})
|
||||
|
||||
try {
|
||||
// 1. Resolve install.ps1
|
||||
const scriptInfo = await resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit })
|
||||
|
||||
// 2. Fetch manifest
|
||||
const manifest = await fetchManifest({ scriptPath: scriptInfo.path, emit, hermesHome, installStamp })
|
||||
emit({
|
||||
type: 'manifest',
|
||||
stages: manifest.stages,
|
||||
protocolVersion: manifest.protocol_version || manifest.protocolVersion || null
|
||||
})
|
||||
|
||||
// 3. Iterate stages in order. Stages flagged needs_user_input are still
|
||||
// invoked -- install.ps1's own -NonInteractive handler in those stages
|
||||
// emits skipped=true. We trust the protocol rather than filtering
|
||||
// client-side.
|
||||
for (const stage of manifest.stages) {
|
||||
if (abortSignal && abortSignal.aborted) {
|
||||
emit({ type: 'failed', error: 'bootstrap cancelled by user' })
|
||||
return { ok: false, cancelled: true }
|
||||
}
|
||||
const ev = await runStage({ scriptPath: scriptInfo.path, stage, emit, hermesHome, abortSignal, installStamp })
|
||||
if (ev.state === 'failed') {
|
||||
emit({ type: 'failed', stage: stage.name, error: ev.error || 'stage failed' })
|
||||
return { ok: false, failedStage: stage.name, error: ev.error }
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Write the bootstrap-complete marker.
|
||||
const markerPayload = {
|
||||
pinnedCommit: installStamp ? installStamp.commit : null,
|
||||
pinnedBranch: installStamp ? installStamp.branch : null
|
||||
}
|
||||
const marker = typeof writeMarker === 'function' ? writeMarker(markerPayload) : markerPayload
|
||||
emit({ type: 'complete', marker })
|
||||
return { ok: true, marker }
|
||||
} catch (err) {
|
||||
emit({ type: 'failed', error: err.message || String(err) })
|
||||
return { ok: false, error: err.message || String(err) }
|
||||
} finally {
|
||||
try {
|
||||
runLog.stream.end()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
runBootstrap,
|
||||
// Exposed for testability
|
||||
parseStageResult,
|
||||
resolveLocalInstallScript,
|
||||
cachedScriptPath
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,184 +0,0 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { fileURLToPath } = require('node:url')
|
||||
|
||||
const DEFAULT_FETCH_TIMEOUT_MS = 15_000
|
||||
const DATA_URL_READ_MAX_BYTES = 16 * 1024 * 1024
|
||||
const TEXT_PREVIEW_SOURCE_MAX_BYTES = 64 * 1024 * 1024
|
||||
|
||||
const SAFE_ENV_SUFFIXES = new Set(['dist', 'example', 'sample', 'template'])
|
||||
const SENSITIVE_EXTENSIONS = new Set(['.kdbx', '.p12', '.pem', '.pfx'])
|
||||
|
||||
function resolveTimeoutMs(timeoutMs, fallbackMs = DEFAULT_FETCH_TIMEOUT_MS) {
|
||||
const fallback =
|
||||
Number.isFinite(fallbackMs) && Number(fallbackMs) > 0 ? Math.round(Number(fallbackMs)) : DEFAULT_FETCH_TIMEOUT_MS
|
||||
const parsed = Number(timeoutMs)
|
||||
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return Math.round(parsed)
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function encryptDesktopSecret(value, safeStorageApi) {
|
||||
const raw = String(value || '')
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
let encryptionAvailable = false
|
||||
|
||||
try {
|
||||
encryptionAvailable = Boolean(safeStorageApi?.isEncryptionAvailable?.())
|
||||
} catch {
|
||||
encryptionAvailable = false
|
||||
}
|
||||
|
||||
if (!encryptionAvailable) {
|
||||
throw new Error(
|
||||
'Secure token storage is unavailable, so Hermes Desktop cannot save remote gateway tokens. ' +
|
||||
'Set HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN in your environment, or enable OS keychain access and try again.'
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
encoding: 'safeStorage',
|
||||
value: safeStorageApi.encryptString(raw).toString('base64')
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error && error.message ? ` (${error.message})` : ''
|
||||
throw new Error(
|
||||
`Failed to encrypt the remote gateway token for secure storage${detail}. ` +
|
||||
'Set HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN in your environment as a fallback.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function sensitiveFileBlockReason(filePath) {
|
||||
const normalized = String(filePath || '')
|
||||
.replace(/\\/g, '/')
|
||||
.toLowerCase()
|
||||
const basename = path.basename(normalized)
|
||||
const ext = path.extname(basename)
|
||||
|
||||
if (!basename) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (normalized.includes('/.ssh/')) {
|
||||
return 'SSH key/config files are blocked.'
|
||||
}
|
||||
|
||||
if (normalized.includes('/.gnupg/')) {
|
||||
return 'GPG key material is blocked.'
|
||||
}
|
||||
|
||||
if (normalized.endsWith('/.aws/credentials')) {
|
||||
return 'AWS credential files are blocked.'
|
||||
}
|
||||
|
||||
if (basename === '.env') {
|
||||
return '.env files are blocked because they commonly contain secrets.'
|
||||
}
|
||||
|
||||
if (basename.startsWith('.env.')) {
|
||||
const suffix = basename.slice('.env.'.length)
|
||||
if (!SAFE_ENV_SUFFIXES.has(suffix)) {
|
||||
return `${basename} is blocked because it appears to contain environment secrets.`
|
||||
}
|
||||
}
|
||||
|
||||
if (/^id_(rsa|dsa|ecdsa|ed25519)(?:\..+)?$/.test(basename) && !basename.endsWith('.pub')) {
|
||||
return 'SSH private key files are blocked.'
|
||||
}
|
||||
|
||||
if (SENSITIVE_EXTENSIONS.has(ext)) {
|
||||
return `${ext} key/certificate files are blocked.`
|
||||
}
|
||||
|
||||
if (basename === '.npmrc' || basename === '.netrc' || basename === '.pypirc') {
|
||||
return `${basename} is blocked because it may include auth credentials.`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveRequestedFilePath(filePath, baseDir = process.cwd(), purpose = 'File read') {
|
||||
const raw = String(filePath || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
throw new Error(`${purpose} failed: file path is required.`)
|
||||
}
|
||||
|
||||
if (raw.includes('\0')) {
|
||||
throw new Error(`${purpose} failed: file path is invalid.`)
|
||||
}
|
||||
|
||||
if (/^file:/i.test(raw)) {
|
||||
try {
|
||||
return fileURLToPath(raw)
|
||||
} catch {
|
||||
throw new Error(`${purpose} failed: file URL is invalid.`)
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedBase = path.resolve(String(baseDir || process.cwd()))
|
||||
return path.resolve(resolvedBase, raw)
|
||||
}
|
||||
|
||||
async function resolveReadableFileForIpc(filePath, options = {}) {
|
||||
const purpose = String(options.purpose || 'File read')
|
||||
const resolvedPath = resolveRequestedFilePath(filePath, options.baseDir, purpose)
|
||||
|
||||
if (options.blockSensitive !== false) {
|
||||
const blockReason = sensitiveFileBlockReason(resolvedPath)
|
||||
if (blockReason) {
|
||||
throw new Error(`${purpose} blocked for sensitive file: ${blockReason}`)
|
||||
}
|
||||
}
|
||||
|
||||
let stat
|
||||
try {
|
||||
stat = await fs.promises.stat(resolvedPath)
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? error.code : ''
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
throw new Error(`${purpose} failed: file does not exist.`)
|
||||
}
|
||||
throw new Error(`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
throw new Error(`${purpose} failed: path points to a directory.`)
|
||||
}
|
||||
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`${purpose} failed: only regular files can be read.`)
|
||||
}
|
||||
|
||||
const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null
|
||||
if (maxBytes && stat.size > maxBytes) {
|
||||
throw new Error(`${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.access(resolvedPath, fs.constants.R_OK)
|
||||
} catch {
|
||||
throw new Error(`${purpose} failed: file is not readable.`)
|
||||
}
|
||||
|
||||
return { resolvedPath, stat }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DATA_URL_READ_MAX_BYTES,
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
TEXT_PREVIEW_SOURCE_MAX_BYTES,
|
||||
encryptDesktopSecret,
|
||||
resolveReadableFileForIpc,
|
||||
resolveTimeoutMs,
|
||||
sensitiveFileBlockReason
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const {
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
encryptDesktopSecret,
|
||||
resolveReadableFileForIpc,
|
||||
resolveTimeoutMs,
|
||||
sensitiveFileBlockReason
|
||||
} = require('./hardening.cjs')
|
||||
|
||||
test('resolveTimeoutMs falls back to defaults and accepts overrides', () => {
|
||||
assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
assert.equal(resolveTimeoutMs(-25), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
assert.equal(resolveTimeoutMs('2750'), 2750)
|
||||
})
|
||||
|
||||
test('encryptDesktopSecret requires available secure storage', () => {
|
||||
assert.equal(
|
||||
encryptDesktopSecret('', { isEncryptionAvailable: () => true, encryptString: () => Buffer.alloc(0) }),
|
||||
null
|
||||
)
|
||||
|
||||
assert.throws(
|
||||
() => encryptDesktopSecret('token', { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0) }),
|
||||
/Secure token storage is unavailable/
|
||||
)
|
||||
})
|
||||
|
||||
test('encryptDesktopSecret stores safeStorage base64 payload', () => {
|
||||
const secret = encryptDesktopSecret('token-123', {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: value => Buffer.from(`enc:${value}`, 'utf8')
|
||||
})
|
||||
|
||||
assert.deepEqual(secret, {
|
||||
encoding: 'safeStorage',
|
||||
value: Buffer.from('enc:token-123', 'utf8').toString('base64')
|
||||
})
|
||||
})
|
||||
|
||||
test('sensitiveFileBlockReason blocks obvious secret file patterns', () => {
|
||||
assert.match(String(sensitiveFileBlockReason('/tmp/.env')), /\.env/)
|
||||
assert.equal(sensitiveFileBlockReason('/tmp/.env.example'), null)
|
||||
assert.match(String(sensitiveFileBlockReason('/Users/me/.ssh/id_ed25519')), /SSH/)
|
||||
assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const textPath = path.join(tempDir, 'notes.txt')
|
||||
fs.writeFileSync(textPath, 'hello world', 'utf8')
|
||||
|
||||
const fromRelative = await resolveReadableFileForIpc('notes.txt', {
|
||||
baseDir: tempDir,
|
||||
maxBytes: 256,
|
||||
purpose: 'File preview'
|
||||
})
|
||||
assert.equal(fromRelative.resolvedPath, textPath)
|
||||
assert.equal(fromRelative.stat.size, 11)
|
||||
|
||||
const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
assert.equal(fromFileUrl.resolvedPath, textPath)
|
||||
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc('missing.txt', {
|
||||
baseDir: tempDir,
|
||||
purpose: 'Text preview'
|
||||
}),
|
||||
/file does not exist/
|
||||
)
|
||||
|
||||
const nestedDir = path.join(tempDir, 'directory')
|
||||
fs.mkdirSync(nestedDir)
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc(nestedDir, {
|
||||
purpose: 'Text preview'
|
||||
}),
|
||||
/path points to a directory/
|
||||
)
|
||||
|
||||
const largePath = path.join(tempDir, 'large.txt')
|
||||
fs.writeFileSync(largePath, 'x'.repeat(40), 'utf8')
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc(largePath, {
|
||||
maxBytes: 8,
|
||||
purpose: 'File preview'
|
||||
}),
|
||||
/file is too large/
|
||||
)
|
||||
|
||||
const envPath = path.join(tempDir, '.env')
|
||||
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc(envPath, {
|
||||
purpose: 'File preview'
|
||||
}),
|
||||
/blocked for sensitive file/
|
||||
)
|
||||
|
||||
const envTemplatePath = path.join(tempDir, '.env.example')
|
||||
fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8')
|
||||
const envTemplate = await resolveReadableFileForIpc(envTemplatePath, {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
assert.equal(envTemplate.resolvedPath, envTemplatePath)
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user