mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-12 13:22:30 +08:00
Compare commits
133 Commits
fix/sqlite
...
auto-fixes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daa88feddd | ||
|
|
78f6e78e72 | ||
|
|
e0ed9dea7c | ||
|
|
7a0aae1427 | ||
|
|
212f4bb2e2 | ||
|
|
92d3c39e5a | ||
|
|
47dcc0c9d0 | ||
|
|
7c3b7c8470 | ||
|
|
8286070cb1 | ||
|
|
631c09384e | ||
|
|
5b1fca042d | ||
|
|
6e3db7f8c7 | ||
|
|
1574b242f9 | ||
|
|
48a17d57d3 | ||
|
|
4bdb2811c5 | ||
|
|
36eccbc951 | ||
|
|
20f833dd84 | ||
|
|
da15df24c6 | ||
|
|
445d41ff41 | ||
|
|
9ea034bf81 | ||
|
|
a5d2bf547f | ||
|
|
d3c24fb172 | ||
|
|
2c0d7351f7 | ||
|
|
8bdf3ff563 | ||
|
|
7562885e93 | ||
|
|
8fb6432b5c | ||
|
|
e3dc0af8b5 | ||
|
|
0d6c686e03 | ||
|
|
9069587bb2 | ||
|
|
7f75b0a7fc | ||
|
|
5f41651336 | ||
|
|
d3c7d8def4 | ||
|
|
e7f23f9f80 | ||
|
|
18274b6d35 | ||
|
|
862e019d75 | ||
|
|
99069cdebb | ||
|
|
a7c696e6ae | ||
|
|
12b63489a7 | ||
|
|
b8880f1245 | ||
|
|
db8772a062 | ||
|
|
b9b463f3bd | ||
|
|
35d777df07 | ||
|
|
1a2f3aea9a | ||
|
|
cd7a8dfde0 | ||
|
|
9b72995a1d | ||
|
|
90bd5b0f9b | ||
|
|
97fb9e1f62 | ||
|
|
54e1864577 | ||
|
|
0b753d8918 | ||
|
|
c2a40b2dc9 | ||
|
|
459cf3402b | ||
|
|
e640bb5e18 | ||
|
|
de33c2413b | ||
|
|
7ae9faecf7 | ||
|
|
8727e67295 | ||
|
|
97e9c64664 | ||
|
|
f39c88befb | ||
|
|
304cdbdc77 | ||
|
|
d37090ac36 | ||
|
|
623165a640 | ||
|
|
dfdc3156fb | ||
|
|
8fa0d8bbbb | ||
|
|
0e67c7231d | ||
|
|
03b8a00e26 | ||
|
|
ca6513542d | ||
|
|
caf557be5b | ||
|
|
29f3dc0809 | ||
|
|
a9f3f08700 | ||
|
|
79a104d037 | ||
|
|
b3bde1fbee | ||
|
|
a0032f5f92 | ||
|
|
3aeaf3755d | ||
|
|
69f1460c3d | ||
|
|
7628f2770a | ||
|
|
0629caac62 | ||
|
|
5f00f36ba9 | ||
|
|
46cf87be16 | ||
|
|
6207d68948 | ||
|
|
678be9f1d8 | ||
|
|
301acc9eaa | ||
|
|
1318cd9b0d | ||
|
|
57dfebe3db | ||
|
|
ed36edde41 | ||
|
|
b196ce80c8 | ||
|
|
b3f77f5c82 | ||
|
|
9d38a2309e | ||
|
|
08e9dcf182 | ||
|
|
94c2a4016b | ||
|
|
24ea21993f | ||
|
|
04ca34b5f3 | ||
|
|
24f6ed53fc | ||
|
|
1c7f31a577 | ||
|
|
703487d7a6 | ||
|
|
3f8b220049 | ||
|
|
9cb2a8abb0 | ||
|
|
0ff097439e | ||
|
|
2d315d30f8 | ||
|
|
c101207b99 | ||
|
|
6abf195682 | ||
|
|
8fc80bc2aa | ||
|
|
f46e7647eb | ||
|
|
1453431881 | ||
|
|
cd7c203ab9 | ||
|
|
53231fb00b | ||
|
|
3eb937a498 | ||
|
|
f82c71396d | ||
|
|
ec0227b435 | ||
|
|
f8361d29c8 | ||
|
|
a0972b9748 | ||
|
|
838aa742cb | ||
|
|
5e50f18b30 | ||
|
|
0b0f60bf22 | ||
|
|
651e632b6d | ||
|
|
949e4cb72a | ||
|
|
07271a6f62 | ||
|
|
c9d5491205 | ||
|
|
df886d0a45 | ||
|
|
5693265775 | ||
|
|
1a47769715 | ||
|
|
3a394210ff | ||
|
|
89371216b2 | ||
|
|
4c03032a24 | ||
|
|
881a9520e3 | ||
|
|
b2e227d249 | ||
|
|
c49d51bf72 | ||
|
|
a4dd08a977 | ||
|
|
b5c655c89e | ||
|
|
540f90190f | ||
|
|
16b841b7f4 | ||
|
|
9c40fc2f2c | ||
|
|
0f8603c571 | ||
|
|
276542c729 | ||
|
|
8eac52054b |
5
.github/actions/detect-changes/action.yml
vendored
5
.github/actions/detect-changes/action.yml
vendored
@@ -10,7 +10,7 @@ outputs:
|
||||
description: Run Python tests / ruff / ty / windows-footguns.
|
||||
value: ${{ steps.classify.outputs.python }}
|
||||
frontend:
|
||||
description: Run the TypeScript typecheck matrix + desktop build.
|
||||
description: Run the TypeScript testing matrix + desktop build.
|
||||
value: ${{ steps.classify.outputs.frontend }}
|
||||
docker_meta:
|
||||
description: Docker setup and meta files have changed.
|
||||
@@ -27,6 +27,9 @@ outputs:
|
||||
mcp_catalog:
|
||||
description: Require MCP catalog security review label.
|
||||
value: ${{ steps.classify.outputs.mcp_catalog }}
|
||||
ci_review:
|
||||
description: Require CI-sensitive file review label.
|
||||
value: ${{ steps.classify.outputs.ci_review }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
|
||||
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
@@ -43,6 +43,7 @@ jobs:
|
||||
deps: ${{ steps.classify.outputs.deps }}
|
||||
docker_meta: ${{ steps.classify.outputs.docker_meta }}
|
||||
mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }}
|
||||
ci_review: ${{ steps.classify.outputs.ci_review }}
|
||||
event_name: ${{ github.event_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -65,16 +66,17 @@ jobs:
|
||||
lint:
|
||||
name: Python lints
|
||||
needs: detect
|
||||
if: needs.detect.outputs.python == 'true'
|
||||
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.ci_review == 'true'
|
||||
uses: ./.github/workflows/lint.yml
|
||||
with:
|
||||
event_name: ${{ needs.detect.outputs.event_name }}
|
||||
ci_review: ${{ needs.detect.outputs.ci_review == 'true' }}
|
||||
|
||||
typecheck:
|
||||
name: TypeScript
|
||||
js-tests:
|
||||
name: JS & TS checks
|
||||
needs: detect
|
||||
if: needs.detect.outputs.frontend == 'true'
|
||||
uses: ./.github/workflows/typecheck.yml
|
||||
uses: ./.github/workflows/js-tests.yml
|
||||
|
||||
docs-site:
|
||||
name: Docs Site
|
||||
@@ -139,7 +141,7 @@ jobs:
|
||||
needs:
|
||||
- tests
|
||||
- lint
|
||||
- typecheck
|
||||
- js-tests
|
||||
- docs-site
|
||||
- history-check
|
||||
- contributor-check
|
||||
@@ -154,14 +156,18 @@ jobs:
|
||||
steps:
|
||||
- name: Evaluate job results
|
||||
env:
|
||||
RESULTS: ${{ toJSON(needs.*.result) }}
|
||||
NEEDS: ${{ toJSON(needs) }}
|
||||
run: |
|
||||
echo "$RESULTS" | python3 -c "
|
||||
echo "$NEEDS" | python3 -c "
|
||||
import json, sys
|
||||
results = json.load(sys.stdin)
|
||||
failed = [r for r in results if r == 'failure']
|
||||
needs = json.load(sys.stdin)
|
||||
failed = [name for name, info in needs.items() if info['result'] == 'failure']
|
||||
for name, info in sorted(needs.items()):
|
||||
result = info['result']
|
||||
icon = '✅' if result in ('success', 'skipped') else '❌'
|
||||
print(f'{icon} {name}: {result}')
|
||||
if failed:
|
||||
print(f'::error::{len(failed)} job(s) failed')
|
||||
print(f'::error::{len(failed)} job(s) failed: {\", \".join(failed)}')
|
||||
sys.exit(1)
|
||||
print('All checks passed (or were skipped)')
|
||||
"
|
||||
|
||||
145
.github/workflows/desktop-autofix.yml
vendored
Normal file
145
.github/workflows/desktop-autofix.yml
vendored
Normal file
@@ -0,0 +1,145 @@
|
||||
name: auto-fix lint issues & fmtting
|
||||
|
||||
# On push to main, run `npm run fix` on each workspace package and commit any changes.
|
||||
|
||||
# Fixable lint issues (import sorting, unused imports, curly braces, etc.) are
|
||||
# auto-corrected on merge so PRs aren't blocked by them. The PR-time eslint
|
||||
# check in typecheck.yml fails only when un-fixable errors remain.
|
||||
#
|
||||
# Pushes made by GITHUB_TOKEN don't trigger further workflow runs, so there's
|
||||
# no infinite loop.
|
||||
#
|
||||
# ── Security model: two-job split ───────────────────────────────────────────
|
||||
#
|
||||
# The eslint process executes repo code (eslint.config.mjs, package.json
|
||||
# scripts, installed plugins). To prevent a malicious PR from getting arbitrary
|
||||
# code execution on a runner with push access, the work is split:
|
||||
#
|
||||
# 1. generate-patch (unprivileged, contents: read only)
|
||||
# Checks out, installs deps, runs eslint --fix, produces a .patch artifact.
|
||||
# Worst case: malicious code runs here on an ephemeral runner with zero
|
||||
# push permissions.
|
||||
#
|
||||
# 2. apply-patch (privileged, contents: write)
|
||||
# Checks out, downloads the patch artifact, applies it, validates that
|
||||
# only files under apps/desktop/src/ and apps/desktop/electron/ changed,
|
||||
# then pushes with --force-with-lease. This job never runs npm, never
|
||||
# installs anything, never executes any repo code. The only input it
|
||||
# trusts is the patch artifact, and the path guard ensures the patch can
|
||||
# only touch linted source files.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '**/*.js'
|
||||
- '**/*.cjs'
|
||||
- '**/*.mjs'
|
||||
- '**/*.ts'
|
||||
- '**/*.tsx'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
|
||||
permissions:
|
||||
contents: read # default; apply-patch job overrides to write
|
||||
|
||||
concurrency:
|
||||
group: desktop-autofix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
generate-patch:
|
||||
name: Generate eslint --fix patch
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
# No permissions override → inherits workflow-level contents: read.
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
# --ignore-scripts: eslint only needs TS sources + eslint packages.
|
||||
- uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci --ignore-scripts
|
||||
|
||||
- name: npm run fix in all workspaces
|
||||
# continue-on-error: if un-fixable errors exist on main, we still want
|
||||
# to commit whatever fixes were applied. The PR-time check in
|
||||
# typecheck.yml is what blocks un-fixable errors from landing.
|
||||
continue-on-error: true
|
||||
run: npm run fix
|
||||
|
||||
- name: Produce patch
|
||||
run: |
|
||||
if git diff --quiet; then
|
||||
echo "No fixes needed."
|
||||
# Empty patch signals "nothing to do" to apply-patch.
|
||||
: > js-fix.patch
|
||||
else
|
||||
git diff > js-fix.patch
|
||||
echo "Patch size: $(wc -c < js-fix.patch) bytes"
|
||||
fi
|
||||
|
||||
- name: Upload patch artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: js-fix-patch
|
||||
path: js-fix.patch
|
||||
retention-days: 1
|
||||
include-hidden-files: true
|
||||
|
||||
apply-patch:
|
||||
name: Apply patch to main
|
||||
needs: generate-patch
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
permissions:
|
||||
contents: write # needed to push to main
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Download patch
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: js-fix-patch
|
||||
|
||||
- name: Apply patch and push
|
||||
env:
|
||||
START_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Empty patch = nothing to do.
|
||||
if [ ! -s js-fix.patch ]; then
|
||||
echo "Patch is empty. No fixes to apply."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Apply the patch produced by the unprivileged job.
|
||||
git apply --check js-fix.patch || {
|
||||
echo "::error::Patch does not apply cleanly. Main may have moved???"
|
||||
exit 0
|
||||
}
|
||||
git apply js-fix.patch
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add -A
|
||||
git commit -m "fmt(js): `npm run fix` on merge"
|
||||
|
||||
# --force-with-lease=<ref>:<sha> makes the precondition check atomic
|
||||
# on the server side: the push only succeeds if origin/main still
|
||||
# points at START_SHA when the server processes it. If main moved
|
||||
# since checkout (another push landed), the server rejects it and
|
||||
# the next run will re-apply on the current state.
|
||||
if git push --force-with-lease=main:"$START_SHA" origin HEAD:main; then
|
||||
echo "Pushed fixes."
|
||||
else
|
||||
echo "Main moved since checkout ($START_SHA). Push rejected."
|
||||
echo "Next run will re-apply on the current state."
|
||||
exit 0
|
||||
fi
|
||||
45
.github/workflows/js-tests.yml
vendored
Normal file
45
.github/workflows/js-tests.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
# .github/workflows/js-tests.yml
|
||||
name: JS Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
workspaces:
|
||||
name: List npm workspaces
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
packages: ${{ steps.set-matrix.outputs.packages }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci --ignore-scripts
|
||||
- id: set-matrix
|
||||
run: |
|
||||
echo "packages=$(npm query .workspace | jq -c '[.[].location]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
check:
|
||||
name: Typecheck & Test
|
||||
needs: workspaces
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
package: ${{ fromJson(needs.workspaces.outputs.packages) }}
|
||||
fail-fast: false # report all failures, not just the first one
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- uses: ./.github/actions/retry
|
||||
with:
|
||||
# --ignore-scripts: TS & tests don't need native deps
|
||||
command: npm ci --ignore-scripts
|
||||
- run: npm run --prefix ${{ matrix.package }} check
|
||||
- run: npm run --prefix ${{ matrix.package }} fix
|
||||
47
.github/workflows/lint.yml
vendored
47
.github/workflows/lint.yml
vendored
@@ -15,6 +15,10 @@ on:
|
||||
description: The event name from the calling orchestrator (pull_request or push).
|
||||
type: string
|
||||
required: true
|
||||
ci_review:
|
||||
description: Whether CI-sensitive files (eslint config, workflows, actions) changed and require a review label.
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -158,3 +162,46 @@ jobs:
|
||||
|
||||
- name: Run footgun checker
|
||||
run: python scripts/check-windows-footguns.py --all
|
||||
|
||||
ci-review:
|
||||
# Require explicit maintainer review when CI-sensitive files change:
|
||||
# eslint config, workflow YAMLs, or composite actions. These files
|
||||
# influence what code the desktop-autofix job executes and pushes to
|
||||
# main, so a malicious PR could inject arbitrary code via a custom eslint
|
||||
# rule's `fix` function. The label gate ensures a human reviews before
|
||||
# merge. Mirrors the mcp-catalog-reviewed pattern in supply-chain-audit.yml.
|
||||
name: CI-sensitive file review
|
||||
if: inputs.event_name == 'pull_request' && inputs.ci_review
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Require ci-reviewed label
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true)
|
||||
if echo "$LABELS" | grep -Fxq 'ci-reviewed'; then
|
||||
echo "ci-reviewed label present."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BODY="## ⚠️ CI-sensitive file review required
|
||||
|
||||
This PR changes CI-sensitive files (eslint config, workflow YAMLs,
|
||||
or composite actions). These files influence what code the
|
||||
desktop-autofix job executes and pushes to main.
|
||||
|
||||
A maintainer should verify:
|
||||
- no new eslint rules with custom \`fix\` functions that write outside linted paths,
|
||||
- no workflow changes that widen permissions or remove guards,
|
||||
- no composite action changes that alter what gets executed.
|
||||
|
||||
After review, add the \`ci-reviewed\` label and re-run this check."
|
||||
|
||||
gh pr comment "$PR" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
|
||||
echo "::error::CI-sensitive changes require the ci-reviewed label."
|
||||
exit 1
|
||||
|
||||
51
.github/workflows/typecheck.yml
vendored
51
.github/workflows/typecheck.yml
vendored
@@ -1,51 +0,0 @@
|
||||
# .github/workflows/typecheck.yml
|
||||
name: Typecheck
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: Check TypeScript
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
package:
|
||||
[ui-tui, web, apps/bootstrap-installer, apps/desktop, apps/shared]
|
||||
fail-fast: false # report all failures, not just the first one
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
# --ignore-scripts: typecheck only needs the TS sources + type defs, not
|
||||
# native builds. Skipping install scripts drops node-pty's node-gyp
|
||||
# header fetch — the transient flake that killed this job pre-`tsc` — and
|
||||
# is faster. retry covers the remaining registry blips.
|
||||
- uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci --ignore-scripts
|
||||
- run: npm run --prefix ${{ matrix.package }} typecheck
|
||||
|
||||
# Production build of the desktop renderer. `typecheck` runs `tsc` only,
|
||||
# which does NOT exercise Vite/Rolldown module resolution — so an
|
||||
# unresolvable package export (e.g. a transitive @assistant-ui/tap that no
|
||||
# longer exports "./react-shim") slips past typecheck and only explodes when
|
||||
# users build apps/desktop from source on install/update. Run the real
|
||||
# `vite build` here so that class of break fails in CI instead.
|
||||
desktop-build:
|
||||
name: Build desktop app
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
# Keep install scripts here: the production build may need node-pty's
|
||||
# native binary. retry handles the transient install-time fetch flakes.
|
||||
- uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci
|
||||
- run: npm run --prefix apps/desktop build
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -69,7 +69,7 @@ hermes_cli/web_dist/
|
||||
apps/desktop/build/
|
||||
apps/desktop/dist/
|
||||
apps/desktop/release/
|
||||
apps/desktop/*.tsbuildinfo
|
||||
*.tsbuildinfo
|
||||
|
||||
# Web UI assets — synced from @nous-research/ui at build time via
|
||||
# `npm run sync-assets` (see web/package.json).
|
||||
|
||||
55
AGENTS.md
55
AGENTS.md
@@ -1354,3 +1354,58 @@ not the specific names.
|
||||
|
||||
Reviewers should reject new change-detector tests; authors should convert
|
||||
them into invariants before re-requesting review.
|
||||
|
||||
### Never read source code in tests
|
||||
|
||||
A test that reads a source file's text is testing *the shape of the
|
||||
source code*, not its behavior. This is a hard antipattern, banned outright.
|
||||
Any test that reads a .py, .ts, .tsx, etc., file is suspect.
|
||||
|
||||
**Why it's actively harmful, not just weak:**
|
||||
|
||||
- It passes when the implementation is subtly broken (the regex matches a
|
||||
call site that exists but is wired wrong) and fails when a correct
|
||||
refactor changes formatting, variable names, or control flow with
|
||||
identical runtime behavior. Both directions of failure are wrong.
|
||||
- It can't be run against a built/bundled/minified artifact, so it silently
|
||||
stops testing anything the moment code moves, gets renamed, or a
|
||||
dependency reformats it.
|
||||
- It actively blocks refactors: reviewers see "keeps a pattern intact" tests
|
||||
fail during pure structural cleanup with no behavior change, and either
|
||||
hand-wave the failure (dangerous) or waste time updating regexes that add
|
||||
nothing (waste).
|
||||
- It gives false confidence. a green suite full of source-regex tests
|
||||
looks like coverage but has never once executed the code path it claims
|
||||
to guard.
|
||||
|
||||
**Do not write:**
|
||||
|
||||
```ts
|
||||
const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8')
|
||||
|
||||
test('backend spawn hides the Windows console', () => {
|
||||
assert.match(source, /spawn\(\s*backend\.command,\s*backend\.args[\s\S]{0,300}hiddenWindowsChildOptions/)
|
||||
})
|
||||
```
|
||||
|
||||
**Do write — extract the logic into a small pure/DI-testable function and
|
||||
call it for real:**
|
||||
|
||||
```ts
|
||||
// backend-spawn.ts
|
||||
export function hiddenWindowsChildOptions(options: SpawnOptionsLike = {}, isWindows = process.platform === 'win32') {
|
||||
if (!isWindows || 'windowsHide' in options) return options
|
||||
return { ...options, windowsHide: true }
|
||||
}
|
||||
|
||||
// backend-spawn.test.ts
|
||||
test('windowsHide defaults to true on Windows, is left alone elsewhere', () => {
|
||||
assert.equal(hiddenWindowsChildOptions({}, true).windowsHide, true)
|
||||
assert.equal(hiddenWindowsChildOptions({}, false).windowsHide, undefined)
|
||||
assert.equal(hiddenWindowsChildOptions({ windowsHide: false }, true).windowsHide, false)
|
||||
})
|
||||
```
|
||||
|
||||
If the logic lives inline in a god-file (`main.ts`, `cli.py`,
|
||||
`gateway/run.py`) and extracting it feels disruptive: that's the actual
|
||||
signal to do the extraction, not to regex around it.
|
||||
|
||||
@@ -110,7 +110,12 @@ def build_tool_title(tool_name: str, args: Dict[str, Any]) -> str:
|
||||
if tool_name == "web_extract":
|
||||
urls = args.get("urls", [])
|
||||
if urls:
|
||||
return f"extract: {urls[0]}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "")
|
||||
first = urls[0]
|
||||
if isinstance(first, dict):
|
||||
first = first.get("url") or first.get("href") or "?"
|
||||
elif not isinstance(first, str):
|
||||
first = "?"
|
||||
return f"extract: {first}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "")
|
||||
return "web extract"
|
||||
if tool_name == "process":
|
||||
action = str(args.get("action") or "").strip() or "manage"
|
||||
|
||||
@@ -3161,21 +3161,20 @@ def apply_pending_steer_to_tool_results(agent, messages: list, num_tool_msgs: in
|
||||
|
||||
|
||||
|
||||
def force_close_tcp_sockets(client: Any) -> int:
|
||||
"""Abort in-flight TCP I/O by shutting down sockets WITHOUT closing FDs.
|
||||
def force_close_tcp_sockets(client: Any, *, release_fds: bool = False) -> int:
|
||||
"""Abort in-flight TCP I/O by shutting down pool sockets.
|
||||
|
||||
When a provider drops a connection mid-stream — or the user issues an
|
||||
interrupt — we want to unblock httpx's reader/writer immediately rather
|
||||
than waiting for the kernel's per-connection timeout. ``shutdown(SHUT_RDWR)``
|
||||
achieves that: it sends FIN, breaks any pending ``recv``/``send`` with EOF
|
||||
or ``EPIPE``, but does NOT release the file descriptor.
|
||||
or ``EPIPE``.
|
||||
|
||||
Historically this helper also called ``socket.close()`` so the FD got
|
||||
released immediately, but that's unsafe when (as is the case for both the
|
||||
interrupt-abort path and stale-call kill path) the helper runs on a
|
||||
different thread than the one driving the request:
|
||||
By default (``release_fds=False``) this helper does **not** call
|
||||
``socket.close()`` / release the FD. That default is load-bearing for
|
||||
cross-thread abort paths (#29507):
|
||||
|
||||
* The Python ``socket.socket`` we close here is the SAME object held by
|
||||
* The Python ``socket.socket`` we close is the SAME object held by
|
||||
httpx's pool, so closing it via Python sets its ``_fd`` to -1 and
|
||||
future operations on that Python object fail safely.
|
||||
* BUT the SSL wrapper (``ssl.SSLSocket``'s underlying OpenSSL ``BIO``)
|
||||
@@ -3187,15 +3186,20 @@ def force_close_tcp_sockets(client: Any) -> int:
|
||||
wrong file (issue #29507: 24-byte TLS application-data record
|
||||
clobbering SQLite header bytes 5..28).
|
||||
|
||||
The fix is to let the owning thread own the close. ``shutdown()`` from any
|
||||
thread is FD-safe; ``close()`` is not. The httpx connection's own close
|
||||
path — which runs from the worker thread when it unwinds — will release
|
||||
the FD via the same ``socket.socket`` object, and because Python's socket
|
||||
close atomically swaps ``_fd`` to -1 *before* issuing ``os.close``, there
|
||||
is no FD-aliasing window when only one thread closes.
|
||||
``shutdown()`` from any thread is FD-safe; ``close()`` is not when a
|
||||
stranger thread still has the BIO holding the raw FD.
|
||||
|
||||
Returns the number of sockets shut down. (Field kept as
|
||||
``tcp_force_closed=N`` in the log line for backwards-compatible parsing.)
|
||||
When the **owning** thread is disposing of a client that is no longer
|
||||
shared (``_close_openai_client`` after replace / request-complete), pass
|
||||
``release_fds=True``. httpx's own ``client.close()`` does not reliably
|
||||
``os.close()`` sockets that were already ``shutdown()``'d, so without an
|
||||
explicit ``sock.close()`` those FDs stay in kernel CLOSED state forever
|
||||
and accumulate under long-lived gateways (issue #61979 — ~1 CLOSED fd
|
||||
per ~6 minutes through a local proxy path).
|
||||
|
||||
Returns the number of sockets shut down (and optionally closed). Field
|
||||
kept as ``tcp_force_closed=N`` in log lines for backwards-compatible
|
||||
parsing.
|
||||
"""
|
||||
import socket as _socket
|
||||
|
||||
@@ -3207,7 +3211,13 @@ def force_close_tcp_sockets(client: Any) -> int:
|
||||
except OSError:
|
||||
# Already shut down / not connected / FD invalid — all benign.
|
||||
pass
|
||||
# IMPORTANT (#29507): do NOT call sock.close() here. See docstring.
|
||||
# IMPORTANT (#29507): never release FDs from stranger-thread
|
||||
# abort paths. Only the owning-thread close path may opt in.
|
||||
if release_fds:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
shutdown_count += 1
|
||||
except Exception as exc:
|
||||
_ra().logger.debug("Force-close TCP sockets sweep error: %s", exc)
|
||||
|
||||
@@ -2102,7 +2102,7 @@ def _convert_user_message(content: Any) -> Dict[str, Any]:
|
||||
if isinstance(content, list):
|
||||
converted_blocks = _convert_content_to_anthropic(content)
|
||||
if not converted_blocks or all(
|
||||
b.get("text", "").strip() == ""
|
||||
(b.get("text") or "").strip() == ""
|
||||
for b in converted_blocks
|
||||
if isinstance(b, dict) and b.get("type") == "text"
|
||||
):
|
||||
|
||||
@@ -4716,8 +4716,8 @@ def resolve_provider_client(
|
||||
if custom_entry is None:
|
||||
custom_entry = _get_named_custom_provider(provider)
|
||||
if custom_entry:
|
||||
custom_base = custom_entry.get("base_url", "").strip()
|
||||
custom_key = custom_entry.get("api_key", "").strip()
|
||||
custom_base = (custom_entry.get("base_url") or "").strip()
|
||||
custom_key = (custom_entry.get("api_key") or "").strip()
|
||||
custom_key_env = (custom_entry.get("key_env") or custom_entry.get("api_key_env") or "").strip()
|
||||
if not custom_key and custom_key_env:
|
||||
custom_key = os.getenv(custom_key_env, "").strip()
|
||||
|
||||
@@ -62,6 +62,11 @@ def _resolve_review_runtime(agent: Any) -> Dict[str, Any]:
|
||||
"api_key": parent_runtime.get("api_key") or None,
|
||||
"base_url": parent_runtime.get("base_url") or None,
|
||||
"api_mode": parent_api_mode,
|
||||
"credential_pool": getattr(agent, "_credential_pool", None),
|
||||
"request_overrides": dict(getattr(agent, "request_overrides", {}) or {}),
|
||||
"max_tokens": getattr(agent, "max_tokens", None),
|
||||
"command": getattr(agent, "acp_command", None),
|
||||
"args": list(getattr(agent, "acp_args", []) or []),
|
||||
"routed": False,
|
||||
}
|
||||
try:
|
||||
@@ -89,10 +94,15 @@ def _resolve_review_runtime(agent: Any) -> Dict[str, Any]:
|
||||
)
|
||||
return {
|
||||
"provider": rp.get("provider") or task_provider,
|
||||
"model": task_model,
|
||||
"model": rp.get("model") or task_model,
|
||||
"api_key": rp.get("api_key"),
|
||||
"base_url": rp.get("base_url"),
|
||||
"api_mode": rp.get("api_mode"),
|
||||
"credential_pool": rp.get("credential_pool"),
|
||||
"request_overrides": dict(rp.get("request_overrides") or {}),
|
||||
"max_tokens": rp.get("max_output_tokens"),
|
||||
"command": rp.get("command"),
|
||||
"args": list(rp.get("args") or []),
|
||||
"routed": True,
|
||||
}
|
||||
except Exception as e:
|
||||
@@ -680,6 +690,12 @@ def _run_review_in_thread(
|
||||
# Match parent's toolset config so ``tools[]`` is byte-identical
|
||||
# in the request body — Anthropic's cache key includes it.
|
||||
# (The runtime whitelist below still restricts dispatch.)
|
||||
_fork_kwargs: Dict[str, Any] = {}
|
||||
if isinstance(_rt.get("max_tokens"), int):
|
||||
_fork_kwargs["max_tokens"] = _rt["max_tokens"]
|
||||
if isinstance(_rt.get("command"), str) and _rt["command"]:
|
||||
_fork_kwargs["acp_command"] = _rt["command"]
|
||||
_fork_kwargs["acp_args"] = _rt.get("args") or []
|
||||
review_agent = AIAgent(
|
||||
model=_rt.get("model") or agent.model,
|
||||
max_iterations=16,
|
||||
@@ -689,11 +705,13 @@ def _run_review_in_thread(
|
||||
api_mode=_rt.get("api_mode"),
|
||||
base_url=_rt.get("base_url") or None,
|
||||
api_key=_rt.get("api_key") or None,
|
||||
credential_pool=getattr(agent, "_credential_pool", None),
|
||||
credential_pool=_rt.get("credential_pool"),
|
||||
request_overrides=_rt.get("request_overrides") or {},
|
||||
parent_session_id=agent.session_id,
|
||||
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
|
||||
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
|
||||
skip_memory=True,
|
||||
**_fork_kwargs,
|
||||
)
|
||||
review_agent._memory_write_origin = "background_review"
|
||||
review_agent._memory_write_context = "background_review"
|
||||
|
||||
@@ -164,6 +164,25 @@ def _validated_openrouter_provider_sort(raw_sort: Any) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _provider_preferences_for_agent(agent) -> Dict[str, Any]:
|
||||
"""Build the validated provider-routing object shared by request paths."""
|
||||
preferences: Dict[str, Any] = {}
|
||||
if agent.providers_allowed:
|
||||
preferences["only"] = agent.providers_allowed
|
||||
if agent.providers_ignored:
|
||||
preferences["ignore"] = agent.providers_ignored
|
||||
if agent.providers_order:
|
||||
preferences["order"] = agent.providers_order
|
||||
provider_sort = _validated_openrouter_provider_sort(agent.provider_sort)
|
||||
if provider_sort:
|
||||
preferences["sort"] = provider_sort
|
||||
if agent.provider_require_parameters:
|
||||
preferences["require_parameters"] = True
|
||||
if agent.provider_data_collection:
|
||||
preferences["data_collection"] = agent.provider_data_collection
|
||||
return preferences
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.getenv(name, str(default)))
|
||||
@@ -801,21 +820,8 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
|
||||
_omit_temp = False
|
||||
_fixed_temp = None
|
||||
|
||||
# Provider preferences (OpenRouter-style)
|
||||
_prefs: Dict[str, Any] = {}
|
||||
if agent.providers_allowed:
|
||||
_prefs["only"] = agent.providers_allowed
|
||||
if agent.providers_ignored:
|
||||
_prefs["ignore"] = agent.providers_ignored
|
||||
if agent.providers_order:
|
||||
_prefs["order"] = agent.providers_order
|
||||
_provider_sort = _validated_openrouter_provider_sort(agent.provider_sort)
|
||||
if _provider_sort:
|
||||
_prefs["sort"] = _provider_sort
|
||||
if agent.provider_require_parameters:
|
||||
_prefs["require_parameters"] = True
|
||||
if agent.provider_data_collection:
|
||||
_prefs["data_collection"] = agent.provider_data_collection
|
||||
# Provider preferences (aggregator profile decides whether to emit them).
|
||||
_prefs = _provider_preferences_for_agent(agent)
|
||||
|
||||
# Anthropic-compatible max-output fallback (last resort only — applied in
|
||||
# build_kwargs *after* ephemeral/user/profile max_tokens, never overriding
|
||||
@@ -1690,18 +1696,28 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
|
||||
if _lm_reasoning_effort is not None:
|
||||
summary_kwargs["reasoning_effort"] = _lm_reasoning_effort
|
||||
|
||||
# Include provider routing preferences
|
||||
provider_preferences = {}
|
||||
if agent.providers_allowed:
|
||||
provider_preferences["only"] = agent.providers_allowed
|
||||
if agent.providers_ignored:
|
||||
provider_preferences["ignore"] = agent.providers_ignored
|
||||
if agent.providers_order:
|
||||
provider_preferences["order"] = agent.providers_order
|
||||
_provider_sort = _validated_openrouter_provider_sort(agent.provider_sort)
|
||||
if _provider_sort:
|
||||
provider_preferences["sort"] = _provider_sort
|
||||
if provider_preferences and (
|
||||
# Merge the profile's canonical body even when routing is unset:
|
||||
# profiles may always emit required metadata such as Portal tags.
|
||||
provider_preferences = _provider_preferences_for_agent(agent)
|
||||
profile_extra_body = {}
|
||||
try:
|
||||
from providers import get_provider_profile
|
||||
|
||||
provider_profile = get_provider_profile(agent.provider)
|
||||
if provider_profile is not None:
|
||||
profile_extra_body = provider_profile.build_extra_body(
|
||||
session_id=getattr(agent, "session_id", None),
|
||||
provider_preferences=provider_preferences or None,
|
||||
model=agent.model,
|
||||
base_url=agent.base_url,
|
||||
reasoning_config=agent.reasoning_config,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if profile_extra_body:
|
||||
summary_extra_body.update(profile_extra_body)
|
||||
if provider_preferences and "provider" not in profile_extra_body and (
|
||||
(agent.provider or "").strip().lower() == "openrouter"
|
||||
or agent._is_openrouter_url()
|
||||
):
|
||||
|
||||
@@ -56,6 +56,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
@@ -412,10 +413,18 @@ def _marker_root(cwd: Path) -> Optional[Path]:
|
||||
"""
|
||||
current = cwd.resolve()
|
||||
home = _home()
|
||||
# Shared world-writable temp roots are never project roots: a stray
|
||||
# manifest in /tmp (left by any process) must not flip every session
|
||||
# whose cwd lives under the temp dir into the coding posture. Same
|
||||
# reasoning as the $HOME skip below.
|
||||
try:
|
||||
temp_root = Path(tempfile.gettempdir()).resolve()
|
||||
except Exception:
|
||||
temp_root = None
|
||||
for depth, parent in enumerate([current, *current.parents]):
|
||||
if depth > 6:
|
||||
break
|
||||
if parent == home:
|
||||
if parent == home or (temp_root is not None and parent == temp_root):
|
||||
continue
|
||||
for marker in _PROJECT_MARKERS:
|
||||
if (parent / marker).exists():
|
||||
|
||||
@@ -613,6 +613,11 @@ def run_conversation(
|
||||
truncated_response_parts: List[str] = []
|
||||
compression_attempts = 0
|
||||
_turn_exit_reason = "unknown" # Diagnostic: why the loop ended
|
||||
# Last composed answer intentionally held back by a verification gate. If
|
||||
# that continuation consumes the remaining budget, this is the best
|
||||
# user-facing result available; it must not be confused with error or
|
||||
# recovery text produced by unrelated exit paths.
|
||||
_pending_verification_response = None
|
||||
|
||||
# Per-turn tally of consecutive successful credential-pool token refreshes,
|
||||
# keyed by (provider, pool-entry-id). A persistent upstream 401 lets
|
||||
@@ -5099,6 +5104,10 @@ def run_conversation(
|
||||
}
|
||||
messages.append(continue_msg)
|
||||
agent._session_messages = messages
|
||||
# An acknowledgment is explicitly non-final. Do not let its
|
||||
# text suppress iteration-limit summarization if this
|
||||
# continuation consumes the remaining budget.
|
||||
final_response = None
|
||||
continue
|
||||
|
||||
codex_ack_continuations = 0
|
||||
@@ -5173,6 +5182,12 @@ def run_conversation(
|
||||
# terminal. Keep a debug breadcrumb in agent.log for tracing.
|
||||
logger.debug("verification stop-loop nudge issued (attempt %d)",
|
||||
agent._verification_stop_nudges)
|
||||
# Keep the attempted answer only as an explicit fallback for
|
||||
# continuation-budget exhaustion. ``final_response`` itself
|
||||
# must be cleared so the finalizer can distinguish this gate
|
||||
# from unrelated error/recovery exits. (#61631)
|
||||
_pending_verification_response = final_response
|
||||
final_response = None
|
||||
continue
|
||||
|
||||
# User verification-loop gate: when the agent edited code this
|
||||
@@ -5224,6 +5239,8 @@ def run_conversation(
|
||||
agent._session_messages = messages
|
||||
logger.debug("pre_verify nudge issued (attempt %d)",
|
||||
agent._pre_verify_nudges)
|
||||
_pending_verification_response = final_response
|
||||
final_response = None
|
||||
continue
|
||||
|
||||
messages.append(final_msg)
|
||||
@@ -5308,6 +5325,7 @@ def run_conversation(
|
||||
original_user_message=original_user_message,
|
||||
_should_review_memory=_should_review_memory,
|
||||
_turn_exit_reason=_turn_exit_reason,
|
||||
_pending_verification_response=_pending_verification_response,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -45,12 +45,26 @@ def _strip_aux_credential(value: Any) -> Optional[str]:
|
||||
|
||||
|
||||
class _ReviewRuntimeBinding(NamedTuple):
|
||||
"""Provider/model for the curator review fork plus optional per-slot overrides."""
|
||||
"""Provider/model for the curator review fork plus per-slot overrides."""
|
||||
|
||||
provider: str
|
||||
model: str
|
||||
explicit_api_key: Optional[str]
|
||||
explicit_base_url: Optional[str]
|
||||
request_overrides: Dict[str, Any]
|
||||
|
||||
|
||||
def _merge_request_overrides(
|
||||
runtime_overrides: Any,
|
||||
slot_extra_body: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Merge resolver metadata with task-local request body fields."""
|
||||
merged = dict(runtime_overrides or {})
|
||||
if isinstance(slot_extra_body, dict) and slot_extra_body:
|
||||
extra_body = dict(merged.get("extra_body") or {})
|
||||
extra_body.update(slot_extra_body)
|
||||
merged["extra_body"] = extra_body
|
||||
return merged
|
||||
|
||||
|
||||
DEFAULT_INTERVAL_HOURS = 24 * 7 # 7 days
|
||||
@@ -1764,6 +1778,7 @@ def _resolve_review_runtime(cfg: Dict[str, Any]) -> _ReviewRuntimeBinding:
|
||||
_task_model,
|
||||
_strip_aux_credential(_cur_task.get("api_key")),
|
||||
_strip_aux_credential(_cur_task.get("base_url")),
|
||||
_merge_request_overrides({}, _cur_task.get("extra_body")),
|
||||
)
|
||||
|
||||
# 2. Legacy curator.auxiliary.{provider,model} (deprecated, pre-unification)
|
||||
@@ -1781,10 +1796,11 @@ def _resolve_review_runtime(cfg: Dict[str, Any]) -> _ReviewRuntimeBinding:
|
||||
str(_legacy_model),
|
||||
_strip_aux_credential(_legacy.get("api_key")),
|
||||
_strip_aux_credential(_legacy.get("base_url")),
|
||||
_merge_request_overrides({}, _legacy.get("extra_body")),
|
||||
)
|
||||
|
||||
# 3. Fall through to the main chat model
|
||||
return _ReviewRuntimeBinding(_main_provider, _main_model, None, None)
|
||||
return _ReviewRuntimeBinding(_main_provider, _main_model, None, None, {})
|
||||
|
||||
|
||||
def _resolve_review_model(cfg: Dict[str, Any]) -> tuple[str, str]:
|
||||
@@ -1850,6 +1866,11 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
|
||||
_base_url = None
|
||||
_api_mode = None
|
||||
_resolved_provider = None
|
||||
_credential_pool = None
|
||||
_request_overrides: Dict[str, Any] = {}
|
||||
_max_tokens = None
|
||||
_acp_command = None
|
||||
_acp_args = None
|
||||
_model_name = ""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
@@ -1867,6 +1888,16 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
|
||||
_base_url = _rp.get("base_url")
|
||||
_api_mode = _rp.get("api_mode")
|
||||
_resolved_provider = _rp.get("provider") or _provider
|
||||
_credential_pool = _rp.get("credential_pool")
|
||||
_request_overrides = _merge_request_overrides(
|
||||
_rp.get("request_overrides"),
|
||||
_binding.request_overrides.get("extra_body"),
|
||||
)
|
||||
_max_tokens = _rp.get("max_output_tokens")
|
||||
_acp_command = _rp.get("command")
|
||||
_acp_args = list(_rp.get("args") or [])
|
||||
if isinstance(_rp.get("model"), str) and _rp["model"].strip():
|
||||
_model_name = _rp["model"].strip()
|
||||
except Exception as e:
|
||||
logger.debug("Curator provider resolution failed: %s", e, exc_info=True)
|
||||
|
||||
@@ -1875,12 +1906,21 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
|
||||
|
||||
review_agent = None
|
||||
try:
|
||||
_agent_kwargs: Dict[str, Any] = {}
|
||||
if isinstance(_max_tokens, int):
|
||||
_agent_kwargs["max_tokens"] = _max_tokens
|
||||
if isinstance(_acp_command, str) and _acp_command:
|
||||
_agent_kwargs["acp_command"] = _acp_command
|
||||
_agent_kwargs["acp_args"] = _acp_args or []
|
||||
review_agent = AIAgent(
|
||||
model=_model_name,
|
||||
provider=_resolved_provider,
|
||||
api_key=_api_key,
|
||||
base_url=_base_url,
|
||||
api_mode=_api_mode,
|
||||
credential_pool=_credential_pool,
|
||||
request_overrides=_request_overrides,
|
||||
**_agent_kwargs,
|
||||
# Umbrella-building over a large skill collection is worth a
|
||||
# high iteration ceiling — the pass typically takes 50-100
|
||||
# API calls against hundreds of candidate skills. The
|
||||
|
||||
@@ -27,6 +27,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_ANSI_RESET = "\033[0m"
|
||||
|
||||
|
||||
def _display_url(value: Any) -> str:
|
||||
"""Extract a display-only URL without assuming model argument types."""
|
||||
if isinstance(value, dict):
|
||||
value = value.get("url") or value.get("href")
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
|
||||
|
||||
# Diff colors — resolved lazily from the skin engine so they adapt
|
||||
# to light/dark themes. Falls back to sensible defaults on import
|
||||
# failure. We cache after first resolution for performance.
|
||||
@@ -1259,7 +1267,7 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]
|
||||
return False, ""
|
||||
|
||||
|
||||
def get_cute_tool_message(
|
||||
def _get_cute_tool_message(
|
||||
tool_name: str, args: dict, duration: float, result: str | None = None,
|
||||
) -> str:
|
||||
"""Generate a formatted tool completion line for CLI quiet mode.
|
||||
@@ -1301,9 +1309,11 @@ def get_cute_tool_message(
|
||||
if tool_name == "web_extract":
|
||||
urls = args.get("urls", [])
|
||||
if urls:
|
||||
url = urls[0] if isinstance(urls, list) else str(urls)
|
||||
url = _display_url(urls[0] if isinstance(urls, list) else urls)
|
||||
if not url:
|
||||
return _wrap(f"┊ 📄 fetch pages {dur}")
|
||||
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
|
||||
extra = f" +{len(urls)-1}" if len(urls) > 1 else ""
|
||||
extra = f" +{len(urls)-1}" if isinstance(urls, list) and len(urls) > 1 else ""
|
||||
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
|
||||
return _wrap(f"┊ 📄 fetch pages {dur}")
|
||||
if tool_name == "terminal":
|
||||
@@ -1433,6 +1443,19 @@ def get_cute_tool_message(
|
||||
return _wrap(f"┊ ⚡ {tool_name[:9]:9} {_trunc(preview, 35)} {dur}")
|
||||
|
||||
|
||||
def get_cute_tool_message(
|
||||
tool_name: str, args: dict, duration: float, result: str | None = None,
|
||||
) -> str:
|
||||
"""Render a completion label without letting cosmetic failures escape."""
|
||||
try:
|
||||
return _get_cute_tool_message(tool_name, args, duration, result=result)
|
||||
except Exception as exc: # noqa: BLE001 — display must never abort a turn
|
||||
logger.debug("Tool completion label failed for %s: %s", tool_name, exc)
|
||||
safe_name = tool_name[:9] if isinstance(tool_name, str) and tool_name else "tool"
|
||||
safe_duration = f"{duration:.1f}s" if isinstance(duration, (int, float)) else "done"
|
||||
return f"┊ ⚡ {safe_name:9} completed {safe_duration}"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Honcho session line (one-liner with clickable OSC 8 hyperlink)
|
||||
# =========================================================================
|
||||
|
||||
@@ -120,7 +120,7 @@ _REFERENCE_SYSTEM_PROMPT = (
|
||||
|
||||
|
||||
def _slot_label(slot: dict[str, str]) -> str:
|
||||
return f"{slot.get('provider', '').strip()}:{slot.get('model', '').strip()}"
|
||||
return f"{(slot.get('provider') or '').strip()}:{(slot.get('model') or '').strip()}"
|
||||
|
||||
|
||||
def _slot_runtime(slot: dict[str, str]) -> dict[str, Any]:
|
||||
|
||||
@@ -1077,7 +1077,7 @@ def _load_context_cache() -> Dict[str, int]:
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
return data.get("context_lengths", {})
|
||||
return data.get("context_lengths") or {}
|
||||
except Exception as e:
|
||||
logger.debug("Failed to load context length cache: %s", e)
|
||||
return {}
|
||||
|
||||
@@ -34,6 +34,7 @@ from typing import Any, Dict, List, Optional
|
||||
from agent.tool_result_classification import (
|
||||
FILE_MUTATING_TOOL_NAMES as _FILE_MUTATING_TOOLS,
|
||||
)
|
||||
from tools.threat_patterns import scan_for_threats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -379,13 +380,21 @@ def make_tool_result_message(name: str, content: Any, tool_call_id: str) -> dict
|
||||
callers should compare by value, not by ``is``.
|
||||
"""
|
||||
wrapped = _maybe_wrap_untrusted(name, content)
|
||||
return {
|
||||
message = {
|
||||
"role": "tool",
|
||||
"name": name,
|
||||
"tool_name": name,
|
||||
"content": wrapped,
|
||||
"tool_call_id": tool_call_id,
|
||||
}
|
||||
try:
|
||||
risk_metadata = _tool_output_risk_metadata(name, content)
|
||||
except Exception as exc:
|
||||
logger.debug("Tool output risk scan failed for %s: %s", name, exc)
|
||||
else:
|
||||
if risk_metadata is not None:
|
||||
message["_tool_output_risk"] = risk_metadata
|
||||
return message
|
||||
|
||||
|
||||
# Tools whose results carry attacker-controllable content. Wrapping their
|
||||
@@ -419,6 +428,42 @@ def _is_untrusted_tool(name: Optional[str]) -> bool:
|
||||
return any(name.startswith(p) for p in _UNTRUSTED_TOOL_PREFIXES)
|
||||
|
||||
|
||||
def _tool_output_risk_metadata(name: str, content: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Classify textual attacker-controlled output without retaining a copy.
|
||||
|
||||
The advisory metadata is internal-only. It records deterministic finding
|
||||
identifiers, never blocks or redacts the normal result, and deliberately
|
||||
omits raw scanned text.
|
||||
"""
|
||||
if not _is_untrusted_tool(name):
|
||||
return None
|
||||
if isinstance(content, str):
|
||||
text_parts = [content]
|
||||
elif isinstance(content, list):
|
||||
text_parts = [
|
||||
item["text"]
|
||||
for item in content
|
||||
if isinstance(item, dict)
|
||||
and item.get("type") == "text"
|
||||
and isinstance(item.get("text"), str)
|
||||
]
|
||||
if not text_parts:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
findings: List[str] = []
|
||||
for text in text_parts:
|
||||
for finding in scan_for_threats(text, scope="context"):
|
||||
if finding not in findings:
|
||||
findings.append(finding)
|
||||
return {
|
||||
"risk": "high" if findings else "low",
|
||||
"findings": findings,
|
||||
"redacted": False,
|
||||
}
|
||||
|
||||
|
||||
def _neutralize_delimiters(content: str) -> str:
|
||||
"""Defang any literal ``untrusted_tool_result`` delimiter embedded in
|
||||
attacker-controlled content so it can't break out of the wrapper.
|
||||
|
||||
@@ -74,6 +74,25 @@ _MAX_TOOL_WORKERS = 8
|
||||
_DEFAULT_CONCURRENT_TOOL_TIMEOUT_S = 420.0
|
||||
|
||||
|
||||
def _parse_tool_arguments(raw_arguments: Any) -> tuple[dict, Optional[str]]:
|
||||
"""Parse model-emitted arguments without repairing or coercing them."""
|
||||
try:
|
||||
arguments = json.loads(raw_arguments)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
arguments = None
|
||||
if isinstance(arguments, dict):
|
||||
return arguments, None
|
||||
return {}, json.dumps(
|
||||
{
|
||||
"error": "Invalid tool arguments",
|
||||
"message": (
|
||||
"Tool arguments must be a valid JSON object; tool was not executed."
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_concurrent_tool_timeout() -> float | None:
|
||||
raw = os.getenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "").strip()
|
||||
if not raw:
|
||||
@@ -337,19 +356,29 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
for tool_call in tool_calls:
|
||||
function_name = tool_call.function.name
|
||||
|
||||
# Reset nudge counters
|
||||
function_args, malformed_args_result = _parse_tool_arguments(
|
||||
tool_call.function.arguments
|
||||
)
|
||||
|
||||
if malformed_args_result is not None:
|
||||
parsed_calls.append(
|
||||
(
|
||||
tool_call,
|
||||
function_name,
|
||||
function_args,
|
||||
[],
|
||||
malformed_args_result,
|
||||
False,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# Reset nudge counters only for a structurally valid invocation.
|
||||
if function_name == "memory":
|
||||
agent._turns_since_memory = 0
|
||||
elif function_name == "skill_manage":
|
||||
agent._iters_since_skill = 0
|
||||
|
||||
try:
|
||||
function_args = json.loads(tool_call.function.arguments)
|
||||
except json.JSONDecodeError:
|
||||
function_args = {}
|
||||
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
|
||||
@@ -935,7 +964,25 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
# image tool result never poisons canonical session history.
|
||||
# String results pass through unchanged.
|
||||
_tool_content = agent._tool_result_content_for_active_model(name, function_result)
|
||||
messages.append(make_tool_result_message(name, _tool_content, tc.id))
|
||||
tool_message = make_tool_result_message(name, _tool_content, tc.id)
|
||||
messages.append(tool_message)
|
||||
risk_metadata = tool_message.get("_tool_output_risk")
|
||||
if (
|
||||
risk_metadata is not None
|
||||
and risk_metadata.get("risk") != "low"
|
||||
and agent.tool_progress_callback
|
||||
):
|
||||
try:
|
||||
agent.tool_progress_callback(
|
||||
"tool.output_risk",
|
||||
name,
|
||||
None,
|
||||
None,
|
||||
tool_call_id=tc.id,
|
||||
risk_metadata=risk_metadata,
|
||||
)
|
||||
except Exception as cb_err:
|
||||
logging.debug("Tool output risk callback error: %s", cb_err)
|
||||
_flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
@@ -990,13 +1037,24 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
|
||||
function_name = tool_call.function.name
|
||||
|
||||
try:
|
||||
function_args = json.loads(tool_call.function.arguments)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Unexpected JSON error after validation: {e}")
|
||||
function_args = {}
|
||||
if not isinstance(function_args, dict):
|
||||
function_args = {}
|
||||
function_args, malformed_args_result = _parse_tool_arguments(
|
||||
tool_call.function.arguments
|
||||
)
|
||||
if malformed_args_result is not None:
|
||||
messages.append(
|
||||
make_tool_result_message(
|
||||
function_name,
|
||||
malformed_args_result,
|
||||
tool_call.id,
|
||||
)
|
||||
)
|
||||
_flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
stage=f"invalid tool arguments {function_name}",
|
||||
)
|
||||
agent._apply_pending_steer_to_tool_results(messages, 1)
|
||||
continue
|
||||
|
||||
# Tool Search unwrap — see execute_tool_calls_concurrent for full
|
||||
# rationale, including the scope gate (the unwrap dispatches the
|
||||
@@ -1584,7 +1642,25 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
# Unwrap _multimodal dicts to an OpenAI-style content list
|
||||
# (see parallel path for rationale). String results pass through.
|
||||
_tool_content = agent._tool_result_content_for_active_model(function_name, function_result)
|
||||
messages.append(make_tool_result_message(function_name, _tool_content, tool_call.id))
|
||||
tool_message = make_tool_result_message(function_name, _tool_content, tool_call.id)
|
||||
messages.append(tool_message)
|
||||
risk_metadata = tool_message.get("_tool_output_risk")
|
||||
if (
|
||||
risk_metadata is not None
|
||||
and risk_metadata.get("risk") != "low"
|
||||
and agent.tool_progress_callback
|
||||
):
|
||||
try:
|
||||
agent.tool_progress_callback(
|
||||
"tool.output_risk",
|
||||
function_name,
|
||||
None,
|
||||
None,
|
||||
tool_call_id=tool_call.id,
|
||||
risk_metadata=risk_metadata,
|
||||
)
|
||||
except Exception as cb_err:
|
||||
logging.debug("Tool output risk callback error: %s", cb_err)
|
||||
_flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
|
||||
@@ -42,6 +42,7 @@ def finalize_turn(
|
||||
original_user_message,
|
||||
_should_review_memory,
|
||||
_turn_exit_reason,
|
||||
_pending_verification_response=None,
|
||||
):
|
||||
"""Run the post-loop finalization and return the turn ``result`` dict.
|
||||
|
||||
@@ -50,10 +51,35 @@ def finalize_turn(
|
||||
"""
|
||||
from agent.conversation_loop import logger
|
||||
|
||||
if final_response is None and (
|
||||
budget_exhausted = (
|
||||
api_call_count >= agent.max_iterations
|
||||
or agent.iteration_budget.remaining <= 0
|
||||
):
|
||||
)
|
||||
budget_fallback_eligible = (
|
||||
budget_exhausted
|
||||
and not interrupted
|
||||
and not failed
|
||||
and str(_turn_exit_reason) in {"unknown", "budget_exhausted"}
|
||||
)
|
||||
continuation_budget_exhausted = (
|
||||
final_response is None
|
||||
and bool(_pending_verification_response)
|
||||
and budget_fallback_eligible
|
||||
)
|
||||
|
||||
iteration_limit_fallback = False
|
||||
preserved_verification_fallback = False
|
||||
if continuation_budget_exhausted:
|
||||
# A verification/continuation gate deliberately withheld a composed
|
||||
# answer, then consumed the remaining budget before producing a newer
|
||||
# one. Preserve that exact answer instead of replacing it with another
|
||||
# fallible model call. The explicit pending value is the provenance
|
||||
# guard: unrelated error/recovery exits can never enter this branch.
|
||||
final_response = _pending_verification_response
|
||||
_turn_exit_reason = f"max_iterations_reached({api_call_count}/{agent.max_iterations})"
|
||||
iteration_limit_fallback = True
|
||||
preserved_verification_fallback = True
|
||||
elif final_response is None and budget_fallback_eligible:
|
||||
# Budget exhausted — ask the model for a summary via one extra
|
||||
# API call with tools stripped. _handle_max_iterations injects a
|
||||
# user message and makes a single toolless request.
|
||||
@@ -68,20 +94,18 @@ def finalize_turn(
|
||||
"— requesting summary..."
|
||||
)
|
||||
final_response = agent._handle_max_iterations(messages, api_call_count)
|
||||
iteration_limit_fallback = True
|
||||
|
||||
if iteration_limit_fallback:
|
||||
# If running as a kanban worker, signal the dispatcher that 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.
|
||||
# protocol violation). This applies whether the user-facing fallback
|
||||
# came from the summary call or an explicitly pending continuation;
|
||||
# both exhausted the task budget and must advance the failure circuit.
|
||||
#
|
||||
# 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.
|
||||
# rather than ``kanban_block`` so this counts toward the dispatcher's
|
||||
# consecutive-failure circuit breaker (#29747 gap 2).
|
||||
_kanban_task = os.environ.get("HERMES_KANBAN_TASK")
|
||||
if _kanban_task:
|
||||
try:
|
||||
@@ -304,6 +328,7 @@ def finalize_turn(
|
||||
# truncated partial (the "The" case from #34452).
|
||||
_is_partial_fragment = (
|
||||
not _is_empty_terminal
|
||||
and not preserved_verification_fallback
|
||||
and not str(_turn_exit_reason).startswith("text_response")
|
||||
and len(_stripped) <= 24
|
||||
and _stripped[-1:] not in {".", "!", "?", "。", "!", "?", "`", ")"}
|
||||
|
||||
5
apps/bootstrap-installer/eslint.config.mjs
Normal file
5
apps/bootstrap-installer/eslint.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
import shared from '../../eslint.config.shared.mjs'
|
||||
|
||||
export default [
|
||||
...shared
|
||||
]
|
||||
@@ -12,7 +12,11 @@
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"tauri:build:debug": "tauri build --debug",
|
||||
"typecheck": "tsc -p . --noEmit"
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"check": "npm run typecheck",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"fix": "npm run lint:fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nous-research/ui": "0.16.0",
|
||||
@@ -37,11 +41,19 @@
|
||||
"tw-shimmer": "^0.4.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-perfectionist": "^5.9.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"globals": "^17.4.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
55
apps/desktop/electron/backend-child.ts
Normal file
55
apps/desktop/electron/backend-child.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* backend-child.ts
|
||||
*
|
||||
* Windows-aware teardown for the desktop's managed backend child process.
|
||||
*
|
||||
* Node's `child.kill()` only signals the direct child. On Windows a backend
|
||||
* that spawned its own grandchildren (a `hermes` REPL, a pty terminal
|
||||
* session, the gateway) survives a plain SIGTERM and keeps files (e.g. the
|
||||
* venv shim) locked. So on Windows we tree-kill via `forceKillProcessTree`;
|
||||
* everywhere else a plain SIGTERM is correct and sufficient (POSIX has no
|
||||
* mandatory locks, and the backend is not spawned detached so there's no
|
||||
* process-group to negative-pid-kill).
|
||||
*
|
||||
* Extracted into its own dependency-free module (no electron import) so the
|
||||
* SIGTERM-vs-tree-kill branching can be asserted directly with a fake child
|
||||
* object and a spy `forceKillProcessTree`, instead of grepping main.ts source
|
||||
* text for the function body.
|
||||
*/
|
||||
|
||||
export interface StopBackendChildDeps {
|
||||
/** Defaults to the real platform check; injectable for tests. */
|
||||
isWindows?: boolean
|
||||
/** Windows tree-kill implementation (real: taskkill /T /F via execFileSync). */
|
||||
forceKillProcessTree: (pid: number) => void
|
||||
}
|
||||
|
||||
export interface KillableChild {
|
||||
pid?: number | null
|
||||
killed?: boolean
|
||||
kill: (signal: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a managed child process, choosing the right strategy for the platform.
|
||||
* No-ops silently if `child` is falsy, already killed, or the kill attempt
|
||||
* throws (the process may already be gone) -- mirrors the original inline
|
||||
* best-effort semantics in main.ts.
|
||||
*/
|
||||
export function stopBackendChild(child: KillableChild | null | undefined, deps: StopBackendChildDeps) {
|
||||
if (!child || child.killed) {
|
||||
return
|
||||
}
|
||||
|
||||
const isWindows = deps.isWindows ?? process.platform === 'win32'
|
||||
|
||||
try {
|
||||
if (isWindows && Number.isInteger(child.pid)) {
|
||||
deps.forceKillProcessTree(child.pid as number)
|
||||
} else {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { dashboardFallbackArgs, serveBackendArgs, sourceDeclaresServe } from './backend-command'
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use strict'
|
||||
|
||||
// Backend subcommand routing for the desktop-managed Hermes process.
|
||||
//
|
||||
// The desktop app launches its own headless backend via `hermes serve` — it
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
appendUniquePathEntries,
|
||||
|
||||
@@ -44,12 +44,14 @@ function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) {
|
||||
if (!entry) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter)
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part || seen.has(part)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seen.add(part)
|
||||
ordered.push(part)
|
||||
}
|
||||
@@ -77,6 +79,7 @@ function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatfor
|
||||
if (!hermesHome) {
|
||||
return hermesHome
|
||||
}
|
||||
|
||||
const resolved = pathModule.resolve(String(hermesHome))
|
||||
const parent = pathModule.dirname(resolved)
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } from './backend-probes'
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ import { EventEmitter } from 'node:events'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
|
||||
@@ -60,6 +60,7 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
|
||||
done = true
|
||||
clearTimeout(timer)
|
||||
child.stdout.off('data', onData)
|
||||
@@ -130,12 +131,14 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
|
||||
done = true
|
||||
clearTimeout(timer)
|
||||
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
|
||||
child.off('exit', onExit)
|
||||
child.off('error', onError)
|
||||
}
|
||||
@@ -171,6 +174,7 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno
|
||||
if (typeof interval.unref === 'function') {
|
||||
interval.unref()
|
||||
}
|
||||
|
||||
check()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
bundledRuntimeImportCheck,
|
||||
|
||||
@@ -2,9 +2,18 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { cachedScriptPath, installedAgentInstallScript, resolveInstallScript, runBootstrap } from './bootstrap-runner'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
buildPinArgs,
|
||||
buildPosixPinArgs,
|
||||
cachedScriptPath,
|
||||
hasExistingGitCheckout,
|
||||
installedAgentInstallScript,
|
||||
resolveInstallScript,
|
||||
runBootstrap
|
||||
} from './bootstrap-runner'
|
||||
|
||||
const SCRIPT_NAME = process.platform === 'win32' ? 'install.ps1' : 'install.sh'
|
||||
|
||||
@@ -54,6 +63,58 @@ test('installedAgentInstallScript resolves the installer in the agent checkout',
|
||||
}
|
||||
})
|
||||
|
||||
test('existing checkout detection requires git metadata', () => {
|
||||
const home = mkTmpHome()
|
||||
|
||||
try {
|
||||
const activeRoot = path.join(home, 'hermes-agent')
|
||||
assert.equal(hasExistingGitCheckout(activeRoot), false)
|
||||
|
||||
fs.mkdirSync(path.join(activeRoot, '.git'), { recursive: true })
|
||||
assert.equal(hasExistingGitCheckout(activeRoot), true)
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('fresh bootstrap args include the packaged commit pin', () => {
|
||||
const installStamp = { commit: 'a'.repeat(40), branch: 'main' }
|
||||
|
||||
assert.deepEqual(buildPinArgs(installStamp), ['-Commit', installStamp.commit, '-Branch', 'main'])
|
||||
assert.deepEqual(
|
||||
buildPosixPinArgs({
|
||||
installStamp,
|
||||
activeRoot: '/tmp/hermes-agent',
|
||||
hermesHome: '/tmp/hermes'
|
||||
}),
|
||||
[
|
||||
'--dir',
|
||||
'/tmp/hermes-agent',
|
||||
'--hermes-home',
|
||||
'/tmp/hermes',
|
||||
'--branch',
|
||||
'main',
|
||||
'--commit',
|
||||
installStamp.commit
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('existing-checkout bootstrap args keep branch but skip the packaged commit pin', () => {
|
||||
const installStamp = { commit: 'a'.repeat(40), branch: 'main' }
|
||||
|
||||
assert.deepEqual(buildPinArgs(installStamp, { pinCommit: false }), ['-Branch', 'main'])
|
||||
assert.deepEqual(
|
||||
buildPosixPinArgs({
|
||||
installStamp,
|
||||
activeRoot: '/tmp/hermes-agent',
|
||||
hermesHome: '/tmp/hermes',
|
||||
pinCommit: false
|
||||
}),
|
||||
['--dir', '/tmp/hermes-agent', '--hermes-home', '/tmp/hermes', '--branch', 'main']
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveInstallScript prefers a cached script without touching the network', async () => {
|
||||
const home = mkTmpHome()
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* the renderer.
|
||||
*
|
||||
* Wired from electron/main.ts:
|
||||
* import { runBootstrap }from './bootstrap-runner.ts'
|
||||
* import { runBootstrap }from './bootstrap-runner'
|
||||
* const result = await runBootstrap({
|
||||
* installStamp, // INSTALL_STAMP from main.ts (may be null in dev)
|
||||
* activeRoot, // ACTIVE_HERMES_ROOT
|
||||
@@ -38,16 +38,10 @@ import fsp from 'node:fs/promises'
|
||||
import https from 'node:https'
|
||||
import path from 'node:path'
|
||||
|
||||
import { hiddenWindowsChildOptions } from './windows-child-options'
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
|
||||
function hiddenWindowsChildOptions(options = {}) {
|
||||
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
|
||||
return options
|
||||
}
|
||||
|
||||
return { ...options, windowsHide: true }
|
||||
}
|
||||
|
||||
const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
|
||||
|
||||
// Stages flagged needs_user_input=true in the manifest are skipped by the
|
||||
@@ -73,6 +67,7 @@ function resolveLocalInstallScript(sourceRepoRoot) {
|
||||
if (!sourceRepoRoot) {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = path.join(sourceRepoRoot, 'scripts', installScriptName())
|
||||
|
||||
try {
|
||||
@@ -96,6 +91,7 @@ function installedAgentInstallScript(hermesHome) {
|
||||
if (!hermesHome) {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = path.join(hermesHome, 'hermes-agent', 'scripts', installScriptName())
|
||||
|
||||
try {
|
||||
@@ -107,6 +103,18 @@ function installedAgentInstallScript(hermesHome) {
|
||||
}
|
||||
}
|
||||
|
||||
function hasExistingGitCheckout(activeRoot) {
|
||||
if (!activeRoot) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return fs.existsSync(path.join(activeRoot, '.git'))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function cachedScriptPath(hermesHome, commit) {
|
||||
return path.join(bootstrapCacheDir(hermesHome), `install-${commit}.${process.platform === 'win32' ? 'ps1' : 'sh'}`)
|
||||
}
|
||||
@@ -413,6 +421,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
||||
if (abortSignal) {
|
||||
abortSignal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
reject(err)
|
||||
})
|
||||
|
||||
@@ -429,6 +438,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
||||
if (stderrBuf) {
|
||||
emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' } as any)
|
||||
}
|
||||
|
||||
resolve({ stdout, stderr, code, signal, killed } as any)
|
||||
})
|
||||
})
|
||||
@@ -505,6 +515,7 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
|
||||
if (abortSignal) {
|
||||
abortSignal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
reject(err)
|
||||
})
|
||||
|
||||
@@ -520,6 +531,7 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
|
||||
if (stderrBuf) {
|
||||
emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
|
||||
}
|
||||
|
||||
resolve({ stdout, stderr, code, signal, killed })
|
||||
})
|
||||
})
|
||||
@@ -529,13 +541,14 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
|
||||
// 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) {
|
||||
// Build the installer branch/pin args from the install stamp. The commit pin
|
||||
// is fresh-install only: once a managed checkout already exists, bootstrap is
|
||||
// a repair/update path and must not let an old packaged app detach the checkout
|
||||
// back to the commit baked into that app.
|
||||
function buildPinArgs(installStamp, { pinCommit = true } = {}) {
|
||||
const args = []
|
||||
|
||||
if (installStamp && installStamp.commit) {
|
||||
if (pinCommit && installStamp && installStamp.commit) {
|
||||
args.push('-Commit', installStamp.commit)
|
||||
}
|
||||
|
||||
@@ -546,26 +559,34 @@ function buildPinArgs(installStamp) {
|
||||
return args
|
||||
}
|
||||
|
||||
function buildPosixPinArgs({ installStamp, activeRoot, hermesHome }) {
|
||||
function buildPosixPinArgs({ installStamp, activeRoot, hermesHome, pinCommit = true }) {
|
||||
const args = ['--dir', activeRoot, '--hermes-home', hermesHome]
|
||||
|
||||
if (installStamp && installStamp.branch) {
|
||||
args.push('--branch', installStamp.branch)
|
||||
}
|
||||
|
||||
if (installStamp && installStamp.commit) {
|
||||
if (pinCommit && installStamp && installStamp.commit) {
|
||||
args.push('--commit', installStamp.commit)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, activeRoot, installStamp }) {
|
||||
async function fetchManifest({
|
||||
scriptPath,
|
||||
installerKind,
|
||||
emit,
|
||||
hermesHome,
|
||||
activeRoot,
|
||||
installStamp,
|
||||
pinCommit
|
||||
}) {
|
||||
const isPosix = installerKind === 'posix'
|
||||
|
||||
const args = isPosix
|
||||
? ['--manifest', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })]
|
||||
: ['-Manifest', ...buildPinArgs(installStamp)]
|
||||
? ['--manifest', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome, pinCommit })]
|
||||
: ['-Manifest', ...buildPinArgs(installStamp, { pinCommit })]
|
||||
|
||||
const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, {
|
||||
emit,
|
||||
@@ -622,7 +643,17 @@ function parseStageResult(stdout) {
|
||||
return null
|
||||
}
|
||||
|
||||
async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, activeRoot, abortSignal, installStamp }) {
|
||||
async function runStage({
|
||||
scriptPath,
|
||||
installerKind,
|
||||
stage,
|
||||
emit,
|
||||
hermesHome,
|
||||
activeRoot,
|
||||
abortSignal,
|
||||
installStamp,
|
||||
pinCommit
|
||||
}) {
|
||||
const startedAt = Date.now()
|
||||
emit({ type: 'stage', name: stage.name, state: 'running' })
|
||||
|
||||
@@ -634,9 +665,9 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
|
||||
stage.name,
|
||||
'--non-interactive',
|
||||
'--json',
|
||||
...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })
|
||||
...buildPosixPinArgs({ installStamp, activeRoot, hermesHome, pinCommit })
|
||||
]
|
||||
: ['-Stage', stage.name, '-NonInteractive', '-Json', ...buildPinArgs(installStamp)]
|
||||
: ['-Stage', stage.name, '-NonInteractive', '-Json', ...buildPinArgs(installStamp, { pinCommit })]
|
||||
|
||||
const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, {
|
||||
emit,
|
||||
@@ -774,6 +805,18 @@ async function runBootstrap(opts) {
|
||||
})
|
||||
|
||||
try {
|
||||
const existingCheckout = hasExistingGitCheckout(activeRoot)
|
||||
const pinCommit = !existingCheckout
|
||||
|
||||
if (existingCheckout && installStamp && installStamp.commit) {
|
||||
emit({
|
||||
type: 'log',
|
||||
line:
|
||||
`[bootstrap] existing checkout detected at ${activeRoot}; ` +
|
||||
`not pinning to packaged install stamp ${installStamp.commit.slice(0, 12)}`
|
||||
})
|
||||
}
|
||||
|
||||
// 1. Resolve the platform installer.
|
||||
const scriptInfo = await resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit })
|
||||
const installerKind = scriptInfo.kind || 'powershell'
|
||||
@@ -785,7 +828,8 @@ async function runBootstrap(opts) {
|
||||
emit,
|
||||
hermesHome,
|
||||
activeRoot,
|
||||
installStamp
|
||||
installStamp,
|
||||
pinCommit
|
||||
})
|
||||
|
||||
emit({
|
||||
@@ -813,7 +857,8 @@ async function runBootstrap(opts) {
|
||||
hermesHome,
|
||||
activeRoot,
|
||||
abortSignal,
|
||||
installStamp
|
||||
installStamp,
|
||||
pinCommit
|
||||
})
|
||||
|
||||
if (ev.state === 'failed') {
|
||||
@@ -847,7 +892,10 @@ async function runBootstrap(opts) {
|
||||
}
|
||||
|
||||
export {
|
||||
buildPinArgs,
|
||||
buildPosixPinArgs,
|
||||
cachedScriptPath,
|
||||
hasExistingGitCheckout,
|
||||
installedAgentInstallScript,
|
||||
// Exposed for testability
|
||||
parseStageResult,
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
AT_COOKIE_VARIANTS,
|
||||
@@ -20,7 +21,9 @@ import {
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveLiveSession,
|
||||
cookiesHavePrivySession,
|
||||
cookiesHaveSession,
|
||||
modeIsRemoteLike,
|
||||
normalizeRemoteBaseUrl,
|
||||
normAuthMode,
|
||||
pathWithGlobalRemoteProfile,
|
||||
@@ -47,6 +50,19 @@ test('normAuthMode coerces to token unless explicitly oauth', () => {
|
||||
assert.equal(normAuthMode('weird'), 'token')
|
||||
})
|
||||
|
||||
// --- modeIsRemoteLike ---
|
||||
|
||||
test('modeIsRemoteLike is true for remote and cloud, false otherwise', () => {
|
||||
// cloud resolves to a remote backend under the hood (Q6), so every resolution
|
||||
// site treats it like remote.
|
||||
assert.equal(modeIsRemoteLike('remote'), true)
|
||||
assert.equal(modeIsRemoteLike('cloud'), true)
|
||||
assert.equal(modeIsRemoteLike('local'), false)
|
||||
assert.equal(modeIsRemoteLike(undefined), false)
|
||||
assert.equal(modeIsRemoteLike(null), false)
|
||||
assert.equal(modeIsRemoteLike('weird'), false)
|
||||
})
|
||||
|
||||
// --- profileRemoteOverride ---
|
||||
|
||||
test('profileRemoteOverride returns null when no profile is given', () => {
|
||||
@@ -86,6 +102,21 @@ test('profileRemoteOverride preserves an explicit oauth auth mode', () => {
|
||||
assert.equal(profileRemoteOverride(config, 'coder').authMode, 'oauth')
|
||||
})
|
||||
|
||||
test('profileRemoteOverride treats a cloud entry as a remote override', () => {
|
||||
// A 'cloud' per-profile entry resolves to the same remote backend a 'remote'
|
||||
// entry would (Q6) — the override must be returned, not dropped.
|
||||
const config = {
|
||||
profiles: {
|
||||
coder: { mode: 'cloud', url: 'https://agent-1.agents.nousresearch.com', authMode: 'oauth' }
|
||||
}
|
||||
}
|
||||
assert.deepEqual(profileRemoteOverride(config, 'coder'), {
|
||||
url: 'https://agent-1.agents.nousresearch.com',
|
||||
authMode: 'oauth',
|
||||
token: undefined
|
||||
})
|
||||
})
|
||||
|
||||
test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
|
||||
assert.equal(profileRemoteOverride({}, 'coder'), null)
|
||||
assert.equal(profileRemoteOverride({ profiles: null }, 'coder'), null)
|
||||
@@ -332,6 +363,35 @@ test('cookiesHaveLiveSession is false for unrelated cookies and non-arrays', ()
|
||||
assert.equal(cookiesHaveLiveSession([]), false)
|
||||
})
|
||||
|
||||
// --- cookiesHavePrivySession (Nous portal / Privy auth, NOT gateway cookies) ---
|
||||
|
||||
test('cookiesHavePrivySession detects the privy-token access cookie', () => {
|
||||
assert.equal(cookiesHavePrivySession([{ name: 'privy-token', value: 'jwt' }]), true)
|
||||
})
|
||||
|
||||
test('cookiesHavePrivySession detects __Host-/__Secure- prefixes and the legacy privy-session name', () => {
|
||||
assert.equal(cookiesHavePrivySession([{ name: '__Host-privy-token', value: 'x' }]), true)
|
||||
assert.equal(cookiesHavePrivySession([{ name: '__Secure-privy-token', value: 'x' }]), true)
|
||||
assert.equal(cookiesHavePrivySession([{ name: 'privy-session', value: 'x' }]), true)
|
||||
})
|
||||
|
||||
test('cookiesHavePrivySession is false for an empty value', () => {
|
||||
assert.equal(cookiesHavePrivySession([{ name: 'privy-token', value: '' }]), false)
|
||||
})
|
||||
|
||||
test('cookiesHavePrivySession does NOT treat hermes gateway cookies as a portal session', () => {
|
||||
// The whole point of Q7: a gateway session cookie is NOT a portal sign-in.
|
||||
assert.equal(cookiesHavePrivySession([{ name: 'hermes_session_at', value: 'x' }]), false)
|
||||
assert.equal(cookiesHavePrivySession([{ name: '__Host-hermes_session_rt', value: 'x' }]), false)
|
||||
})
|
||||
|
||||
test('cookiesHavePrivySession is false for unrelated cookies and non-arrays', () => {
|
||||
assert.equal(cookiesHavePrivySession([{ name: 'other', value: 'x' }]), false)
|
||||
assert.equal(cookiesHavePrivySession(null), false)
|
||||
assert.equal(cookiesHavePrivySession(undefined), false)
|
||||
assert.equal(cookiesHavePrivySession([]), false)
|
||||
})
|
||||
|
||||
// --- tokenPreview ---
|
||||
|
||||
test('tokenPreview returns null for empty', () => {
|
||||
|
||||
@@ -37,6 +37,15 @@
|
||||
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
|
||||
const RT_COOKIE_VARIANTS = ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt']
|
||||
|
||||
// The Nous portal (NAS) does NOT use Hermes gateway session cookies — it is a
|
||||
// Privy-authed Next.js app. NAS `auth()` (src/server/auth/session.ts) reads the
|
||||
// `privy-token` access-token cookie (with `privy-id-token` alongside), which is
|
||||
// also exactly what the `/api/agents` cookie-auth path validates. So portal
|
||||
// sign-in / discovery liveness must look for the Privy cookie, NOT the gateway
|
||||
// cookies above. `privy-token` is the access token (the required signal);
|
||||
// variants cover the secured-prefix forms and the older `privy-session` name.
|
||||
const PRIVY_SESSION_COOKIE_VARIANTS = ['__Host-privy-token', '__Secure-privy-token', 'privy-token', 'privy-session']
|
||||
|
||||
function normalizeRemoteBaseUrl(rawUrl) {
|
||||
const value = String(rawUrl || '').trim()
|
||||
|
||||
@@ -150,20 +159,31 @@ function normAuthMode(mode) {
|
||||
return mode === 'oauth' ? 'oauth' : 'token'
|
||||
}
|
||||
|
||||
// True for connection modes that resolve to a REMOTE backend. 'cloud' is a
|
||||
// Hermes Cloud connection (cloud-auto-discovery Q3/Q6): it carries a
|
||||
// remote-shaped block and reuses the entire remote connect/probe/reconnect
|
||||
// path, so every resolution site treats it exactly like 'remote'. The only
|
||||
// places that distinguish cloud from remote are the settings UI (which card to
|
||||
// show) and config persistence (remembering the provenance). Centralized here
|
||||
// so no resolution site forgets the third arm.
|
||||
function modeIsRemoteLike(mode) {
|
||||
return mode === 'remote' || mode === 'cloud'
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a profile's explicit remote override from a connection config, or null
|
||||
* when it has none (so the caller falls back to env → global remote → local).
|
||||
*
|
||||
* The config may carry a `profiles` map keyed by name; an entry counts as an
|
||||
* override only with `mode === 'remote'` and a non-empty `url`. Pure: `token`
|
||||
* is the raw stored secret; main.ts decrypts it. Returns
|
||||
* override only with a remote-like `mode` (remote or cloud) and a non-empty
|
||||
* `url`. Pure: `token` is the raw stored secret; main.ts decrypts it. Returns
|
||||
* `{ url, authMode, token } | null`.
|
||||
*/
|
||||
function profileRemoteOverride(config, profile) {
|
||||
const key = connectionScopeKey(profile)
|
||||
const entry = key ? config?.profiles?.[key] : null
|
||||
|
||||
if (!entry || typeof entry !== 'object' || entry.mode !== 'remote') {
|
||||
if (!entry || typeof entry !== 'object' || !modeIsRemoteLike(entry.mode)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -292,6 +312,22 @@ function cookiesHaveLiveSession(cookies) {
|
||||
return cookies.some(c => c && c.value && (AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name)))
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the cookie jar holds a live Nous PORTAL (Privy) session — a non-empty
|
||||
* `privy-token` (access-token) cookie, or a variant. This is the portal
|
||||
* analogue of `cookiesHaveLiveSession`: the portal authenticates via Privy, not
|
||||
* the Hermes gateway session cookies, so cloud sign-in / discovery liveness
|
||||
* must check THIS, not the gateway helpers. (NAS `auth()` and the `/api/agents`
|
||||
* cookie path both key off `privy-token`.)
|
||||
*/
|
||||
function cookiesHavePrivySession(cookies) {
|
||||
if (!Array.isArray(cookies)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return cookies.some(c => c && c.value && PRIVY_SESSION_COOKIE_VARIANTS.includes(c.name))
|
||||
}
|
||||
|
||||
export {
|
||||
AT_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
@@ -299,10 +335,13 @@ export {
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveLiveSession,
|
||||
cookiesHavePrivySession,
|
||||
cookiesHaveSession,
|
||||
modeIsRemoteLike,
|
||||
normalizeRemoteBaseUrl,
|
||||
normAuthMode,
|
||||
pathWithGlobalRemoteProfile,
|
||||
PRIVY_SESSION_COOKIE_VARIANTS,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
adoptServedDashboardToken,
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
buildPosixCleanupScript,
|
||||
|
||||
@@ -106,6 +106,7 @@ function resolveRemovableAppPath(execPath, platform, env: any = {}) {
|
||||
if (env.APPIMAGE) {
|
||||
return env.APPIMAGE
|
||||
}
|
||||
|
||||
// Unpacked electron-builder tree: …/linux-unpacked/hermes
|
||||
const dir = p.dirname(exe)
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { readDirForIpc } from './fs-read-dir'
|
||||
|
||||
function mkTmpDir() {
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { probeGatewayWebSocket } from './gateway-ws-probe'
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ function probeGatewayWebSocket<T>(
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
clearTimers()
|
||||
|
||||
@@ -107,6 +108,7 @@ function probeGatewayWebSocket<T>(
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
opened = true
|
||||
// Upgrade accepted. Give the server a brief window to reject the
|
||||
// credential post-handshake (early close) before declaring success.
|
||||
@@ -189,6 +191,7 @@ function extractErrorReason(event) {
|
||||
if (event instanceof Error) {
|
||||
return event.message
|
||||
}
|
||||
|
||||
const err = event.error || event.message
|
||||
|
||||
if (err instanceof Error) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { resolveRenamePath } from './git-review-ops'
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { gitRootForIpc } from './git-root'
|
||||
|
||||
function mkTmpDir() {
|
||||
@@ -19,20 +20,23 @@ test('gitRootForIpc returns null for invalid and device paths', async () => {
|
||||
assert.equal(await gitRootForIpc('file:///%E0%A4%A'), null)
|
||||
})
|
||||
|
||||
test('gitRootForIpc resolves directories files missing descendants and file URLs', async t => {
|
||||
test('gitRootForIpc resolves directories files missing descendants and file URLs', async () => {
|
||||
const root = mkTmpDir()
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
|
||||
|
||||
const gitDir = path.join(root, '.git')
|
||||
const srcDir = path.join(root, 'src')
|
||||
const filePath = path.join(srcDir, 'index.ts')
|
||||
fs.mkdirSync(gitDir)
|
||||
fs.mkdirSync(srcDir)
|
||||
fs.writeFileSync(filePath, 'export {}\n', 'utf8')
|
||||
try {
|
||||
const gitDir = path.join(root, '.git')
|
||||
const srcDir = path.join(root, 'src')
|
||||
const filePath = path.join(srcDir, 'index.ts')
|
||||
fs.mkdirSync(gitDir)
|
||||
fs.mkdirSync(srcDir)
|
||||
fs.writeFileSync(filePath, 'export {}\n', 'utf8')
|
||||
|
||||
assert.equal(await gitRootForIpc(root), root)
|
||||
assert.equal(await gitRootForIpc(srcDir), root)
|
||||
assert.equal(await gitRootForIpc(filePath), root)
|
||||
assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root)
|
||||
assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root)
|
||||
assert.equal(await gitRootForIpc(root), root)
|
||||
assert.equal(await gitRootForIpc(srcDir), root)
|
||||
assert.equal(await gitRootForIpc(filePath), root)
|
||||
assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root)
|
||||
assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,7 +3,8 @@ import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
addWorktree,
|
||||
|
||||
@@ -2,9 +2,10 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
encryptDesktopSecret,
|
||||
@@ -122,171 +123,184 @@ test('resolveRequestedPathForIpc expands ~ to the home directory', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => {
|
||||
test('resolveReadableFileForIpc validates existence type size and sensitivity', async () => {
|
||||
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')
|
||||
try {
|
||||
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)
|
||||
|
||||
const spacedPath = path.join(tempDir, 'notes with spaces.txt')
|
||||
fs.writeFileSync(spacedPath, 'space ok', 'utf8')
|
||||
|
||||
const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
|
||||
assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath)
|
||||
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc('missing.txt', {
|
||||
const fromRelative = await resolveReadableFileForIpc('notes.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,
|
||||
maxBytes: 256,
|
||||
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, {
|
||||
assert.equal(fromRelative.resolvedPath, textPath)
|
||||
assert.equal(fromRelative.stat.size, 11)
|
||||
|
||||
const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), {
|
||||
purpose: 'File preview'
|
||||
}),
|
||||
/blocked for sensitive file/
|
||||
)
|
||||
})
|
||||
|
||||
const envTemplatePath = path.join(tempDir, '.env.example')
|
||||
fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8')
|
||||
assert.equal(fromFileUrl.resolvedPath, textPath)
|
||||
|
||||
const envTemplate = await resolveReadableFileForIpc(envTemplatePath, {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
const spacedPath = path.join(tempDir, 'notes with spaces.txt')
|
||||
fs.writeFileSync(spacedPath, 'space ok', 'utf8')
|
||||
|
||||
assert.equal(envTemplate.resolvedPath, envTemplatePath)
|
||||
const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
|
||||
assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath)
|
||||
|
||||
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)
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc blocks common sensitive files', async t => {
|
||||
test('resolveReadableFileForIpc blocks common sensitive files', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-sensitive-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const sshDir = path.join(tempDir, '.ssh')
|
||||
fs.mkdirSync(sshDir)
|
||||
try {
|
||||
const sshDir = path.join(tempDir, '.ssh')
|
||||
fs.mkdirSync(sshDir)
|
||||
|
||||
const blockedFiles = [
|
||||
path.join(tempDir, '.env'),
|
||||
path.join(tempDir, '.npmrc'),
|
||||
path.join(sshDir, 'id_ed25519'),
|
||||
path.join(tempDir, 'cert.pem'),
|
||||
path.join(tempDir, 'cert.p12'),
|
||||
path.join(tempDir, 'cert.pfx')
|
||||
]
|
||||
const blockedFiles = [
|
||||
path.join(tempDir, '.env'),
|
||||
path.join(tempDir, '.npmrc'),
|
||||
path.join(sshDir, 'id_ed25519'),
|
||||
path.join(tempDir, 'cert.pem'),
|
||||
path.join(tempDir, 'cert.p12'),
|
||||
path.join(tempDir, 'cert.pfx')
|
||||
]
|
||||
|
||||
for (const filePath of blockedFiles) {
|
||||
fs.writeFileSync(filePath, 'secret', 'utf8')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file')
|
||||
for (const filePath of blockedFiles) {
|
||||
fs.writeFileSync(filePath, 'secret', 'utf8')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file')
|
||||
}
|
||||
|
||||
const allowed = path.join(tempDir, '.env.example')
|
||||
fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8')
|
||||
assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed)
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const allowed = path.join(tempDir, '.env.example')
|
||||
fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8')
|
||||
assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async t => {
|
||||
test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-realpath-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const envPath = path.join(tempDir, '.env')
|
||||
const linkPath = path.join(tempDir, 'safe-name.txt')
|
||||
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
|
||||
|
||||
try {
|
||||
fs.symlinkSync(envPath, linkPath, 'file')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`symlink creation is not permitted on this platform (${error.code})`)
|
||||
const envPath = path.join(tempDir, '.env')
|
||||
const linkPath = path.join(tempDir, 'safe-name.txt')
|
||||
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
|
||||
|
||||
return
|
||||
try {
|
||||
fs.symlinkSync(envPath, linkPath, 'file')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
// symlink creation is not permitted on this platform — skip
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
throw error
|
||||
await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file')
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file')
|
||||
})
|
||||
|
||||
test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async t => {
|
||||
test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const directory = path.join(tempDir, 'project')
|
||||
const filePath = path.join(tempDir, 'file.txt')
|
||||
fs.mkdirSync(directory)
|
||||
fs.writeFileSync(filePath, 'not a directory', 'utf8')
|
||||
|
||||
const resolved = await resolveDirectoryForIpc(directory)
|
||||
assert.equal(resolved.resolvedPath, directory)
|
||||
assert.equal(resolved.stat.isDirectory(), true)
|
||||
|
||||
await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR')
|
||||
await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT')
|
||||
await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path')
|
||||
})
|
||||
|
||||
test('resolveDirectoryForIpc accepts directory symlinks or junctions', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const directory = path.join(tempDir, 'actual-project')
|
||||
const linkPath = path.join(tempDir, 'linked-project')
|
||||
fs.mkdirSync(directory)
|
||||
|
||||
try {
|
||||
fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`directory symlink creation is not permitted on this platform (${error.code})`)
|
||||
const directory = path.join(tempDir, 'project')
|
||||
const filePath = path.join(tempDir, 'file.txt')
|
||||
fs.mkdirSync(directory)
|
||||
fs.writeFileSync(filePath, 'not a directory', 'utf8')
|
||||
|
||||
return
|
||||
const resolved = await resolveDirectoryForIpc(directory)
|
||||
assert.equal(resolved.resolvedPath, directory)
|
||||
assert.equal(resolved.stat.isDirectory(), true)
|
||||
|
||||
await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR')
|
||||
await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT')
|
||||
await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path')
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveDirectoryForIpc accepts directory symlinks or junctions', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-'))
|
||||
|
||||
try {
|
||||
const directory = path.join(tempDir, 'actual-project')
|
||||
const linkPath = path.join(tempDir, 'linked-project')
|
||||
fs.mkdirSync(directory)
|
||||
|
||||
try {
|
||||
fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
// directory symlink creation is not permitted on this platform — skip
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
throw error
|
||||
const resolved = await resolveDirectoryForIpc(linkPath)
|
||||
assert.equal(resolved.resolvedPath, linkPath)
|
||||
assert.equal(resolved.stat.isDirectory(), true)
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const resolved = await resolveDirectoryForIpc(linkPath)
|
||||
assert.equal(resolved.resolvedPath, linkPath)
|
||||
assert.equal(resolved.stat.isDirectory(), true)
|
||||
})
|
||||
|
||||
@@ -112,6 +112,7 @@ function sensitiveFileBlockReason(filePath) {
|
||||
|
||||
function ipcPathError(code: any, message: string): Error & { code: any } {
|
||||
const error = new Error(message) as Error & { code: any }
|
||||
|
||||
;(error as any).code = code
|
||||
|
||||
return error
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
createLinkTitleWindow,
|
||||
|
||||
@@ -55,6 +55,7 @@ export function readLinkTitleWindowTitle(window) {
|
||||
if (!window || window.isDestroyed()) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const contents = window.webContents
|
||||
|
||||
if (!contents || contents.isDestroyed()) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Regression coverage for the OAuth-session Electron net.request path.
|
||||
*
|
||||
* Electron net rejects manual Content-Length/Host headers with
|
||||
* net::ERR_INVALID_ARGUMENT. Node HTTP helpers may still set Content-Length;
|
||||
* this guard is scoped to fetchJsonViaOauthSession only.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8')
|
||||
|
||||
function extractFetchJsonViaOauthSession() {
|
||||
const start = source.indexOf('function fetchJsonViaOauthSession')
|
||||
const end = source.indexOf('// Mint a single-use WS ticket', start)
|
||||
assert.notEqual(start, -1, 'fetchJsonViaOauthSession should exist')
|
||||
assert.notEqual(end, -1, 'fetchJsonViaOauthSession boundary should exist')
|
||||
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
test('OAuth Electron net request does not set forbidden Content-Length header', () => {
|
||||
const fn = extractFetchJsonViaOauthSession()
|
||||
|
||||
assert.match(fn, /electronNet\.request/)
|
||||
assert.doesNotMatch(fn, /setHeader\(['"]Content-Length['"]/)
|
||||
assert.match(fn, /request\.write\(body\)/)
|
||||
})
|
||||
@@ -43,6 +43,15 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl),
|
||||
oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl),
|
||||
oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl),
|
||||
// Hermes Cloud: one portal login powers discovery + silent per-agent sign-in
|
||||
// (cloud-auto-discovery Phase 3).
|
||||
cloud: {
|
||||
status: () => ipcRenderer.invoke('hermes:cloud:status'),
|
||||
login: () => ipcRenderer.invoke('hermes:cloud:login'),
|
||||
logout: () => ipcRenderer.invoke('hermes:cloud:logout'),
|
||||
discover: org => ipcRenderer.invoke('hermes:cloud:discover', org),
|
||||
agentSignIn: dashboardUrl => ipcRenderer.invoke('hermes:cloud:agent-sign-in', dashboardUrl)
|
||||
},
|
||||
profile: {
|
||||
get: () => ipcRenderer.invoke('hermes:profile:get'),
|
||||
set: name => ipcRenderer.invoke('hermes:profile:set', name)
|
||||
@@ -195,6 +204,14 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:backend-exit', listener)
|
||||
},
|
||||
// Soft gateway-mode apply finished tearing down the primary backend. Renderer
|
||||
// should wipe session lists + re-dial without a window reload.
|
||||
onConnectionApplied: callback => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('hermes:connection:applied', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:connection:applied', listener)
|
||||
},
|
||||
onPowerResume: callback => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('hermes:power-resume', listener)
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
const ELECTRON_DIR = import.meta.dirname
|
||||
|
||||
function readElectronFile(name) {
|
||||
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// prepareProfileDeleteRequest must return the torn-down profile name so the
|
||||
// caller can skip ensureBackend for that profile (issue #52279).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('prepareProfileDeleteRequest returns the torn-down profile name', () => {
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
// Locate the function definition and its closing brace.
|
||||
const fnStart = source.indexOf('async function prepareProfileDeleteRequest(')
|
||||
assert.notEqual(fnStart, -1, 'prepareProfileDeleteRequest function not found')
|
||||
|
||||
// The function must contain "return profile" (pool and primary paths).
|
||||
const fnBody = source.slice(fnStart, fnStart + 800)
|
||||
const returnProfileCount = (fnBody.match(/return profile/g) || []).length
|
||||
assert.ok(
|
||||
returnProfileCount >= 2,
|
||||
`expected at least 2 "return profile" statements (primary + pool paths), found ${returnProfileCount}`
|
||||
)
|
||||
|
||||
// The early-exit guard must return null (not void/undefined).
|
||||
assert.match(fnBody, /return null/, 'early-exit guard should return null, not undefined')
|
||||
})
|
||||
|
||||
test('hermes:api handler routes profile-delete requests to the primary backend', () => {
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
// The handler must capture prepareProfileDeleteRequest's return value.
|
||||
assert.match(
|
||||
source,
|
||||
/const tornDownProfile = await prepareProfileDeleteRequest\(request\)/,
|
||||
'handler should capture the return value of prepareProfileDeleteRequest'
|
||||
)
|
||||
|
||||
// The handler must use the return value to skip ensureBackend for the
|
||||
// torn-down profile, routing to the primary (null) instead.
|
||||
assert.match(
|
||||
source,
|
||||
/const routeProfile = tornDownProfile \? null : profile/,
|
||||
'handler should route to primary backend when a profile was just torn down'
|
||||
)
|
||||
|
||||
// ensureBackend must be called with the conditional route profile.
|
||||
assert.match(
|
||||
source,
|
||||
/const connection = await ensureBackend\(routeProfile\)/,
|
||||
'handler should pass routeProfile (not raw profile) to ensureBackend'
|
||||
)
|
||||
})
|
||||
82
apps/desktop/electron/profile-delete-routing.test.ts
Normal file
82
apps/desktop/electron/profile-delete-routing.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// profileNameFromDeleteRequest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('profileNameFromDeleteRequest parses a DELETE /api/profiles/<name> path', () => {
|
||||
assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/worker' }), 'worker')
|
||||
})
|
||||
|
||||
test('profileNameFromDeleteRequest lowercases the profile name', () => {
|
||||
assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/Worker' }), 'worker')
|
||||
})
|
||||
|
||||
test('profileNameFromDeleteRequest returns null for non-DELETE methods', () => {
|
||||
assert.equal(profileNameFromDeleteRequest({ method: 'GET', path: '/api/profiles/worker' }), null)
|
||||
})
|
||||
|
||||
test('profileNameFromDeleteRequest returns null when the path does not match', () => {
|
||||
assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/sessions' }), null)
|
||||
})
|
||||
|
||||
test('profileNameFromDeleteRequest returns null for an empty/whitespace name', () => {
|
||||
assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/%20' }), null)
|
||||
})
|
||||
|
||||
test('profileNameFromDeleteRequest returns null for an undecodable path segment', () => {
|
||||
assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/%E0%A4%A' }), null)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// decideProfileDeleteAction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const deps = {
|
||||
isDefaultProfile: p => p === 'default',
|
||||
isValidProfileName: p => /^[a-z0-9][a-z0-9_-]{0,63}$/.test(p),
|
||||
primaryProfileKey: () => 'primary-profile'
|
||||
}
|
||||
|
||||
test('decideProfileDeleteAction is a noop for the default profile', () => {
|
||||
assert.deepEqual(decideProfileDeleteAction('default', deps), { action: 'noop', profile: null })
|
||||
})
|
||||
|
||||
test('decideProfileDeleteAction is a noop for null (no profile parsed)', () => {
|
||||
assert.deepEqual(decideProfileDeleteAction(null, deps), { action: 'noop', profile: null })
|
||||
})
|
||||
|
||||
test('decideProfileDeleteAction is a noop for an invalid profile name', () => {
|
||||
assert.deepEqual(decideProfileDeleteAction('Not Valid!', deps), { action: 'noop', profile: null })
|
||||
})
|
||||
|
||||
test('decideProfileDeleteAction tears down the primary backend for the primary profile', () => {
|
||||
assert.deepEqual(decideProfileDeleteAction('primary-profile', deps), {
|
||||
action: 'teardown-primary',
|
||||
profile: 'primary-profile'
|
||||
})
|
||||
})
|
||||
|
||||
test('decideProfileDeleteAction tears down the pool backend for any other valid profile', () => {
|
||||
assert.deepEqual(decideProfileDeleteAction('worker', deps), { action: 'teardown-pool', profile: 'worker' })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveRouteProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('resolveRouteProfile routes to the primary backend (null) when a profile was torn down', () => {
|
||||
assert.equal(resolveRouteProfile('worker', 'other-profile'), null)
|
||||
})
|
||||
|
||||
test('resolveRouteProfile passes the requested profile through when nothing was torn down', () => {
|
||||
assert.equal(resolveRouteProfile(null, 'other-profile'), 'other-profile')
|
||||
})
|
||||
|
||||
test('resolveRouteProfile passes through undefined when nothing was torn down and no profile was requested', () => {
|
||||
assert.equal(resolveRouteProfile(null, undefined), undefined)
|
||||
})
|
||||
95
apps/desktop/electron/profile-delete-routing.ts
Normal file
95
apps/desktop/electron/profile-delete-routing.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// Profile-delete routing logic for the `hermes:api` IPC handler.
|
||||
//
|
||||
// When the renderer issues DELETE /api/profiles/<name>, the handler must
|
||||
// tear down that profile's backend (primary window backend or pool backend)
|
||||
// and then route the *next* request away from the just-deleted profile's
|
||||
// pool backend -- spawning a fresh one would call ensure_hermes_home() and
|
||||
// recreate the profile directory the delete just removed, leaving a zombie
|
||||
// process behind (issue #52279).
|
||||
//
|
||||
// These helpers are pure so they can be unit-tested without Electron.
|
||||
|
||||
/**
|
||||
* Parse a `hermes:api` request into the profile name a DELETE targets, or
|
||||
* null when the request is not a profile-delete at all (wrong method, wrong
|
||||
* path, empty/invalid name).
|
||||
*/
|
||||
export function profileNameFromDeleteRequest(request) {
|
||||
if (!request || String(request.method || 'GET').toUpperCase() !== 'DELETE') {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = String(request.path || '').match(/^\/api\/profiles\/([^/?#]+)(?:[?#].*)?$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
let raw = ''
|
||||
|
||||
try {
|
||||
raw = decodeURIComponent(match[1])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const name = raw.trim()
|
||||
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (name.toLowerCase() === 'default') {
|
||||
return 'default'
|
||||
}
|
||||
|
||||
return name.toLowerCase()
|
||||
}
|
||||
|
||||
export type ProfileDeleteAction = 'noop' | 'teardown-primary' | 'teardown-pool'
|
||||
|
||||
export interface ProfileDeleteDecision {
|
||||
action: ProfileDeleteAction
|
||||
profile: string | null
|
||||
}
|
||||
|
||||
export interface ProfileDeleteDecisionDeps {
|
||||
isDefaultProfile: (profile: string) => boolean
|
||||
isValidProfileName: (profile: string) => boolean
|
||||
primaryProfileKey: () => string
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision logic for prepareProfileDeleteRequest: given the parsed
|
||||
* profile name (or null), decide which side-effecting branch the caller
|
||||
* should take and what profile name it should ultimately report as
|
||||
* torn-down. No I/O, no async -- the caller performs the actual teardown
|
||||
* based on `action`.
|
||||
*/
|
||||
export function decideProfileDeleteAction(
|
||||
profile: string | null,
|
||||
deps: ProfileDeleteDecisionDeps
|
||||
): ProfileDeleteDecision {
|
||||
if (!profile || deps.isDefaultProfile(profile) || !deps.isValidProfileName(profile)) {
|
||||
return { action: 'noop', profile: null }
|
||||
}
|
||||
|
||||
if (profile === deps.primaryProfileKey()) {
|
||||
return { action: 'teardown-primary', profile }
|
||||
}
|
||||
|
||||
return { action: 'teardown-pool', profile }
|
||||
}
|
||||
|
||||
/**
|
||||
* Route the next `hermes:api` request away from the primary/window backend
|
||||
* whenever a profile was just torn down -- otherwise ensureBackend would
|
||||
* spawn a fresh pool backend for the deleted profile, whose
|
||||
* ensure_hermes_home() recreates the directory the delete just removed.
|
||||
*/
|
||||
export function resolveRouteProfile(
|
||||
tornDownProfile: string | null,
|
||||
profile: string | undefined
|
||||
): string | null | undefined {
|
||||
return tornDownProfile ? null : profile
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry } from './session-windows'
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
MACOS_TAHOE_DARWIN_MAJOR,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { resolveBehindCount, shouldCountCommits } from './update-count'
|
||||
|
||||
|
||||
@@ -14,10 +14,11 @@
|
||||
|
||||
import fs from 'fs'
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
isPidAlive,
|
||||
markerPath,
|
||||
@@ -40,6 +41,7 @@ const ALIVE: typeof process.kill = () => true // injected kill that "succeeds" =
|
||||
|
||||
const DEAD: typeof process.kill = () => {
|
||||
const err = new Error('no such process')
|
||||
|
||||
;(err as any).code = 'ESRCH'
|
||||
throw err
|
||||
}
|
||||
@@ -93,6 +95,7 @@ test('isPidAlive: own pid is alive, impossible pid is dead', () => {
|
||||
test('isPidAlive: EPERM counts as alive (process owned by another user)', () => {
|
||||
const eperm = () => {
|
||||
const err = new Error('operation not permitted')
|
||||
|
||||
;(err as any).code = 'EPERM'
|
||||
throw err
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { runRebuildWithRetry, shouldRetryRebuild } from './update-rebuild'
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
buildRelaunchScript,
|
||||
|
||||
@@ -63,6 +63,7 @@ function resolveUnpackedRelease(execPath, updateRoot, platform) {
|
||||
if (!execPath || !updateRoot) {
|
||||
return null
|
||||
}
|
||||
|
||||
const releaseDir = path.join(updateRoot, 'apps', 'desktop', 'release')
|
||||
const unpacked = path.join(releaseDir, unpackedDirName(platform))
|
||||
const normalizedExec = path.resolve(String(execPath))
|
||||
@@ -116,6 +117,7 @@ function sandboxPreflight(unpackedDir, statSync) {
|
||||
if (!unpackedDir) {
|
||||
return { ok: false, reason: 'no-unpacked-dir', path: null }
|
||||
}
|
||||
|
||||
const sandboxPath = path.join(unpackedDir, 'chrome-sandbox')
|
||||
let st
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
canonicalGitHubRemote,
|
||||
|
||||
@@ -22,6 +22,7 @@ function canonicalGitHubRemote(url) {
|
||||
if (!url) {
|
||||
return ''
|
||||
}
|
||||
|
||||
let value = String(url).trim()
|
||||
|
||||
if (value.startsWith('git@github.com:')) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { __testing, extractThemes, readCentralDirectory } from './vscode-marketplace'
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
computeWindowOptions,
|
||||
@@ -118,8 +119,8 @@ test('computeWindowOptions does not clamp when displays are unknown', () => {
|
||||
|
||||
// ─── debounce ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('debounce coalesces a burst into one trailing run', t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
test('debounce coalesces a burst into one trailing run', () => {
|
||||
vi.useFakeTimers()
|
||||
let calls = 0
|
||||
|
||||
const d = debounce(() => {
|
||||
@@ -130,14 +131,16 @@ test('debounce coalesces a burst into one trailing run', t => {
|
||||
d()
|
||||
d()
|
||||
assert.equal(calls, 0)
|
||||
t.mock.timers.tick(249)
|
||||
vi.advanceTimersByTime(249)
|
||||
assert.equal(calls, 0)
|
||||
t.mock.timers.tick(1)
|
||||
vi.advanceTimersByTime(1)
|
||||
assert.equal(calls, 1)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
test('debounce.flush runs now and cancels the pending timer', t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
test('debounce.flush runs now and cancels the pending timer', () => {
|
||||
vi.useFakeTimers()
|
||||
let calls = 0
|
||||
|
||||
const d = debounce(() => {
|
||||
@@ -147,6 +150,8 @@ test('debounce.flush runs now and cancels the pending timer', t => {
|
||||
d()
|
||||
d.flush()
|
||||
assert.equal(calls, 1)
|
||||
t.mock.timers.tick(1000)
|
||||
vi.advanceTimersByTime(1000)
|
||||
assert.equal(calls, 1)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
@@ -61,6 +61,7 @@ function onScreen(bounds, displays) {
|
||||
if (!a) {
|
||||
return false
|
||||
}
|
||||
|
||||
const x = Math.min(bounds.x + bounds.width, a.x + a.width) - Math.max(bounds.x, a.x)
|
||||
const y = Math.min(bounds.y + bounds.height, a.y + a.height) - Math.max(bounds.y, a.y)
|
||||
|
||||
|
||||
132
apps/desktop/electron/windows-child-options.test.ts
Normal file
132
apps/desktop/electron/windows-child-options.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { stopBackendChild } from './backend-child'
|
||||
import { hiddenWindowsChildOptions } from './windows-child-options'
|
||||
|
||||
test('hiddenWindowsChildOptions adds windowsHide:true on Windows when unset', () => {
|
||||
assert.deepEqual(hiddenWindowsChildOptions({}, true), { windowsHide: true })
|
||||
})
|
||||
|
||||
test('hiddenWindowsChildOptions preserves an existing windowsHide:false on Windows', () => {
|
||||
assert.deepEqual(hiddenWindowsChildOptions({ windowsHide: false }, true), { windowsHide: false })
|
||||
})
|
||||
|
||||
test('hiddenWindowsChildOptions preserves an existing windowsHide:true on Windows', () => {
|
||||
assert.deepEqual(hiddenWindowsChildOptions({ windowsHide: true }, true), { windowsHide: true })
|
||||
})
|
||||
|
||||
test('hiddenWindowsChildOptions leaves options unchanged off Windows', () => {
|
||||
assert.deepEqual(hiddenWindowsChildOptions({}, false), {})
|
||||
assert.deepEqual(hiddenWindowsChildOptions({ stdio: 'ignore' }, false), { stdio: 'ignore' })
|
||||
})
|
||||
|
||||
test('hiddenWindowsChildOptions merges windowsHide alongside other options on Windows', () => {
|
||||
assert.deepEqual(hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, true), {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true
|
||||
})
|
||||
})
|
||||
|
||||
test('hiddenWindowsChildOptions defaults isWindows from process.platform when omitted', () => {
|
||||
const result = hiddenWindowsChildOptions({})
|
||||
const expectedHide = process.platform === 'win32'
|
||||
|
||||
assert.equal(Boolean(result.windowsHide), expectedHide)
|
||||
})
|
||||
|
||||
function makeChild(overrides: Partial<{ pid: number | null; killed: boolean }> = {}) {
|
||||
const calls: string[] = []
|
||||
|
||||
return {
|
||||
calls,
|
||||
child: {
|
||||
kill: (signal: string) => {
|
||||
calls.push(signal)
|
||||
},
|
||||
killed: overrides.killed ?? false,
|
||||
pid: 'pid' in overrides ? overrides.pid : 1234
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('stopBackendChild tree-kills on Windows when the child has a pid', () => {
|
||||
const { child, calls } = makeChild({ pid: 4242 })
|
||||
const treeKillCalls: number[] = []
|
||||
|
||||
stopBackendChild(child, {
|
||||
forceKillProcessTree: (pid: number) => treeKillCalls.push(pid),
|
||||
isWindows: true
|
||||
})
|
||||
|
||||
assert.deepEqual(treeKillCalls, [4242])
|
||||
assert.deepEqual(calls, [], 'SIGTERM must not be sent when the Windows tree-kill path is taken')
|
||||
})
|
||||
|
||||
test('stopBackendChild sends SIGTERM on non-Windows platforms', () => {
|
||||
const { child, calls } = makeChild({ pid: 4242 })
|
||||
const treeKillCalls: number[] = []
|
||||
|
||||
stopBackendChild(child, {
|
||||
forceKillProcessTree: (pid: number) => treeKillCalls.push(pid),
|
||||
isWindows: false
|
||||
})
|
||||
|
||||
assert.deepEqual(calls, ['SIGTERM'])
|
||||
assert.deepEqual(treeKillCalls, [], 'tree-kill must not run off Windows')
|
||||
})
|
||||
|
||||
test('stopBackendChild falls back to SIGTERM on Windows when the pid is not an integer', () => {
|
||||
const { child, calls } = makeChild({ pid: null })
|
||||
const treeKillCalls: number[] = []
|
||||
|
||||
stopBackendChild(child, {
|
||||
forceKillProcessTree: (pid: number) => treeKillCalls.push(pid),
|
||||
isWindows: true
|
||||
})
|
||||
|
||||
assert.deepEqual(calls, ['SIGTERM'])
|
||||
assert.deepEqual(treeKillCalls, [])
|
||||
})
|
||||
|
||||
test('stopBackendChild is a no-op for an already-killed child', () => {
|
||||
const { child, calls } = makeChild({ killed: true })
|
||||
const treeKillCalls: number[] = []
|
||||
|
||||
stopBackendChild(child, {
|
||||
forceKillProcessTree: (pid: number) => treeKillCalls.push(pid),
|
||||
isWindows: true
|
||||
})
|
||||
|
||||
assert.deepEqual(calls, [])
|
||||
assert.deepEqual(treeKillCalls, [])
|
||||
})
|
||||
|
||||
test('stopBackendChild is a no-op for a null/undefined child', () => {
|
||||
const treeKillCalls: number[] = []
|
||||
|
||||
assert.doesNotThrow(() => {
|
||||
stopBackendChild(null, { forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), isWindows: true })
|
||||
stopBackendChild(undefined, { forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), isWindows: true })
|
||||
})
|
||||
assert.deepEqual(treeKillCalls, [])
|
||||
})
|
||||
|
||||
test('stopBackendChild swallows errors thrown by the kill strategy', () => {
|
||||
const child = {
|
||||
kill: () => {
|
||||
throw new Error('ESRCH: no such process')
|
||||
},
|
||||
killed: false,
|
||||
pid: 99
|
||||
}
|
||||
|
||||
assert.doesNotThrow(() => {
|
||||
stopBackendChild(child, {
|
||||
forceKillProcessTree: () => {},
|
||||
isWindows: false
|
||||
})
|
||||
})
|
||||
})
|
||||
37
apps/desktop/electron/windows-child-options.ts
Normal file
37
apps/desktop/electron/windows-child-options.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* windows-child-options.ts
|
||||
*
|
||||
* Shared helper for opting Windows child processes (spawn/execFileSync) into
|
||||
* a hidden console. Windows spawns a visible console window per child by
|
||||
* default; every desktop-launched helper process (git, curl, taskkill, the
|
||||
* backend itself, the bootstrap PowerShell runner, ...) needs `windowsHide:
|
||||
* true` so the user doesn't see consoles flashing on screen.
|
||||
*
|
||||
* Extracted into its own dependency-free module (no electron import) so it
|
||||
* can be unit-tested directly for both platforms without reading source
|
||||
* text, and so main.ts and bootstrap-runner.ts share exactly one
|
||||
* implementation instead of each defining their own copy.
|
||||
*/
|
||||
|
||||
import type { ExecFileSyncOptionsWithStringEncoding } from 'node:child_process'
|
||||
|
||||
/**
|
||||
* Merge `windowsHide: true` into `options` when running on Windows, unless
|
||||
* the caller already specified a `windowsHide` value (which is preserved
|
||||
* as-is, including an explicit `false` for cases that intentionally want a
|
||||
* visible/interactive console). No-op on non-Windows platforms.
|
||||
*
|
||||
* @param options - spawn/execFileSync options to (possibly) augment.
|
||||
* @param isWindows - defaults to the real platform check; injectable for
|
||||
* tests so both branches can be exercised without mocking process.platform.
|
||||
*/
|
||||
export function hiddenWindowsChildOptions(
|
||||
options: any = {},
|
||||
isWindows: boolean = process.platform === 'win32'
|
||||
): ExecFileSyncOptionsWithStringEncoding {
|
||||
if (!isWindows || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
|
||||
return options as any
|
||||
}
|
||||
|
||||
return { ...options, windowsHide: true } as any
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ELECTRON_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// TODO FIXME these tests all grep source code for specific things. This is an antipattern.
|
||||
// Tests should NEVER read src, only assert behavior.
|
||||
|
||||
function readElectronFile(name) {
|
||||
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
function requireHiddenChildOptions(source, needle) {
|
||||
const match = needle instanceof RegExp ? needle.exec(source) : null
|
||||
const index = needle instanceof RegExp ? (match?.index ?? -1) : source.indexOf(needle)
|
||||
assert.notEqual(index, -1, `missing call site: ${needle}`)
|
||||
const snippet = source.slice(index, index + 700)
|
||||
assert.match(
|
||||
snippet,
|
||||
/hiddenWindowsChildOptions\(/,
|
||||
`expected ${needle} to wrap child-process options with hiddenWindowsChildOptions`
|
||||
)
|
||||
}
|
||||
|
||||
test('desktop background child processes opt into hidden Windows consoles', () => {
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
assert.match(source, /function hiddenWindowsChildOptions\(options: any = \{\}\)/)
|
||||
|
||||
requireHiddenChildOptions(source, "execFileSync(\n 'reg'")
|
||||
requireHiddenChildOptions(source, /execFileSync\(\s*pyExe/)
|
||||
requireHiddenChildOptions(source, /spawn\(\s*resolveGitBinary\(\)/)
|
||||
requireHiddenChildOptions(source, "execFileSync('taskkill'")
|
||||
requireHiddenChildOptions(source, /spawn\(\s*command,\s*args/)
|
||||
requireHiddenChildOptions(source, "spawn('curl'")
|
||||
requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/)
|
||||
requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/)
|
||||
requireHiddenChildOptions(source, /spawn\(\s*py,\s*\['-m', 'hermes_cli\.main', 'uninstall', '--gui-summary'\]/)
|
||||
|
||||
assert.match(source, /function unwrapWindowsVenvHermesCommand\(command, backendArgs\)/)
|
||||
assert.match(source, /function getVenvSitePackagesEntries\(venvRoot\)/)
|
||||
assert.match(source, /path\.join\(venvRoot, 'Lib', 'site-packages'\)/)
|
||||
assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.backendArgs\]/)
|
||||
})
|
||||
|
||||
test('desktop backend launches console python so child consoles are inherited, not pythonw', () => {
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
// The flash fix is structural: the backend runs as a console-subsystem
|
||||
// python.exe under hiddenWindowsChildOptions() (-> CREATE_NO_WINDOW), so it
|
||||
// owns ONE windowless console that every descendant spawn inherits. Launching
|
||||
// it as GUI-subsystem pythonw.exe is what made each child allocate (and flash)
|
||||
// its own console, so the backend command must never be pythonw.
|
||||
assert.doesNotMatch(source, /pythonw\.exe'\)/, 'backend must not be launched via pythonw.exe')
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/function getNoConsoleVenvPython\b/,
|
||||
'pythonw-conversion helper should be gone; console python is launched directly'
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/function applyWindowsNoConsoleSpawnHints\b/,
|
||||
'pythonw spawn-hint rewriter should be gone'
|
||||
)
|
||||
|
||||
// Console python restores stdout, so the port is announced on the normal
|
||||
// HERMES_DASHBOARD_READY stdout line — no ready-file side channel is set.
|
||||
assert.doesNotMatch(source, /readyFile: true/, 'no backend should opt into the pythonw ready-file path')
|
||||
|
||||
// Both desktop backend launches must still go through hiddenWindowsChildOptions
|
||||
// so the single backend console is created windowless.
|
||||
requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/)
|
||||
requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/)
|
||||
})
|
||||
|
||||
test('desktop backend teardown tree-kills Windows backend descendants', () => {
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
const helperIndex = source.indexOf('function stopBackendChild(child)')
|
||||
assert.notEqual(helperIndex, -1, 'missing backend teardown helper')
|
||||
const helperSnippet = source.slice(helperIndex, helperIndex + 500)
|
||||
assert.match(helperSnippet, /IS_WINDOWS && Number\.isInteger\(child\.pid\)/)
|
||||
assert.match(helperSnippet, /forceKillProcessTree\(child\.pid\)/)
|
||||
assert.match(helperSnippet, /child\.kill\('SIGTERM'\)/)
|
||||
|
||||
const resetIndex = source.indexOf('function resetHermesConnection()')
|
||||
assert.notEqual(resetIndex, -1, 'missing resetHermesConnection')
|
||||
const resetSnippet = source.slice(resetIndex, resetIndex + 300)
|
||||
assert.match(resetSnippet, /stopBackendChild\(hermesProcess\)/)
|
||||
assert.doesNotMatch(resetSnippet, /hermesProcess\.kill\('SIGTERM'\)/)
|
||||
|
||||
const quitIndex = source.indexOf("app.on('before-quit'")
|
||||
assert.notEqual(quitIndex, -1, 'missing before-quit handler')
|
||||
const quitSnippet = source.slice(quitIndex, quitIndex + 900)
|
||||
assert.match(quitSnippet, /stopBackendChild\(hermesProcess\)/)
|
||||
assert.doesNotMatch(quitSnippet, /hermesProcess\.kill\('SIGTERM'\)/)
|
||||
})
|
||||
|
||||
test('intentional or interactive desktop child processes stay documented', () => {
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
assert.match(source, /windowsHide: false/)
|
||||
assert.match(source, /handOffWindowsBootstrapRecovery/)
|
||||
assert.match(source, /'--repair', '--branch'/)
|
||||
assert.match(source, /'--update', '--branch'/)
|
||||
assert.match(source, /nodePty\.spawn\(command, args/)
|
||||
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
|
||||
})
|
||||
|
||||
test('bootstrap PowerShell runner hides Windows console children', () => {
|
||||
const source = readElectronFile('bootstrap-runner.ts')
|
||||
|
||||
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
|
||||
requireHiddenChildOptions(source, /spawn\(\s*ps,\s*fullArgs/)
|
||||
})
|
||||
209
apps/desktop/electron/windows-hermes-path.test.ts
Normal file
209
apps/desktop/electron/windows-hermes-path.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
// Unit tests for the pure Windows `hermes` resolution helpers extracted from
|
||||
// main.ts's findOnPath(), handOffWindowsBootstrapRecovery(), and
|
||||
// unwrapWindowsVenvHermesCommand(). These pin the two Windows resolution bugs
|
||||
// that caused desktop reinstall loops:
|
||||
// 1. buildPathExtCandidates() — PATHEXT extensions must be tried BEFORE the
|
||||
// empty extension, or an extensionless Git-Bash `hermes` shim shadows
|
||||
// the real hermes.cmd/hermes.exe.
|
||||
// 2. chooseUpdaterArgs() — must gate on haveRealInstall (any real-install
|
||||
// signal), not just the hermes.exe console-script shim, or healthy
|
||||
// installs get forced into a destructive --repair.
|
||||
// 3. resolveVenvHermesCommand() — must probe the venv python via
|
||||
// canImportHermesCli() before trusting it, or a broken venv gets
|
||||
// re-selected forever instead of falling through to bootstrap.
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { buildPathExtCandidates, chooseUpdaterArgs, getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path'
|
||||
|
||||
test('buildPathExtCandidates: Windows tries PATHEXT extensions before the empty extension', () => {
|
||||
const extensions = buildPathExtCandidates('.COM;.EXE;.BAT;.CMD', true)
|
||||
|
||||
assert.deepEqual(extensions, ['.COM', '.EXE', '.BAT', '.CMD', ''])
|
||||
assert.equal(extensions[extensions.length - 1], '', 'empty extension must be last, not first')
|
||||
assert.notEqual(extensions[0], '', 'the buggy empty-extension-first order must not return')
|
||||
})
|
||||
|
||||
test('buildPathExtCandidates: defaults to .COM;.EXE;.BAT;.CMD when PATHEXT is unset on Windows', () => {
|
||||
assert.deepEqual(buildPathExtCandidates(undefined, true), ['.COM', '.EXE', '.BAT', '.CMD', ''])
|
||||
})
|
||||
|
||||
test('buildPathExtCandidates: respects a custom PATHEXT, still empty-last', () => {
|
||||
assert.deepEqual(buildPathExtCandidates('.EXE;.PS1', true), ['.EXE', '.PS1', ''])
|
||||
})
|
||||
|
||||
test('buildPathExtCandidates: non-Windows only tries the bare name', () => {
|
||||
assert.deepEqual(buildPathExtCandidates('.COM;.EXE;.BAT;.CMD', false), [''])
|
||||
assert.deepEqual(buildPathExtCandidates(undefined, false), [''])
|
||||
})
|
||||
|
||||
test('chooseUpdaterArgs: gentle --update when a real-install signal is present', () => {
|
||||
assert.deepEqual(chooseUpdaterArgs(true, 'main'), ['--update', '--branch', 'main'])
|
||||
})
|
||||
|
||||
test('chooseUpdaterArgs: destructive --repair only when NO real-install signal is present', () => {
|
||||
assert.deepEqual(chooseUpdaterArgs(false, 'main'), ['--repair', '--branch', 'main'])
|
||||
})
|
||||
|
||||
test('chooseUpdaterArgs: passes the branch through unchanged in both cases', () => {
|
||||
assert.deepEqual(chooseUpdaterArgs(true, 'release/1.2'), ['--update', '--branch', 'release/1.2'])
|
||||
assert.deepEqual(chooseUpdaterArgs(false, 'release/1.2'), ['--repair', '--branch', 'release/1.2'])
|
||||
})
|
||||
|
||||
function makeDeps(overrides: Partial<Parameters<typeof resolveVenvHermesCommand>[2]> = {}) {
|
||||
return {
|
||||
isWindows: true,
|
||||
isCommandScript: () => false,
|
||||
fileExists: () => true,
|
||||
directoryExists: () => false,
|
||||
canImportHermesCli: () => true,
|
||||
getVenvPython: (venvRoot: string) => `${venvRoot}/Scripts/python.exe`,
|
||||
getVenvSitePackagesEntries: () => [],
|
||||
buildDesktopBackendEnv: () => ({ FAKE_ENV: '1' }),
|
||||
hermesHome: '/fake/hermes-home',
|
||||
resolvePath: (...segments: string[]) => segments.join('/').replace(/\/+/g, '/'),
|
||||
dirname: (p: string) => p.slice(0, p.lastIndexOf('/')) || '/',
|
||||
basename: (p: string) => p.slice(p.lastIndexOf('/') + 1),
|
||||
rememberLog: () => {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
test('resolveVenvHermesCommand: returns null off Windows', () => {
|
||||
const deps = makeDeps({ isWindows: false })
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns null for a .cmd/.bat script command', () => {
|
||||
const deps = makeDeps({ isCommandScript: () => true })
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.cmd', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns null when the basename is not hermes/hermes.exe', () => {
|
||||
const deps = makeDeps()
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/python.exe', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns null when the parent dir is not Scripts', () => {
|
||||
const deps = makeDeps()
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/bin/hermes.exe', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns null when the venv python does not exist on disk', () => {
|
||||
const deps = makeDeps({ fileExists: () => false })
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: probes the venv python before trusting it (returns null on failed probe)', () => {
|
||||
let probed = false
|
||||
|
||||
const deps = makeDeps({
|
||||
canImportHermesCli: (python: string) => {
|
||||
probed = true
|
||||
assert.equal(python, '/root/venv/Scripts/python.exe')
|
||||
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const result = resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve'], deps)
|
||||
|
||||
assert.equal(probed, true, 'must probe the venv interpreter; a broken venv must not be re-selected forever')
|
||||
assert.equal(result, null, 'a failed probe must fall through (return null) so the resolver reaches bootstrap')
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns the resolved python backend descriptor when the probe passes', () => {
|
||||
const deps = makeDeps()
|
||||
const result = resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve', '--port', '0'], deps)
|
||||
|
||||
assert.ok(result, 'a passing probe must return a backend descriptor, not null')
|
||||
assert.equal(result.command, '/root/venv/Scripts/python.exe')
|
||||
assert.deepEqual(result.args, ['-m', 'hermes_cli.main', 'serve', '--port', '0'])
|
||||
assert.equal(result.bootstrap, false)
|
||||
assert.equal(result.kind, 'python')
|
||||
assert.equal(result.shell, false)
|
||||
assert.deepEqual(result.env, { FAKE_ENV: '1' })
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: is case-insensitive on hermes.exe and the Scripts dir name', () => {
|
||||
const deps = makeDeps()
|
||||
|
||||
assert.ok(resolveVenvHermesCommand('/root/venv/Scripts/HERMES.EXE', [], deps))
|
||||
assert.ok(resolveVenvHermesCommand('/root/venv/SCRIPTS/hermes.exe', [], deps))
|
||||
})
|
||||
|
||||
// ── getVenvSitePackagesEntries ─────────────────────────────────────────────
|
||||
|
||||
test('getVenvSitePackagesEntries: returns Lib/site-packages on Windows when it exists', () => {
|
||||
const expected = path.join('C:\\venv', 'Lib', 'site-packages')
|
||||
|
||||
const result = getVenvSitePackagesEntries('C:\\venv', {
|
||||
isWindows: true,
|
||||
directoryExists: p => p === expected
|
||||
})
|
||||
|
||||
assert.deepEqual(result, [expected])
|
||||
})
|
||||
|
||||
test('getVenvSitePackagesEntries: returns empty on Windows when site-packages does not exist', () => {
|
||||
const result = getVenvSitePackagesEntries('C:\\venv', {
|
||||
isWindows: true,
|
||||
directoryExists: () => false
|
||||
})
|
||||
|
||||
assert.deepEqual(result, [])
|
||||
})
|
||||
|
||||
test('getVenvSitePackagesEntries: reads pyvenv.cfg version on POSIX and resolves lib/pythonX.Y/site-packages', () => {
|
||||
const result = getVenvSitePackagesEntries('/venv', {
|
||||
isWindows: false,
|
||||
directoryExists: p => p === '/venv/lib/python3.12/site-packages',
|
||||
readFile: () => 'version_info = 3.12.1\n'
|
||||
})
|
||||
|
||||
assert.deepEqual(result, ['/venv/lib/python3.12/site-packages'])
|
||||
})
|
||||
|
||||
test('getVenvSitePackagesEntries: returns empty on POSIX when pyvenv.cfg is missing', () => {
|
||||
const result = getVenvSitePackagesEntries('/venv', {
|
||||
isWindows: false,
|
||||
directoryExists: () => true,
|
||||
readFile: () => undefined
|
||||
})
|
||||
|
||||
assert.deepEqual(result, [])
|
||||
})
|
||||
|
||||
test('getVenvSitePackagesEntries: returns empty on POSIX when pyvenv.cfg has no version_info', () => {
|
||||
const result = getVenvSitePackagesEntries('/venv', {
|
||||
isWindows: false,
|
||||
directoryExists: () => true,
|
||||
readFile: () => 'home = /usr/bin\n'
|
||||
})
|
||||
|
||||
assert.deepEqual(result, [])
|
||||
})
|
||||
|
||||
test('getVenvSitePackagesEntries: returns empty on POSIX when version is present but site-packages dir is absent', () => {
|
||||
const result = getVenvSitePackagesEntries('/venv', {
|
||||
isWindows: false,
|
||||
directoryExists: () => false,
|
||||
readFile: () => 'version_info = 3.11\n'
|
||||
})
|
||||
|
||||
assert.deepEqual(result, [])
|
||||
})
|
||||
|
||||
test('getVenvSitePackagesEntries: returns empty for a falsy venvRoot', () => {
|
||||
assert.deepEqual(getVenvSitePackagesEntries('', { isWindows: true, directoryExists: () => true }), [])
|
||||
assert.deepEqual(getVenvSitePackagesEntries(null, { isWindows: true, directoryExists: () => true }), [])
|
||||
assert.deepEqual(getVenvSitePackagesEntries(undefined, { isWindows: true, directoryExists: () => true }), [])
|
||||
})
|
||||
287
apps/desktop/electron/windows-hermes-path.ts
Normal file
287
apps/desktop/electron/windows-hermes-path.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* windows-hermes-path.ts
|
||||
*
|
||||
* Pure, dependency-injected pieces of Windows `hermes` resolution pulled out
|
||||
* of main.ts's findOnPath(), handOffWindowsBootstrapRecovery(), and
|
||||
* unwrapWindowsVenvHermesCommand(). Each of the three functions here pins one
|
||||
* of the Windows resolution bugs that caused desktop reinstall loops:
|
||||
*
|
||||
* 1. buildPathExtCandidates() — findOnPath() tried the empty extension
|
||||
* FIRST, so an extensionless Git-Bash `hermes` shim shadowed the real
|
||||
* hermes.cmd/hermes.exe; the shim then failed the --version probe and
|
||||
* the desktop fell through to a spurious bootstrap/repair. The fix:
|
||||
* PATHEXT extensions first, empty extension LAST.
|
||||
* 2. chooseUpdaterArgs() — handOffWindowsBootstrapRecovery() chose
|
||||
* --update vs the destructive --repair by checking ONLY
|
||||
* venv\Scripts\hermes.exe (the console-script shim, written at the END
|
||||
* of venv setup and absent in interrupted states), so it escalated to a
|
||||
* full venv recreate even on healthy installs. The fix: gate on ANY
|
||||
* real-install signal, not just the shim.
|
||||
* 3. resolveVenvHermesCommand() — unwrapWindowsVenvHermesCommand() returned
|
||||
* the venv python with NO runtime probe (bypassing the caller's
|
||||
* --version check too), so a venv broken mid-update (e.g. missing
|
||||
* python-dotenv) was re-selected forever: Retry / "Repair install"
|
||||
* resolved the same dead interpreter instead of falling through to the
|
||||
* bootstrap installer. The fix: probe-before-trust.
|
||||
*
|
||||
* Kept in a standalone ts module (no Electron imports, dependencies passed
|
||||
* as parameters) so it can be unit-tested with `node --test` without
|
||||
* mocking Electron or the filesystem, same pattern as backend-probes.ts and
|
||||
* backend-command.ts.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Build the ordered list of extensions findOnPath() should try when
|
||||
* resolving a bare command name off PATH.
|
||||
*
|
||||
* On Windows this MUST try PATHEXT extensions (.COM;.EXE;.BAT;.CMD by
|
||||
* default) BEFORE the bare/empty-extension name: a real command resolves via
|
||||
* its .exe/.cmd per Windows command-resolution semantics, and an
|
||||
* extensionless file (e.g. a Git-Bash shell-script shim named `hermes`) must
|
||||
* not shadow `hermes.cmd`/`hermes.exe`. The empty entry is kept LAST so
|
||||
* callers that already include the extension (py.exe, pwsh.exe,
|
||||
* powershell.exe) still resolve.
|
||||
*
|
||||
* On non-Windows platforms there is no PATHEXT concept: only the bare name
|
||||
* is tried.
|
||||
*
|
||||
* @param {string | undefined} pathext - process.env.PATHEXT (or undefined).
|
||||
* @param {boolean} isWindows
|
||||
* @returns {string[]} extensions to try, in order, always ending in ''.
|
||||
*/
|
||||
export function buildPathExtCandidates(pathext: string | undefined, isWindows: boolean): string[] {
|
||||
if (!isWindows) {
|
||||
return ['']
|
||||
}
|
||||
|
||||
return [...(pathext || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean), '']
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the Windows bootstrap-recovery updater invocation: the gentle
|
||||
* in-place --update when ANY real-install signal is present, the
|
||||
* destructive --repair (full venv recreate) otherwise.
|
||||
*
|
||||
* haveRealInstall must be computed by the caller from ALL real-install
|
||||
* signals (venv python interpreter, venv hermes shim, bootstrap-complete
|
||||
* marker) — gating on just the hermes.exe console-script shim alone is the
|
||||
* regression this function's callers must avoid: that shim is written at
|
||||
* the END of venv setup and is absent in exactly the interrupted/quarantined
|
||||
* states this recovery exists to heal.
|
||||
*
|
||||
* @param {boolean} haveRealInstall
|
||||
* @param {string} branch
|
||||
* @returns {string[]} updater argv, e.g. ['--update', '--branch', 'main'].
|
||||
*/
|
||||
export function chooseUpdaterArgs(haveRealInstall: boolean, branch: string): string[] {
|
||||
return haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the site-packages directory entries for a Python venv.
|
||||
*
|
||||
* On Windows, venv layout is `<venvRoot>/Lib/site-packages`.
|
||||
* On POSIX, it's `<venvRoot>/lib/python<version>/site-packages` where
|
||||
* `<version>` (e.g. `3.12`) is read from the venv's `pyvenv.cfg`
|
||||
* `version_info` field.
|
||||
*
|
||||
* Returns only directories that actually exist on disk. Returns an empty
|
||||
* array when `venvRoot` is falsy or no matching site-packages dir is found.
|
||||
*
|
||||
* Extracted from main.ts so the platform branching can be tested without
|
||||
* reading source text. `isWindows` and `directoryExists` are injectable;
|
||||
* `readFile` defaults to `fs.readFileSync` but can be overridden for tests.
|
||||
*/
|
||||
export function getVenvSitePackagesEntries(
|
||||
venvRoot: string | undefined | null,
|
||||
opts: {
|
||||
isWindows?: boolean
|
||||
directoryExists?: (p: string) => boolean
|
||||
readFile?: (p: string) => string | undefined
|
||||
} = {}
|
||||
): string[] {
|
||||
const entries: string[] = []
|
||||
|
||||
if (!venvRoot) {
|
||||
return entries
|
||||
}
|
||||
|
||||
const isWindows = opts.isWindows ?? process.platform === 'win32'
|
||||
|
||||
const directoryExists = opts.directoryExists ?? ((p: string) => {
|
||||
try {
|
||||
return fs.statSync(p).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const readFile = opts.readFile ?? ((p: string) => {
|
||||
try {
|
||||
return fs.readFileSync(p, 'utf8')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
|
||||
if (isWindows) {
|
||||
const sitePackages = path.join(venvRoot, 'Lib', 'site-packages')
|
||||
|
||||
if (directoryExists(sitePackages)) {
|
||||
entries.push(sitePackages)
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
const cfg = readFile(path.join(venvRoot, 'pyvenv.cfg'))
|
||||
|
||||
const version = (() => {
|
||||
if (!cfg) {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = cfg.match(/^version_info\s*=\s*(\d+\.\d+)/im)
|
||||
|
||||
return match ? match[1].trim() : null
|
||||
})()
|
||||
|
||||
if (version) {
|
||||
const sitePackages = path.join(venvRoot, 'lib', `python${version}`, 'site-packages')
|
||||
|
||||
if (directoryExists(sitePackages)) {
|
||||
entries.push(sitePackages)
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
export interface ResolveVenvHermesCommandDeps {
|
||||
isWindows: boolean
|
||||
isCommandScript: (command: string) => boolean
|
||||
fileExists: (filePath: string) => boolean
|
||||
directoryExists: (filePath: string) => boolean
|
||||
canImportHermesCli: (python: string, opts?: { env?: Record<string, string> }) => boolean
|
||||
getVenvPython: (venvRoot: string) => string
|
||||
getVenvSitePackagesEntries: (venvRoot: string) => string[]
|
||||
buildDesktopBackendEnv: (opts: {
|
||||
hermesHome: string
|
||||
pythonPathEntries: string[]
|
||||
venvRoot: string
|
||||
}) => Record<string, string>
|
||||
hermesHome: string
|
||||
resolvePath: (...segments: string[]) => string
|
||||
dirname: (p: string) => string
|
||||
basename: (p: string) => string
|
||||
rememberLog?: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* If `command` is a Windows venv `hermes`/`hermes.exe` console-script shim
|
||||
* (i.e. `<venvRoot>/Scripts/hermes(.exe)`), resolve it to the underlying
|
||||
* venv python invoked as `python -m hermes_cli.main <backendArgs>` — but
|
||||
* ONLY after smoke-testing that interpreter with canImportHermesCli(). A
|
||||
* venv whose update died mid-`pip install` still has python.exe + hermes.exe
|
||||
* on disk, but the backend dies on its first import (e.g.
|
||||
* ModuleNotFoundError: dotenv) before the gateway ever binds. Returning it
|
||||
* unprobed also bypasses the caller's `--version` probe, so Retry/"Repair
|
||||
* install" re-resolves the same broken venv forever instead of falling
|
||||
* through to the bootstrap installer.
|
||||
*
|
||||
* Mirrors isActiveRuntimeUsable(): probes with the checkout on PYTHONPATH so
|
||||
* a healthy source-tree venv passes.
|
||||
*
|
||||
* Returns null when `command` is not a venv hermes shim, the underlying
|
||||
* python doesn't exist, or the import probe fails. Otherwise returns the
|
||||
* resolved backend descriptor.
|
||||
*/
|
||||
export function resolveVenvHermesCommand(
|
||||
command: string,
|
||||
backendArgs: string[],
|
||||
deps: ResolveVenvHermesCommandDeps
|
||||
): {
|
||||
label: string
|
||||
command: string
|
||||
args: string[]
|
||||
bootstrap: false
|
||||
env: Record<string, string>
|
||||
kind: 'python'
|
||||
root: string
|
||||
shell: false
|
||||
} | null {
|
||||
const {
|
||||
isWindows,
|
||||
isCommandScript,
|
||||
fileExists,
|
||||
directoryExists,
|
||||
canImportHermesCli,
|
||||
getVenvPython,
|
||||
getVenvSitePackagesEntries,
|
||||
buildDesktopBackendEnv,
|
||||
hermesHome,
|
||||
resolvePath,
|
||||
dirname,
|
||||
basename,
|
||||
rememberLog
|
||||
} = deps
|
||||
|
||||
if (!isWindows || !command || isCommandScript(command)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const resolved = resolvePath(String(command))
|
||||
|
||||
if (!/^hermes(?:\.exe)?$/i.test(basename(resolved))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const scriptsDir = dirname(resolved)
|
||||
|
||||
if (basename(scriptsDir).toLowerCase() !== 'scripts') {
|
||||
return null
|
||||
}
|
||||
|
||||
const venvRoot = dirname(scriptsDir)
|
||||
const python = getVenvPython(venvRoot)
|
||||
|
||||
if (!fileExists(python)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const root = dirname(venvRoot)
|
||||
|
||||
if (
|
||||
!canImportHermesCli(python, {
|
||||
env: {
|
||||
PYTHONPATH: [...(directoryExists(root) ? [root] : []), process.env.PYTHONPATH]
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.join(path.delimiter)
|
||||
}
|
||||
})
|
||||
) {
|
||||
rememberLog?.(
|
||||
`Ignoring venv Hermes at ${python}: runtime import probe failed (broken/partial venv); falling through to bootstrap.`
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
label: `existing Hermes Python at ${python}`,
|
||||
command: python,
|
||||
args: ['-m', 'hermes_cli.main', ...backendArgs],
|
||||
bootstrap: false,
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome,
|
||||
pythonPathEntries: [...(directoryExists(root) ? [root] : []), ...getVenvSitePackagesEntries(venvRoot)],
|
||||
venvRoot
|
||||
}),
|
||||
kind: 'python',
|
||||
root,
|
||||
shell: false
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
// Regression guards for Windows `hermes` resolution in main.ts.
|
||||
//
|
||||
// main.ts has no module.exports, so these follow the repo's source-assertion
|
||||
// test pattern (see windows-child-process.test.ts). They pin the two Windows
|
||||
// resolution bugs that caused desktop reinstall loops:
|
||||
// 1. findOnPath() tried the empty extension FIRST, so an extensionless
|
||||
// Git-Bash `hermes` shim shadowed the real hermes.cmd/hermes.exe; the
|
||||
// shim then failed the --version probe and the desktop fell through to a
|
||||
// spurious bootstrap/repair.
|
||||
// 2. handOffWindowsBootstrapRecovery() chose --update vs the destructive
|
||||
// --repair by checking ONLY venv\Scripts\hermes.exe (the console-script
|
||||
// shim, written at the END of venv setup and absent in interrupted
|
||||
// states), so it escalated to a full venv recreate even on healthy
|
||||
// installs.
|
||||
// 3. unwrapWindowsVenvHermesCommand() returned the venv python with NO
|
||||
// runtime probe (bypassing the caller's --version check too), so a venv
|
||||
// broken mid-update (e.g. missing python-dotenv) was re-selected forever:
|
||||
// Retry / "Repair install" resolved the same dead interpreter instead of
|
||||
// falling through to the bootstrap installer.
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
function readMain() {
|
||||
return fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8').replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
test('findOnPath tries PATHEXT extensions before the bare (empty) name on Windows', () => {
|
||||
const source = readMain()
|
||||
// Fixed order: PATHEXT first, empty string LAST.
|
||||
assert.match(
|
||||
source,
|
||||
/\(process\.env\.PATHEXT \|\| '\.COM;\.EXE;\.BAT;\.CMD'\)\.split\(';'\)\.filter\(Boolean\), ''\]/,
|
||||
'extensions array must end with the empty string, not start with it'
|
||||
)
|
||||
// The buggy empty-first order must not return.
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/\['', \.\.\.\(process\.env\.PATHEXT/,
|
||||
'empty-extension-first order regressed: an extensionless shim can shadow hermes.cmd/.exe'
|
||||
)
|
||||
})
|
||||
|
||||
test('Windows bootstrap recovery chooses --update when any real-install signal is present', () => {
|
||||
const source = readMain()
|
||||
assert.match(source, /const haveRealInstall =/, 'recovery must compute haveRealInstall')
|
||||
assert.match(source, /fileExists\(venvPython\)/, 'recovery must accept the venv interpreter as a real-install signal')
|
||||
assert.match(
|
||||
source,
|
||||
/\.hermes-bootstrap-complete/,
|
||||
'recovery must accept the bootstrap-complete marker as a real-install signal'
|
||||
)
|
||||
assert.match(source, /updaterArgs = haveRealInstall \? \['--update'/, 'updaterArgs must gate on haveRealInstall')
|
||||
// The old too-narrow check (only venv\Scripts\hermes.exe) must not return.
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/updaterArgs = fileExists\(venvHermes\) \?/,
|
||||
'recovery regressed to gating only on the hermes.exe shim, which forces destructive --repair'
|
||||
)
|
||||
})
|
||||
|
||||
test('unwrapWindowsVenvHermesCommand smoke-tests the venv python before trusting it', () => {
|
||||
const source = readMain()
|
||||
const fnStart = source.indexOf('function unwrapWindowsVenvHermesCommand(')
|
||||
assert.notEqual(fnStart, -1, 'unwrapWindowsVenvHermesCommand must exist in main.ts')
|
||||
// Slice out just the function body (up to the next top-level function decl)
|
||||
const fnEnd = source.indexOf('\nfunction ', fnStart + 1)
|
||||
const body = source.slice(fnStart, fnEnd === -1 ? undefined : fnEnd)
|
||||
assert.match(
|
||||
body,
|
||||
/canImportHermesCli\(python/,
|
||||
'unwrap must probe the venv interpreter; returning it unprobed re-selects a broken venv ' +
|
||||
'forever (Retry/Repair loop on a mid-update venv missing e.g. python-dotenv)'
|
||||
)
|
||||
assert.match(
|
||||
body,
|
||||
/return null\s*\n\s*\}\s*\n\s*return \{/,
|
||||
'a failed probe must fall through (return null) so the resolver reaches the bootstrap rung'
|
||||
)
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } from './windows-user-env'
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ function parseRegQueryValue(stdout, name) {
|
||||
if (!stdout || !name) {
|
||||
return null
|
||||
}
|
||||
|
||||
const typePattern = /^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/
|
||||
|
||||
for (const rawLine of String(stdout).split(/\r?\n/)) {
|
||||
@@ -70,6 +71,7 @@ function readWindowsUserEnvVar(
|
||||
if (platform !== 'win32' || !name) {
|
||||
return null
|
||||
}
|
||||
|
||||
let stdout
|
||||
|
||||
try {
|
||||
@@ -88,6 +90,7 @@ function readWindowsUserEnvVar(
|
||||
if (raw == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const expanded = expandWindowsEnvRefs(raw, env).trim()
|
||||
|
||||
return expanded || null
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { isPackagedInstallPath } from './workspace-cwd'
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
decodeClipboardImageBase64,
|
||||
|
||||
@@ -5,9 +5,18 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { clampZoomLevel, percentToZoomLevel, ZOOM_STORAGE_KEY, zoomLevelToPercent } from './zoom'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
clampZoomLevel,
|
||||
installZoomReassertOnWindowEvents,
|
||||
percentToZoomLevel,
|
||||
ZOOM_REASSERT_WINDOW_EVENTS,
|
||||
ZOOM_STORAGE_KEY,
|
||||
zoomLevelToPercent,
|
||||
zoomWiringForWindowKind
|
||||
} from './zoom'
|
||||
|
||||
test('storage key stays stable so persisted zoom survives upgrades', () => {
|
||||
assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel')
|
||||
@@ -53,3 +62,59 @@ test('extreme percentages clamp to the level bounds', () => {
|
||||
assert.equal(percentToZoomLevel(1), -9)
|
||||
assert.equal(percentToZoomLevel(1_000_000), 9)
|
||||
})
|
||||
|
||||
test('installZoomReassertOnWindowEvents wires show and restore', () => {
|
||||
const handlers = new Map()
|
||||
|
||||
const win = {
|
||||
isDestroyed: () => false,
|
||||
on(event, listener) {
|
||||
handlers.set(event, listener)
|
||||
}
|
||||
}
|
||||
|
||||
let calls = 0
|
||||
installZoomReassertOnWindowEvents(win, () => {
|
||||
calls += 1
|
||||
})
|
||||
|
||||
assert.deepEqual([...handlers.keys()], [...ZOOM_REASSERT_WINDOW_EVENTS])
|
||||
handlers.get('show')()
|
||||
handlers.get('restore')()
|
||||
assert.equal(calls, 2)
|
||||
})
|
||||
|
||||
test('installZoomReassertOnWindowEvents skips destroyed windows', () => {
|
||||
const handlers = new Map()
|
||||
let destroyed = false
|
||||
|
||||
const win = {
|
||||
isDestroyed: () => destroyed,
|
||||
on(event, listener) {
|
||||
handlers.set(event, listener)
|
||||
}
|
||||
}
|
||||
|
||||
let calls = 0
|
||||
installZoomReassertOnWindowEvents(win, () => {
|
||||
calls += 1
|
||||
})
|
||||
destroyed = true
|
||||
handlers.get('show')()
|
||||
assert.equal(calls, 0)
|
||||
})
|
||||
|
||||
// Zoom-wiring contract: chat windows keep global UI zoom, the pet overlay
|
||||
// opts out. Tested via the extracted config — no source-text regex.
|
||||
test('chat windows opt into zoom', () => {
|
||||
assert.deepEqual(zoomWiringForWindowKind('chat'), { zoom: true })
|
||||
})
|
||||
|
||||
test('pet overlay opts out of zoom', () => {
|
||||
assert.deepEqual(zoomWiringForWindowKind('petOverlay'), { zoom: false })
|
||||
})
|
||||
|
||||
test('unknown window kinds default to chat (zoom enabled)', () => {
|
||||
assert.deepEqual(zoomWiringForWindowKind('unknown'), { zoom: true })
|
||||
assert.deepEqual(zoomWiringForWindowKind(undefined), { zoom: true })
|
||||
})
|
||||
|
||||
@@ -31,3 +31,40 @@ export function percentToZoomLevel(percent) {
|
||||
|
||||
return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE))
|
||||
}
|
||||
|
||||
// Chromium on Windows can drop webContents zoom when a BrowserWindow is minimized
|
||||
// and restored. Re-apply the persisted level on these lifecycle transitions.
|
||||
export const ZOOM_REASSERT_WINDOW_EVENTS = ['show', 'restore']
|
||||
|
||||
export function installZoomReassertOnWindowEvents(win, reassert) {
|
||||
if (!win?.on) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const event of ZOOM_REASSERT_WINDOW_EVENTS) {
|
||||
win.on(event, () => {
|
||||
if (win.isDestroyed?.()) {
|
||||
return
|
||||
}
|
||||
|
||||
reassert()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom-wiring decision per window kind. Chat windows (main + session) keep
|
||||
* global UI zoom; the pet overlay opts out because it sizes its own OS window
|
||||
* to the sprite and inheriting zoom would crop it.
|
||||
*
|
||||
* Extracted so the "pet opts out, everything else opts in" contract is
|
||||
* unit-testable without booting a BrowserWindow or reading source.
|
||||
*/
|
||||
export const ZOOM_WINDOW_CONFIG = {
|
||||
chat: { zoom: true },
|
||||
petOverlay: { zoom: false }
|
||||
} as const
|
||||
|
||||
export function zoomWiringForWindowKind(kind) {
|
||||
return ZOOM_WINDOW_CONFIG[kind] ?? ZOOM_WINDOW_CONFIG.chat
|
||||
}
|
||||
|
||||
@@ -1,119 +1,11 @@
|
||||
import js from '@eslint/js'
|
||||
import typescriptEslint from '@typescript-eslint/eslint-plugin'
|
||||
import typescriptParser from '@typescript-eslint/parser'
|
||||
import perfectionist from 'eslint-plugin-perfectionist'
|
||||
import reactPlugin from 'eslint-plugin-react'
|
||||
import hooksPlugin from 'eslint-plugin-react-hooks'
|
||||
import unusedImports from 'eslint-plugin-unused-imports'
|
||||
import globals from 'globals'
|
||||
|
||||
const noopRule = {
|
||||
meta: { schema: [], type: 'problem' },
|
||||
create: () => ({})
|
||||
}
|
||||
|
||||
const customRules = {
|
||||
rules: {
|
||||
'no-process-cwd': noopRule,
|
||||
'no-process-env-top-level': noopRule,
|
||||
'no-sync-fs': noopRule,
|
||||
'no-top-level-dynamic-import': noopRule,
|
||||
'no-top-level-side-effects': noopRule
|
||||
}
|
||||
}
|
||||
import shared from '../../eslint.config.shared.mjs'
|
||||
|
||||
export default [
|
||||
...shared,
|
||||
{
|
||||
ignores: ['**/node_modules/**', '**/dist/**', 'src/**/*.js']
|
||||
},
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node
|
||||
},
|
||||
parser: typescriptParser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: { jsx: true },
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': typescriptEslint,
|
||||
'custom-rules': customRules,
|
||||
perfectionist,
|
||||
react: reactPlugin,
|
||||
'react-hooks': hooksPlugin,
|
||||
'unused-imports': unusedImports
|
||||
},
|
||||
files: ['**/*.test.tsx'],
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
curly: ['error', 'all'],
|
||||
'no-fallthrough': ['error', { allowEmptyCase: true }],
|
||||
'no-undef': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'padding-line-between-statements': [
|
||||
1,
|
||||
{
|
||||
blankLine: 'always',
|
||||
next: [
|
||||
'block-like',
|
||||
'block',
|
||||
'return',
|
||||
'if',
|
||||
'class',
|
||||
'continue',
|
||||
'debugger',
|
||||
'break',
|
||||
'multiline-const',
|
||||
'multiline-let'
|
||||
],
|
||||
prev: '*'
|
||||
},
|
||||
{
|
||||
blankLine: 'always',
|
||||
next: '*',
|
||||
prev: ['case', 'default', 'multiline-const', 'multiline-let', 'multiline-block-like']
|
||||
},
|
||||
{ blankLine: 'never', next: ['block', 'block-like'], prev: ['case', 'default'] },
|
||||
{ blankLine: 'always', next: ['block', 'block-like'], prev: ['block', 'block-like'] },
|
||||
{ blankLine: 'always', next: ['empty'], prev: 'export' },
|
||||
{ blankLine: 'never', next: 'iife', prev: ['block', 'block-like', 'empty'] }
|
||||
],
|
||||
'perfectionist/sort-exports': ['error', { order: 'asc', type: 'natural' }],
|
||||
'perfectionist/sort-imports': [
|
||||
'error',
|
||||
{
|
||||
groups: ['side-effect', 'builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
|
||||
order: 'asc',
|
||||
type: 'natural'
|
||||
}
|
||||
],
|
||||
'perfectionist/sort-jsx-props': ['error', { order: 'asc', type: 'natural' }],
|
||||
'perfectionist/sort-named-exports': ['error', { order: 'asc', type: 'natural' }],
|
||||
'perfectionist/sort-named-imports': ['error', { order: 'asc', type: 'natural' }],
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'unused-imports/no-unused-imports': 'error'
|
||||
},
|
||||
settings: {
|
||||
react: { version: 'detect' }
|
||||
'no-restricted-globals': ['warn', 'document']
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.js', '**/*.cjs', '**/*.mjs'],
|
||||
ignores: ['**/node_modules/**', '**/dist/**'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
globals: { ...globals.node },
|
||||
sourceType: 'module'
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['*.config.*']
|
||||
}
|
||||
]
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"profile:main": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
|
||||
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
|
||||
"start": "npm run build && electron .",
|
||||
"build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && tsc -b && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs",
|
||||
"build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs",
|
||||
"postbuild": "node scripts/assert-dist-built.mjs",
|
||||
"prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs",
|
||||
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.mjs",
|
||||
@@ -37,14 +37,16 @@
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.ts electron/hardening.test.ts electron/backend-env.test.ts electron/backend-probes.test.ts electron/backend-ready.test.ts electron/bootstrap-runner.test.ts electron/connection-config.test.ts electron/dashboard-token.test.ts electron/gateway-ws-probe.test.ts electron/oauth-net-request.test.ts electron/desktop-uninstall.test.ts electron/session-windows.test.ts electron/link-title-window.test.ts electron/workspace-cwd.test.ts electron/fs-read-dir.test.ts electron/git-root.test.ts electron/git-worktree-ops.test.ts electron/windows-child-process.test.ts electron/update-remote.test.ts electron/update-count.test.ts electron/update-rebuild.test.ts electron/update-marker.test.ts electron/update-relaunch.test.ts electron/windows-user-env.test.ts electron/wsl-clipboard-image.test.ts electron/titlebar-overlay-width.test.ts electron/window-state.test.ts electron/zoom.test.ts electron/windows-hermes-resolution.test.ts electron/oauth-session-request.test.ts",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.ts' 'vite.config.ts'",
|
||||
"fix": "npm run lint:fix && npm run fmt",
|
||||
"test:ui": "vitest run --environment jsdom",
|
||||
"preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174"
|
||||
"test:ui": "vitest run --project ui",
|
||||
"test:desktop:platforms": "vitest run --project electron",
|
||||
"test": "vitest run",
|
||||
"preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174",
|
||||
"check": "npm run typecheck && npm run test && npm run test:desktop:all && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "^0.12.28",
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use strict"
|
||||
|
||||
// Build-time guard: refuse to hand a half-built renderer to electron-builder.
|
||||
//
|
||||
// `npm run pack` / `npm run dist*` are `npm run build && npm run builder`.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
'use strict'
|
||||
/**
|
||||
* before-pack.mjs — electron-builder beforePack hook.
|
||||
*
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
readdirSync,
|
||||
rmSync
|
||||
} from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { isMain } from './utils.mjs'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
@@ -91,11 +92,6 @@ export function stageNodePty({ platform = process.platform, arch = process.arch
|
||||
// lib/**/*.js — the JS surface node-pty's `main` points into.
|
||||
copyGlobByExt(join(srcRoot, 'lib'), join(destRoot, 'lib'), ['.js'])
|
||||
|
||||
// build/Release/* — present when node-pty was compiled locally
|
||||
// (e.g. no prebuild available for this Electron ABI/platform combo).
|
||||
// Some installs won't have this at all if prebuild-install succeeded.
|
||||
copyBuildRelease(join(srcRoot, 'build/Release'), join(destRoot, 'build/Release'))
|
||||
|
||||
// prebuilds/<platform>-<arch>/* — the prebuild-install payload for the
|
||||
// *target* we're packaging, not necessarily the host running this script.
|
||||
// Explicit extensions only, to skip the ~25MB of Windows .pdb symbols
|
||||
@@ -117,13 +113,45 @@ export function stageNodePty({ platform = process.platform, arch = process.arch
|
||||
cpSync(join(prebuildDir, entry.name), join(destPrebuild, entry.name))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`[stage-native-deps] no prebuild found at prebuilds/${platform}-${arch} for node-pty. ` +
|
||||
`If build/Release/* above is also empty, this target will fail at runtime. ` +
|
||||
`Run "npx electron-rebuild -w node-pty" for this target, or check that ` +
|
||||
`node-pty's published prebuilds cover ${platform}-${arch}.`
|
||||
}
|
||||
|
||||
// build/Release/* — present when node-pty was compiled locally
|
||||
// (e.g. no prebuild available for this Electron ABI/platform combo).
|
||||
// Some installs won't have this at all if prebuild-install succeeded.
|
||||
const buildReleaseDir = join(srcRoot, 'build/Release')
|
||||
copyBuildRelease(buildReleaseDir, join(destRoot, 'build/Release'))
|
||||
|
||||
// If neither a prebuild nor build/Release produced a .node binary for this
|
||||
// target, run electron-rebuild to compile one from source. This happens on
|
||||
// CI (npm ci --ignore-scripts skips postinstall) and on platforms where
|
||||
// node-pty doesn't publish prebuilds (e.g. linux-x64).
|
||||
const stagedDirs = [
|
||||
join(destRoot, 'prebuilds', `${platform}-${arch}`),
|
||||
join(destRoot, 'build/Release')
|
||||
]
|
||||
const hasNativeBinary = stagedDirs.some(dir => {
|
||||
if (!existsSync(dir)) return false
|
||||
return readdirSync(dir, { recursive: true }).some(name => String(name).endsWith('.node'))
|
||||
})
|
||||
|
||||
if (!hasNativeBinary) {
|
||||
console.log(
|
||||
`[stage-native-deps] no prebuilt or compiled native binary for ${platform}-${arch}; ` +
|
||||
`running electron-rebuild to compile from source...`
|
||||
)
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['../../node_modules/.bin/electron-rebuild', '-f', '-w', 'node-pty'],
|
||||
{ cwd: projectRoot, stdio: 'inherit' }
|
||||
)
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`electron-rebuild failed for ${platform}-${arch} (exit ${result.status}). ` +
|
||||
`Cannot stage node-pty without a native binary.`
|
||||
)
|
||||
}
|
||||
// Re-copy build/Release after electron-rebuild populated it.
|
||||
copyBuildRelease(buildReleaseDir, join(destRoot, 'build/Release'))
|
||||
}
|
||||
|
||||
console.log(`[stage-native-deps] staged node-pty (${platform}-${arch}) -> ${destRoot}`)
|
||||
|
||||
@@ -42,7 +42,7 @@ const APP = (() => {
|
||||
const unpacked = path.join(RELEASE_ROOT, 'linux-unpacked')
|
||||
return {
|
||||
appPath: unpacked,
|
||||
binary: path.join(unpacked, 'hermes'),
|
||||
binary: path.join(unpacked, 'Hermes'),
|
||||
resourcesPath: path.join(unpacked, 'resources'),
|
||||
asarPath: path.join(unpacked, 'resources', 'app.asar'),
|
||||
unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html')
|
||||
@@ -108,10 +108,9 @@ function expectedNativeDepPaths() {
|
||||
function ensurePlatformBuilds() {
|
||||
if (PLATFORM === 'darwin') return
|
||||
if (PLATFORM === 'win32') return
|
||||
if (PLATFORM === 'linux') return
|
||||
die(
|
||||
`Desktop bundle validation is only wired for darwin / win32 today; platform=${PLATFORM} ` +
|
||||
`is not yet supported. The thin-installer story for Linux ships in Phase 2 alongside ` +
|
||||
`install.sh's stage protocol.`
|
||||
`Desktop bundle validation is only wired for darwin / win32 / linux; platform=${PLATFORM} is not supported.`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use strict"
|
||||
|
||||
/**
|
||||
* Writes apps/desktop/build/install-stamp.json with the git ref the desktop
|
||||
* .exe should pin to at first-launch bootstrap time. This file ships inside
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n/context'
|
||||
@@ -10,12 +10,17 @@ function makeAttachment(id: string, label = 'test.pdf'): ComposerAttachment {
|
||||
return { id, kind: 'file', label }
|
||||
}
|
||||
|
||||
function renderWithI18n(ui: React.ReactNode) {
|
||||
return render(
|
||||
<I18nProvider configClient={{ getConfig: async () => ({}), saveConfig: async () => ({ ok: true }) }}>
|
||||
{ui}
|
||||
</I18nProvider>
|
||||
)
|
||||
async function renderWithI18n(ui: React.ReactNode) {
|
||||
let result: ReturnType<typeof render>
|
||||
await act(async () => {
|
||||
result = render(
|
||||
<I18nProvider configClient={{ getConfig: async () => ({}), saveConfig: async () => ({ ok: true }) }}>
|
||||
{ui}
|
||||
</I18nProvider>
|
||||
)
|
||||
})
|
||||
|
||||
return result!
|
||||
}
|
||||
|
||||
describe('AttachmentList', () => {
|
||||
@@ -23,23 +28,22 @@ describe('AttachmentList', () => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders valid attachments', () => {
|
||||
it('renders valid attachments', async () => {
|
||||
const attachments = [makeAttachment('a', 'doc.pdf'), makeAttachment('b', 'img.png')]
|
||||
renderWithI18n(<AttachmentList attachments={attachments} />)
|
||||
await renderWithI18n(<AttachmentList attachments={attachments} />)
|
||||
expect(screen.getByText('doc.pdf')).toBeDefined()
|
||||
expect(screen.getByText('img.png')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders empty list without error', () => {
|
||||
renderWithI18n(<AttachmentList attachments={[]} />)
|
||||
it('renders empty list without error', async () => {
|
||||
const { container } = await renderWithI18n(<AttachmentList attachments={[]} />)
|
||||
|
||||
const container =
|
||||
screen.getByTestId?.('composer-attachments') ?? document.querySelector('[data-slot="composer-attachments"]')
|
||||
const attachmentList = container.querySelector('[data-slot="composer-attachments"]')
|
||||
|
||||
expect(container).toBeDefined()
|
||||
expect(attachmentList).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not crash when attachments array contains undefined entries', () => {
|
||||
it('does not crash when attachments array contains undefined entries', async () => {
|
||||
// Repro: session switch can leave stale/undefined entries in the
|
||||
// attachments array, causing a TypeError at attachment.refText.
|
||||
const attachments = [
|
||||
@@ -48,21 +52,17 @@ describe('AttachmentList', () => {
|
||||
makeAttachment('b', 'also-good.png')
|
||||
]
|
||||
|
||||
expect(() => {
|
||||
renderWithI18n(<AttachmentList attachments={attachments} />)
|
||||
}).not.toThrow()
|
||||
await expect(renderWithI18n(<AttachmentList attachments={attachments} />)).resolves.toBeTruthy()
|
||||
|
||||
// Only valid attachments should render
|
||||
expect(screen.getByText('good.pdf')).toBeDefined()
|
||||
expect(screen.getByText('also-good.png')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not crash when attachments array contains null entries', () => {
|
||||
it('does not crash when attachments array contains null entries', async () => {
|
||||
const attachments = [null as unknown as ComposerAttachment, makeAttachment('a', 'valid.txt')]
|
||||
|
||||
expect(() => {
|
||||
renderWithI18n(<AttachmentList attachments={attachments} />)
|
||||
}).not.toThrow()
|
||||
await expect(renderWithI18n(<AttachmentList attachments={attachments} />)).resolves.toBeTruthy()
|
||||
|
||||
expect(screen.getByText('valid.txt')).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('PreviewPane console state', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('does not watch backend-only remote filesystem previews locally', () => {
|
||||
it('does not watch backend-only remote filesystem previews locally', async () => {
|
||||
const watchPreviewFile = vi.fn(async () => ({ id: 'watch-1', path: '/remote/file.txt' }))
|
||||
const onPreviewFileChanged = vi.fn(() => vi.fn())
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
@@ -31,38 +31,43 @@ describe('PreviewPane console state', () => {
|
||||
}
|
||||
})
|
||||
|
||||
render(
|
||||
<PreviewPane
|
||||
setTitlebarToolGroup={vi.fn()}
|
||||
target={{
|
||||
kind: 'file',
|
||||
label: 'file.txt',
|
||||
path: '/remote/file.txt',
|
||||
previewKind: 'text',
|
||||
source: '/remote/file.txt',
|
||||
url: 'file:///remote/file.txt'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
await act(async () => {
|
||||
render(
|
||||
<PreviewPane
|
||||
setTitlebarToolGroup={vi.fn()}
|
||||
target={{
|
||||
kind: 'file',
|
||||
label: 'file.txt',
|
||||
path: '/remote/file.txt',
|
||||
previewKind: 'text',
|
||||
source: '/remote/file.txt',
|
||||
url: 'file:///remote/file.txt'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
expect(watchPreviewFile).not.toHaveBeenCalled()
|
||||
expect(onPreviewFileChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not rebuild the pane titlebar group for streamed console logs', () => {
|
||||
it('does not rebuild the pane titlebar group for streamed console logs', async () => {
|
||||
const setTitlebarToolGroup = vi.fn()
|
||||
|
||||
const rendered = render(
|
||||
<PreviewPane
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
target={{
|
||||
kind: 'url',
|
||||
label: 'Preview',
|
||||
source: 'http://localhost:5174',
|
||||
url: 'http://localhost:5174'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
let rendered!: ReturnType<typeof render>
|
||||
await act(async () => {
|
||||
rendered = render(
|
||||
<PreviewPane
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
target={{
|
||||
kind: 'url',
|
||||
label: 'Preview',
|
||||
source: 'http://localhost:5174',
|
||||
url: 'http://localhost:5174'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const initialCalls = setTitlebarToolGroup.mock.calls.length
|
||||
const webview = rendered.container.querySelector('webview')
|
||||
|
||||
@@ -97,6 +97,7 @@ function fakeDesktop() {
|
||||
})),
|
||||
onBootProgress: vi.fn(() => () => undefined),
|
||||
onBackendExit: vi.fn(() => () => undefined),
|
||||
onConnectionApplied: vi.fn(() => () => undefined),
|
||||
onPowerResume: vi.fn(() => () => undefined),
|
||||
onWindowStateChanged: vi.fn(() => () => undefined),
|
||||
touchBackend: vi.fn(async () => undefined),
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
setPrimaryGateway,
|
||||
touchSecondaryGateways
|
||||
} from '@/store/gateway'
|
||||
import { $gatewaySwitching, wipeSessionListsForGatewaySwitch } from '@/store/gateway-switch'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'
|
||||
import {
|
||||
@@ -130,7 +131,7 @@ export function useGatewayBoot({
|
||||
}
|
||||
|
||||
const attemptReconnect = async () => {
|
||||
if (cancelled || reconnecting || gatewayOpen()) {
|
||||
if (cancelled || reconnecting || gatewayOpen() || $gatewaySwitching.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -181,7 +182,7 @@ export function useGatewayBoot({
|
||||
} finally {
|
||||
reconnecting = false
|
||||
|
||||
if (!cancelled && !gatewayOpen()) {
|
||||
if (!cancelled && !gatewayOpen() && !$gatewaySwitching.get()) {
|
||||
if (reconnectAttempt >= RECONNECT_ESCALATE_AFTER && !escalated) {
|
||||
escalated = true
|
||||
failDesktopBoot(translateNow('boot.errors.gatewayConnectionLost'))
|
||||
@@ -193,7 +194,7 @@ export function useGatewayBoot({
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (cancelled || reconnecting || reconnectTimer !== null || gatewayOpen()) {
|
||||
if (cancelled || reconnecting || reconnectTimer !== null || gatewayOpen() || $gatewaySwitching.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -207,7 +208,7 @@ export function useGatewayBoot({
|
||||
}
|
||||
|
||||
const reconnectNow = () => {
|
||||
if (cancelled || !bootCompleted) {
|
||||
if (cancelled || !bootCompleted || $gatewaySwitching.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -221,7 +222,97 @@ export function useGatewayBoot({
|
||||
}
|
||||
}
|
||||
|
||||
const offBootProgress = desktop.onBootProgress(payload => applyDesktopBootProgress(payload))
|
||||
// Adopt the profile the primary (window) backend booted as, so same-profile
|
||||
// resumes are no-op swaps and reconnects target the right backend.
|
||||
// Best-effort: a missing preference means "default". Shared by boot + soft
|
||||
// switch.
|
||||
async function adoptPrimaryProfile() {
|
||||
try {
|
||||
const pref = await desktop.profile?.get?.()
|
||||
const profileKey = (pref?.profile ?? '').trim() || 'default'
|
||||
$activeGatewayProfile.set(profileKey)
|
||||
setPrimaryGateway(gateway, profileKey)
|
||||
void ensureGatewayForProfile(profileKey)
|
||||
} catch {
|
||||
$activeGatewayProfile.set('default')
|
||||
}
|
||||
}
|
||||
|
||||
// Seed the working dir from the backend default on a fresh view (nothing
|
||||
// open yet). Shared by boot + soft switch.
|
||||
async function seedDefaultCwd() {
|
||||
await ensureDefaultWorkspaceCwd()
|
||||
const remoteDefault = await desktopDefaultCwd().catch(() => null)
|
||||
|
||||
if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) {
|
||||
setCurrentCwd(remoteDefault.cwd)
|
||||
setCurrentBranch(remoteDefault.branch || '')
|
||||
}
|
||||
}
|
||||
|
||||
// Soft gateway-mode apply: main tore down the primary without reloading.
|
||||
// Wipe session lists so skeletons retrigger, then re-dial in place.
|
||||
const softSwitch = async () => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
$gatewaySwitching.set(true)
|
||||
clearReconnectTimer()
|
||||
reconnectAttempt = 0
|
||||
escalated = false
|
||||
reauthNotified = false
|
||||
wipeSessionListsForGatewaySwitch()
|
||||
|
||||
try {
|
||||
gateway.close()
|
||||
closeSecondaryGateways()
|
||||
|
||||
const conn = await desktop.getConnection()
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
publish(conn)
|
||||
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
|
||||
await gateway.connect(wsUrl)
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
await adoptPrimaryProfile()
|
||||
await seedDefaultCwd()
|
||||
await callbacksRef.current.refreshHermesConfig().catch(() => undefined)
|
||||
await callbacksRef.current.refreshSessions().catch(() => undefined)
|
||||
completeDesktopBoot()
|
||||
bootCompleted = true
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
failDesktopBoot(message)
|
||||
notifyError(err, translateNow('boot.errors.desktopBootFailed'))
|
||||
setSessionsLoading(false)
|
||||
}
|
||||
} finally {
|
||||
$gatewaySwitching.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
const offBootProgress = desktop.onBootProgress(payload => {
|
||||
// Soft switch / post-boot startHermes re-emits progress — ignore so the
|
||||
// cold-boot CONNECTING overlay stays down. Errors still surface.
|
||||
if ($gatewaySwitching.get() || bootCompleted) {
|
||||
if (payload.error) {
|
||||
applyDesktopBootProgress(payload)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
applyDesktopBootProgress(payload)
|
||||
})
|
||||
void desktop
|
||||
.getBootProgress()
|
||||
.then(snapshot => applyDesktopBootProgress(snapshot))
|
||||
@@ -258,7 +349,7 @@ export function useGatewayBoot({
|
||||
if (bootCompleted) {
|
||||
completeDesktopBoot()
|
||||
}
|
||||
} else if (bootCompleted && (st === 'closed' || st === 'error')) {
|
||||
} else if (bootCompleted && !$gatewaySwitching.get() && (st === 'closed' || st === 'error')) {
|
||||
// The socket dropped after a healthy boot (typically sleep/wake). Try
|
||||
// to bring it back instead of leaving the composer stuck disabled.
|
||||
scheduleReconnect()
|
||||
@@ -270,6 +361,7 @@ export function useGatewayBoot({
|
||||
// Wake signals: power resume (macOS/Windows), network coming back, and the
|
||||
// window regaining focus/visibility. Each nudges an immediate reconnect.
|
||||
const offPowerResume = desktop.onPowerResume?.(() => reconnectNow())
|
||||
const offConnectionApplied = desktop.onConnectionApplied?.(() => void softSwitch())
|
||||
|
||||
const onOnline = () => reconnectNow()
|
||||
|
||||
@@ -319,6 +411,10 @@ export function useGatewayBoot({
|
||||
})
|
||||
|
||||
const offExit = desktop.onBackendExit(() => {
|
||||
if ($gatewaySwitching.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
if ($desktopBoot.get().running || $desktopBoot.get().visible) {
|
||||
failDesktopBoot(translateNow('boot.errors.backgroundExitedDuringStartup'))
|
||||
}
|
||||
@@ -357,31 +453,14 @@ export function useGatewayBoot({
|
||||
return
|
||||
}
|
||||
|
||||
// Record which profile the primary (window) backend booted as, so
|
||||
// same-profile resumes are no-op swaps and any reconnect targets the
|
||||
// right backend. Best-effort: a missing preference means "default".
|
||||
try {
|
||||
const pref = await desktop.profile?.get?.()
|
||||
const profileKey = (pref?.profile ?? '').trim() || 'default'
|
||||
$activeGatewayProfile.set(profileKey)
|
||||
setPrimaryGateway(gateway, profileKey)
|
||||
void ensureGatewayForProfile(profileKey)
|
||||
} catch {
|
||||
$activeGatewayProfile.set('default')
|
||||
}
|
||||
await adoptPrimaryProfile()
|
||||
|
||||
setDesktopBootStep({
|
||||
phase: 'renderer.config',
|
||||
message: translateNow('boot.steps.loadingSettings'),
|
||||
progress: 97
|
||||
})
|
||||
await ensureDefaultWorkspaceCwd()
|
||||
const remoteDefault = await desktopDefaultCwd().catch(() => null)
|
||||
|
||||
if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) {
|
||||
setCurrentCwd(remoteDefault.cwd)
|
||||
setCurrentBranch(remoteDefault.branch || '')
|
||||
}
|
||||
await seedDefaultCwd()
|
||||
|
||||
await callbacksRef.current.refreshHermesConfig()
|
||||
|
||||
@@ -411,6 +490,7 @@ export function useGatewayBoot({
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
$gatewaySwitching.set(false)
|
||||
clearReconnectTimer()
|
||||
clearInterval(keepaliveTimer)
|
||||
offWorking()
|
||||
@@ -419,6 +499,7 @@ export function useGatewayBoot({
|
||||
window.removeEventListener('online', onOnline)
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
offPowerResume?.()
|
||||
offConnectionApplied?.()
|
||||
offState()
|
||||
offEvent()
|
||||
offExit()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -53,12 +53,16 @@ afterEach(() => {
|
||||
|
||||
async function renderMessaging() {
|
||||
const { MessagingView } = await import('./index')
|
||||
let result: ReturnType<typeof render>
|
||||
await act(async () => {
|
||||
result = render(
|
||||
<MemoryRouter>
|
||||
<MessagingView />
|
||||
</MemoryRouter>
|
||||
)
|
||||
})
|
||||
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<MessagingView />
|
||||
</MemoryRouter>
|
||||
)
|
||||
return result!
|
||||
}
|
||||
|
||||
describe('MessagingView setup-guide link', () => {
|
||||
@@ -82,7 +86,9 @@ describe('MessagingView setup-guide link', () => {
|
||||
await renderMessaging()
|
||||
|
||||
const link = await screen.findByText('Open setup guide')
|
||||
fireEvent.click(link)
|
||||
await act(async () => {
|
||||
fireEvent.click(link)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(openExternalLink).toHaveBeenCalledWith(docsUrl))
|
||||
})
|
||||
|
||||
@@ -62,9 +62,9 @@ describe('usePreviewRouting', () => {
|
||||
$currentCwd.set('/work')
|
||||
$messages.set([])
|
||||
$previewTarget.set(null)
|
||||
window.localStorage.clear()
|
||||
clearSessionPreviewRegistry()
|
||||
handleEvent = () => undefined
|
||||
window.localStorage.clear()
|
||||
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
@@ -78,9 +78,9 @@ describe('usePreviewRouting', () => {
|
||||
cleanup()
|
||||
$messages.set([])
|
||||
$previewTarget.set(null)
|
||||
window.localStorage.clear()
|
||||
clearSessionPreviewRegistry()
|
||||
vi.restoreAllMocks()
|
||||
clearSessionPreviewRegistry()
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('opens the active session preview from the registry', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -43,6 +43,18 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap render() in act() so the Harness's useEffect (onReady callback +
|
||||
// internal state from usePromptActions) flushes synchronously instead of
|
||||
// spilling async state updates outside act().
|
||||
async function actRender(ui: React.ReactElement) {
|
||||
let result: ReturnType<typeof render>
|
||||
await act(async () => {
|
||||
result = render(ui)
|
||||
})
|
||||
|
||||
return result!
|
||||
}
|
||||
|
||||
interface HarnessHandle {
|
||||
cancelRun: () => Promise<void>
|
||||
restoreToMessage: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise<void>
|
||||
@@ -118,10 +130,14 @@ function Harness({
|
||||
|
||||
useEffect(() => {
|
||||
onReady({
|
||||
cancelRun: actions.cancelRun,
|
||||
restoreToMessage: actions.restoreToMessage,
|
||||
steerPrompt: actions.steerPrompt,
|
||||
submitText: actions.submitText
|
||||
cancelRun: (...args: Parameters<typeof actions.cancelRun>) =>
|
||||
act(async () => actions.cancelRun(...args)) as Promise<void>,
|
||||
restoreToMessage: (...args: Parameters<typeof actions.restoreToMessage>) =>
|
||||
act(async () => actions.restoreToMessage(...args)) as Promise<void>,
|
||||
steerPrompt: (...args: Parameters<typeof actions.steerPrompt>) =>
|
||||
act(async () => actions.steerPrompt(...args)) as Promise<boolean>,
|
||||
submitText: (...args: Parameters<typeof actions.submitText>) =>
|
||||
act(async () => actions.submitText(...args)) as Promise<boolean>
|
||||
})
|
||||
}, [actions.cancelRun, actions.restoreToMessage, actions.steerPrompt, actions.submitText, onReady])
|
||||
|
||||
@@ -146,7 +162,9 @@ describe('usePromptActions /title', () => {
|
||||
)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
await handle!.submitText('/title New title')
|
||||
|
||||
@@ -170,7 +188,9 @@ describe('usePromptActions /title', () => {
|
||||
)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
await handle!.submitText('/title Fresh chat')
|
||||
|
||||
@@ -188,7 +208,9 @@ describe('usePromptActions /title', () => {
|
||||
const requestGateway = vi.fn(async () => ({ output: 'Title: Old title' }) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
await handle!.submitText('/title')
|
||||
|
||||
@@ -208,7 +230,9 @@ describe('usePromptActions /title', () => {
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
await handle!.submitText('/title way too long title')
|
||||
|
||||
@@ -247,7 +271,7 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={s => states.push(s)}
|
||||
@@ -297,7 +321,7 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={s => states.push(s)}
|
||||
@@ -335,7 +359,7 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
@@ -365,7 +389,7 @@ describe('usePromptActions desktop slash pickers', () => {
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
@@ -385,7 +409,7 @@ describe('usePromptActions desktop slash pickers', () => {
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
openMemoryGraph={openMemoryGraph}
|
||||
@@ -418,7 +442,7 @@ describe('usePromptActions desktop slash pickers', () => {
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
@@ -448,7 +472,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={s => seeds.push(s)}
|
||||
@@ -481,7 +505,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness
|
||||
busyRef={busyRef}
|
||||
onReady={h => (handle = h)}
|
||||
@@ -523,7 +547,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
@@ -567,7 +591,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={s => seeds.push(s)}
|
||||
@@ -589,7 +613,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness
|
||||
busyRef={busyRef}
|
||||
onReady={h => (handle = h)}
|
||||
@@ -615,7 +639,7 @@ describe('usePromptActions steerPrompt', () => {
|
||||
const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
@@ -634,7 +658,7 @@ describe('usePromptActions steerPrompt', () => {
|
||||
const requestGateway = vi.fn(async () => ({ status: 'rejected' }) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
@@ -647,7 +671,7 @@ describe('usePromptActions steerPrompt', () => {
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
@@ -658,7 +682,7 @@ describe('usePromptActions steerPrompt', () => {
|
||||
const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
@@ -690,7 +714,7 @@ describe('usePromptActions restoreToMessage', () => {
|
||||
let lastState: Record<string, unknown> = {}
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={state => (lastState = state)}
|
||||
@@ -725,7 +749,7 @@ describe('usePromptActions restoreToMessage', () => {
|
||||
let lastState: Record<string, unknown> = {}
|
||||
let handle: HarnessHandle | null = null
|
||||
|
||||
render(
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={state => (lastState = state)}
|
||||
@@ -758,7 +782,7 @@ describe('usePromptActions restoreToMessage', () => {
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
@@ -786,7 +810,7 @@ describe('usePromptActions restoreToMessage', () => {
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
@@ -801,7 +825,7 @@ describe('usePromptActions restoreToMessage', () => {
|
||||
|
||||
let lastState: Record<string, unknown> = {}
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={state => (lastState = state)}
|
||||
@@ -875,7 +899,7 @@ describe('usePromptActions file attachment sync', () => {
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
@@ -929,7 +953,7 @@ describe('usePromptActions file attachment sync', () => {
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
@@ -958,7 +982,7 @@ describe('usePromptActions file attachment sync', () => {
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
@@ -1018,7 +1042,7 @@ describe('usePromptActions eager-upload races', () => {
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
await waitFor(() => expect(handle).not.toBeNull())
|
||||
@@ -1076,7 +1100,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
@@ -1119,7 +1143,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
@@ -1152,7 +1176,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={s => states.push(s)}
|
||||
@@ -1182,7 +1206,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
@@ -1226,7 +1250,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
@@ -1239,7 +1263,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
|
||||
expect(calls[2]?.params).toEqual({
|
||||
session_id: RECOVERED_SESSION_ID,
|
||||
text: 'message during starved loop'
|
||||
@@ -1266,7 +1290,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness
|
||||
activeSessionId={null}
|
||||
createBackendSessionForSend={createBackendSessionForSend}
|
||||
@@ -1297,7 +1321,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
await actRender(
|
||||
<Harness
|
||||
activeSessionId={null}
|
||||
createBackendSessionForSend={createBackendSessionForSend}
|
||||
@@ -1353,7 +1377,7 @@ describe('usePromptActions eager attachment upload (drop-time)', () => {
|
||||
{ id: 'file:devis', kind: 'file', label: 'DEVIS_signed.pdf', path: '/Users/mahmoud/Downloads/DEVIS_signed.pdf' }
|
||||
])
|
||||
|
||||
render(
|
||||
await actRender(
|
||||
<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
@@ -1383,7 +1407,7 @@ describe('usePromptActions eager attachment upload (drop-time)', () => {
|
||||
|
||||
$composerAttachments.set([{ id: 'file:x', kind: 'file', label: 'x.pdf', path: '/abs/x.pdf' }])
|
||||
|
||||
render(
|
||||
await actRender(
|
||||
<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
@@ -1407,7 +1431,7 @@ describe('usePromptActions eager attachment upload (drop-time)', () => {
|
||||
}
|
||||
])
|
||||
|
||||
render(
|
||||
await actRender(
|
||||
<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
|
||||
@@ -3,19 +3,25 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import type { DesktopAuthProvider, DesktopCloudAgent, DesktopCloudOrg, DesktopConnectionProbeResult } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
|
||||
import { ExternalLink } from '@/lib/external-link'
|
||||
import { AlertCircle, Check, Cloud, FileText, Globe, HelpCircle, Loader2, LogIn, Monitor, RefreshCw } from '@/lib/icons'
|
||||
import { selectableCardClass } from '@/lib/selectable-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { previewGatewaySwitch } from '@/store/gateway-switch'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $profiles, refreshActiveProfile } from '@/store/profile'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
|
||||
|
||||
type Mode = 'local' | 'remote'
|
||||
type Mode = 'local' | 'remote' | 'cloud'
|
||||
type AuthMode = 'oauth' | 'token'
|
||||
type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
|
||||
// Hermes Cloud discovery lifecycle for the cloud-mode panel.
|
||||
type CloudDiscoverStatus = 'idle' | 'loading' | 'done' | 'error'
|
||||
|
||||
interface GatewaySettingsState {
|
||||
envOverride: boolean
|
||||
@@ -25,6 +31,7 @@ interface GatewaySettingsState {
|
||||
remoteTokenPreview: string | null
|
||||
remoteTokenSet: boolean
|
||||
remoteUrl: string
|
||||
cloudOrg: string
|
||||
}
|
||||
|
||||
const EMPTY_STATE: GatewaySettingsState = {
|
||||
@@ -34,13 +41,15 @@ const EMPTY_STATE: GatewaySettingsState = {
|
||||
remoteOauthConnected: false,
|
||||
remoteTokenPreview: null,
|
||||
remoteTokenSet: false,
|
||||
remoteUrl: ''
|
||||
remoteUrl: '',
|
||||
cloudOrg: ''
|
||||
}
|
||||
|
||||
function ModeCard({
|
||||
active,
|
||||
description,
|
||||
disabled,
|
||||
hint,
|
||||
icon: Icon,
|
||||
onSelect,
|
||||
title
|
||||
@@ -48,6 +57,7 @@ function ModeCard({
|
||||
active: boolean
|
||||
description: string
|
||||
disabled?: boolean
|
||||
hint?: string
|
||||
icon: typeof Monitor
|
||||
onSelect: () => void
|
||||
title: string
|
||||
@@ -55,22 +65,29 @@ function ModeCard({
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-xl border p-3 text-left transition',
|
||||
active
|
||||
? 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
: 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) hover:bg-(--chrome-action-hover)',
|
||||
disabled && 'cursor-not-allowed opacity-50'
|
||||
'flex h-full min-h-0 w-full flex-col p-3 text-left disabled:cursor-not-allowed disabled:opacity-50',
|
||||
selectableCardClass({ active, prominent: true })
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
<span>{title}</span>
|
||||
{active ? <Check className="ml-auto size-4 text-primary" /> : null}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 text-[length:var(--conversation-text-font-size)] font-medium">{title}</span>
|
||||
{hint ? (
|
||||
<Tip label={hint}>
|
||||
<span
|
||||
className="grid size-3.5 shrink-0 cursor-help place-items-center text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)"
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<HelpCircle className="size-3.5" />
|
||||
</span>
|
||||
</Tip>
|
||||
) : null}
|
||||
{active ? <Check className="ml-auto size-3.5 shrink-0 text-primary" /> : null}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
<p className="mt-1.5 flex-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{description}
|
||||
</p>
|
||||
</button>
|
||||
@@ -100,11 +117,38 @@ export function GatewaySettings() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [previewingSwitch, setPreviewingSwitch] = useState(false)
|
||||
const [signingIn, setSigningIn] = useState(false)
|
||||
const [state, setState] = useState<GatewaySettingsState>(EMPTY_STATE)
|
||||
const [remoteToken, setRemoteToken] = useState('')
|
||||
const [lastTest, setLastTest] = useState<null | string>(null)
|
||||
|
||||
// --- Hermes Cloud (cloud mode) state ---
|
||||
// One portal session powers discovery + the silent per-agent cascade. These
|
||||
// track the cloud panel: whether we're signed in, the discovered agent list,
|
||||
// and which agent is mid-connect.
|
||||
const [cloudSignedIn, setCloudSignedIn] = useState(false)
|
||||
const [cloudSigningIn, setCloudSigningIn] = useState(false)
|
||||
const [cloudAgents, setCloudAgents] = useState<DesktopCloudAgent[]>([])
|
||||
const [cloudDiscover, setCloudDiscover] = useState<CloudDiscoverStatus>('idle')
|
||||
const [cloudConnectingId, setCloudConnectingId] = useState<null | string>(null)
|
||||
// Multi-org users: when discovery returns needsOrgSelection, we hold the org
|
||||
// list here and show a picker. `cloudOrg` is the chosen org slug/id (null =
|
||||
// not yet chosen / single-org user).
|
||||
const [cloudOrgs, setCloudOrgs] = useState<DesktopCloudOrg[]>([])
|
||||
const [cloudOrg, setCloudOrgState] = useState<null | string>(null)
|
||||
// Mirror the selected org into a ref so connect reads the CURRENT value, not a
|
||||
// value captured in a stale render closure. discoverCloud() resolves the org
|
||||
// asynchronously (from the NAS response) and a user can click Connect in the
|
||||
// same render tick; without the ref, connectCloudAgent could persist a null
|
||||
// org even though discovery just resolved one. Always set both together.
|
||||
const cloudOrgRef = useRef<null | string>(null)
|
||||
|
||||
const setCloudOrg = (value: null | string) => {
|
||||
cloudOrgRef.current = value
|
||||
setCloudOrgState(value)
|
||||
}
|
||||
|
||||
// Connection scope: null = the global/default connection (the original
|
||||
// behavior); a profile name = that profile's per-profile remote override, so
|
||||
// each profile can point at its own backend.
|
||||
@@ -163,6 +207,22 @@ export function GatewaySettings() {
|
||||
// OAuth login button or the session-token entry box. The effective auth mode
|
||||
// prefers a fresh probe result over the saved value.
|
||||
const trimmedUrl = state.remoteUrl.trim()
|
||||
|
||||
// The dashboardUrl of the currently-connected cloud instance (the saved
|
||||
// cloud connection's remoteUrl), normalized for comparison against each
|
||||
// discovered agent's dashboardUrl so we can highlight the active one and hide
|
||||
// its Connect button. Empty unless the saved connection is a cloud one.
|
||||
// The saved cloud URL was stored via the main-side normalizeRemoteBaseUrl
|
||||
// (which lowercases the host through URL.toString()), but a discovered agent's
|
||||
// dashboardUrl arrives raw from NAS — so normalize both sides the same way
|
||||
// (trim, drop trailing slash, lowercase) or a host-casing difference would
|
||||
// silently break the connected-highlight.
|
||||
const normalizeCloudUrl = (url: string) => url.trim().replace(/\/+$/, '').toLowerCase()
|
||||
const connectedCloudUrl = state.mode === 'cloud' ? normalizeCloudUrl(state.remoteUrl) : ''
|
||||
|
||||
const isConnectedAgent = (agent: DesktopCloudAgent) =>
|
||||
Boolean(connectedCloudUrl && agent.dashboardUrl && normalizeCloudUrl(agent.dashboardUrl) === connectedCloudUrl)
|
||||
|
||||
useEffect(() => {
|
||||
if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
|
||||
setProbeStatus('idle')
|
||||
@@ -379,6 +439,234 @@ export function GatewaySettings() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hermes Cloud handlers ---
|
||||
|
||||
// Pull the discovered agent list over the shared portal session. Tolerant of
|
||||
// a lapsed session: a needsCloudLogin error flips us back to signed-out.
|
||||
// `org` scopes discovery for multi-org users; when discovery comes back with
|
||||
// needsOrgSelection we surface the org list and show a picker instead.
|
||||
const discoverCloud = async (org?: string) => {
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.cloud) {
|
||||
return
|
||||
}
|
||||
|
||||
setCloudDiscover('loading')
|
||||
|
||||
try {
|
||||
const result = await desktop.cloud.discover(org)
|
||||
|
||||
if ('needsOrgSelection' in result && result.needsOrgSelection) {
|
||||
// Multi-org user with no org chosen yet: show the picker. Don't clear a
|
||||
// previously-chosen org list on a refresh.
|
||||
setCloudOrgs(result.orgs)
|
||||
setCloudAgents([])
|
||||
setCloudDiscover('done')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Single org (or org now chosen): we have agents.
|
||||
setCloudAgents('agents' in result ? result.agents : [])
|
||||
|
||||
// Record the org AUTHORITATIVELY from the response (NAS echoes the org the
|
||||
// list was scoped to), falling back to the org we requested. This is what
|
||||
// gets persisted on connect, so it must be set even on single-membership
|
||||
// auto-resolve where no picker ran and no `org` arg was passed.
|
||||
const resolvedOrgRef = 'org' in result && result.org ? (result.org.slug ?? result.org.id) : null
|
||||
|
||||
if (resolvedOrgRef) {
|
||||
setCloudOrg(resolvedOrgRef)
|
||||
} else if (org) {
|
||||
setCloudOrg(org)
|
||||
}
|
||||
|
||||
setCloudDiscover('done')
|
||||
} catch (err) {
|
||||
setCloudAgents([])
|
||||
setCloudDiscover('error')
|
||||
|
||||
// A lapsed/absent portal session means we're effectively signed out.
|
||||
if (err && typeof err === 'object' && 'needsCloudLogin' in err) {
|
||||
setCloudSignedIn(false)
|
||||
}
|
||||
|
||||
notifyError(err, g.cloudDiscoverFailed)
|
||||
}
|
||||
}
|
||||
|
||||
// User picked an org from the multi-org picker: remember it and re-run
|
||||
// discovery scoped to it.
|
||||
const selectCloudOrg = (org: DesktopCloudOrg) => {
|
||||
const ref = org.slug ?? org.id
|
||||
setCloudOrg(ref)
|
||||
void discoverCloud(ref)
|
||||
}
|
||||
|
||||
// "Change org": clear the selected org and re-discover with no org arg. A
|
||||
// multi-org user gets NAS's 409 → the picker; a single-org user auto-resolves
|
||||
// back to their one org. Also clear the agent list so the current org's
|
||||
// agents don't linger under the picker while discovery re-runs.
|
||||
const changeCloudOrg = () => {
|
||||
setCloudOrg(null)
|
||||
setCloudAgents([])
|
||||
void discoverCloud()
|
||||
}
|
||||
|
||||
// On entering cloud mode (or scope change), read the portal session status and
|
||||
// auto-discover when already signed in, so the picker is populated on open.
|
||||
useEffect(() => {
|
||||
if (state.mode !== 'cloud') {
|
||||
return
|
||||
}
|
||||
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.cloud) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
desktop.cloud
|
||||
.status()
|
||||
.then(status => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setCloudSignedIn(status.signedIn)
|
||||
|
||||
if (status.signedIn) {
|
||||
// Restore the persisted org (if any) so we reopen straight into that
|
||||
// org's agent list instead of the picker; discoverCloud(org) also
|
||||
// records it as the selected org. Empty → normal discovery (single-org
|
||||
// resolves automatically; multi-org shows the picker).
|
||||
const savedOrg = state.cloudOrg || ''
|
||||
|
||||
if (savedOrg) {
|
||||
setCloudOrg(savedOrg)
|
||||
}
|
||||
|
||||
void discoverCloud(savedOrg || undefined)
|
||||
} else {
|
||||
setCloudAgents([])
|
||||
setCloudOrgs([])
|
||||
setCloudOrg(null)
|
||||
setCloudDiscover('idle')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setCloudSignedIn(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => void (cancelled = true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload on mode/scope change only
|
||||
}, [state.mode, scope])
|
||||
|
||||
const cloudSignIn = async () => {
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.cloud) {
|
||||
return
|
||||
}
|
||||
|
||||
setCloudSigningIn(true)
|
||||
|
||||
try {
|
||||
const result = await desktop.cloud.login()
|
||||
setCloudSignedIn(result.signedIn)
|
||||
|
||||
if (result.signedIn) {
|
||||
await discoverCloud()
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, g.cloudSignInFailed)
|
||||
} finally {
|
||||
setCloudSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cloudSignOut = async () => {
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.cloud) {
|
||||
return
|
||||
}
|
||||
|
||||
setCloudSigningIn(true)
|
||||
|
||||
try {
|
||||
await desktop.cloud.logout()
|
||||
setCloudSignedIn(false)
|
||||
setCloudAgents([])
|
||||
setCloudOrgs([])
|
||||
setCloudOrg(null)
|
||||
setCloudDiscover('idle')
|
||||
notify({ kind: 'success', title: g.cloudSignedOutTitle, message: g.cloudSignedOutMessage })
|
||||
} catch (err) {
|
||||
notifyError(err, g.signOutFailed)
|
||||
} finally {
|
||||
setCloudSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Select a discovered agent: drive the silent per-agent cascade (no second
|
||||
// prompt — the shared portal session auto-approves), then persist a cloud-mode
|
||||
// connection pointed at its dashboardUrl and apply it (soft-reconnects in place).
|
||||
const connectCloudAgent = async (agent: DesktopCloudAgent) => {
|
||||
if (!agent.dashboardUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.cloud) {
|
||||
return
|
||||
}
|
||||
|
||||
setCloudConnectingId(agent.id)
|
||||
|
||||
try {
|
||||
const result = await desktop.cloud.agentSignIn(agent.dashboardUrl)
|
||||
|
||||
if (!result.connected) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: t.boot.failure.signInIncompleteTitle,
|
||||
message: t.boot.failure.signInIncompleteMessage
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Persist a cloud-mode connection (remote-shaped, oauth) and soft-reconnect.
|
||||
// Include the selected org so Settings reopens into the same org + instance.
|
||||
// Read the REF (not the cloudOrg state) so a just-resolved org from
|
||||
// discovery in this same render tick is captured, not a stale null.
|
||||
const next = await desktop.applyConnectionConfig({
|
||||
mode: 'cloud',
|
||||
profile: scope ?? undefined,
|
||||
remoteAuthMode: 'oauth',
|
||||
remoteUrl: agent.dashboardUrl,
|
||||
cloudOrg: cloudOrgRef.current ?? undefined
|
||||
})
|
||||
|
||||
setState(next)
|
||||
notify({ kind: 'success', title: g.cloudConnectedTitle, message: g.cloudConnectedTo(agent.name) })
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'needsCloudLogin' in err) {
|
||||
setCloudSignedIn(false)
|
||||
}
|
||||
|
||||
notifyError(err, g.cloudConnectFailed)
|
||||
} finally {
|
||||
setCloudConnectingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const testRemote = async () => {
|
||||
if (!canUseRemote) {
|
||||
notify({
|
||||
@@ -465,131 +753,306 @@ export function GatewaySettings() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<ModeCard
|
||||
active={state.mode === 'local'}
|
||||
description={g.localDesc}
|
||||
disabled={state.envOverride}
|
||||
icon={Monitor}
|
||||
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
|
||||
title={g.localTitle}
|
||||
/>
|
||||
<ModeCard
|
||||
active={state.mode === 'remote'}
|
||||
description={g.remoteDesc}
|
||||
disabled={state.envOverride}
|
||||
icon={Globe}
|
||||
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
|
||||
title={g.remoteTitle}
|
||||
/>
|
||||
<div className="mb-5 grid gap-2">
|
||||
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
|
||||
{g.modeTitle}
|
||||
</div>
|
||||
<div className="grid auto-rows-fr grid-cols-1 gap-2 min-[42rem]:grid-cols-3">
|
||||
<ModeCard
|
||||
active={state.mode === 'local'}
|
||||
description={g.localDesc}
|
||||
disabled={state.envOverride}
|
||||
icon={Monitor}
|
||||
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
|
||||
title={g.localTitle}
|
||||
/>
|
||||
<ModeCard
|
||||
active={state.mode === 'cloud'}
|
||||
description={g.cloudDesc}
|
||||
disabled={state.envOverride}
|
||||
icon={Cloud}
|
||||
onSelect={() => setState(current => ({ ...current, mode: 'cloud' }))}
|
||||
title={g.cloudTitle}
|
||||
/>
|
||||
<ModeCard
|
||||
active={state.mode === 'remote'}
|
||||
description={g.remoteDesc}
|
||||
disabled={state.envOverride}
|
||||
hint={g.remoteAuthHint}
|
||||
icon={Globe}
|
||||
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
|
||||
title={g.remoteTitle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-1">
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event => setState(current => ({ ...current, remoteUrl: event.target.value }))}
|
||||
placeholder="https://gateway.example.com/hermes"
|
||||
value={state.remoteUrl}
|
||||
/>
|
||||
}
|
||||
description={g.remoteUrlDesc}
|
||||
title={g.remoteUrlTitle}
|
||||
/>
|
||||
|
||||
{state.mode === 'remote' && probeStatus === 'probing' ? (
|
||||
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{g.probing}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.mode === 'remote' && probeStatus === 'error' ? (
|
||||
<div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
{g.probeError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* OAuth / password gateways: present a sign-in button + connection status. */}
|
||||
{state.mode === 'remote' && authResolved && authMode === 'oauth' ? (
|
||||
{/* Hermes Cloud panel: one portal sign-in, then a discovered-agent picker
|
||||
whose selection drives the silent per-agent cascade + a cloud
|
||||
connection. Replaces the URL/token form while in cloud mode. */}
|
||||
{state.mode === 'cloud' && !state.envOverride ? (
|
||||
<div className="mt-5 grid gap-1">
|
||||
<ListRow
|
||||
action={
|
||||
oauthConnected ? (
|
||||
cloudSignedIn ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Pill tone="primary">
|
||||
<Check className="size-3" /> {g.signedIn}
|
||||
<Check className="size-3" /> {g.cloudSignedIn}
|
||||
</Pill>
|
||||
<Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline">
|
||||
{signingIn ? <Loader2 className="animate-spin" /> : null}
|
||||
<Button disabled={cloudSigningIn} onClick={() => void cloudSignOut()} variant="outline">
|
||||
{cloudSigningIn ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.signOut}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}>
|
||||
{signingIn ? <Loader2 className="animate-spin" /> : <LogIn />}
|
||||
{isPasswordProvider ? g.signIn : g.signInWith(providerLabel)}
|
||||
<Button disabled={cloudSigningIn} onClick={() => void cloudSignIn()}>
|
||||
{cloudSigningIn ? <Loader2 className="animate-spin" /> : <LogIn />}
|
||||
{g.cloudSignIn}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
description={
|
||||
oauthConnected
|
||||
? isPasswordProvider
|
||||
? g.authSignedInPassword
|
||||
: g.authSignedInOauth
|
||||
: isPasswordProvider
|
||||
? g.authNeedsPassword
|
||||
: g.authNeedsOauth(providerLabel)
|
||||
}
|
||||
title={g.authTitle}
|
||||
description={cloudSignedIn ? g.cloudSignedInDesc : g.cloudNeedsSignIn}
|
||||
title={g.cloudSignInTitle}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Session-token gateways: keep the existing token entry box. */}
|
||||
{state.mode === 'remote' && authResolved && authMode === 'token' ? (
|
||||
{cloudSignedIn ? (
|
||||
cloudOrgs.length > 0 && !cloudOrg ? (
|
||||
// Multi-org user who hasn't picked an org yet: show the org picker
|
||||
// instead of the agent list. Selecting one re-runs discovery
|
||||
// scoped to it.
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
|
||||
{g.cloudOrgPickerTitle}
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
{cloudOrgs.map(orgEntry => (
|
||||
<ListRow
|
||||
action={
|
||||
<Button onClick={() => selectCloudOrg(orgEntry)} size="sm">
|
||||
{g.cloudOrgSelect}
|
||||
</Button>
|
||||
}
|
||||
description={g.cloudOrgRole(orgEntry.role)}
|
||||
key={orgEntry.id}
|
||||
title={orgEntry.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
|
||||
{g.cloudAgentsTitle}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{cloudOrg ? (
|
||||
// Let the user switch orgs. Gating on cloudOrgs.length would
|
||||
// hide this after a restore-open (which discovers straight
|
||||
// into the saved org and never populates the org list). So
|
||||
// show it whenever an org is selected: clicking clears the
|
||||
// org and re-runs discovery with no org arg — a multi-org
|
||||
// user gets the picker (NAS 409), a single-org user simply
|
||||
// auto-resolves back to their one org (harmless).
|
||||
<Button onClick={() => changeCloudOrg()} size="sm" variant="text">
|
||||
{g.cloudOrgChange}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
disabled={cloudDiscover === 'loading'}
|
||||
onClick={() => void discoverCloud(cloudOrg ?? undefined)}
|
||||
size="sm"
|
||||
variant="text"
|
||||
>
|
||||
{cloudDiscover === 'loading' ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||
{g.cloudRefresh}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cloudDiscover === 'loading' ? (
|
||||
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{g.cloudLoadingAgents}
|
||||
</div>
|
||||
) : cloudAgents.length === 0 ? (
|
||||
<div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>
|
||||
{g.cloudNoAgents.before}
|
||||
<ExternalLink href="https://portal.nousresearch.com/agents" showExternalIcon={false}>
|
||||
{g.cloudNoAgents.linkText}
|
||||
</ExternalLink>
|
||||
{g.cloudNoAgents.after}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-1">
|
||||
{cloudAgents.map(agent => {
|
||||
const connected = isConnectedAgent(agent)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('rounded-md px-2', connected && 'bg-primary/5 ring-1 ring-primary/25')}
|
||||
key={agent.id}
|
||||
>
|
||||
<ListRow
|
||||
action={
|
||||
connected ? (
|
||||
<Pill tone="primary">
|
||||
<Check className="mr-1 inline size-3" />
|
||||
{g.cloudConnectedPill}
|
||||
</Pill>
|
||||
) : (
|
||||
<Button
|
||||
disabled={!agent.dashboardUrl || cloudConnectingId !== null}
|
||||
onClick={() => void connectCloudAgent(agent)}
|
||||
size="sm"
|
||||
>
|
||||
{cloudConnectingId === agent.id ? <Loader2 className="animate-spin" /> : null}
|
||||
{agent.dashboardUrl
|
||||
? cloudConnectingId === agent.id
|
||||
? g.cloudConnecting
|
||||
: g.cloudConnect
|
||||
: g.cloudAgentProvisioning}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
description={g.cloudStatusLabel(agent.dashboardGatewayState)}
|
||||
title={agent.name}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.mode === 'remote' && !state.envOverride ? (
|
||||
<div className="mt-5 grid gap-1">
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
autoComplete="off"
|
||||
className={cn('h-8 font-mono', CONTROL_TEXT)}
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event => setRemoteToken(event.target.value)}
|
||||
placeholder={
|
||||
state.remoteTokenSet ? g.existingToken(state.remoteTokenPreview ?? g.savedToken) : g.pasteSessionToken
|
||||
}
|
||||
type="password"
|
||||
value={remoteToken}
|
||||
onChange={event => setState(current => ({ ...current, remoteUrl: event.target.value }))}
|
||||
placeholder="https://gateway.example.com/hermes"
|
||||
value={state.remoteUrl}
|
||||
/>
|
||||
}
|
||||
description={g.tokenDesc}
|
||||
title={g.tokenTitle}
|
||||
description={g.remoteUrlDesc}
|
||||
title={g.remoteUrlTitle}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{state.mode === 'remote' && probeStatus === 'probing' ? (
|
||||
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{g.probing}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.mode === 'remote' && probeStatus === 'error' ? (
|
||||
<div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
{g.probeError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* OAuth / password gateways: present a sign-in button + connection status. */}
|
||||
{state.mode === 'remote' && authResolved && authMode === 'oauth' ? (
|
||||
<ListRow
|
||||
action={
|
||||
oauthConnected ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Pill tone="primary">
|
||||
<Check className="size-3" /> {g.signedIn}
|
||||
</Pill>
|
||||
<Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline">
|
||||
{signingIn ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.signOut}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}>
|
||||
{signingIn ? <Loader2 className="animate-spin" /> : <LogIn />}
|
||||
{isPasswordProvider ? g.signIn : g.signInWith(providerLabel)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
description={
|
||||
oauthConnected
|
||||
? isPasswordProvider
|
||||
? g.authSignedInPassword
|
||||
: g.authSignedInOauth
|
||||
: isPasswordProvider
|
||||
? g.authNeedsPassword
|
||||
: g.authNeedsOauth(providerLabel)
|
||||
}
|
||||
title={g.authTitle}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Session-token gateways: keep the existing token entry box. */}
|
||||
{state.mode === 'remote' && authResolved && authMode === 'token' ? (
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
autoComplete="off"
|
||||
className={cn('h-8 font-mono', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event => setRemoteToken(event.target.value)}
|
||||
placeholder={
|
||||
state.remoteTokenSet
|
||||
? g.existingToken(state.remoteTokenPreview ?? g.savedToken)
|
||||
: g.pasteSessionToken
|
||||
}
|
||||
type="password"
|
||||
value={remoteToken}
|
||||
/>
|
||||
}
|
||||
description={g.tokenDesc}
|
||||
title={g.tokenTitle}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center justify-end gap-4">
|
||||
<Button
|
||||
className="mr-auto"
|
||||
disabled={state.envOverride || testing || !canUseRemote}
|
||||
onClick={() => void testRemote()}
|
||||
size="sm"
|
||||
variant="text"
|
||||
>
|
||||
{testing ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.testRemote}
|
||||
</Button>
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
|
||||
{g.saveForRestart}
|
||||
</Button>
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
|
||||
{saving ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.saveAndReconnect}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Test/Save apply to local + remote. Cloud connects via the agent picker
|
||||
above (which applies a cloud connection on select), so its only
|
||||
bottom-row action would be redundant — hidden in cloud mode. */}
|
||||
{state.mode !== 'cloud' ? (
|
||||
<div className="mt-6 flex flex-wrap items-center justify-end gap-4">
|
||||
{state.mode === 'remote' ? (
|
||||
<Button
|
||||
className="mr-auto"
|
||||
disabled={state.envOverride || testing || !canUseRemote}
|
||||
onClick={() => void testRemote()}
|
||||
size="sm"
|
||||
variant="text"
|
||||
>
|
||||
{testing ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.testRemote}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
disabled={state.envOverride || saving}
|
||||
onClick={() => void save(false)}
|
||||
size="sm"
|
||||
variant="textStrong"
|
||||
>
|
||||
{g.saveForRestart}
|
||||
</Button>
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
|
||||
{saving ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.saveAndReconnect}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 grid gap-1">
|
||||
<ListRow
|
||||
@@ -602,6 +1065,26 @@ export function GatewaySettings() {
|
||||
description={g.diagnosticsDesc}
|
||||
title={g.diagnostics}
|
||||
/>
|
||||
{import.meta.env.DEV ? (
|
||||
<ListRow
|
||||
action={
|
||||
<Button
|
||||
disabled={previewingSwitch}
|
||||
onClick={() => {
|
||||
setPreviewingSwitch(true)
|
||||
void previewGatewaySwitch().finally(() => setPreviewingSwitch(false))
|
||||
}}
|
||||
size="sm"
|
||||
variant="textStrong"
|
||||
>
|
||||
{previewingSwitch ? <Loader2 className="animate-spin" /> : null}
|
||||
Preview soft switch
|
||||
</Button>
|
||||
}
|
||||
description="Wipe session lists so sidebar skeletons retrigger — no real backend teardown."
|
||||
title="Dev · soft switch"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsContent>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -32,7 +33,8 @@ vi.mock('@/hermes', () => ({
|
||||
saveMoaModels: (body: unknown) => saveMoaModels(body),
|
||||
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
|
||||
getHermesConfigRecord: () => getHermesConfigRecord(),
|
||||
saveHermesConfig: (config: unknown) => saveHermesConfig(config)
|
||||
saveHermesConfig: (config: unknown) => saveHermesConfig(config),
|
||||
setApiRequestProfile: () => {}
|
||||
}))
|
||||
|
||||
vi.mock('@/store/onboarding', () => ({
|
||||
@@ -71,8 +73,13 @@ afterEach(() => {
|
||||
|
||||
async function renderModelSettings() {
|
||||
const { ModelSettings } = await import('./model-settings')
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
return render(<ModelSettings />)
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ModelSettings />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ModelSettings', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MemoryProviderConfig } from '@/types/hermes'
|
||||
@@ -97,7 +97,12 @@ afterEach(() => {
|
||||
async function renderPanel(provider = 'hindsight') {
|
||||
const { ProviderConfigPanel } = await import('./provider-config-panel')
|
||||
|
||||
return render(<ProviderConfigPanel provider={provider} />)
|
||||
let result: ReturnType<typeof render>
|
||||
await act(async () => {
|
||||
result = render(<ProviderConfigPanel provider={provider} />)
|
||||
})
|
||||
|
||||
return result!
|
||||
}
|
||||
|
||||
describe('ProviderConfigPanel', () => {
|
||||
@@ -115,9 +120,13 @@ describe('ProviderConfigPanel', () => {
|
||||
await renderPanel()
|
||||
|
||||
expect(await screen.findByLabelText('API URL')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
|
||||
})
|
||||
expect(screen.queryByLabelText('API URL')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
|
||||
})
|
||||
expect(await screen.findByLabelText('API URL')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -125,9 +134,11 @@ describe('ProviderConfigPanel', () => {
|
||||
await renderPanel()
|
||||
|
||||
const apiUrl = await screen.findByLabelText('API URL')
|
||||
fireEvent.change(apiUrl, { target: { value: 'http://localhost:8888' } })
|
||||
fireEvent.change(screen.getByLabelText('Bank ID'), { target: { value: 'ben-bank' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await act(async () => {
|
||||
fireEvent.change(apiUrl, { target: { value: 'http://localhost:8888' } })
|
||||
fireEvent.change(screen.getByLabelText('Bank ID'), { target: { value: 'ben-bank' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
expect(saveMemoryProviderConfig).toHaveBeenCalledWith('hindsight', {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user