mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-11 21:02:12 +08:00
Compare commits
296 Commits
v2026.7.7
...
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 | ||
|
|
a7f65e3bcd | ||
|
|
50c66b2f8e | ||
|
|
46613071e4 | ||
|
|
bdecf0ab94 | ||
|
|
bd16395255 | ||
|
|
c75789f245 | ||
|
|
0a4b4d6df5 | ||
|
|
a801046669 | ||
|
|
b5226caff8 | ||
|
|
79f1274802 | ||
|
|
9cbac6418b | ||
|
|
549b87c9aa | ||
|
|
b298fd5db1 | ||
|
|
10c0d9b2a7 | ||
|
|
26f040ef20 | ||
|
|
8e2ce43525 | ||
|
|
c71d19c0ea | ||
|
|
d2e64fcb89 | ||
|
|
501616e8e6 | ||
|
|
1f57ed2a53 | ||
|
|
a23d5073fb | ||
|
|
fe25806a6b | ||
|
|
8e3f9537db | ||
|
|
b06e2f846c | ||
|
|
77db9d6bf3 | ||
|
|
aea570db4e | ||
|
|
e87c495dc2 | ||
|
|
5829fe1378 | ||
|
|
111544d544 | ||
|
|
4af484d3dd | ||
|
|
5da7b23d6f | ||
|
|
7efee32868 | ||
|
|
a3828a94d0 | ||
|
|
db117af478 | ||
|
|
bd767b574b | ||
|
|
3a1a3c7e67 | ||
|
|
daedf4f627 | ||
|
|
d23990f527 | ||
|
|
73b611ad19 | ||
|
|
709da844b5 | ||
|
|
cbdf87b21f | ||
|
|
9e9608ecc3 | ||
|
|
5a4249146f | ||
|
|
411d599764 | ||
|
|
1e16120603 | ||
|
|
3ed7c8a8da | ||
|
|
cb79518d4f | ||
|
|
f5bc18f901 | ||
|
|
e7648d5912 | ||
|
|
63ddd022a2 | ||
|
|
724ab9098d | ||
|
|
4ed910c689 | ||
|
|
d928017742 | ||
|
|
8cfada0df4 | ||
|
|
74609f926c | ||
|
|
1d689e1920 | ||
|
|
fbbb8415c3 | ||
|
|
750c1310a6 | ||
|
|
f556edc10d | ||
|
|
20cb385328 | ||
|
|
040e30aa72 | ||
|
|
c454d32feb | ||
|
|
9a18a2de12 | ||
|
|
91ece5c2fc | ||
|
|
c889941916 | ||
|
|
6f42bf344c | ||
|
|
3e88cae243 | ||
|
|
0b2b08d54c | ||
|
|
a4ba8c9640 | ||
|
|
3fe7f6d27a | ||
|
|
d54a8f7079 | ||
|
|
8f18f6c695 | ||
|
|
cbf685356d | ||
|
|
55dbc3ffb5 | ||
|
|
f4d5cfd0fd | ||
|
|
32a0f9e17a | ||
|
|
473407174b | ||
|
|
ad8f103048 | ||
|
|
3e24b16f56 | ||
|
|
88a58ff135 | ||
|
|
bf913abc2e | ||
|
|
513dba42e6 | ||
|
|
56a8e81d33 | ||
|
|
7a65530fa5 | ||
|
|
39d09453f9 | ||
|
|
fac85518fc | ||
|
|
6d65210252 | ||
|
|
e0ed5dc9ed | ||
|
|
d8bc4f242f | ||
|
|
2a293319b1 | ||
|
|
449706cb52 | ||
|
|
e21ba91221 | ||
|
|
0a01b2087d | ||
|
|
e721ad89e3 | ||
|
|
be1346cf2a | ||
|
|
74e28f7d10 | ||
|
|
1792a3aa72 | ||
|
|
0569a637d0 | ||
|
|
31e39dec84 | ||
|
|
7a25017a00 | ||
|
|
7570bb4ad4 | ||
|
|
4f220fc88b | ||
|
|
22eb1af23a | ||
|
|
4df16c429b | ||
|
|
0d5549a945 | ||
|
|
c95cf313c9 | ||
|
|
664878b0ba | ||
|
|
1e2ad17afd | ||
|
|
206c042392 | ||
|
|
24d5bda1ef | ||
|
|
d7e4d94e2f | ||
|
|
492be28e50 | ||
|
|
7d0ddbb2ff | ||
|
|
49fa04a235 | ||
|
|
346e5673de | ||
|
|
9a4341aa90 | ||
|
|
4d611ba0c3 | ||
|
|
d6a275b735 | ||
|
|
62ada5175c | ||
|
|
aabfedcac0 | ||
|
|
76381e2a8e | ||
|
|
8e734810df | ||
|
|
ae5e39005b | ||
|
|
0cf2e39c41 | ||
|
|
75efd73961 | ||
|
|
d39c62409b | ||
|
|
b848fcbf11 | ||
|
|
63c4100fe6 | ||
|
|
58e1647b49 | ||
|
|
efb226b586 | ||
|
|
e10c8eba00 | ||
|
|
7e3986ae68 | ||
|
|
c0fbee990e | ||
|
|
65372395eb | ||
|
|
f75f3cd713 | ||
|
|
5057f03bfd | ||
|
|
ee0b54e16c | ||
|
|
b64b802131 | ||
|
|
4b27be1114 | ||
|
|
aab351bfa6 | ||
|
|
ac6dd598a4 | ||
|
|
465cbe8bb5 | ||
|
|
5413c42f2c | ||
|
|
98804dbeef | ||
|
|
7ecc822e11 | ||
|
|
1192f29450 | ||
|
|
6eeed3f1e8 | ||
|
|
c75e1d1b87 | ||
|
|
832c5f9bc9 | ||
|
|
8eac52054b | ||
|
|
f64e4f4f57 | ||
|
|
48788032da | ||
|
|
f5ef7ee9da | ||
|
|
ecc6725855 | ||
|
|
8a573bb6e7 | ||
|
|
24e9ed73c2 | ||
|
|
e6077af279 | ||
|
|
862aee4956 | ||
|
|
a208b7eeb4 | ||
|
|
6695640c1d | ||
|
|
2e1982f83d | ||
|
|
4d7f8ade3e | ||
|
|
9de9c25f62 | ||
|
|
c30c9753b6 |
2
.envrc
2
.envrc
@@ -1,4 +1,4 @@
|
||||
watch_file pyproject.toml uv.lock
|
||||
watch_file pyproject.toml uv.lock hermes
|
||||
watch_file package-lock.json package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json
|
||||
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix nix/hermes-agent.nix nix/desktop.nix
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -74,7 +74,7 @@ Esto no es una barra de calidad — es una decisión de acoplamiento y mantenimi
|
||||
| Requisito | Notas |
|
||||
|-----------|-------|
|
||||
| **Git** | Con la extensión `git-lfs` instalada |
|
||||
| **Python 3.11+** | uv lo instalará si falta |
|
||||
| **Python 3.11–3.13** | uv lo instalará si falta |
|
||||
| **uv** | Gestor de paquetes Python rápido ([instalar](https://docs.astral.sh/uv/)) |
|
||||
| **Node.js 20+** | Opcional — necesario para herramientas de navegador y puente WhatsApp (coincide con los engines de `package.json` raíz) |
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "hermes-agent",
|
||||
"name": "Hermes Agent",
|
||||
"version": "0.18.1",
|
||||
"version": "0.18.2",
|
||||
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
|
||||
"repository": "https://github.com/NousResearch/hermes-agent",
|
||||
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
|
||||
@@ -9,7 +9,7 @@
|
||||
"license": "MIT",
|
||||
"distribution": {
|
||||
"uvx": {
|
||||
"package": "hermes-agent[acp]==0.18.1",
|
||||
"package": "hermes-agent[acp]==0.18.2",
|
||||
"args": ["hermes-acp"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,8 +77,8 @@ def _build_codex_gpt5_autoraise_notice(autoraise: Dict[str, Any]) -> str:
|
||||
include the exact opt-back-out command.
|
||||
"""
|
||||
model = str(autoraise.get("model") or "gpt-5.4/5.5").strip().lower().rsplit("/", 1)[-1]
|
||||
# gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5 family is
|
||||
# capped at 272K by the Codex OAuth backend.
|
||||
# gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5/5.6 family
|
||||
# is capped at 272K by the Codex OAuth backend.
|
||||
cap = "128K" if model.startswith("gpt-5.3-codex-spark") else "272K"
|
||||
from_pct = int(round(autoraise["from"] * 100))
|
||||
to_pct = int(round(autoraise["to"] * 100))
|
||||
|
||||
@@ -1564,6 +1564,17 @@ def anthropic_prompt_cache_policy(
|
||||
model_lower = eff_model.lower()
|
||||
provider_lower = eff_provider.lower()
|
||||
is_claude = "claude" in model_lower
|
||||
# Kimi / Moonshot family via OpenRouter: same cache_control wire format
|
||||
# as Claude on OpenRouter (envelope layout). Without this branch
|
||||
# moonshotai/kimi-k2.6 falls through to (False, False), serving ~1%
|
||||
# cache hits on 64K-token prompts and re-billing the full prompt on
|
||||
# every turn. Observed within-turn progression with cache enabled:
|
||||
# 1% → 67% → 84% → 97% (#25970). Reuses the canonical family matcher
|
||||
# (covers bare k1./k2./k25 release slugs the substring check missed).
|
||||
from agent.anthropic_adapter import _model_name_is_kimi_family
|
||||
is_kimi = (
|
||||
_model_name_is_kimi_family(eff_model) or "moonshot" in model_lower
|
||||
)
|
||||
is_openrouter = base_url_host_matches(eff_base_url, "openrouter.ai")
|
||||
# Nous Portal proxies to OpenRouter behind the scenes — identical
|
||||
# OpenAI-wire envelope cache_control semantics. Treat it as an
|
||||
@@ -1577,7 +1588,7 @@ def anthropic_prompt_cache_policy(
|
||||
|
||||
if is_native_anthropic:
|
||||
return True, True
|
||||
if (is_openrouter or is_nous_portal) and is_claude:
|
||||
if (is_openrouter or is_nous_portal) and (is_claude or is_kimi):
|
||||
return True, False
|
||||
# Nous Portal Qwen (e.g. qwen3.6-plus) takes the same envelope-layout
|
||||
# cache_control path as Portal Claude. Portal proxies to OpenRouter
|
||||
@@ -1805,13 +1816,30 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
||||
# ── Swap core runtime fields ──
|
||||
agent.model = new_model
|
||||
agent.provider = new_provider
|
||||
# Use new base_url when provided; only fall back to current when the
|
||||
# new provider genuinely has no endpoint (e.g. native SDK providers).
|
||||
# Without this guard the old provider's URL (e.g. Ollama's localhost
|
||||
# address) would persist silently after switching to a cloud provider
|
||||
# that returns an empty base_url string.
|
||||
# Use the new base_url when provided. When it's empty AND the
|
||||
# provider is actually changing, do NOT fall back to the current
|
||||
# (old provider's) URL — that silently pairs the new provider label
|
||||
# with the previous provider's endpoint (e.g. new_provider=minimax
|
||||
# paired with the leftover api.githubcopilot.com URL), and every
|
||||
# request after the switch 400s at the wrong host. This mismatched
|
||||
# pair also gets snapshotted into _primary_runtime below, so it
|
||||
# keeps re-applying on every subsequent turn until a full restart.
|
||||
# Fail loud instead: the caller (model_switch.switch_model())
|
||||
# already resolves base_url for every real provider, so an empty
|
||||
# value here means resolution failed upstream, not that the
|
||||
# provider genuinely has none. Re-selecting the SAME provider with
|
||||
# an empty base_url (e.g. a credential-only refresh) is still fine
|
||||
# to keep the current URL. See #47828.
|
||||
old_norm_provider = (old_provider or "").strip().lower()
|
||||
new_norm_provider = (new_provider or "").strip().lower()
|
||||
if base_url:
|
||||
agent.base_url = base_url
|
||||
elif old_norm_provider != new_norm_provider:
|
||||
raise ValueError(
|
||||
f"switch_model: no base_url resolved for provider "
|
||||
f"'{new_provider}' (switching from '{old_provider}'); "
|
||||
"refusing to keep the previous provider's endpoint"
|
||||
)
|
||||
agent.api_mode = api_mode
|
||||
# Invalidate transport cache — new api_mode may need a different transport
|
||||
if hasattr(agent, "_transport_cache"):
|
||||
@@ -1925,6 +1953,11 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
||||
_sm_timeout = get_provider_request_timeout(agent.provider, agent.model)
|
||||
if _sm_timeout is not None:
|
||||
agent._client_kwargs["timeout"] = _sm_timeout
|
||||
# Reapply provider-specific headers (e.g. OpenRouter HTTP-Referer,
|
||||
# X-Title) that were lost when _client_kwargs was rebuilt from
|
||||
# scratch. Without this, model switches clear attribution headers
|
||||
# and OpenRouter logs show "Unknown" for subsequent requests.
|
||||
agent._apply_client_headers_for_base_url(effective_base)
|
||||
agent.client = agent._create_openai_client(
|
||||
dict(agent._client_kwargs),
|
||||
reason="switch_model",
|
||||
@@ -3128,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``)
|
||||
@@ -3154,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
|
||||
|
||||
@@ -3174,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"
|
||||
):
|
||||
|
||||
@@ -314,15 +314,18 @@ def _is_arcee_trinity_thinking(model: Optional[str]) -> bool:
|
||||
return bare == "trinity-large-thinking"
|
||||
|
||||
|
||||
# Context window enforced by ChatGPT's Codex OAuth backend for gpt-5.4/5.5.
|
||||
# The raw OpenAI API and OpenRouter expose 1.05M for the same slug, but the
|
||||
# Codex backend hard-caps at 272K (verified live: a ~330K-token request to
|
||||
# Context window enforced by ChatGPT's Codex OAuth backend for the
|
||||
# gpt-5.4 / gpt-5.5 / gpt-5.6 families. The raw OpenAI API and OpenRouter
|
||||
# expose 1.05M for the same slugs, but the Codex backend hard-caps at 272K
|
||||
# (verified live for 5.4/5.5: a ~330K-token request to
|
||||
# chatgpt.com/backend-api/codex/responses is rejected with
|
||||
# ``context_length_exceeded`` while ~250K succeeds). With a 272K ceiling the
|
||||
# default 50% compaction trigger fires at ~136K — wasteful, since the model
|
||||
# can hold far more raw context before summarization actually buys anything.
|
||||
# We raise the trigger to 85% (~231K) on this exact route so Codex gpt-5.4/
|
||||
# gpt-5.5 sessions use the window they actually have.
|
||||
# ``context_length_exceeded`` while ~250K succeeds; gpt-5.6 shares the same
|
||||
# 272K Codex cap — see _CODEX_OAUTH_CONTEXT_FALLBACK in model_metadata.py).
|
||||
# With a 272K ceiling the default 50% compaction trigger fires at ~136K —
|
||||
# wasteful, since the model can hold far more raw context before
|
||||
# summarization actually buys anything. We raise the trigger to 85% (~231K)
|
||||
# on this exact route so Codex gpt-5.4 / gpt-5.5 / gpt-5.6 sessions use the
|
||||
# window they actually have.
|
||||
_CODEX_GPT54_GPT55_COMPACTION_THRESHOLD = 0.85
|
||||
|
||||
# gpt-5.3-codex-spark is Codex-OAuth-only (ChatGPT Pro entitlement) with a
|
||||
@@ -336,14 +339,16 @@ _CODEX_SPARK_COMPACTION_THRESHOLD = 0.70
|
||||
|
||||
|
||||
def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool:
|
||||
"""True for gpt-5.4 / gpt-5.5 on the ChatGPT Codex OAuth backend.
|
||||
"""True for gpt-5.4 / gpt-5.5 / gpt-5.6 on the ChatGPT Codex OAuth backend.
|
||||
|
||||
Matches only the Codex OAuth route (provider ``openai-codex``), not the
|
||||
direct OpenAI API, OpenRouter, or GitHub Copilot paths — those expose a
|
||||
larger context window for the same slug and must keep the user's default
|
||||
compaction threshold. ``gpt-5.4-pro`` / ``gpt-5.5-pro`` and dated snapshots
|
||||
are matched via prefix so the override tracks both 272K-capped families
|
||||
without re-listing every variant.
|
||||
compaction threshold. ``-pro`` variants and dated snapshots are matched
|
||||
via prefix so the override tracks every 272K-capped family (5.4, 5.5,
|
||||
5.6 sol/terra/luna incl. their ``-pro`` modes) without re-listing every
|
||||
variant. (Name kept for backward compatibility with the
|
||||
``compression.codex_gpt55_autoraise`` config key.)
|
||||
"""
|
||||
prov = (provider or "").strip().lower()
|
||||
if prov != "openai-codex":
|
||||
@@ -356,6 +361,9 @@ def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = Non
|
||||
or bare == "gpt-5.5"
|
||||
or bare.startswith("gpt-5.5-")
|
||||
or bare.startswith("gpt-5.5.")
|
||||
or bare == "gpt-5.6"
|
||||
or bare.startswith("gpt-5.6-")
|
||||
or bare.startswith("gpt-5.6.")
|
||||
)
|
||||
|
||||
|
||||
@@ -410,11 +418,12 @@ def _compression_threshold_for_model(
|
||||
|
||||
Per-model/route overrides:
|
||||
- Arcee Trinity Large Thinking → 0.75 (preserve reasoning context).
|
||||
- gpt-5.4 / gpt-5.5 on the Codex OAuth route → 0.85, because Codex caps
|
||||
both families at 272K and the default 50% trigger would compact at
|
||||
~136K. Gated by ``allow_codex_gpt55_autoraise`` (historical config-key
|
||||
name kept for backward compatibility) so the user can opt back down to
|
||||
the global default (the caller passes the config flag through here).
|
||||
- gpt-5.4 / gpt-5.5 / gpt-5.6 on the Codex OAuth route → 0.85, because
|
||||
Codex caps all three families at 272K and the default 50% trigger
|
||||
would compact at ~136K. Gated by ``allow_codex_gpt55_autoraise``
|
||||
(historical config-key name kept for backward compatibility) so the
|
||||
user can opt back down to the global default (the caller passes the
|
||||
config flag through here).
|
||||
- gpt-5.3-codex-spark on the Codex OAuth route → 0.70, because the model
|
||||
has a native 128K window and the default 50% trigger would compact at
|
||||
~64K — wasting half the usable context. Not gated by the gpt-5.5
|
||||
@@ -4707,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
|
||||
@@ -1399,6 +1405,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
||||
fb_api_mode = "bedrock_converse"
|
||||
|
||||
old_model = agent.model
|
||||
old_provider = agent.provider
|
||||
|
||||
# Clear the per-config context_length override so the fallback
|
||||
# model's actual context window is resolved instead of inheriting
|
||||
@@ -1544,6 +1551,16 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
||||
f"🔄 Primary model failed — switching to fallback: "
|
||||
f"{fb_model} via {fb_provider}"
|
||||
)
|
||||
# The buffered line above is dropped on successful recovery, but a
|
||||
# provider/model switch is a durable state change operators must see
|
||||
# even when the fallback succeeds. Record a one-shot notice that the
|
||||
# success path surfaces exactly once via _emit_pending_fallback_notice
|
||||
# (see run_agent.py); it is discarded on terminal failure since the
|
||||
# buffered line is flushed instead. See fallback-observability fix.
|
||||
agent._pending_fallback_notice = (
|
||||
f"🔄 Switched to fallback model: {old_model} via {old_provider} "
|
||||
f"→ {fb_model} via {fb_provider}"
|
||||
)
|
||||
logger.info(
|
||||
"Fallback activated: %s → %s (%s)",
|
||||
old_model, fb_model, fb_provider,
|
||||
@@ -1679,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():
|
||||
|
||||
@@ -193,8 +193,10 @@ _HISTORICAL_SUMMARY_PREFIXES = (
|
||||
_MIN_SUMMARY_TOKENS = 2000
|
||||
# Proportion of compressed content to allocate for summary
|
||||
_SUMMARY_RATIO = 0.20
|
||||
# Absolute ceiling for summary tokens (even on very large context windows)
|
||||
_SUMMARY_TOKENS_CEILING = 12_000
|
||||
# Absolute ceiling for summary tokens (even on very large context windows).
|
||||
# Summaries must stay within a 1K-10K token envelope — anything larger is
|
||||
# itself a context-pressure source and slows every compaction.
|
||||
_SUMMARY_TOKENS_CEILING = 10_000
|
||||
|
||||
# Placeholder used when pruning old tool results
|
||||
_PRUNED_TOOL_PLACEHOLDER = "[Old tool output cleared to save context space]"
|
||||
@@ -227,6 +229,16 @@ _AUTO_FOCUS_MAX_CHARS = 700
|
||||
# back the old large-tool-output case where nothing can be compacted.
|
||||
_MAX_TAIL_MESSAGE_FLOOR = 8
|
||||
|
||||
# Models with context windows below this get their compression threshold
|
||||
# floored at ``_SMALL_CTX_THRESHOLD_PERCENT`` (raise-only — an explicitly
|
||||
# higher user/model threshold always wins). At the default 50% trigger a
|
||||
# 128K-262K model compacts with only ~64-131K consumed; the incompressible
|
||||
# floor (system prompt + tool schemas + protected tail + rolling summary)
|
||||
# eats most of the reclaimed headroom, so compaction re-fires every 1-2
|
||||
# turns and the session spends most of its wall-clock summarizing.
|
||||
_SMALL_CTX_WINDOW_LIMIT = 512_000
|
||||
_SMALL_CTX_THRESHOLD_PERCENT = 0.75
|
||||
|
||||
|
||||
_PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+")
|
||||
|
||||
@@ -883,6 +895,18 @@ class ContextCompressor(ContextEngine):
|
||||
self.provider = provider
|
||||
self.api_mode = api_mode
|
||||
self.context_length = context_length
|
||||
# Re-apply the small-context threshold floor for the NEW window,
|
||||
# starting from the originally-configured percent (not the possibly
|
||||
# floored live value) so a small -> large switch drops back to the
|
||||
# configured threshold and a large -> small switch gains the floor.
|
||||
# Guard with getattr: compressors unpickled/constructed before this
|
||||
# attribute existed fall back to the live value.
|
||||
_configured_pct = getattr(
|
||||
self, "_configured_threshold_percent", self.threshold_percent,
|
||||
)
|
||||
self.threshold_percent = self._effective_threshold_percent(
|
||||
context_length, _configured_pct,
|
||||
)
|
||||
# max_tokens=None here means "caller didn't specify" → keep the existing
|
||||
# output reservation. A switch that genuinely changes the output budget
|
||||
# passes the new value explicitly. (#43547)
|
||||
@@ -945,6 +969,23 @@ class ContextCompressor(ContextEngine):
|
||||
return None
|
||||
return ivalue if ivalue > 0 else None
|
||||
|
||||
@staticmethod
|
||||
def _effective_threshold_percent(
|
||||
context_length: int, threshold_percent: float,
|
||||
) -> float:
|
||||
"""Apply the small-context threshold floor (raise-only).
|
||||
|
||||
Models under ``_SMALL_CTX_WINDOW_LIMIT`` (512K) trigger at no less
|
||||
than ``_SMALL_CTX_THRESHOLD_PERCENT`` (75%) of the window. An
|
||||
explicitly higher threshold (user config or per-model autoraise,
|
||||
e.g. Codex gpt-5.5's 85%) always wins; only lower values are raised.
|
||||
Large-context models keep the configured value — at 512K+ the default
|
||||
50% trigger already leaves ample post-compaction headroom.
|
||||
"""
|
||||
if context_length and context_length < _SMALL_CTX_WINDOW_LIMIT:
|
||||
return max(threshold_percent, _SMALL_CTX_THRESHOLD_PERCENT)
|
||||
return threshold_percent
|
||||
|
||||
@staticmethod
|
||||
def _compute_threshold_tokens(
|
||||
context_length: int, threshold_percent: float, max_tokens: int | None = None,
|
||||
@@ -1032,6 +1073,18 @@ class ContextCompressor(ContextEngine):
|
||||
config_context_length=config_context_length,
|
||||
provider=provider,
|
||||
)
|
||||
# Small-context threshold floor: models under 512K trigger at >=75%
|
||||
# so compaction doesn't fire with half the window still free (the
|
||||
# incompressible floor makes 50%-triggered compaction thrash on
|
||||
# 128K-262K models). Raise-only; must run AFTER context_length is
|
||||
# resolved and BEFORE threshold_tokens is derived. The pre-floor
|
||||
# value is kept so update_model() can re-derive for a new window
|
||||
# (switching small -> large must drop back to the configured value).
|
||||
self._configured_threshold_percent = self.threshold_percent
|
||||
self.threshold_percent = self._effective_threshold_percent(
|
||||
self.context_length, self.threshold_percent,
|
||||
)
|
||||
threshold_percent = self.threshold_percent
|
||||
# Floor: never compress below MINIMUM_CONTEXT_LENGTH tokens even if
|
||||
# the percentage would suggest a lower value. This prevents premature
|
||||
# compression on large-context models at 50% while keeping the % sane
|
||||
@@ -1410,11 +1463,26 @@ class ContextCompressor(ContextEngine):
|
||||
(API keys, tokens, passwords) from leaking into the summary that
|
||||
gets sent to the auxiliary model and persisted across compactions.
|
||||
"""
|
||||
# Lazy import (matches title_generator.py) — agent_runtime_helpers
|
||||
# pulls in heavy transitive imports we don't want at module load.
|
||||
from agent.agent_runtime_helpers import strip_think_blocks
|
||||
|
||||
parts = []
|
||||
for msg in turns:
|
||||
role = msg.get("role", "unknown")
|
||||
content = redact_sensitive_text(msg.get("content") or "")
|
||||
content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content)
|
||||
# Strip inline reasoning blocks (<think>, <reasoning>, etc.) from
|
||||
# assistant content before it reaches the summarizer. Reasoning
|
||||
# traces are transient scratch work — feeding them to the aux
|
||||
# model wastes summarizer context and risks scratch-work
|
||||
# conclusions being preserved as facts in the summary. The native
|
||||
# ``reasoning`` message field is already excluded (only
|
||||
# ``content`` is serialized); this closes the inline-tag path
|
||||
# used when native thinking is disabled or the provider inlines
|
||||
# traces into content.
|
||||
if role == "assistant" and content:
|
||||
content = strip_think_blocks(None, content)
|
||||
|
||||
# Tool results: keep enough content for the summarizer
|
||||
if role == "tool":
|
||||
@@ -1880,7 +1948,15 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
"api_mode": self.api_mode,
|
||||
},
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": int(summary_budget * 1.3),
|
||||
# NO max_tokens: the output cap must never truncate a summary.
|
||||
# ``summary_budget`` is prompt-level guidance only ("Target ~N
|
||||
# tokens" above). Most OpenAI-compatible wires already omit the
|
||||
# param (see _build_call_kwargs), but the Anthropic Messages
|
||||
# wire and NVIDIA NIM forward it — a hard cap there cut
|
||||
# summaries mid-section (thinking models burn the cap on
|
||||
# reasoning first), producing truncated/thinking-only
|
||||
# summaries and compaction loops. Omitting lets the adapter
|
||||
# fall back to the model's native output ceiling.
|
||||
# timeout resolved from auxiliary.compression.timeout config by call_llm
|
||||
}
|
||||
if self.summary_model:
|
||||
@@ -1920,6 +1996,16 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
f"(provider={self.provider or 'auto'} "
|
||||
f"model={self.summary_model or self.model})"
|
||||
)
|
||||
# Strip reasoning blocks the summarizer model may have emitted
|
||||
# (<think>...</think> etc. from thinking models like MiniMax,
|
||||
# DeepSeek, QwQ). Without this the trace is stored in
|
||||
# _previous_summary, injected into the conversation, AND fed back
|
||||
# into every subsequent iterative-update prompt — compounding
|
||||
# token bloat across compactions. Mirrors title_generator.py.
|
||||
from agent.agent_runtime_helpers import strip_think_blocks
|
||||
stripped = strip_think_blocks(None, content).strip()
|
||||
if stripped:
|
||||
content = stripped
|
||||
# Redact the summary output as well — the summarizer LLM may
|
||||
# ignore prompt instructions and echo back secrets verbatim.
|
||||
summary = redact_sensitive_text(content.strip())
|
||||
|
||||
@@ -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
|
||||
@@ -5062,8 +5067,11 @@ def run_conversation(
|
||||
# Reset retry counter/signature on successful content
|
||||
agent._empty_content_retries = 0
|
||||
agent._thinking_prefill_retries = 0
|
||||
# Successful content reached — drop any buffered retry
|
||||
# status from earlier failed attempts in this turn.
|
||||
# Successful content reached — surface the one-shot fallback
|
||||
# switch notice (if a fallback activated this turn) before
|
||||
# dropping the noisy retry buffer, so a provider/model switch
|
||||
# stays visible even when the fallback succeeds.
|
||||
agent._emit_pending_fallback_notice()
|
||||
agent._clear_status_buffer()
|
||||
|
||||
from agent.agent_runtime_helpers import (
|
||||
@@ -5096,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
|
||||
@@ -5170,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
|
||||
@@ -5221,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)
|
||||
@@ -5305,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)
|
||||
# =========================================================================
|
||||
|
||||
@@ -783,6 +783,55 @@ class MemoryManager:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def commit_session_boundary_async(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
new_session_id: str,
|
||||
parent_session_id: str = "",
|
||||
reason: str = "new_session",
|
||||
) -> None:
|
||||
"""Queue old-session extraction + provider rebinding as ONE serialized task.
|
||||
|
||||
Session rotation (/new) must deliver ``on_session_end`` (end-of-session
|
||||
extraction — an LLM-bound call that can take seconds) strictly BEFORE
|
||||
``on_session_switch`` (which rebinds provider-internal ``_session_id`` /
|
||||
turn buffers to the new session). Running extraction inline blocked the
|
||||
/new command for the whole LLM round-trip (#16454); running it on an
|
||||
ad-hoc thread raced the inline switch — providers key off internal
|
||||
state, so a late ``on_session_end`` ran against post-switch bindings
|
||||
(transcript misattributed to the new session id, double-ingest of the
|
||||
old turn buffer, new-session buffers cleared).
|
||||
|
||||
Submitting BOTH hooks as one task on the manager's single background
|
||||
worker gives both properties at a single chokepoint: the caller returns
|
||||
immediately, and the worker's FIFO order serializes end→switch against
|
||||
every other provider write (per-turn ``sync_all``, prefetches), which
|
||||
already share the same worker. If the executor is unavailable,
|
||||
``_submit_background`` degrades to inline execution — the pre-#16454
|
||||
synchronous behavior, slow but correct.
|
||||
"""
|
||||
if not self._providers:
|
||||
return
|
||||
snapshot = list(messages or [])
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
self.on_session_end(snapshot)
|
||||
except Exception as e: # pragma: no cover - on_session_end guards per-provider
|
||||
logger.warning("Session-boundary extraction failed: %s", e)
|
||||
try:
|
||||
self.on_session_switch(
|
||||
new_session_id,
|
||||
parent_session_id=parent_session_id,
|
||||
reset=True,
|
||||
reason=reason,
|
||||
)
|
||||
except Exception as e: # pragma: no cover - on_session_switch guards per-provider
|
||||
logger.warning("Session-boundary switch failed: %s", e)
|
||||
|
||||
self._submit_background(_run)
|
||||
|
||||
def on_session_switch(
|
||||
self,
|
||||
new_session_id: str,
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -111,6 +111,15 @@ _MODEL_CACHE_TTL = 3600
|
||||
_endpoint_model_metadata_cache: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
||||
_endpoint_model_metadata_cache_time: Dict[str, float] = {}
|
||||
_ENDPOINT_MODEL_CACHE_TTL = 300
|
||||
# Bounded-lifetime cache: after the first successful probe we remember the
|
||||
# server type so subsequent refreshes skip the full waterfall (no more 404
|
||||
# spam every 5 minutes on non-matching endpoints like /api/v1/models on vllm).
|
||||
# Entries expire after _ENDPOINT_PROBE_TTL_SECONDS so a server swap on the
|
||||
# same port (stop Ollama, start LM Studio) is eventually re-detected instead
|
||||
# of being pinned to the stale type for the whole process lifetime.
|
||||
# Values are (server_type, monotonic_timestamp).
|
||||
_ENDPOINT_PROBE_TTL_SECONDS = 3600.0
|
||||
_endpoint_probe_path_cache: Dict[str, tuple] = {}
|
||||
|
||||
|
||||
def _get_model_metadata_cache_path() -> Path:
|
||||
@@ -220,6 +229,12 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
# ChatGPT Codex OAuth caps it at 272K; both paths resolve via their own
|
||||
# provider-aware branches (_resolve_codex_oauth_context_length + models.dev).
|
||||
# This hardcoded value is only reached when every probe misses.
|
||||
# GPT-5.6 series (Sol/Terra/Luna, GA 2026-07-09) — 1.05M on the direct
|
||||
# OpenAI API (same as gpt-5.5). Codex OAuth caps these at 272K.
|
||||
# (Lookups length-sort keys at match time, so dict order is cosmetic.)
|
||||
"gpt-5.6-luna": 1050000,
|
||||
"gpt-5.6-terra": 1050000,
|
||||
"gpt-5.6-sol": 1050000,
|
||||
"gpt-5.5": 1050000,
|
||||
"gpt-5.4-nano": 400000, # 400k (not 1.05M like full 5.4)
|
||||
"gpt-5.4-mini": 400000, # 400k (not 1.05M like full 5.4)
|
||||
@@ -289,11 +304,13 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
# Premium+); /v1/responses additionally enforces a ~262144 input+output
|
||||
# budget, but the usable context (what we track here) is 200k.
|
||||
"grok-composer": 200000, # grok-composer-2.5-fast (Grok Build CLI)
|
||||
"grok-build-latest": 500000, # alias of grok-4.5 (early access)
|
||||
"grok-build": 256000, # grok-build-0.1
|
||||
"grok-code-fast": 256000, # grok-code-fast-1
|
||||
"grok-2-vision": 8192, # grok-2-vision, -1212, -latest
|
||||
"grok-4-fast": 2000000, # grok-4-fast-(non-)reasoning, also matches -reasoning
|
||||
"grok-4.20": 2000000, # grok-4.20-0309-(non-)reasoning, -multi-agent-0309
|
||||
"grok-4.5": 500000, # grok-4.5, grok-4.5-latest — 500K context per docs.x.ai
|
||||
"grok-4.3": 1000000, # grok-4.3, grok-4.3-latest — 1M context per docs.x.ai
|
||||
"grok-4": 256000, # grok-4, grok-4-0709
|
||||
"grok-3": 131072, # grok-3, grok-3-mini, grok-3-fast, grok-3-mini-fast
|
||||
@@ -305,6 +322,8 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
# OpenRouter live metadata reports 262144 (256 × 1024); align the
|
||||
# static fallback so cache and offline both agree (issue #22268).
|
||||
"hy3-preview": 262144,
|
||||
# Tencent — Hy3 (GA successor to Hy3 Preview), same 256K window.
|
||||
"hy3": 262144,
|
||||
# Nemotron — NVIDIA's open-weights series (128K context across all sizes)
|
||||
"nemotron": 131072,
|
||||
# Arcee
|
||||
@@ -345,6 +364,11 @@ _GROK_EFFORT_CAPABLE_PREFIXES = (
|
||||
"grok-3-mini",
|
||||
"grok-4.20-multi-agent",
|
||||
"grok-4.3",
|
||||
# grok-4.5: verified live against /v1/responses 2026-07-08 — accepts
|
||||
# effort low/medium/high (default: high when omitted) but REJECTS
|
||||
# "none" ("This model does not support `reasoning_effort` value `none`"),
|
||||
# unlike grok-4.3. models.dev agrees: effort values [low, medium, high].
|
||||
"grok-4.5",
|
||||
)
|
||||
|
||||
|
||||
@@ -623,66 +647,109 @@ def is_local_endpoint(base_url: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _localhost_to_ipv4(url: str) -> str:
|
||||
"""Rewrite a ``localhost`` HOST to ``127.0.0.1`` in a probe URL.
|
||||
|
||||
On Windows dual-stack machines, httpx resolves ``localhost`` to ``::1``
|
||||
first and pays a ~2s IPv6 connect timeout before falling back to IPv4
|
||||
when the local server only listens on IPv4 (LM Studio, Ollama defaults).
|
||||
Probing the IPv4 loopback directly skips that penalty.
|
||||
|
||||
Only the URL's own host component is rewritten (anchored at the scheme),
|
||||
so a non-localhost URL whose path or query merely embeds the substring
|
||||
``http://localhost...`` (e.g. ``?upstream=http://localhost:11434``)
|
||||
passes through untouched.
|
||||
"""
|
||||
if not url:
|
||||
return url
|
||||
return re.sub(
|
||||
r"^(https?://)localhost(?=[:/]|$)",
|
||||
r"\g<1>127.0.0.1",
|
||||
url,
|
||||
count=1,
|
||||
)
|
||||
|
||||
|
||||
def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]:
|
||||
"""Detect which local server is running at base_url by probing known endpoints.
|
||||
|
||||
Returns one of: "ollama", "lm-studio", "vllm", "llamacpp", or None.
|
||||
|
||||
The result is cached for the lifetime of the process so that repeated
|
||||
calls (e.g. every 5-minute metadata refresh) never re-run the waterfall
|
||||
and never spray 404s at endpoints the server does not expose.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
normalized = _normalize_base_url(base_url)
|
||||
|
||||
# Resolve localhost to IPv4 to avoid 2s IPv6 timeout on Windows dual-stack.
|
||||
# Applied to ``normalized`` before deriving server/LM Studio URLs AND
|
||||
# before the cache lookup, so localhost and 127.0.0.1 share a cache entry.
|
||||
normalized = _localhost_to_ipv4(normalized)
|
||||
|
||||
server_url = normalized
|
||||
if server_url.endswith("/v1"):
|
||||
server_url = server_url[:-3]
|
||||
lmstudio_url = _lmstudio_server_root(base_url)
|
||||
lmstudio_url = _lmstudio_server_root(normalized)
|
||||
|
||||
cached = _endpoint_probe_path_cache.get(server_url)
|
||||
if cached is not None and (time.monotonic() - cached[1]) < _ENDPOINT_PROBE_TTL_SECONDS:
|
||||
return cached[0]
|
||||
|
||||
headers = _auth_headers(api_key)
|
||||
|
||||
result: Optional[str] = None
|
||||
try:
|
||||
with httpx.Client(timeout=2.0, headers=headers) as client:
|
||||
# LM Studio exposes /api/v1/models — check first (most specific)
|
||||
try:
|
||||
r = client.get(f"{lmstudio_url}/api/v1/models")
|
||||
if r.status_code == 200:
|
||||
return "lm-studio"
|
||||
result = "lm-studio"
|
||||
except Exception:
|
||||
pass
|
||||
# Ollama exposes /api/tags and responds with {"models": [...]}
|
||||
# LM Studio returns {"error": "Unexpected endpoint"} with status 200
|
||||
# on this path, so we must verify the response contains "models".
|
||||
try:
|
||||
r = client.get(f"{server_url}/api/tags")
|
||||
if r.status_code == 200:
|
||||
try:
|
||||
if result is None:
|
||||
# Ollama exposes /api/tags and responds with {"models": [...]}
|
||||
# LM Studio returns {"error": "Unexpected endpoint"} with status 200
|
||||
# on this path, so we must verify the response contains "models".
|
||||
try:
|
||||
r = client.get(f"{server_url}/api/tags")
|
||||
if r.status_code == 200:
|
||||
try:
|
||||
data = r.json()
|
||||
if "models" in data:
|
||||
result = "ollama"
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
if result is None:
|
||||
# llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix)
|
||||
try:
|
||||
r = client.get(f"{server_url}/v1/props")
|
||||
if r.status_code != 200:
|
||||
r = client.get(f"{server_url}/props") # fallback for older builds
|
||||
if r.status_code == 200 and "default_generation_settings" in r.text:
|
||||
result = "llamacpp"
|
||||
except Exception:
|
||||
pass
|
||||
if result is None:
|
||||
# vLLM: /version
|
||||
try:
|
||||
r = client.get(f"{server_url}/version")
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
if "models" in data:
|
||||
return "ollama"
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
# llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix)
|
||||
try:
|
||||
r = client.get(f"{server_url}/v1/props")
|
||||
if r.status_code != 200:
|
||||
r = client.get(f"{server_url}/props") # fallback for older builds
|
||||
if r.status_code == 200 and "default_generation_settings" in r.text:
|
||||
return "llamacpp"
|
||||
except Exception:
|
||||
pass
|
||||
# vLLM: /version
|
||||
try:
|
||||
r = client.get(f"{server_url}/version")
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
if "version" in data:
|
||||
return "vllm"
|
||||
except Exception:
|
||||
pass
|
||||
if "version" in data:
|
||||
result = "vllm"
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
if result is not None:
|
||||
_endpoint_probe_path_cache[server_url] = (result, time.monotonic())
|
||||
return result
|
||||
|
||||
|
||||
def _iter_nested_dicts(value: Any):
|
||||
@@ -786,7 +853,10 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any
|
||||
return _model_metadata_cache
|
||||
|
||||
try:
|
||||
response = requests.get(OPENROUTER_MODELS_URL, timeout=10, verify=_resolve_requests_verify())
|
||||
# Tuple (connect, read) — flat timeout=10 means urllib3 can block 10s per
|
||||
# retry stage through proxies that 403 CONNECT, ballooning to minutes
|
||||
# (#46620). 5s connect / 10s read fails fast on unreachable hosts.
|
||||
response = requests.get(OPENROUTER_MODELS_URL, timeout=(5, 10), verify=_resolve_requests_verify())
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
@@ -864,7 +934,7 @@ def fetch_endpoint_model_metadata(
|
||||
response = requests.get(
|
||||
server_url.rstrip("/") + "/api/v1/models",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
timeout=(5, 10),
|
||||
verify=_resolve_requests_verify(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -912,7 +982,7 @@ def fetch_endpoint_model_metadata(
|
||||
for candidate in candidates:
|
||||
url = candidate.rstrip("/") + "/models"
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=10, verify=_resolve_requests_verify())
|
||||
response = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify())
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
cache: Dict[str, Dict[str, Any]] = {}
|
||||
@@ -1007,19 +1077,29 @@ 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 {}
|
||||
|
||||
|
||||
def _context_cache_key(model: str, base_url: str) -> str:
|
||||
"""Canonical ``model@base_url`` key for the persistent context cache.
|
||||
|
||||
Trailing slashes are stripped so ``http://host/v1`` and
|
||||
``http://host/v1/`` share one entry instead of creating duplicates
|
||||
that can go stale independently.
|
||||
"""
|
||||
return f"{model}@{(base_url or '').rstrip('/')}"
|
||||
|
||||
|
||||
def save_context_length(model: str, base_url: str, length: int) -> None:
|
||||
"""Persist a discovered context length for a model+provider combo.
|
||||
|
||||
Cache key is ``model@base_url`` so the same model name served from
|
||||
different providers can have different limits.
|
||||
"""
|
||||
key = f"{model}@{base_url}"
|
||||
key = _context_cache_key(model, base_url)
|
||||
cache = _load_context_cache()
|
||||
if cache.get(key) == length:
|
||||
return # already stored
|
||||
@@ -1036,18 +1116,43 @@ def save_context_length(model: str, base_url: str, length: int) -> None:
|
||||
|
||||
def get_cached_context_length(model: str, base_url: str) -> Optional[int]:
|
||||
"""Look up a previously discovered context length for model+provider."""
|
||||
key = f"{model}@{base_url}"
|
||||
key = _context_cache_key(model, base_url)
|
||||
cache = _load_context_cache()
|
||||
return cache.get(key)
|
||||
hit = cache.get(key)
|
||||
if hit is not None:
|
||||
return hit
|
||||
# Legacy rows written before key normalization may carry a trailing
|
||||
# slash — honor them rather than re-probing. Checked regardless of the
|
||||
# caller's slash form: the row's shape and the caller's shape can differ
|
||||
# in either direction (old slashed row + new normalized config, or the
|
||||
# reverse), so probe the literal form and the slashed canonical form.
|
||||
for legacy_key in (f"{model}@{base_url}", f"{key}/"):
|
||||
if legacy_key != key:
|
||||
hit = cache.get(legacy_key)
|
||||
if hit is not None:
|
||||
return hit
|
||||
return None
|
||||
|
||||
|
||||
def _invalidate_cached_context_length(model: str, base_url: str) -> None:
|
||||
"""Drop a stale cache entry so it gets re-resolved on the next lookup."""
|
||||
key = f"{model}@{base_url}"
|
||||
key = _context_cache_key(model, base_url)
|
||||
cache = _load_context_cache()
|
||||
if key not in cache:
|
||||
# Invalidation must also drop the in-memory TTL probe entries for this
|
||||
# pair — otherwise the next resolution inside the TTL window reuses the
|
||||
# very value we just declared stale and re-persists it.
|
||||
bare = _strip_provider_prefix(model)
|
||||
stripped = (base_url or "").rstrip("/")
|
||||
_LOCAL_CTX_PROBE_CACHE.pop((bare, stripped), None)
|
||||
_LOCAL_CTX_PROBE_CACHE.pop(("ollama_show", bare, stripped), None)
|
||||
# Clear every key shape for this pair: canonical, the caller's literal
|
||||
# form, and the slashed legacy form — same set get_cached_context_length
|
||||
# consults, so a lookup can never resurrect a row invalidation missed.
|
||||
stale_keys = {key, f"{model}@{base_url}", f"{key}/"}
|
||||
if not any(k in cache for k in stale_keys):
|
||||
return
|
||||
del cache[key]
|
||||
for k in stale_keys:
|
||||
cache.pop(k, None)
|
||||
path = _get_context_cache_path()
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -1334,7 +1439,7 @@ def query_ollama_num_ctx(model: str, base_url: str, api_key: str = "") -> Option
|
||||
import httpx
|
||||
|
||||
bare_model = _strip_provider_prefix(model)
|
||||
server_url = base_url.rstrip("/")
|
||||
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
|
||||
if server_url.endswith("/v1"):
|
||||
server_url = server_url[:-3]
|
||||
|
||||
@@ -1395,7 +1500,7 @@ def query_ollama_supports_vision(model: str, base_url: str, api_key: str = "") -
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
server_url = base_url.rstrip("/")
|
||||
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
|
||||
if server_url.endswith("/v1"):
|
||||
server_url = server_url[:-3]
|
||||
|
||||
@@ -1434,6 +1539,12 @@ def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Opti
|
||||
hosting behind a reverse proxy, etc. For non-Ollama servers the POST
|
||||
returns 404/405 quickly; the function handles errors gracefully.
|
||||
|
||||
Results are cached in ``_LOCAL_CTX_PROBE_CACHE`` (same 30s TTL,
|
||||
positive-only — see ``_query_local_context_length``) so back-to-back
|
||||
resolutions during one startup issue a single POST instead of one per
|
||||
call site. Failures are never memoized: a server that isn't up yet must
|
||||
be re-probed once it comes up.
|
||||
|
||||
For hosted servers the GGUF ``model_info.*.context_length`` is the
|
||||
authoritative source: the user can't set their own ``num_ctx``, and the
|
||||
OpenAI-compat ``/v1/models`` endpoint correctly omits ``context_length``
|
||||
@@ -1445,9 +1556,28 @@ def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Opti
|
||||
The order is flipped vs ``query_ollama_num_ctx()`` because local users
|
||||
control ``num_ctx`` themselves; hosted users can't.
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
# Namespaced cache key: shares the TTL store with
|
||||
# _query_local_context_length but never collides with its (model, url)
|
||||
# keys — the two probes can return different values for the same pair.
|
||||
cache_key = ("ollama_show", _strip_provider_prefix(model), base_url.rstrip("/"))
|
||||
now = _time.monotonic()
|
||||
cached = _LOCAL_CTX_PROBE_CACHE.get(cache_key)
|
||||
if cached is not None and (now - cached[1]) < _LOCAL_CTX_PROBE_TTL_SECONDS:
|
||||
return cached[0]
|
||||
|
||||
result = _query_ollama_api_show_uncached(model, base_url, api_key=api_key)
|
||||
if result: # positive-only — never memoize a failed probe
|
||||
_LOCAL_CTX_PROBE_CACHE[cache_key] = (result, now)
|
||||
return result
|
||||
|
||||
|
||||
def _query_ollama_api_show_uncached(model: str, base_url: str, api_key: str = "") -> Optional[int]:
|
||||
"""Uncached body of ``_query_ollama_api_show`` — one POST to ``/api/show``."""
|
||||
import httpx
|
||||
|
||||
server_url = base_url.rstrip("/")
|
||||
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
|
||||
if server_url.endswith("/v1"):
|
||||
server_url = server_url[:-3]
|
||||
|
||||
@@ -1566,10 +1696,10 @@ def _query_local_context_length_uncached(model: str, base_url: str, api_key: str
|
||||
model = _strip_provider_prefix(model)
|
||||
|
||||
# Strip /v1 suffix to get the server root
|
||||
server_url = base_url.rstrip("/")
|
||||
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
|
||||
if server_url.endswith("/v1"):
|
||||
server_url = server_url[:-3]
|
||||
lmstudio_url = _lmstudio_server_root(base_url)
|
||||
lmstudio_url = _localhost_to_ipv4(_lmstudio_server_root(base_url))
|
||||
|
||||
headers = _auth_headers(api_key)
|
||||
|
||||
@@ -1679,7 +1809,7 @@ def _query_anthropic_context_length(model: str, base_url: str, api_key: str) ->
|
||||
"x-api-key": api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
}
|
||||
resp = requests.get(url, headers=headers, timeout=10, verify=_resolve_requests_verify())
|
||||
resp = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify())
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
@@ -1713,6 +1843,9 @@ _CODEX_OAUTH_CONTEXT_FALLBACK: Dict[str, int] = {
|
||||
"gpt-5.3-codex-spark": 128_000,
|
||||
"gpt-5.2-codex": 272_000,
|
||||
"gpt-5.4-mini": 272_000,
|
||||
"gpt-5.6-sol": 272_000,
|
||||
"gpt-5.6-terra": 272_000,
|
||||
"gpt-5.6-luna": 272_000,
|
||||
"gpt-5.5": 272_000,
|
||||
"gpt-5.4": 272_000,
|
||||
"gpt-5.2": 272_000,
|
||||
@@ -1746,7 +1879,7 @@ def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]:
|
||||
resp = requests.get(
|
||||
"https://chatgpt.com/backend-api/codex/models?client_version=1.0.0",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=10,
|
||||
timeout=(5, 10),
|
||||
verify=_resolve_requests_verify(),
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
@@ -2430,5 +2563,82 @@ def estimate_request_tokens_rough(
|
||||
if messages:
|
||||
total += estimate_messages_tokens_rough(messages)
|
||||
if tools:
|
||||
total += (len(str(tools)) + 3) // 4
|
||||
total += _estimate_tools_tokens_rough(tools)
|
||||
return total
|
||||
|
||||
|
||||
# NOTE: tool schemas can be large. Avoid repeated `str(tools)` conversions,
|
||||
# which are CPU-heavy and can stall GUI event loops under GIL pressure.
|
||||
#
|
||||
# Keyed by ``id(tools)``. A long-lived gateway/desktop backend builds many
|
||||
# transient tool lists over its lifetime, so the cache is bounded and evicts
|
||||
# oldest-first (insertion-ordered dict) once it exceeds the cap. The cap is
|
||||
# generous relative to how rarely toolsets are rebuilt within a process.
|
||||
_TOOLS_TOKENS_CACHE: dict[int, Tuple[int, str, str, int]] = {}
|
||||
_TOOLS_TOKENS_CACHE_MAX = 256
|
||||
|
||||
|
||||
def _tool_name_for_cache(tool: Any) -> str:
|
||||
if not isinstance(tool, dict):
|
||||
return ""
|
||||
fn = tool.get("function")
|
||||
if isinstance(fn, dict):
|
||||
name = fn.get("name")
|
||||
if isinstance(name, str):
|
||||
return name
|
||||
name = tool.get("name")
|
||||
return name if isinstance(name, str) else ""
|
||||
|
||||
|
||||
def _estimate_tools_tokens_rough(tools: List[Dict[str, Any]]) -> int:
|
||||
if not tools:
|
||||
return 0
|
||||
|
||||
# Cache by list identity. Tools are rebuilt rarely (toolset changes),
|
||||
# but token estimates are requested frequently (preflight, compaction).
|
||||
key = id(tools)
|
||||
n = len(tools)
|
||||
first = _tool_name_for_cache(tools[0]) if n else ""
|
||||
last = _tool_name_for_cache(tools[-1]) if n else ""
|
||||
|
||||
cached = _TOOLS_TOKENS_CACHE.get(key)
|
||||
if cached is not None:
|
||||
cached_n, cached_first, cached_last, cached_tokens = cached
|
||||
if cached_n == n and cached_first == first and cached_last == last:
|
||||
return cached_tokens
|
||||
|
||||
# Fast, stable rough estimate: sum lengths of the major schema fields.
|
||||
# This avoids the pathological `str(tools)` path while still scaling with
|
||||
# schema size (descriptions + parameters dominate).
|
||||
total_chars = 0
|
||||
for tool in tools:
|
||||
if not isinstance(tool, dict):
|
||||
continue
|
||||
fn = tool.get("function")
|
||||
if isinstance(fn, dict):
|
||||
name = fn.get("name") or ""
|
||||
desc = fn.get("description") or ""
|
||||
params = fn.get("parameters") or {}
|
||||
else:
|
||||
name = tool.get("name") or ""
|
||||
desc = tool.get("description") or ""
|
||||
params = tool.get("parameters") or {}
|
||||
|
||||
if isinstance(name, str):
|
||||
total_chars += len(name)
|
||||
if isinstance(desc, str):
|
||||
total_chars += len(desc)
|
||||
# Parameters can be nested; JSON is closer to over-the-wire size than repr().
|
||||
try:
|
||||
total_chars += len(json.dumps(params, ensure_ascii=False, separators=(",", ":")))
|
||||
except Exception:
|
||||
total_chars += len(str(params))
|
||||
|
||||
tokens = (total_chars + 3) // 4
|
||||
# Bound the cache: drop the oldest entry when the cap is exceeded so a
|
||||
# long-running process can't accumulate an unbounded number of stale
|
||||
# ``id(tools)`` entries (id values are recycled after GC anyway).
|
||||
if len(_TOOLS_TOKENS_CACHE) >= _TOOLS_TOKENS_CACHE_MAX:
|
||||
_TOOLS_TOKENS_CACHE.pop(next(iter(_TOOLS_TOKENS_CACHE)), None)
|
||||
_TOOLS_TOKENS_CACHE[key] = (n, first, last, tokens)
|
||||
return tokens
|
||||
|
||||
@@ -7,6 +7,7 @@ assemble pieces, then combines them with memory and ephemeral prompts.
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import contextvars
|
||||
from collections import OrderedDict
|
||||
@@ -17,6 +18,8 @@ from typing import Optional
|
||||
|
||||
from agent.runtime_cwd import resolve_agent_cwd
|
||||
from agent.skill_utils import (
|
||||
EXCLUDED_SKILL_DIRS,
|
||||
SKILL_SUPPORT_DIRS,
|
||||
extract_skill_conditions,
|
||||
extract_skill_description,
|
||||
get_all_skills_dirs,
|
||||
@@ -25,6 +28,7 @@ from agent.skill_utils import (
|
||||
parse_frontmatter,
|
||||
skill_matches_environment,
|
||||
skill_matches_platform,
|
||||
skill_matches_platform_list,
|
||||
)
|
||||
from utils import atomic_json_write
|
||||
|
||||
@@ -743,6 +747,17 @@ PLATFORM_HINTS = {
|
||||
"or 'all'). Do not promise the user that a deliver='origin' or "
|
||||
"default-deliver cron job will message them in this session."
|
||||
),
|
||||
"desktop": (
|
||||
"You are chatting inside the Hermes desktop app — a graphical chat "
|
||||
"surface, not a terminal. Use markdown freely: it renders with full "
|
||||
"GitHub flavor (tables, code blocks with syntax highlighting, math "
|
||||
"via $...$, task lists, blockquote callouts). "
|
||||
"You can deliver files natively — include MEDIA:/absolute/path/to/file "
|
||||
"in your response. Images (.png, .jpg, .webp) appear inline, audio and "
|
||||
"video play inline, and other files arrive as download links. You can "
|
||||
"also include image URLs in markdown format  and they "
|
||||
"render inline as photos."
|
||||
),
|
||||
"sms": (
|
||||
"You are communicating via SMS. Keep responses concise and use plain text "
|
||||
"only — no markdown, no formatting. SMS messages are limited to ~1600 "
|
||||
@@ -1127,22 +1142,6 @@ def build_environment_hints() -> str:
|
||||
f"`uname -a && whoami && pwd`."
|
||||
)
|
||||
|
||||
# Hermes desktop GUI — any agent running under the desktop app should know
|
||||
# it. HERMES_DESKTOP marks the backend powering the chat; HERMES_DESKTOP_TERMINAL
|
||||
# marks a hermes launched in the embedded terminal pane. Both set by main.cjs.
|
||||
_truthy = ("1", "true", "yes")
|
||||
_in_desktop = (os.getenv("HERMES_DESKTOP") or "").strip().lower() in _truthy
|
||||
_in_desktop_term = (os.getenv("HERMES_DESKTOP_TERMINAL") or "").strip().lower() in _truthy
|
||||
if _in_desktop or _in_desktop_term:
|
||||
_desktop_hint = "Runtime surface: you're running inside the Hermes desktop GUI app."
|
||||
if _in_desktop_term:
|
||||
_desktop_hint += (
|
||||
" You're in its embedded terminal pane, beside the GUI chat — the user can "
|
||||
"select your output (⌥-drag on macOS, Shift-drag elsewhere) and press "
|
||||
"⌘/Ctrl+L to send it to the chat composer."
|
||||
)
|
||||
hints.append(_desktop_hint)
|
||||
|
||||
if is_wsl():
|
||||
hints.append(WSL_ENVIRONMENT_HINT)
|
||||
|
||||
@@ -1276,13 +1275,26 @@ def clear_skills_system_prompt_cache(*, clear_snapshot: bool = False) -> None:
|
||||
def _build_skills_manifest(skills_dir: Path) -> dict[str, list[int]]:
|
||||
"""Build an mtime/size manifest of all SKILL.md and DESCRIPTION.md files."""
|
||||
manifest: dict[str, list[int]] = {}
|
||||
for filename in ("SKILL.md", "DESCRIPTION.md"):
|
||||
for path in iter_skill_index_files(skills_dir, filename):
|
||||
skills_dir_str = str(skills_dir)
|
||||
base = os.path.join(skills_dir_str, "")
|
||||
prefix_len = len(base)
|
||||
for root, dirs, files in os.walk(skills_dir_str, followlinks=True):
|
||||
has_skill_md = "SKILL.md" in files
|
||||
dirs[:] = [
|
||||
d
|
||||
for d in dirs
|
||||
if d not in EXCLUDED_SKILL_DIRS
|
||||
and not (has_skill_md and d in SKILL_SUPPORT_DIRS)
|
||||
]
|
||||
for filename in ("SKILL.md", "DESCRIPTION.md"):
|
||||
if filename not in files:
|
||||
continue
|
||||
path = os.path.join(root, filename)
|
||||
try:
|
||||
st = path.stat()
|
||||
st = os.stat(path)
|
||||
except OSError:
|
||||
continue
|
||||
manifest[str(path.relative_to(skills_dir))] = [st.st_mtime_ns, st.st_size]
|
||||
manifest[path[prefix_len:]] = [st.st_mtime_ns, st.st_size]
|
||||
return manifest
|
||||
|
||||
|
||||
@@ -1414,6 +1426,22 @@ def _skill_should_show(
|
||||
return True
|
||||
|
||||
|
||||
def _current_session_platform_hint() -> str:
|
||||
"""Return the active platform without importing the gateway package on CLI startup."""
|
||||
platform = os.environ.get("HERMES_PLATFORM") or os.environ.get("HERMES_SESSION_PLATFORM")
|
||||
if platform:
|
||||
return platform
|
||||
|
||||
session_context = sys.modules.get("gateway.session_context")
|
||||
get_session_env = getattr(session_context, "get_session_env", None) if session_context else None
|
||||
if get_session_env is None:
|
||||
return ""
|
||||
try:
|
||||
return get_session_env("HERMES_SESSION_PLATFORM") or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def build_skills_system_prompt(
|
||||
available_tools: "set[str] | None" = None,
|
||||
available_toolsets: "set[str] | None" = None,
|
||||
@@ -1448,15 +1476,10 @@ def build_skills_system_prompt(
|
||||
# ── Layer 1: in-process LRU cache ─────────────────────────────────
|
||||
# Include the resolved platform so per-platform disabled-skill lists
|
||||
# produce distinct cache entries (gateway serves multiple platforms).
|
||||
from gateway.session_context import get_session_env
|
||||
_platform_hint = (
|
||||
os.environ.get("HERMES_PLATFORM")
|
||||
or get_session_env("HERMES_SESSION_PLATFORM")
|
||||
or ""
|
||||
)
|
||||
_platform_hint = _current_session_platform_hint()
|
||||
disabled = get_disabled_skill_names(_platform_hint or None)
|
||||
cache_key = (
|
||||
str(skills_dir.resolve()),
|
||||
str(skills_dir),
|
||||
tuple(str(d) for d in external_dirs),
|
||||
tuple(sorted(str(t) for t in (available_tools or set()))),
|
||||
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
|
||||
@@ -1485,7 +1508,7 @@ def build_skills_system_prompt(
|
||||
category = entry.get("category") or "general"
|
||||
frontmatter_name = entry.get("frontmatter_name") or skill_name
|
||||
platforms = entry.get("platforms") or []
|
||||
if not skill_matches_platform({"platforms": platforms}):
|
||||
if not skill_matches_platform_list(platforms):
|
||||
continue
|
||||
if frontmatter_name in disabled or skill_name in disabled:
|
||||
continue
|
||||
|
||||
@@ -66,9 +66,13 @@ _REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = (
|
||||
("nemotron-3-ultra", 600),
|
||||
("nemotron-3-super", 600),
|
||||
("nemotron-3-nano", 300),
|
||||
# DeepSeek — R1 reasoning model on hosted NIM / DeepSeek direct.
|
||||
# DeepSeek — R1 and V4 reasoning models on hosted NIM / DeepSeek direct.
|
||||
# V4 series emits reasoning_content in a separate delta field before
|
||||
# final content, requiring the same extended stale timeout floor.
|
||||
("deepseek-r1", 600),
|
||||
("deepseek-reasoner", 600),
|
||||
("deepseek-v4-flash", 600),
|
||||
("deepseek-v4-pro", 600),
|
||||
# Qwen — QwQ reasoning + Qwen3 thinking variants. QwQ-32B
|
||||
# preview is the stable slug; ``qwen3`` covers the family of
|
||||
# thinking-mode Qwen3 models (qwen3-235b-a22b, qwen3-32b, etc.)
|
||||
@@ -190,6 +194,10 @@ def get_reasoning_stale_timeout_floor(model: object) -> Optional[float]:
|
||||
300.0
|
||||
>>> get_reasoning_stale_timeout_floor("deepseek/deepseek-r1")
|
||||
600.0
|
||||
>>> get_reasoning_stale_timeout_floor("deepseek/deepseek-v4-flash")
|
||||
600.0
|
||||
>>> get_reasoning_stale_timeout_floor("deepseek/deepseek-v4-pro")
|
||||
600.0
|
||||
>>> get_reasoning_stale_timeout_floor("qwen/qwen3-235b-a22b-thinking")
|
||||
180.0
|
||||
>>> get_reasoning_stale_timeout_floor("x-ai/grok-4-fast-reasoning")
|
||||
|
||||
@@ -160,27 +160,8 @@ def parse_frontmatter(content: str) -> Tuple[Dict[str, Any], str]:
|
||||
# ── Platform matching ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
|
||||
"""Return True when the skill is compatible with the current OS.
|
||||
|
||||
Skills declare platform requirements via a top-level ``platforms`` list
|
||||
in their YAML frontmatter::
|
||||
|
||||
platforms: [macos] # macOS only
|
||||
platforms: [macos, linux] # macOS and Linux
|
||||
|
||||
If the field is absent or empty the skill is compatible with **all**
|
||||
platforms (backward-compatible default).
|
||||
|
||||
Termux note: on Termux/Android, ``sys.platform`` is ``"linux"`` on
|
||||
older Pythons but became ``"android"`` on Python 3.13+. Termux is a
|
||||
Linux userland riding on the Android kernel, so skills tagged
|
||||
``linux`` are treated as compatible in Termux regardless of which
|
||||
``sys.platform`` value Python reports. Individual Linux commands
|
||||
inside a skill may still misbehave (no systemd, BusyBox utils, no
|
||||
apt/dnf, etc.) but that is on the skill, not on platform gating.
|
||||
"""
|
||||
platforms = frontmatter.get("platforms")
|
||||
def skill_matches_platform_list(platforms: Any) -> bool:
|
||||
"""Return True when *platforms* is compatible with the current OS."""
|
||||
if not platforms:
|
||||
return True
|
||||
if not isinstance(platforms, list):
|
||||
@@ -204,6 +185,29 @@ def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
|
||||
"""Return True when the skill is compatible with the current OS.
|
||||
|
||||
Skills declare platform requirements via a top-level ``platforms`` list
|
||||
in their YAML frontmatter::
|
||||
|
||||
platforms: [macos] # macOS only
|
||||
platforms: [macos, linux] # macOS and Linux
|
||||
|
||||
If the field is absent or empty the skill is compatible with **all**
|
||||
platforms (backward-compatible default).
|
||||
|
||||
Termux note: on Termux/Android, ``sys.platform`` is ``"linux"`` on
|
||||
older Pythons but became ``"android"`` on Python 3.13+. Termux is a
|
||||
Linux userland riding on the Android kernel, so skills tagged
|
||||
``linux`` are treated as compatible in Termux regardless of which
|
||||
``sys.platform`` value Python reports. Individual Linux commands
|
||||
inside a skill may still misbehave (no systemd, BusyBox utils, no
|
||||
apt/dnf, etc.) but that is on the skill, not on platform gating.
|
||||
"""
|
||||
return skill_matches_platform_list(frontmatter.get("platforms"))
|
||||
|
||||
|
||||
# ── Environment matching ──────────────────────────────────────────────────
|
||||
|
||||
# Recognized environment tags and how each is detected. An environment tag is
|
||||
@@ -787,8 +791,9 @@ def iter_skill_index_files(skills_dir: Path, filename: str):
|
||||
``SKILL.md`` files, but they are progressive-disclosure data loaded through
|
||||
``skill_view(..., file_path=...)`` rather than active skill roots.
|
||||
"""
|
||||
matches = []
|
||||
for root, dirs, files in os.walk(skills_dir, followlinks=True):
|
||||
skills_dir_str = str(skills_dir)
|
||||
matches: list[str] = []
|
||||
for root, dirs, files in os.walk(skills_dir_str, followlinks=True):
|
||||
has_skill_md = "SKILL.md" in files
|
||||
dirs[:] = [
|
||||
d
|
||||
@@ -797,9 +802,9 @@ def iter_skill_index_files(skills_dir: Path, filename: str):
|
||||
and not (has_skill_md and d in SKILL_SUPPORT_DIRS)
|
||||
]
|
||||
if filename in files:
|
||||
matches.append(Path(root) / filename)
|
||||
for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):
|
||||
yield path
|
||||
matches.append(os.path.join(root, filename))
|
||||
for path in sorted(matches):
|
||||
yield Path(path)
|
||||
|
||||
|
||||
# ── Namespace helpers for plugin-provided skills ───────────────────────────
|
||||
|
||||
@@ -24,6 +24,7 @@ Pure helpers that read the agent's state. AIAgent keeps thin forwarders.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.prompt_builder import (
|
||||
@@ -44,6 +45,7 @@ from agent.prompt_builder import (
|
||||
drain_truncation_warnings,
|
||||
)
|
||||
from agent.runtime_cwd import resolve_context_cwd
|
||||
from utils import is_truthy_value
|
||||
|
||||
|
||||
def _ra():
|
||||
@@ -110,6 +112,36 @@ def _resolve_platform_hint(agent: Any, platform_key: str, default_hint: str) ->
|
||||
return base
|
||||
|
||||
|
||||
_TUI_EMBEDDED_PANE_CLARIFIER = (
|
||||
" You're in its embedded terminal pane, beside the GUI chat — the user can "
|
||||
"select your output (Option-drag on macOS, Shift-drag elsewhere) and press "
|
||||
"Cmd/Ctrl+L to send it to the chat composer."
|
||||
)
|
||||
|
||||
|
||||
def _tui_embedded_pane_clarifier(hint: str) -> str:
|
||||
"""Append the desktop-embedded-terminal-pane clarifier to a tui hint.
|
||||
|
||||
Triggered by ``HERMES_DESKTOP_TERMINAL=1`` (set by ``main.cjs`` only on the
|
||||
shell env of the desktop's embedded TUI PTY — never on the chat backend).
|
||||
This is a runtime-surface qualifier, not a config override, so it lives at
|
||||
the resolution site rather than inside ``_resolve_platform_hint`` (which
|
||||
is purely the config-platform_hints override applier). Byte-stable for the
|
||||
cache: called once per session build, deterministically from env state.
|
||||
|
||||
Idempotent and empty-safe: re-applying on an already-augmented hint is a
|
||||
no-op, and an empty input returns empty (we never synthesize the
|
||||
clarifier without its tui framing).
|
||||
"""
|
||||
if not hint:
|
||||
return hint
|
||||
if _TUI_EMBEDDED_PANE_CLARIFIER in hint:
|
||||
return hint
|
||||
if not is_truthy_value(os.getenv("HERMES_DESKTOP_TERMINAL")):
|
||||
return hint
|
||||
return hint + _TUI_EMBEDDED_PANE_CLARIFIER
|
||||
|
||||
|
||||
def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) -> Dict[str, str]:
|
||||
"""Assemble the system prompt as three ordered parts.
|
||||
|
||||
@@ -398,6 +430,8 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
pass
|
||||
|
||||
_effective_hint = _resolve_platform_hint(agent, platform_key, _default_hint)
|
||||
if platform_key == "tui" and _effective_hint:
|
||||
_effective_hint = _tui_embedded_pane_clarifier(_effective_hint)
|
||||
if _effective_hint:
|
||||
stable_parts.append(_effective_hint)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,7 +9,6 @@ which has provider-specific conditionals for max_tokens defaults,
|
||||
reasoning configuration, temperature handling, and extra_body assembly.
|
||||
"""
|
||||
|
||||
import copy
|
||||
from typing import Any, Dict
|
||||
|
||||
from agent.lmstudio_reasoning import resolve_lmstudio_effort
|
||||
@@ -195,27 +194,63 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
if not needs_sanitize:
|
||||
return messages
|
||||
|
||||
sanitized = copy.deepcopy(messages)
|
||||
for msg in sanitized:
|
||||
sanitized = list(messages)
|
||||
for msg_idx, msg in enumerate(messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
msg.pop("codex_reasoning_items", None)
|
||||
msg.pop("codex_message_items", None)
|
||||
msg.pop("tool_name", None)
|
||||
msg.pop("timestamp", None) # #47868 — leak into strict providers
|
||||
|
||||
copied_msg: dict[str, Any] | None = None
|
||||
|
||||
def mutable_msg() -> dict[str, Any]:
|
||||
nonlocal copied_msg
|
||||
if copied_msg is None:
|
||||
copied_msg = dict(msg)
|
||||
sanitized[msg_idx] = copied_msg
|
||||
return copied_msg
|
||||
|
||||
if (
|
||||
"codex_reasoning_items" in msg
|
||||
or "codex_message_items" in msg
|
||||
or "tool_name" in msg
|
||||
or "timestamp" in msg # #47868 — leak into strict providers
|
||||
):
|
||||
out_msg = mutable_msg()
|
||||
out_msg.pop("codex_reasoning_items", None)
|
||||
out_msg.pop("codex_message_items", None)
|
||||
out_msg.pop("tool_name", None)
|
||||
out_msg.pop("timestamp", None) # #47868 — leak into strict providers
|
||||
|
||||
|
||||
# Drop all Hermes-internal scaffolding markers (``_``-prefixed).
|
||||
# OpenAI's message schema has no ``_``-prefixed fields, so this
|
||||
# is safe and future-proofs against new markers being added.
|
||||
for key in [k for k in msg if isinstance(k, str) and k.startswith("_")]:
|
||||
msg.pop(key, None)
|
||||
internal_keys = [k for k in msg if isinstance(k, str) and k.startswith("_")]
|
||||
if internal_keys:
|
||||
out_msg = mutable_msg()
|
||||
for key in internal_keys:
|
||||
out_msg.pop(key, None)
|
||||
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if isinstance(tool_calls, list):
|
||||
for tc in tool_calls:
|
||||
copied_tool_calls: list[Any] | None = None
|
||||
for tc_idx, tc in enumerate(tool_calls):
|
||||
if isinstance(tc, dict):
|
||||
tc.pop("call_id", None)
|
||||
tc.pop("response_item_id", None)
|
||||
if strip_extra_content:
|
||||
tc.pop("extra_content", None)
|
||||
should_copy_tc = (
|
||||
"call_id" in tc
|
||||
or "response_item_id" in tc
|
||||
or (strip_extra_content and "extra_content" in tc)
|
||||
)
|
||||
if should_copy_tc:
|
||||
if copied_tool_calls is None:
|
||||
copied_tool_calls = list(tool_calls)
|
||||
copied_tc = dict(tc)
|
||||
copied_tc.pop("call_id", None)
|
||||
copied_tc.pop("response_item_id", None)
|
||||
if strip_extra_content:
|
||||
copied_tc.pop("extra_content", None)
|
||||
copied_tool_calls[tc_idx] = copied_tc
|
||||
if copied_tool_calls is not None:
|
||||
mutable_msg()["tool_calls"] = copied_tool_calls
|
||||
return sanitized
|
||||
|
||||
def convert_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
|
||||
@@ -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 {".", "!", "?", "。", "!", "?", "`", ")"}
|
||||
|
||||
@@ -103,6 +103,54 @@ _UTC_NOW = lambda: datetime.now(timezone.utc)
|
||||
# Official docs snapshot entries. Models whose published pricing and cache
|
||||
# semantics are stable enough to encode exactly.
|
||||
_OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
# ── OpenAI GPT-5.6 series (Sol/Terra/Luna) ───────────────────────────
|
||||
# Announced in limited preview 2026-06-26; GA 2026-07-09 at the same
|
||||
# rates (Sol $5/$30, Terra $2.50/$15, Luna $1/$6 per 1M in/out). Cache
|
||||
# writes are billed at 1.25x the uncached input rate; cache reads get the
|
||||
# standard 90% discount (0.10x input, confirmed: Sol $0.50/M cached).
|
||||
# Note: "Sol Fast mode" ($12.5/$75, up to 750 tok/s via Cerebras) is a
|
||||
# separate serving tier, not covered by these entries. The "-pro"
|
||||
# variants (high-effort modes, GA alongside base tiers) bill at the
|
||||
# SAME per-token rates and are aliased onto these entries below the
|
||||
# dict (they cost more per task by consuming more tokens, not by a
|
||||
# higher rate — verified against OpenRouter's live pricing 2026-07-09).
|
||||
# Source: https://openai.com/index/previewing-gpt-5-6-sol/
|
||||
(
|
||||
"openai",
|
||||
"gpt-5.6-sol",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("5.00"),
|
||||
output_cost_per_million=Decimal("30.00"),
|
||||
cache_read_cost_per_million=Decimal("0.50"),
|
||||
cache_write_cost_per_million=Decimal("6.25"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://openai.com/index/previewing-gpt-5-6-sol/",
|
||||
pricing_version="openai-gpt-5.6-2026-07",
|
||||
),
|
||||
(
|
||||
"openai",
|
||||
"gpt-5.6-terra",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("2.50"),
|
||||
output_cost_per_million=Decimal("15.00"),
|
||||
cache_read_cost_per_million=Decimal("0.25"),
|
||||
cache_write_cost_per_million=Decimal("3.125"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://openai.com/index/previewing-gpt-5-6-sol/",
|
||||
pricing_version="openai-gpt-5.6-2026-07",
|
||||
),
|
||||
(
|
||||
"openai",
|
||||
"gpt-5.6-luna",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("1.00"),
|
||||
output_cost_per_million=Decimal("6.00"),
|
||||
cache_read_cost_per_million=Decimal("0.10"),
|
||||
cache_write_cost_per_million=Decimal("1.25"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://openai.com/index/previewing-gpt-5-6-sol/",
|
||||
pricing_version="openai-gpt-5.6-2026-07",
|
||||
),
|
||||
# ── Anthropic Claude 4.8 ─────────────────────────────────────────────
|
||||
# Same $5/$25 base pricing as 4.6/4.7. Fast-mode variant is a separate
|
||||
# model ID with 2x premium (vs the 6x premium on older Opus generations).
|
||||
@@ -563,6 +611,15 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
),
|
||||
}
|
||||
|
||||
# GPT-5.6 "-pro" high-effort variants bill at the same per-token rates as
|
||||
# their base tiers (more tokens per task, not a higher rate). Alias them
|
||||
# onto the base entries so the snapshot stays single-source.
|
||||
for _base_56 in ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"):
|
||||
_OFFICIAL_DOCS_PRICING[("openai", f"{_base_56}-pro")] = _OFFICIAL_DOCS_PRICING[
|
||||
("openai", _base_56)
|
||||
]
|
||||
del _base_56
|
||||
|
||||
|
||||
def _to_decimal(value: Any) -> Optional[Decimal]:
|
||||
if value is None:
|
||||
@@ -602,7 +659,11 @@ def resolve_billing_route(
|
||||
return BillingRoute(provider="nous", model=model, base_url=base_url or _NOUS_DEFAULT_BASE_URL, billing_mode="official_models_api")
|
||||
if provider_name == "anthropic":
|
||||
return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
||||
if provider_name == "openai":
|
||||
# "openai-api" is the picker/registry slug for direct api.openai.com; it
|
||||
# bills identically to bare "openai", so normalize it here — otherwise the
|
||||
# ("openai", <model>) _OFFICIAL_DOCS_PRICING keys are unreachable from the
|
||||
# openai-api provider path.
|
||||
if provider_name in {"openai", "openai-api"}:
|
||||
return BillingRoute(provider="openai", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
||||
if provider_name in {"minimax", "minimax-cn"}:
|
||||
return BillingRoute(provider=provider_name, model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Bootstrap orchestration.
|
||||
//!
|
||||
//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.cjs`.
|
||||
//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.ts`.
|
||||
//! Drives install.ps1 / install.sh stage-by-stage, emits progress events
|
||||
//! over the Tauri `bootstrap` channel, writes a forensic log to
|
||||
//! HERMES_HOME/logs/bootstrap-<timestamp>.log.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Event types streamed from Rust → React.
|
||||
//!
|
||||
//! These mirror `apps/desktop/electron/bootstrap-runner.cjs`'s event shape
|
||||
//! These mirror `apps/desktop/electron/bootstrap-runner.ts`'s event shape
|
||||
//! 1:1 so the React installer code can be roughly identical to the Electron
|
||||
//! install-overlay we'll replace.
|
||||
//!
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! 3. Network: download from GitHub raw at a pinned commit or branch.
|
||||
//! Commit pins are immutable; branch pins are HEAD-tracking.
|
||||
//!
|
||||
//! Mirrors `apps/desktop/electron/bootstrap-runner.cjs`'s `resolveInstallScript`,
|
||||
//! Mirrors `apps/desktop/electron/bootstrap-runner.ts`'s `resolveInstallScript`,
|
||||
//! but the dev-checkout resolution is driven by an env var rather than the
|
||||
//! Electron app's APP_ROOT/../.. trick, because Hermes-Setup.exe is meant
|
||||
//! to live OUTSIDE any repo checkout.
|
||||
@@ -64,7 +64,7 @@ impl ScriptKind {
|
||||
}
|
||||
|
||||
/// Validates a string looks like a git SHA (7+ hex chars). Mirrors
|
||||
/// `STAMP_COMMIT_RE` from bootstrap-runner.cjs.
|
||||
/// `STAMP_COMMIT_RE` from bootstrap-runner.ts.
|
||||
fn is_valid_commit(s: &str) -> bool {
|
||||
let len = s.len();
|
||||
(7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())
|
||||
|
||||
@@ -150,7 +150,7 @@ fn repair_macos_installer_helper(path: &Path) {
|
||||
fn repair_macos_installer_helper(_path: &Path) {}
|
||||
|
||||
/// Where install.ps1 writes the bootstrap-complete marker (existence-only file
|
||||
/// the Electron app also checks). Per main.cjs:
|
||||
/// the Electron app also checks). Per main.ts:
|
||||
/// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete')
|
||||
/// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so
|
||||
/// this is a probe helper, not a definitive path.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Drives PowerShell (Windows) or bash (Unix) for install.ps1 / install.sh.
|
||||
//!
|
||||
//! Port of `spawnPowerShell` from bootstrap-runner.cjs, with the same
|
||||
//! Port of `spawnPowerShell` from bootstrap-runner.ts, with the same
|
||||
//! line-buffered stdout/stderr streaming + cancellation semantics.
|
||||
//!
|
||||
//! On Windows we pass `-NoProfile -ExecutionPolicy Bypass -File <script>`.
|
||||
@@ -19,7 +19,7 @@ pub struct StreamSink {
|
||||
pub on_stderr_line: Box<dyn Fn(&str) + Send + Sync>,
|
||||
}
|
||||
|
||||
/// Outcome of a script invocation. Mirrors bootstrap-runner.cjs's
|
||||
/// Outcome of a script invocation. Mirrors bootstrap-runner.ts's
|
||||
/// `{stdout, stderr, code, signal, killed}` shape.
|
||||
#[derive(Debug)]
|
||||
pub struct ScriptResult {
|
||||
@@ -258,7 +258,7 @@ fn interpreter_label() -> String {
|
||||
/// Parses the LAST line of stdout that looks like a JSON object matching
|
||||
/// the install.ps1 stage-result contract: `{ok: bool, stage: string, ...}`.
|
||||
///
|
||||
/// Mirrors `parseStageResult` from bootstrap-runner.cjs. install.ps1 may
|
||||
/// Mirrors `parseStageResult` from bootstrap-runner.ts. install.ps1 may
|
||||
/// print info/banner lines before the result frame; we scan from the end.
|
||||
pub fn parse_stage_result(stdout: &str) -> Option<crate::events::StageResultPayload> {
|
||||
for line in stdout.lines().rev() {
|
||||
|
||||
@@ -85,7 +85,7 @@ Installers are built and uploaded to GitHub Releases manually. macOS/Windows sig
|
||||
|
||||
### How it works
|
||||
|
||||
The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — through the framework-agnostic client in [`apps/shared`](../shared/) (the same client the web dashboard consumes), and reuses the agent runtime rather than embedding `hermes --tui`. The app is **self-contained**: it runs its own `hermes serve` backend and never opens or requires the web dashboard UI. (For backward compatibility, a runtime that predates the `serve` command automatically falls back to a headless `dashboard --no-open` — see `electron/backend-command.cjs` — so mid-upgrade installs never break.) The install, backend-resolution, and self-update logic all live in `electron/main.cjs`.
|
||||
The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — through the framework-agnostic client in [`apps/shared`](../shared/) (the same client the web dashboard consumes), and reuses the agent runtime rather than embedding `hermes --tui`. The app is **self-contained**: it runs its own `hermes serve` backend and never opens or requires the web dashboard UI. (For backward compatibility, a runtime that predates the `serve` command automatically falls back to a headless `dashboard --no-open` — see `electron/backend-command.ts` — so mid-upgrade installs never break.) The install, backend-resolution, and self-update logic all live in `electron/main.ts`.
|
||||
|
||||
### Verification
|
||||
|
||||
|
||||
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,9 +1,8 @@
|
||||
'use strict'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
import { test } from 'vitest'
|
||||
|
||||
const { serveBackendArgs, dashboardFallbackArgs, sourceDeclaresServe } = require('./backend-command.cjs')
|
||||
import { dashboardFallbackArgs, serveBackendArgs, sourceDeclaresServe } from './backend-command'
|
||||
|
||||
test('serveBackendArgs builds a headless serve invocation', () => {
|
||||
assert.deepEqual(serveBackendArgs(), ['serve', '--host', '127.0.0.1', '--port', '0'])
|
||||
@@ -61,5 +60,6 @@ test('sourceDeclaresServe does not false-positive on the substring "server"', ()
|
||||
dashboard_parser = subparsers.add_parser("dashboard", help="Start the web UI dashboard")
|
||||
from hermes_cli.web_server import start_server # web server
|
||||
`
|
||||
|
||||
assert.equal(sourceDeclaresServe(oldSource), false)
|
||||
})
|
||||
@@ -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
|
||||
@@ -17,8 +15,9 @@
|
||||
* Build the canonical headless backend argv (always `serve`).
|
||||
* @param {string} [profile] optional Hermes profile to pin via `--profile`.
|
||||
*/
|
||||
function serveBackendArgs(profile) {
|
||||
export function serveBackendArgs(profile?: string) {
|
||||
const head = profile ? ['--profile', profile] : []
|
||||
|
||||
return [...head, 'serve', '--host', '127.0.0.1', '--port', '0']
|
||||
}
|
||||
|
||||
@@ -28,9 +27,13 @@ function serveBackendArgs(profile) {
|
||||
* `-m hermes_cli.main` and any `--profile <name>`). Returns a copy; if there is
|
||||
* no `serve` token the argv is returned unchanged.
|
||||
*/
|
||||
function dashboardFallbackArgs(args) {
|
||||
export function dashboardFallbackArgs(args) {
|
||||
const i = args.indexOf('serve')
|
||||
if (i === -1) return args.slice()
|
||||
|
||||
if (i === -1) {
|
||||
return args.slice()
|
||||
}
|
||||
|
||||
return [...args.slice(0, i), 'dashboard', '--no-open', ...args.slice(i + 1)]
|
||||
}
|
||||
|
||||
@@ -40,12 +43,6 @@ function dashboardFallbackArgs(args) {
|
||||
* specifically so the substring "server" (e.g. "start_server", "web server")
|
||||
* never produces a false positive.
|
||||
*/
|
||||
function sourceDeclaresServe(dashboardPySource) {
|
||||
export function sourceDeclaresServe(dashboardPySource) {
|
||||
return /add_parser\(\s*["']serve["']/.test(String(dashboardPySource || ''))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
serveBackendArgs,
|
||||
dashboardFallbackArgs,
|
||||
sourceDeclaresServe
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const path = require('node:path')
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
|
||||
const {
|
||||
POSIX_SANE_PATH_ENTRIES,
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
appendUniquePathEntries,
|
||||
buildDesktopBackendEnv,
|
||||
buildDesktopBackendPath,
|
||||
normalizeHermesHomeRoot,
|
||||
pathEnvKey
|
||||
} = require('./backend-env.cjs')
|
||||
pathEnvKey,
|
||||
POSIX_SANE_PATH_ENTRIES
|
||||
} from './backend-env'
|
||||
|
||||
test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => {
|
||||
const result = buildDesktopBackendPath({
|
||||
@@ -1,4 +1,4 @@
|
||||
const path = require('node:path')
|
||||
import path from 'node:path'
|
||||
|
||||
// Match the POSIX fallback surface used by the Python terminal environment.
|
||||
// macOS apps launched from Finder/Dock often inherit only /usr/bin:/bin:/usr/sbin:/sbin,
|
||||
@@ -23,12 +23,16 @@ function pathModuleForPlatform(platform = process.platform) {
|
||||
}
|
||||
|
||||
function pathEnvKey(env = process.env, platform = process.platform) {
|
||||
if (platform !== 'win32') return 'PATH'
|
||||
if (platform !== 'win32') {
|
||||
return 'PATH'
|
||||
}
|
||||
|
||||
return Object.keys(env || {}).find(key => key.toUpperCase() === 'PATH') || 'PATH'
|
||||
}
|
||||
|
||||
function currentPathValue(env = process.env, platform = process.platform) {
|
||||
const key = pathEnvKey(env, platform)
|
||||
|
||||
return env?.[key] || ''
|
||||
}
|
||||
|
||||
@@ -37,10 +41,17 @@ function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) {
|
||||
const ordered = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry) continue
|
||||
if (!entry) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter)
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part || seen.has(part)) continue
|
||||
if (!part || seen.has(part)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seen.add(part)
|
||||
ordered.push(part)
|
||||
}
|
||||
@@ -55,7 +66,7 @@ function buildDesktopBackendPath({
|
||||
currentPath = '',
|
||||
platform = process.platform,
|
||||
pathModule = pathModuleForPlatform(platform)
|
||||
} = {}) {
|
||||
}: any = {}) {
|
||||
const delimiter = delimiterForPlatform(platform)
|
||||
const hermesNodeBin = hermesHome ? pathModule.join(hermesHome, 'node', 'bin') : null
|
||||
const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null
|
||||
@@ -64,13 +75,18 @@ function buildDesktopBackendPath({
|
||||
return appendUniquePathEntries([hermesNodeBin, venvBin, currentPath, saneEntries], { delimiter })
|
||||
}
|
||||
|
||||
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) } = {}) {
|
||||
if (!hermesHome) return hermesHome
|
||||
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) }: any = {}) {
|
||||
if (!hermesHome) {
|
||||
return hermesHome
|
||||
}
|
||||
|
||||
const resolved = pathModule.resolve(String(hermesHome))
|
||||
const parent = pathModule.dirname(resolved)
|
||||
|
||||
if (pathModule.basename(parent).toLowerCase() === 'profiles') {
|
||||
return pathModule.dirname(parent)
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
@@ -81,7 +97,7 @@ function buildDesktopBackendEnv({
|
||||
currentEnv = process.env,
|
||||
platform = process.platform,
|
||||
pathModule = pathModuleForPlatform(platform)
|
||||
} = {}) {
|
||||
}: any = {}) {
|
||||
const delimiter = delimiterForPlatform(platform)
|
||||
const currentPythonPath = currentEnv?.PYTHONPATH || ''
|
||||
const key = pathEnvKey(currentEnv, platform)
|
||||
@@ -98,12 +114,12 @@ function buildDesktopBackendEnv({
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
POSIX_SANE_PATH_ENTRIES,
|
||||
export {
|
||||
appendUniquePathEntries,
|
||||
buildDesktopBackendEnv,
|
||||
buildDesktopBackendPath,
|
||||
delimiterForPlatform,
|
||||
normalizeHermesHomeRoot,
|
||||
pathEnvKey
|
||||
pathEnvKey,
|
||||
POSIX_SANE_PATH_ENTRIES
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
/**
|
||||
* Tests for electron/backend-probes.cjs.
|
||||
* Tests for electron/backend-probes.ts.
|
||||
*
|
||||
* Run with: node --test electron/backend-probes.test.cjs
|
||||
* Run with: node --test electron/backend-probes.test.ts
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } from './backend-probes'
|
||||
|
||||
// Resolve the host's own Node binary -- guaranteed to be on disk and
|
||||
// runnable. We use it as both a stand-in for "a python that doesn't
|
||||
@@ -67,6 +68,7 @@ test('verifyHermesCli returns true when --version exits 0', () => {
|
||||
// verifyHermesCli only cares about the exit code.
|
||||
const scriptPath = path.join(os.tmpdir(), `hermes-probes-ok-${Date.now()}-${process.pid}.cjs`)
|
||||
fs.writeFileSync(scriptPath, 'process.exit(0)\n')
|
||||
|
||||
try {
|
||||
// Use node as the launcher and our script as the "command". Pass
|
||||
// shell:false (default) -- node is a real binary, no shim.
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* backend-probes.cjs
|
||||
* backend-probes.ts
|
||||
*
|
||||
* Cheap "does this candidate backend actually work" checks used by
|
||||
* resolveHermesBackend (main.cjs). The resolver walks a ladder of
|
||||
* resolveHermesBackend (main.ts). The resolver walks a ladder of
|
||||
* candidates -- bootstrap marker, `hermes` on PATH, system Python with
|
||||
* hermes_cli installed -- and historically returned the first candidate
|
||||
* whose binary existed on disk. That assumption breaks when a user has
|
||||
@@ -27,12 +27,12 @@
|
||||
* via the caller's catch block if it chooses)
|
||||
* - any throw -> false (never propagate -- resolver wants a boolean)
|
||||
*
|
||||
* Kept in a standalone cjs module so it can be unit-tested with
|
||||
* Kept in a standalone ts module so it can be unit-tested with
|
||||
* `node --test` without dragging in the electron runtime (same pattern
|
||||
* as bootstrap-platform.cjs and hardening.cjs).
|
||||
* as bootstrap-platform.ts and hardening.ts).
|
||||
*/
|
||||
|
||||
const { execFileSync } = require('node:child_process')
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
const PROBE_TIMEOUT_MS = 5000
|
||||
|
||||
@@ -62,12 +62,14 @@ function hermesRuntimeImportProbe() {
|
||||
* through PYTHONPATH but lack PyYAML, then die on the first real CLI import.
|
||||
*
|
||||
* @param {string} pythonPath - Absolute path to a python.exe / python.
|
||||
* @param {object} [opts]
|
||||
* @param {object} [opts.env] - Additional environment for the probe.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function canImportHermesCli(pythonPath, opts = {}) {
|
||||
if (!pythonPath) return false
|
||||
function canImportHermesCli(pythonPath: string, opts: { env?: Record<string, string> } = {}) {
|
||||
if (!pythonPath) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync(pythonPath, ['-c', hermesRuntimeImportProbe()], {
|
||||
env: { ...process.env, ...(opts.env || {}) },
|
||||
@@ -75,6 +77,7 @@ function canImportHermesCli(pythonPath, opts = {}) {
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
@@ -95,31 +98,29 @@ function canImportHermesCli(pythonPath, opts = {}) {
|
||||
*
|
||||
* @param {string} hermesCommand - Resolved absolute path to a hermes
|
||||
* executable (or an interpreter+script wrapper).
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.shell] - Whether to run through a shell. For
|
||||
* .cmd/.bat shims on Windows execFileSync needs shell:true to find
|
||||
* the cmd interpreter; mirrors the same flag isCommandScript() drives
|
||||
* in resolveHermesBackend.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function verifyHermesCli(hermesCommand, opts = {}) {
|
||||
if (!hermesCommand) return false
|
||||
function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
|
||||
if (!hermesCommand) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync(hermesCommand, ['--version'], {
|
||||
stdio: 'ignore',
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
shell: Boolean(opts.shell),
|
||||
shell: Boolean(opts?.shell),
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
canImportHermesCli,
|
||||
hermesRuntimeImportProbe,
|
||||
verifyHermesCli,
|
||||
PROBE_TIMEOUT_MS
|
||||
}
|
||||
export { canImportHermesCli, hermesRuntimeImportProbe, PROBE_TIMEOUT_MS, verifyHermesCli }
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Tests for electron/backend-ready.cjs.
|
||||
* Tests for electron/backend-ready.ts.
|
||||
*
|
||||
* Run with: node --test electron/backend-ready.test.cjs
|
||||
* Run with: node --test electron/backend-ready.test.ts
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* Covers the cold-start port-announcement deadline (issue #50209): the clock
|
||||
@@ -11,29 +11,35 @@
|
||||
* HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS, clamped to a 45s floor.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { EventEmitter } = require('node:events')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const {
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
readDashboardReadyFile,
|
||||
resolvePortAnnounceTimeoutMs,
|
||||
waitForDashboardPort,
|
||||
waitForDashboardPortAnnouncement,
|
||||
waitForDashboardReadyFile,
|
||||
resolvePortAnnounceTimeoutMs,
|
||||
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
MIN_PORT_ANNOUNCE_TIMEOUT_MS
|
||||
} = require('./backend-ready.cjs')
|
||||
waitForDashboardReadyFile
|
||||
} from './backend-ready'
|
||||
|
||||
type FakeChildProcess = EventEmitter & {
|
||||
stdout: EventEmitter
|
||||
}
|
||||
|
||||
// A minimal stand-in for a spawned child process: an EventEmitter with a
|
||||
// stdout EventEmitter, matching the surface waitForDashboardPort consumes
|
||||
// (child.stdout.on('data'), child.on('exit'|'error') + the .off() teardown).
|
||||
function makeFakeChild() {
|
||||
const child = new EventEmitter()
|
||||
function makeFakeChild(): FakeChildProcess {
|
||||
const child = new EventEmitter() as FakeChildProcess
|
||||
child.stdout = new EventEmitter()
|
||||
|
||||
return child
|
||||
}
|
||||
|
||||
@@ -139,6 +145,7 @@ test('a late announcement after timeout does not throw (listeners torn down)', a
|
||||
|
||||
function mkTmpReadyFile() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-ready-test-'))
|
||||
|
||||
return {
|
||||
dir,
|
||||
file: path.join(dir, 'ready.json'),
|
||||
@@ -148,6 +155,7 @@ function mkTmpReadyFile() {
|
||||
|
||||
test('readDashboardReadyFile returns a valid port from JSON', () => {
|
||||
const tmp = mkTmpReadyFile()
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tmp.file, JSON.stringify({ port: 4567 }))
|
||||
assert.equal(readDashboardReadyFile(tmp.file), 4567)
|
||||
@@ -158,6 +166,7 @@ test('readDashboardReadyFile returns a valid port from JSON', () => {
|
||||
|
||||
test('readDashboardReadyFile ignores missing, malformed, or invalid files', () => {
|
||||
const tmp = mkTmpReadyFile()
|
||||
|
||||
try {
|
||||
assert.equal(readDashboardReadyFile(tmp.file), null)
|
||||
fs.writeFileSync(tmp.file, '{')
|
||||
@@ -172,6 +181,7 @@ test('readDashboardReadyFile ignores missing, malformed, or invalid files', () =
|
||||
test('waitForDashboardReadyFile resolves when the ready file appears', async () => {
|
||||
const tmp = mkTmpReadyFile()
|
||||
const child = makeFakeChild()
|
||||
|
||||
try {
|
||||
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
|
||||
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 8765 })), 20)
|
||||
@@ -184,6 +194,7 @@ test('waitForDashboardReadyFile resolves when the ready file appears', async ()
|
||||
test('waitForDashboardPortAnnouncement uses ready file when provided', async () => {
|
||||
const tmp = mkTmpReadyFile()
|
||||
const child = makeFakeChild()
|
||||
|
||||
try {
|
||||
const p = waitForDashboardPortAnnouncement(child, { readyFile: tmp.file, timeoutMs: 1000 })
|
||||
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 9876 })), 20)
|
||||
@@ -196,6 +207,7 @@ test('waitForDashboardPortAnnouncement uses ready file when provided', async ()
|
||||
test('waitForDashboardReadyFile rejects when the child exits before file readiness', async () => {
|
||||
const tmp = mkTmpReadyFile()
|
||||
const child = makeFakeChild()
|
||||
|
||||
try {
|
||||
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
|
||||
child.emit('exit', 1, null)
|
||||
@@ -1,4 +1,4 @@
|
||||
const fs = require('node:fs')
|
||||
import fs from 'node:fs'
|
||||
|
||||
// `hermes serve` announces HERMES_BACKEND_READY; the legacy `hermes dashboard`
|
||||
// backend announces HERMES_DASHBOARD_READY. Accept either so the desktop spawn
|
||||
@@ -26,9 +26,11 @@ const MIN_PORT_ANNOUNCE_TIMEOUT_MS = 45_000
|
||||
*/
|
||||
function resolvePortAnnounceTimeoutMs(env = process.env) {
|
||||
const parsed = Number(env.HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS)
|
||||
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return Math.max(MIN_PORT_ANNOUNCE_TIMEOUT_MS, Math.round(parsed))
|
||||
}
|
||||
|
||||
return DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
@@ -55,7 +57,10 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
|
||||
let done = false
|
||||
|
||||
function cleanup() {
|
||||
if (done) return
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
|
||||
done = true
|
||||
clearTimeout(timer)
|
||||
child.stdout.off('data', onData)
|
||||
@@ -66,13 +71,16 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
|
||||
function onData(chunk) {
|
||||
buf += chunk.toString()
|
||||
let nl
|
||||
|
||||
while ((nl = buf.indexOf('\n')) !== -1) {
|
||||
const line = buf.slice(0, nl)
|
||||
buf = buf.slice(nl + 1)
|
||||
const m = line.match(_READY_RE)
|
||||
|
||||
if (m) {
|
||||
cleanup()
|
||||
resolve(parseInt(m[1], 10))
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -99,11 +107,15 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
|
||||
})
|
||||
}
|
||||
|
||||
function readDashboardReadyFile(readyFile) {
|
||||
if (!readyFile) return null
|
||||
function readDashboardReadyFile(readyFile: fs.PathOrFileDescriptor) {
|
||||
if (!readyFile) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(readyFile, 'utf8'))
|
||||
const port = Number(parsed?.port)
|
||||
|
||||
return Number.isInteger(port) && port > 0 ? port : null
|
||||
} catch {
|
||||
return null
|
||||
@@ -116,16 +128,24 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno
|
||||
let interval = null
|
||||
|
||||
function cleanup() {
|
||||
if (done) return
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
|
||||
done = true
|
||||
clearTimeout(timer)
|
||||
if (interval) clearInterval(interval)
|
||||
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
|
||||
child.off('exit', onExit)
|
||||
child.off('error', onError)
|
||||
}
|
||||
|
||||
function check() {
|
||||
const port = readDashboardReadyFile(readyFile)
|
||||
|
||||
if (port) {
|
||||
cleanup()
|
||||
resolve(port)
|
||||
@@ -150,25 +170,37 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno
|
||||
child.on('exit', onExit)
|
||||
child.on('error', onError)
|
||||
interval = setInterval(check, 50)
|
||||
if (typeof interval.unref === 'function') interval.unref()
|
||||
|
||||
if (typeof interval.unref === 'function') {
|
||||
interval.unref()
|
||||
}
|
||||
|
||||
check()
|
||||
})
|
||||
}
|
||||
|
||||
function waitForDashboardPortAnnouncement(child, options = {}) {
|
||||
function waitForDashboardPortAnnouncement(
|
||||
child,
|
||||
options: {
|
||||
readyFile?: fs.PathOrFileDescriptor
|
||||
timeoutMs?: number
|
||||
} = {}
|
||||
) {
|
||||
const timeoutMs = options.timeoutMs ?? resolvePortAnnounceTimeoutMs()
|
||||
|
||||
if (options.readyFile) {
|
||||
return waitForDashboardReadyFile(options.readyFile, child, timeoutMs)
|
||||
}
|
||||
|
||||
return waitForDashboardPort(child, timeoutMs)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
waitForDashboardPort,
|
||||
waitForDashboardPortAnnouncement,
|
||||
waitForDashboardReadyFile,
|
||||
export {
|
||||
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
readDashboardReadyFile,
|
||||
resolvePortAnnounceTimeoutMs,
|
||||
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
MIN_PORT_ANNOUNCE_TIMEOUT_MS
|
||||
waitForDashboardPort,
|
||||
waitForDashboardPortAnnouncement,
|
||||
waitForDashboardReadyFile
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
const {
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
bundledRuntimeImportCheck,
|
||||
detectRemoteDisplay,
|
||||
isWindowsBinaryPathInWsl,
|
||||
isWslEnvironment
|
||||
} = require('./bootstrap-platform.cjs')
|
||||
} from './bootstrap-platform'
|
||||
|
||||
test('isWslEnvironment detects WSL2 env vars on linux', () => {
|
||||
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
|
||||
@@ -85,27 +84,3 @@ test('detectRemoteDisplay honors the HERMES_DESKTOP_DISABLE_GPU override both wa
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
test('packaged electron entrypoints do not require unpackaged npm modules', () => {
|
||||
const electronDir = __dirname
|
||||
const entrypoints = ['main.cjs', 'preload.cjs', 'bootstrap-platform.cjs']
|
||||
// - electron: provided by the electron runtime, always resolvable in packaged builds.
|
||||
// - node-pty: hoisted by workspace dedup AND shipped via extraResources to
|
||||
// resources/native-deps/node-pty (see scripts/stage-native-deps.cjs). main.cjs
|
||||
// has a try/catch fallback at line ~38 that resolves the staged copy when the
|
||||
// bare require fails in the packaged asar, so the bare require itself is by
|
||||
// design rather than an oversight.
|
||||
const allowedBareRequires = new Set(['electron', 'node-pty'])
|
||||
const requirePattern = /require\(['"]([^'"]+)['"]\)/g
|
||||
|
||||
for (const entrypoint of entrypoints) {
|
||||
const source = fs.readFileSync(path.join(electronDir, entrypoint), 'utf8')
|
||||
const bareRequires = Array.from(source.matchAll(requirePattern))
|
||||
.map(match => match[1])
|
||||
.filter(specifier => !specifier.startsWith('node:'))
|
||||
.filter(specifier => !specifier.startsWith('.'))
|
||||
.filter(specifier => !allowedBareRequires.has(specifier))
|
||||
|
||||
assert.deepEqual(bareRequires, [], `${entrypoint} has unpackaged runtime requires`)
|
||||
}
|
||||
})
|
||||
@@ -1,20 +1,32 @@
|
||||
const fs = require('node:fs')
|
||||
import fs from 'node:fs'
|
||||
|
||||
function isWslEnvironment(env = process.env, platform = process.platform, kernelRelease = null) {
|
||||
if (platform !== 'linux') return false
|
||||
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true
|
||||
if (platform !== 'linux') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
const release = kernelRelease ?? fs.readFileSync('/proc/sys/kernel/osrelease', 'utf8')
|
||||
|
||||
return /microsoft|wsl/i.test(release)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isWindowsBinaryPathInWsl(filePath, options = {}) {
|
||||
function isWindowsBinaryPathInWsl(
|
||||
filePath,
|
||||
options: { isWsl?: boolean; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}
|
||||
) {
|
||||
const isWsl = options.isWsl ?? isWslEnvironment(options.env, options.platform)
|
||||
if (!isWsl) return false
|
||||
|
||||
if (!isWsl) {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalized = String(filePath || '')
|
||||
.replace(/\\/g, '/')
|
||||
@@ -48,19 +60,27 @@ const GPU_OVERRIDE_OFF = new Set(['0', 'false', 'no', 'off'])
|
||||
*
|
||||
* Pure + dependency-free so it can be unit-tested and called before app ready.
|
||||
*/
|
||||
function detectRemoteDisplay(options = {}) {
|
||||
function detectRemoteDisplay(options: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}) {
|
||||
const env = options.env ?? process.env
|
||||
const platform = options.platform ?? process.platform
|
||||
|
||||
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (GPU_OVERRIDE_ON.has(override)) return 'override (HERMES_DESKTOP_DISABLE_GPU)'
|
||||
if (GPU_OVERRIDE_OFF.has(override)) return null
|
||||
|
||||
if (GPU_OVERRIDE_ON.has(override)) {
|
||||
return 'override (HERMES_DESKTOP_DISABLE_GPU)'
|
||||
}
|
||||
|
||||
if (GPU_OVERRIDE_OFF.has(override)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Launched from an SSH session → the display is X11-forwarded or otherwise
|
||||
// remote. Covers the common `ssh user@box` + GUI-forwarding case.
|
||||
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return 'ssh-session'
|
||||
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) {
|
||||
return 'ssh-session'
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
// X11 forwarding sets DISPLAY to "<host>:N" (e.g. "localhost:10.0"); a
|
||||
@@ -68,6 +88,7 @@ function detectRemoteDisplay(options = {}) {
|
||||
// NB: WSLg deliberately isn't treated as remote — it reports
|
||||
// GPU-accelerated vGPU surfaces locally and doesn't show the flicker.
|
||||
const display = String(env.DISPLAY || '')
|
||||
|
||||
if (display.includes(':') && display.split(':')[0]) {
|
||||
return `x11-forwarding (DISPLAY=${display})`
|
||||
}
|
||||
@@ -77,15 +98,13 @@ function detectRemoteDisplay(options = {}) {
|
||||
// RDP sessions report SESSIONNAME like "RDP-Tcp#7"; the local console is
|
||||
// "Console".
|
||||
const sessionName = String(env.SESSIONNAME || '')
|
||||
if (/^rdp-/i.test(sessionName)) return `rdp (SESSIONNAME=${sessionName})`
|
||||
|
||||
if (/^rdp-/i.test(sessionName)) {
|
||||
return `rdp (SESSIONNAME=${sessionName})`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
bundledRuntimeImportCheck,
|
||||
detectRemoteDisplay,
|
||||
isWindowsBinaryPathInWsl,
|
||||
isWslEnvironment
|
||||
}
|
||||
export { bundledRuntimeImportCheck, detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment }
|
||||
@@ -1,15 +1,19 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const {
|
||||
runBootstrap,
|
||||
resolveInstallScript,
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
buildPinArgs,
|
||||
buildPosixPinArgs,
|
||||
cachedScriptPath,
|
||||
hasExistingGitCheckout,
|
||||
installedAgentInstallScript,
|
||||
cachedScriptPath
|
||||
} = require('./bootstrap-runner.cjs')
|
||||
resolveInstallScript,
|
||||
runBootstrap
|
||||
} from './bootstrap-runner'
|
||||
|
||||
const SCRIPT_NAME = process.platform === 'win32' ? 'install.ps1' : 'install.sh'
|
||||
|
||||
@@ -22,6 +26,7 @@ test('runBootstrap bails immediately when the signal is already aborted', async
|
||||
controller.abort()
|
||||
|
||||
const events = []
|
||||
|
||||
const result = await runBootstrap({
|
||||
installStamp: null,
|
||||
activeRoot: '/tmp/hermes-runner-test',
|
||||
@@ -42,6 +47,7 @@ test('runBootstrap bails immediately when the signal is already aborted', async
|
||||
|
||||
test('installedAgentInstallScript resolves the installer in the agent checkout', () => {
|
||||
const home = mkTmpHome()
|
||||
|
||||
try {
|
||||
assert.equal(installedAgentInstallScript(home), null, 'absent before the checkout exists')
|
||||
|
||||
@@ -57,8 +63,61 @@ 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()
|
||||
|
||||
try {
|
||||
const commit = 'a'.repeat(40)
|
||||
const cached = cachedScriptPath(home, commit)
|
||||
@@ -66,6 +125,7 @@ test('resolveInstallScript prefers a cached script without touching the network'
|
||||
fs.writeFileSync(cached, '#!/bin/sh\necho cached\n')
|
||||
|
||||
const logs = []
|
||||
|
||||
const result = await resolveInstallScript({
|
||||
installStamp: { commit },
|
||||
sourceRepoRoot: null,
|
||||
@@ -82,6 +142,7 @@ test('resolveInstallScript prefers a cached script without touching the network'
|
||||
|
||||
test('resolveInstallScript falls back to the installed agent checkout on a 404', async () => {
|
||||
const home = mkTmpHome()
|
||||
|
||||
try {
|
||||
const commit = 'a'.repeat(40)
|
||||
// Seed the installed agent checkout so the fallback has something to resolve.
|
||||
@@ -91,6 +152,7 @@ test('resolveInstallScript falls back to the installed agent checkout on a 404',
|
||||
fs.writeFileSync(installed, '#!/bin/sh\necho fallback\n')
|
||||
|
||||
const logs = []
|
||||
|
||||
const result = await resolveInstallScript({
|
||||
installStamp: { commit },
|
||||
sourceRepoRoot: null,
|
||||
@@ -117,6 +179,7 @@ test('resolveInstallScript falls back to the installed agent checkout on a 404',
|
||||
|
||||
test('resolveInstallScript rethrows when the 404 fallback is unavailable', async () => {
|
||||
const home = mkTmpHome()
|
||||
|
||||
try {
|
||||
const commit = 'a'.repeat(40)
|
||||
// No installed agent checkout seeded -> nothing to fall back to.
|
||||
@@ -1,16 +1,14 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* bootstrap-runner.cjs
|
||||
* bootstrap-runner.ts
|
||||
*
|
||||
* Drives apps/desktop's first-launch install of Hermes Agent by spawning
|
||||
* scripts/install.ps1 stage-by-stage and streaming progress events back to
|
||||
* the renderer.
|
||||
*
|
||||
* Wired from electron/main.cjs:
|
||||
* const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
* Wired from electron/main.ts:
|
||||
* import { runBootstrap }from './bootstrap-runner'
|
||||
* const result = await runBootstrap({
|
||||
* installStamp, // INSTALL_STAMP from main.cjs (may be null in dev)
|
||||
* installStamp, // INSTALL_STAMP from main.ts (may be null in dev)
|
||||
* activeRoot, // ACTIVE_HERMES_ROOT
|
||||
* sourceRepoRoot, // SOURCE_REPO_ROOT (for dev install.ps1 lookup)
|
||||
* hermesHome, // HERMES_HOME
|
||||
@@ -34,21 +32,16 @@
|
||||
* no UI consumes them yet)
|
||||
*/
|
||||
|
||||
const fs = require('node:fs')
|
||||
const fsp = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
const https = require('node:https')
|
||||
const { spawn } = require('node:child_process')
|
||||
import { spawn } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
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
|
||||
@@ -71,10 +64,15 @@ function installScriptKind() {
|
||||
}
|
||||
|
||||
function resolveLocalInstallScript(sourceRepoRoot) {
|
||||
if (!sourceRepoRoot) return null
|
||||
if (!sourceRepoRoot) {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = path.join(sourceRepoRoot, 'scripts', installScriptName())
|
||||
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.R_OK)
|
||||
|
||||
return candidate
|
||||
} catch {
|
||||
return null
|
||||
@@ -90,16 +88,33 @@ function bootstrapCacheDir(hermesHome) {
|
||||
// the pinned commit can't be fetched from GitHub (e.g. a locally-built desktop
|
||||
// app stamped to an unpushed HEAD).
|
||||
function installedAgentInstallScript(hermesHome) {
|
||||
if (!hermesHome) return null
|
||||
if (!hermesHome) {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = path.join(hermesHome, 'hermes-agent', 'scripts', installScriptName())
|
||||
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.R_OK)
|
||||
|
||||
return candidate
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
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'}`)
|
||||
}
|
||||
@@ -110,6 +125,7 @@ function downloadInstallScript(commit, destPath) {
|
||||
// verification beyond "did the file we wrote pass a syntax probe."
|
||||
const scriptName = installScriptName()
|
||||
const url = `https://raw.githubusercontent.com/NousResearch/hermes-agent/${commit}/scripts/${scriptName}`
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
||||
const tmpPath = destPath + '.tmp'
|
||||
@@ -129,8 +145,10 @@ function downloadInstallScript(commit, destPath) {
|
||||
`Failed to download ${scriptName}: HTTP ${res2.statusCode} from redirect ${res.headers.location}`
|
||||
)
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const out2 = fs.createWriteStream(tmpPath)
|
||||
res2.pipe(out2)
|
||||
out2.on('finish', () => {
|
||||
@@ -141,18 +159,24 @@ function downloadInstallScript(commit, destPath) {
|
||||
out2.on('error', reject)
|
||||
})
|
||||
.on('error', reject)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
out.close()
|
||||
|
||||
try {
|
||||
fs.unlinkSync(tmpPath)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
reject(new Error(`Failed to download ${scriptName}: HTTP ${res.statusCode} from ${url}`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
res.pipe(out)
|
||||
out.on('finish', () => {
|
||||
out.close()
|
||||
@@ -165,6 +189,7 @@ function downloadInstallScript(commit, destPath) {
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
@@ -174,6 +199,7 @@ function downloadInstallScript(commit, destPath) {
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
@@ -187,11 +213,13 @@ async function resolveInstallScript({
|
||||
_download = downloadInstallScript
|
||||
}) {
|
||||
// 1. Dev shortcut: prefer a local checkout's installer so we can iterate
|
||||
// without pushing. SOURCE_REPO_ROOT comes from main.cjs (path.resolve
|
||||
// without pushing. SOURCE_REPO_ROOT comes from main.ts (path.resolve
|
||||
// of APP_ROOT/../..).
|
||||
const localScript = resolveLocalInstallScript(sourceRepoRoot)
|
||||
|
||||
if (localScript) {
|
||||
emit({ type: 'log', line: `[bootstrap] using local ${installScriptName()} at ${localScript}` })
|
||||
|
||||
return { path: localScript, source: 'local', kind: installScriptKind() }
|
||||
}
|
||||
|
||||
@@ -204,12 +232,14 @@ async function resolveInstallScript({
|
||||
}
|
||||
|
||||
const cached = cachedScriptPath(hermesHome, installStamp.commit)
|
||||
|
||||
try {
|
||||
await fsp.access(cached, fs.constants.R_OK)
|
||||
emit({
|
||||
type: 'log',
|
||||
line: `[bootstrap] using cached ${installScriptName()} for ${installStamp.commit.slice(0, 12)}`
|
||||
})
|
||||
|
||||
return { path: cached, source: 'cache', commit: installStamp.commit, kind: installScriptKind() }
|
||||
} catch {
|
||||
// not cached; download
|
||||
@@ -219,17 +249,20 @@ async function resolveInstallScript({
|
||||
type: 'log',
|
||||
line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub`
|
||||
})
|
||||
|
||||
try {
|
||||
await _download(installStamp.commit, cached)
|
||||
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
|
||||
|
||||
return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() }
|
||||
} catch (err) {
|
||||
// The pinned commit may not be fetchable from GitHub -- most commonly a
|
||||
// locally-built desktop app stamped to an unpushed HEAD (see
|
||||
// write-build-stamp.cjs fromLocalGit). Fall back to the installer that
|
||||
// write-build-stamp.mjs fromLocalGit). Fall back to the installer that
|
||||
// ships inside the already-installed agent checkout so dev/self-builds can
|
||||
// still bootstrap instead of dying with a fatal 404.
|
||||
const installed = installedAgentInstallScript(hermesHome)
|
||||
|
||||
if (installed) {
|
||||
emit({
|
||||
type: 'log',
|
||||
@@ -237,15 +270,18 @@ async function resolveInstallScript({
|
||||
`[bootstrap] GitHub fetch failed (${err.message}); ` +
|
||||
`falling back to installed agent ${installScriptName()} at ${installed}`
|
||||
})
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(cached), { recursive: true })
|
||||
fs.copyFileSync(installed, cached)
|
||||
|
||||
return { path: cached, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
|
||||
} catch {
|
||||
// Cache copy failed (read-only FS, etc.) -- use the source path directly.
|
||||
return { path: installed, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
|
||||
}
|
||||
}
|
||||
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -271,31 +307,41 @@ function powershellUnderRoot(root) {
|
||||
function resolveWindowsPowerShell() {
|
||||
for (const v of ['SystemRoot', 'windir']) {
|
||||
const root = process.env[v]
|
||||
|
||||
if (root) {
|
||||
const candidate = powershellUnderRoot(root)
|
||||
|
||||
try {
|
||||
if (fs.statSync(candidate).isFile()) return candidate
|
||||
if (fs.statSync(candidate).isFile()) {
|
||||
return candidate
|
||||
}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pathDirs = (process.env.PATH || process.env.Path || '').split(path.delimiter).filter(Boolean)
|
||||
|
||||
for (const exe of ['powershell.exe', 'pwsh.exe']) {
|
||||
for (const dir of pathDirs) {
|
||||
const candidate = path.join(dir, exe)
|
||||
|
||||
try {
|
||||
if (fs.statSync(candidate).isFile()) return candidate
|
||||
if (fs.statSync(candidate).isFile()) {
|
||||
return candidate
|
||||
}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'powershell.exe'
|
||||
}
|
||||
|
||||
function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, hermesHome } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, hermesHome }: any = {}) {
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
|
||||
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
|
||||
|
||||
@@ -319,12 +365,14 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
||||
|
||||
const onAbort = () => {
|
||||
killed = true
|
||||
|
||||
try {
|
||||
child.kill('SIGTERM')
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
if (abortSignal) {
|
||||
if (abortSignal.aborted) {
|
||||
onAbort()
|
||||
@@ -342,10 +390,14 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
||||
stdout += chunk
|
||||
stdoutBuf += chunk
|
||||
let nl
|
||||
|
||||
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
|
||||
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
|
||||
stdoutBuf = stdoutBuf.slice(nl + 1)
|
||||
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
|
||||
|
||||
if (line) {
|
||||
emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -354,30 +406,46 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
||||
stderr += chunk
|
||||
stderrBuf += chunk
|
||||
let nl
|
||||
|
||||
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
|
||||
const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
|
||||
stderrBuf = stderrBuf.slice(nl + 1)
|
||||
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
|
||||
|
||||
if (line) {
|
||||
emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
child.on('error', err => {
|
||||
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
|
||||
if (abortSignal) {
|
||||
abortSignal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
reject(err)
|
||||
})
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
|
||||
if (abortSignal) {
|
||||
abortSignal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
// Flush any trailing bytes
|
||||
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
|
||||
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
|
||||
resolve({ stdout, stderr, code, signal, killed })
|
||||
if (stdoutBuf) {
|
||||
emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' } as any)
|
||||
}
|
||||
|
||||
if (stderrBuf) {
|
||||
emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' } as any)
|
||||
}
|
||||
|
||||
resolve({ stdout, stderr, code, signal, killed } as any)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome }: any = {}) {
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const child = spawn('bash', [scriptPath, ...args], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
@@ -392,12 +460,14 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
|
||||
|
||||
const onAbort = () => {
|
||||
killed = true
|
||||
|
||||
try {
|
||||
child.kill('SIGTERM')
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
if (abortSignal) {
|
||||
if (abortSignal.aborted) {
|
||||
onAbort()
|
||||
@@ -414,10 +484,14 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
|
||||
stdout += chunk
|
||||
stdoutBuf += chunk
|
||||
let nl
|
||||
|
||||
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
|
||||
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
|
||||
stdoutBuf = stdoutBuf.slice(nl + 1)
|
||||
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
|
||||
|
||||
if (line) {
|
||||
emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -426,22 +500,38 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
|
||||
stderr += chunk
|
||||
stderrBuf += chunk
|
||||
let nl
|
||||
|
||||
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
|
||||
const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
|
||||
stderrBuf = stderrBuf.slice(nl + 1)
|
||||
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
|
||||
|
||||
if (line) {
|
||||
emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
child.on('error', err => {
|
||||
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
|
||||
if (abortSignal) {
|
||||
abortSignal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
reject(err)
|
||||
})
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
|
||||
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
|
||||
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
|
||||
if (abortSignal) {
|
||||
abortSignal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
if (stdoutBuf) {
|
||||
emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
|
||||
}
|
||||
|
||||
if (stderrBuf) {
|
||||
emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
|
||||
}
|
||||
|
||||
resolve({ stdout, stderr, code, signal, killed })
|
||||
})
|
||||
})
|
||||
@@ -451,53 +541,74 @@ 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)
|
||||
}
|
||||
|
||||
if (installStamp && installStamp.branch) {
|
||||
args.push('-Branch', installStamp.branch)
|
||||
}
|
||||
|
||||
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,
|
||||
stageName: '__manifest__',
|
||||
hermesHome
|
||||
})
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} failed: exit ${result.code}\n${result.stderr || result.stdout}`
|
||||
)
|
||||
}
|
||||
|
||||
// The manifest is the LAST JSON line on stdout (install.ps1 may print
|
||||
// banner / info lines first depending on Console.OutputEncoding effects).
|
||||
// Find the last line that parses as JSON with a `stages` field.
|
||||
const lines = result.stdout.split(/\r?\n/).filter(Boolean)
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const parsed = JSON.parse(lines[i])
|
||||
|
||||
if (parsed && Array.isArray(parsed.stages)) {
|
||||
return parsed
|
||||
}
|
||||
@@ -505,6 +616,7 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} produced no parseable JSON payload\n${result.stdout}`
|
||||
)
|
||||
@@ -515,9 +627,11 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
|
||||
// for the double-emit bug we addressed in the install.ps1 PR).
|
||||
function parseStageResult(stdout) {
|
||||
const lines = stdout.split(/\r?\n/).filter(Boolean)
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const parsed = JSON.parse(lines[i])
|
||||
|
||||
if (parsed && typeof parsed.ok === 'boolean' && typeof parsed.stage === 'string') {
|
||||
return parsed
|
||||
}
|
||||
@@ -525,23 +639,36 @@ function parseStageResult(stdout) {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
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' })
|
||||
|
||||
const isPosix = installerKind === 'posix'
|
||||
|
||||
const args = isPosix
|
||||
? [
|
||||
'--stage',
|
||||
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,
|
||||
stageName: stage.name,
|
||||
@@ -554,6 +681,7 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
|
||||
if (result.killed) {
|
||||
const ev = { type: 'stage', name: stage.name, state: 'failed', durationMs, error: 'cancelled by user' }
|
||||
emit(ev)
|
||||
|
||||
return ev
|
||||
}
|
||||
|
||||
@@ -568,20 +696,26 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
|
||||
error: `${isPosix ? 'install.sh --stage' : 'install.ps1 -Stage'} ${stage.name} produced no JSON result frame (exit=${result.code})`,
|
||||
json: null
|
||||
}
|
||||
|
||||
emit(ev)
|
||||
|
||||
return ev
|
||||
}
|
||||
|
||||
if (json.ok && json.skipped) {
|
||||
const ev = { type: 'stage', name: stage.name, state: 'skipped', durationMs, json }
|
||||
emit(ev)
|
||||
|
||||
return ev
|
||||
}
|
||||
|
||||
if (json.ok) {
|
||||
const ev = { type: 'stage', name: stage.name, state: 'succeeded', durationMs, json }
|
||||
emit(ev)
|
||||
|
||||
return ev
|
||||
}
|
||||
|
||||
const ev = {
|
||||
type: 'stage',
|
||||
name: stage.name,
|
||||
@@ -590,7 +724,9 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
|
||||
json,
|
||||
error: json.reason || `exit code ${result.code}`
|
||||
}
|
||||
|
||||
emit(ev)
|
||||
|
||||
return ev
|
||||
}
|
||||
|
||||
@@ -603,6 +739,7 @@ function openRunLog(logRoot) {
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const logPath = path.join(logRoot, `bootstrap-${ts}.log`)
|
||||
const stream = fs.createWriteStream(logPath, { flags: 'a' })
|
||||
|
||||
return { path: logPath, stream }
|
||||
}
|
||||
|
||||
@@ -619,7 +756,7 @@ async function runBootstrap(opts) {
|
||||
logRoot,
|
||||
onEvent,
|
||||
abortSignal,
|
||||
writeMarker // callback to write the bootstrap-complete marker; main.cjs provides
|
||||
writeMarker // callback to write the bootstrap-complete marker; main.ts provides
|
||||
} = opts
|
||||
|
||||
// Bail before spawning anything if the user already cancelled — otherwise an
|
||||
@@ -633,6 +770,7 @@ async function runBootstrap(opts) {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false, cancelled: true }
|
||||
}
|
||||
|
||||
@@ -646,8 +784,11 @@ async function runBootstrap(opts) {
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof onEvent === 'function') onEvent(ev)
|
||||
if (typeof onEvent === 'function') {
|
||||
onEvent(ev)
|
||||
}
|
||||
} catch (err) {
|
||||
// Don't let a subscriber bug crash the bootstrap
|
||||
runLog.stream.write(`emit error: ${err && err.message}\n`)
|
||||
@@ -664,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'
|
||||
@@ -675,8 +828,10 @@ async function runBootstrap(opts) {
|
||||
emit,
|
||||
hermesHome,
|
||||
activeRoot,
|
||||
installStamp
|
||||
installStamp,
|
||||
pinCommit
|
||||
})
|
||||
|
||||
emit({
|
||||
type: 'manifest',
|
||||
stages: manifest.stages,
|
||||
@@ -690,8 +845,10 @@ async function runBootstrap(opts) {
|
||||
for (const stage of manifest.stages) {
|
||||
if (abortSignal && abortSignal.aborted) {
|
||||
emit({ type: 'failed', error: 'bootstrap cancelled by user' })
|
||||
|
||||
return { ok: false, cancelled: true }
|
||||
}
|
||||
|
||||
const ev = await runStage({
|
||||
scriptPath: scriptInfo.path,
|
||||
installerKind,
|
||||
@@ -700,11 +857,14 @@ async function runBootstrap(opts) {
|
||||
hermesHome,
|
||||
activeRoot,
|
||||
abortSignal,
|
||||
installStamp
|
||||
installStamp,
|
||||
pinCommit
|
||||
})
|
||||
|
||||
if (ev.state === 'failed') {
|
||||
emit({ type: 'failed', stage: stage.name, error: ev.error || 'stage failed' })
|
||||
return { ok: false, failedStage: stage.name, error: ev.error }
|
||||
emit({ type: 'failed', stage: stage.name, error: (ev as any).error || 'stage failed' })
|
||||
|
||||
return { ok: false, failedStage: stage.name, error: (ev as any).error }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -713,11 +873,14 @@ async function runBootstrap(opts) {
|
||||
pinnedCommit: installStamp ? installStamp.commit : null,
|
||||
pinnedBranch: installStamp ? installStamp.branch : null
|
||||
}
|
||||
|
||||
const marker = typeof writeMarker === 'function' ? writeMarker(markerPayload) : markerPayload
|
||||
emit({ type: 'complete', marker })
|
||||
|
||||
return { ok: true, marker }
|
||||
} catch (err) {
|
||||
emit({ type: 'failed', error: err.message || String(err) })
|
||||
|
||||
return { ok: false, error: err.message || String(err) }
|
||||
} finally {
|
||||
try {
|
||||
@@ -728,12 +891,15 @@ async function runBootstrap(opts) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
runBootstrap,
|
||||
export {
|
||||
buildPinArgs,
|
||||
buildPosixPinArgs,
|
||||
cachedScriptPath,
|
||||
hasExistingGitCheckout,
|
||||
installedAgentInstallScript,
|
||||
// Exposed for testability
|
||||
parseStageResult,
|
||||
resolveLocalInstallScript,
|
||||
resolveInstallScript,
|
||||
installedAgentInstallScript,
|
||||
cachedScriptPath
|
||||
resolveLocalInstallScript,
|
||||
runBootstrap
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Tests for electron/connection-config.cjs.
|
||||
* Tests for electron/connection-config.ts.
|
||||
*
|
||||
* Run with: node --test electron/connection-config.test.cjs
|
||||
* Run with: node --test electron/connection-config.test.ts
|
||||
* (Wire into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* These are the pure helpers behind the remote-gateway connection settings:
|
||||
@@ -10,26 +10,29 @@
|
||||
* and the OAuth session-cookie detector.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
const {
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
AT_COOKIE_VARIANTS,
|
||||
RT_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
cookiesHavePrivySession,
|
||||
cookiesHaveSession,
|
||||
modeIsRemoteLike,
|
||||
normalizeRemoteBaseUrl,
|
||||
normAuthMode,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
RT_COOKIE_VARIANTS,
|
||||
tokenPreview
|
||||
} = require('./connection-config.cjs')
|
||||
} from './connection-config'
|
||||
|
||||
// --- connectionScopeKey / normAuthMode ---
|
||||
|
||||
@@ -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', () => {
|
||||
@@ -73,6 +89,7 @@ test('profileRemoteOverride returns the per-profile remote with defaulted auth m
|
||||
coder: { mode: 'remote', url: ' https://coder.example.com/hermes ', token: { value: 'sek' } }
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(profileRemoteOverride(config, 'coder'), {
|
||||
url: 'https://coder.example.com/hermes',
|
||||
authMode: 'token',
|
||||
@@ -85,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)
|
||||
@@ -331,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', () => {
|
||||
@@ -365,6 +426,7 @@ test('resolveTestWsUrl (oauth, mint ok) builds a ?ticket= URL', async () => {
|
||||
const url = await resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
|
||||
mintTicket: async () => 'tkt-9'
|
||||
})
|
||||
|
||||
assert.equal(url, 'wss://gw.example.com/api/ws?ticket=tkt-9')
|
||||
})
|
||||
|
||||
@@ -376,13 +438,14 @@ test('resolveTestWsUrl (oauth, mint FAILS) throws — must NOT skip WS validatio
|
||||
throw new Error('401 ticket mint failed')
|
||||
}
|
||||
}),
|
||||
err => {
|
||||
(err: any) => {
|
||||
// Actionable, points the user at re-auth, and preserves the cause + flag
|
||||
// the boot overlay uses to offer a sign-in prompt.
|
||||
assert.match(err.message, /WebSocket ticket/i)
|
||||
assert.match(err.message, /sign in again/i)
|
||||
assert.equal(err.needsOauthLogin, true)
|
||||
assert.ok(err.cause instanceof Error)
|
||||
|
||||
return true
|
||||
}
|
||||
)
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* connection-config.cjs
|
||||
* connection-config.ts
|
||||
*
|
||||
* Pure, electron-free helpers for the desktop's remote-gateway connection
|
||||
* config: URL normalization, WS-URL construction (token vs OAuth ticket),
|
||||
* auth-mode classification, and the auth-mode coercion rules.
|
||||
*
|
||||
* Kept standalone (no `require('electron')`) so it can be unit-tested with
|
||||
* `node --test` — same pattern as backend-probes.cjs / bootstrap-platform.cjs.
|
||||
* main.cjs requires these and wires them into the electron-coupled IPC layer.
|
||||
* Kept standalone (no `import 'electron'`) so it can be unit-tested with
|
||||
* `node --test` — same pattern as backend-probes.ts / bootstrap-platform.ts.
|
||||
* main.ts requires these and wires them into the electron-coupled IPC layer.
|
||||
*
|
||||
* Background on the two auth models a remote gateway can use:
|
||||
* - 'token': legacy static dashboard session token. REST uses an
|
||||
@@ -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()
|
||||
|
||||
@@ -45,6 +54,7 @@ function normalizeRemoteBaseUrl(rawUrl) {
|
||||
}
|
||||
|
||||
let parsed
|
||||
|
||||
try {
|
||||
parsed = new URL(value)
|
||||
} catch (error) {
|
||||
@@ -83,7 +93,7 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
|
||||
* exercise the same transport the app actually uses.
|
||||
*
|
||||
* The OAuth ticket-minter is injected (`mintTicket(baseUrl) -> Promise<ticket>`)
|
||||
* so this stays electron-free and unit-testable; main.cjs passes the real
|
||||
* so this stays electron-free and unit-testable; main.ts passes the real
|
||||
* `mintGatewayWsTicket`.
|
||||
*
|
||||
* Return semantics:
|
||||
@@ -93,7 +103,7 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
|
||||
* - oauth, mint fails → THROWS (NOT a skip)
|
||||
*
|
||||
* The oauth-mint-failure throw is the important case: the real boot path
|
||||
* (resolveRemoteBackend in main.cjs) treats a mint failure as a hard
|
||||
* (resolveRemoteBackend in main.ts) treats a mint failure as a hard
|
||||
* "session expired" auth error and refuses to connect. Swallowing it here
|
||||
* would re-introduce the exact false-positive this test exists to catch —
|
||||
* HTTP /api/status passes, the test reports "reachable", then the renderer
|
||||
@@ -105,13 +115,16 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
|
||||
* @param {{ mintTicket: (baseUrl: string) => Promise<string> }} deps
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
|
||||
async function resolveTestWsUrl(baseUrl, authMode, token, deps: any = {}) {
|
||||
if (authMode === 'oauth') {
|
||||
const mintTicket = deps.mintTicket
|
||||
|
||||
if (typeof mintTicket !== 'function') {
|
||||
throw new Error('resolveTestWsUrl: a mintTicket function is required in OAuth mode.')
|
||||
}
|
||||
|
||||
let ticket
|
||||
|
||||
try {
|
||||
ticket = await mintTicket(baseUrl)
|
||||
} catch (error) {
|
||||
@@ -119,15 +132,19 @@ async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
|
||||
'Reached the gateway over HTTP, but could not mint a WebSocket ticket for the OAuth session ' +
|
||||
'(it may have expired). Open Settings → Gateway and sign in again.'
|
||||
)
|
||||
err.needsOauthLogin = true
|
||||
|
||||
;(err as any).needsOauthLogin = true
|
||||
err.cause = error
|
||||
throw err
|
||||
}
|
||||
|
||||
return buildGatewayWsUrlWithTicket(baseUrl, ticket)
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return null
|
||||
}
|
||||
|
||||
return buildGatewayWsUrl(baseUrl, token)
|
||||
}
|
||||
|
||||
@@ -142,23 +159,36 @@ 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.cjs 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
|
||||
}
|
||||
|
||||
const url = String(entry.url || '').trim()
|
||||
|
||||
if (!url) {
|
||||
return null
|
||||
}
|
||||
@@ -172,18 +202,21 @@ function profileRemoteOverride(config, profile) {
|
||||
* query parameter. Local pooled backends and per-profile remote overrides do not
|
||||
* need this: they already run against a backend scoped to the target profile.
|
||||
*/
|
||||
function pathWithGlobalRemoteProfile(path, profile, opts = {}) {
|
||||
function pathWithGlobalRemoteProfile(path, profile, opts: any = {}) {
|
||||
const scopedProfile = connectionScopeKey(profile)
|
||||
|
||||
if (!scopedProfile || !opts.globalRemote || opts.profileRemoteOverride) {
|
||||
return path
|
||||
}
|
||||
|
||||
const rawPath = String(path || '')
|
||||
|
||||
if (!rawPath) {
|
||||
return path
|
||||
}
|
||||
|
||||
let parsed
|
||||
|
||||
try {
|
||||
parsed = new URL(rawPath, 'http://hermes.local')
|
||||
} catch {
|
||||
@@ -224,9 +257,18 @@ function authModeFromStatus(statusBody) {
|
||||
* Returns 'oauth' | 'token'.
|
||||
*/
|
||||
function resolveAuthMode(inputAuthMode, existingAuthMode) {
|
||||
if (inputAuthMode === 'oauth') return 'oauth'
|
||||
if (inputAuthMode === 'token') return 'token'
|
||||
if (existingAuthMode === 'oauth') return 'oauth'
|
||||
if (inputAuthMode === 'oauth') {
|
||||
return 'oauth'
|
||||
}
|
||||
|
||||
if (inputAuthMode === 'token') {
|
||||
return 'token'
|
||||
}
|
||||
|
||||
if (existingAuthMode === 'oauth') {
|
||||
return 'oauth'
|
||||
}
|
||||
|
||||
return 'token'
|
||||
}
|
||||
|
||||
@@ -242,7 +284,10 @@ function resolveAuthMode(inputAuthMode, existingAuthMode) {
|
||||
* need to know whether an unexpired access token is present right now.
|
||||
*/
|
||||
function cookiesHaveSession(cookies) {
|
||||
if (!Array.isArray(cookies)) return false
|
||||
if (!Array.isArray(cookies)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value)
|
||||
}
|
||||
|
||||
@@ -260,24 +305,46 @@ function cookiesHaveSession(cookies) {
|
||||
* the RT is also dead/revoked).
|
||||
*/
|
||||
function cookiesHaveLiveSession(cookies) {
|
||||
if (!Array.isArray(cookies)) return false
|
||||
if (!Array.isArray(cookies)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return cookies.some(c => c && c.value && (AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name)))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
* 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,
|
||||
RT_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
cookiesHavePrivySession,
|
||||
cookiesHaveSession,
|
||||
modeIsRemoteLike,
|
||||
normalizeRemoteBaseUrl,
|
||||
normAuthMode,
|
||||
pathWithGlobalRemoteProfile,
|
||||
PRIVY_SESSION_COOKIE_VARIANTS,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
RT_COOKIE_VARIANTS,
|
||||
tokenPreview
|
||||
}
|
||||
@@ -1,21 +1,22 @@
|
||||
/**
|
||||
* Tests for electron/dashboard-token.cjs.
|
||||
* Tests for electron/dashboard-token.ts.
|
||||
*
|
||||
* Run with: node --test electron/dashboard-token.test.cjs
|
||||
* Run with: node --test electron/dashboard-token.test.ts
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
const {
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
adoptServedDashboardToken,
|
||||
dashboardIndexUrl,
|
||||
extractInjectedDashboardToken,
|
||||
fetchPublicText,
|
||||
isForeignBackendToken,
|
||||
resolveServedDashboardToken
|
||||
} = require('./dashboard-token.cjs')
|
||||
} from './dashboard-token'
|
||||
|
||||
test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
|
||||
const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>'
|
||||
@@ -39,9 +40,11 @@ test('dashboardIndexUrl preserves dashboard path prefixes', () => {
|
||||
|
||||
test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
|
||||
const logs = []
|
||||
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async url => {
|
||||
assert.equal(url, 'http://127.0.0.1:9120/')
|
||||
|
||||
return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
|
||||
},
|
||||
rememberLog: line => logs.push(line)
|
||||
@@ -100,8 +103,9 @@ test('isForeignBackendToken only flags a mismatched token from a dead child', ()
|
||||
[{ servedToken: null, spawnToken: 'mine', childAlive: false }, false],
|
||||
[{ servedToken: '', spawnToken: 'mine', childAlive: false }, false]
|
||||
]
|
||||
|
||||
for (const [input, expected] of cases) {
|
||||
assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input))
|
||||
assert.equal(isForeignBackendToken(input as any), expected, JSON.stringify(input))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -128,6 +132,7 @@ test('adoptServedDashboardToken refuses a foreign token when our child is dead',
|
||||
|
||||
test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => {
|
||||
const logs = []
|
||||
|
||||
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
childAlive: () => true,
|
||||
fetchText: async () => {
|
||||
@@ -9,29 +9,39 @@
|
||||
|
||||
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
|
||||
|
||||
async function fetchPublicText(url, options = {}) {
|
||||
async function fetchPublicText(url, options: any = {}) {
|
||||
const { protocol } = new URL(url)
|
||||
|
||||
if (protocol !== 'http:' && protocol !== 'https:') {
|
||||
throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
|
||||
}
|
||||
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
|
||||
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
|
||||
if (error.name === 'TimeoutError') {
|
||||
throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
throw error
|
||||
})
|
||||
|
||||
const text = await res.text()
|
||||
|
||||
if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`${res.status}: ${text || res.statusText}`)
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
function extractInjectedDashboardToken(html) {
|
||||
const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
|
||||
if (!match) return null
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(match[1])
|
||||
} catch {
|
||||
@@ -43,11 +53,13 @@ function dashboardIndexUrl(baseUrl) {
|
||||
return `${String(baseUrl || '').replace(/\/+$/, '')}/`
|
||||
}
|
||||
|
||||
async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
|
||||
async function resolveServedDashboardToken(baseUrl, fallbackToken, options: any = {}) {
|
||||
const fetchText = options.fetchText || fetchPublicText
|
||||
|
||||
const html = await fetchText(dashboardIndexUrl(baseUrl), {
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
|
||||
})
|
||||
|
||||
const servedToken = extractInjectedDashboardToken(html)
|
||||
|
||||
if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
|
||||
@@ -76,6 +88,7 @@ function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
|
||||
async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) {
|
||||
const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
|
||||
options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
|
||||
|
||||
return spawnToken
|
||||
})
|
||||
|
||||
@@ -88,10 +101,10 @@ async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, labe
|
||||
return servedToken
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
|
||||
export {
|
||||
adoptServedDashboardToken,
|
||||
dashboardIndexUrl,
|
||||
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
|
||||
extractInjectedDashboardToken,
|
||||
fetchPublicText,
|
||||
isForeignBackendToken,
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Tests for electron/desktop-uninstall.cjs.
|
||||
* Tests for electron/desktop-uninstall.ts.
|
||||
*
|
||||
* Run with: node --test electron/desktop-uninstall.test.cjs
|
||||
* Run with: node --test electron/desktop-uninstall.test.ts
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* These are the pure helpers behind the desktop Chat GUI uninstaller: the
|
||||
@@ -9,19 +9,20 @@
|
||||
* cleanup-script builders (POSIX + Windows).
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
const {
|
||||
UNINSTALL_MODES,
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
buildPosixCleanupScript,
|
||||
buildWindowsCleanupScript,
|
||||
modeRemovesAgent,
|
||||
modeRemovesUserData,
|
||||
resolveRemovableAppPath,
|
||||
shouldRemoveAppBundle,
|
||||
UNINSTALL_MODES,
|
||||
uninstallArgsForMode
|
||||
} = require('./desktop-uninstall.cjs')
|
||||
} from './desktop-uninstall'
|
||||
|
||||
// --- uninstallArgsForMode ---
|
||||
|
||||
@@ -132,6 +133,7 @@ test('buildPosixCleanupScript waits for the PID, runs the uninstall module, remo
|
||||
appPath: '/opt/hermes/linux-unpacked',
|
||||
hermesHome: '/home/x/.hermes'
|
||||
})
|
||||
|
||||
assert.match(script, /^#!\/bin\/bash/)
|
||||
assert.match(script, /pid=4321/)
|
||||
assert.match(script, /kill -0 "\$pid"/)
|
||||
@@ -152,6 +154,7 @@ test('buildPosixCleanupScript exports PYTHONPATH when pythonPath is set (lite/fu
|
||||
appPath: null,
|
||||
hermesHome: '/home/x/.hermes'
|
||||
})
|
||||
|
||||
// System python + source on PYTHONPATH so import hermes_cli works while the
|
||||
// venv is torn down.
|
||||
assert.match(script, /export PYTHONPATH='\/home\/x\/\.hermes\/hermes-agent'/)
|
||||
@@ -168,6 +171,7 @@ test('buildPosixCleanupScript omits PYTHONPATH when pythonPath is null (gui)', (
|
||||
appPath: null,
|
||||
hermesHome: '/h'
|
||||
})
|
||||
|
||||
assert.doesNotMatch(script, /export PYTHONPATH/)
|
||||
})
|
||||
|
||||
@@ -181,6 +185,7 @@ test('buildPosixCleanupScript omits the bundle rm when appPath is null', () => {
|
||||
appPath: null,
|
||||
hermesHome: '/h'
|
||||
})
|
||||
|
||||
assert.doesNotMatch(script, /rm -rf '\//)
|
||||
// Still runs the uninstall.
|
||||
assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'lite'/)
|
||||
@@ -196,6 +201,7 @@ test('buildPosixCleanupScript single-quote-escapes paths with apostrophes', () =
|
||||
appPath: null,
|
||||
hermesHome: '/h'
|
||||
})
|
||||
|
||||
// The apostrophe is closed-escaped-reopened so the shell sees the literal.
|
||||
assert.match(script, /'\/home\/o'\\''brien\/python'/)
|
||||
})
|
||||
@@ -212,6 +218,7 @@ test('buildWindowsCleanupScript waits (bounded) for PID, runs uninstall, rmdir b
|
||||
appPath: 'C:\\Users\\x\\AppData\\Local\\Programs\\Hermes',
|
||||
hermesHome: 'C:\\Users\\x\\AppData\\Local\\hermes'
|
||||
})
|
||||
|
||||
assert.match(script, /@echo off/)
|
||||
assert.match(script, /set "PID=9988"/)
|
||||
// PYTHONPATH set so a system python can import hermes_cli from source.
|
||||
@@ -238,6 +245,7 @@ test('buildWindowsCleanupScript omits PYTHONPATH + rmdir when not needed (gui, n
|
||||
appPath: null,
|
||||
hermesHome: 'C:\\h'
|
||||
})
|
||||
|
||||
assert.doesNotMatch(script, /rmdir/)
|
||||
assert.doesNotMatch(script, /set "PYTHONPATH=/)
|
||||
})
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* desktop-uninstall.cjs
|
||||
* desktop-uninstall.ts
|
||||
*
|
||||
* Pure, electron-free helpers for the desktop Chat GUI uninstaller. These map
|
||||
* the three user-facing uninstall modes to the `hermes uninstall` CLI flags,
|
||||
* resolve the running app bundle/exe so a detached cleanup script can remove
|
||||
* it after the app quits, and build that cleanup script for each OS.
|
||||
*
|
||||
* Kept standalone (no `require('electron')`) so it can be unit-tested with
|
||||
* `node --test` — same pattern as connection-config.cjs / backend-probes.cjs.
|
||||
* main.cjs requires these and wires them into the electron-coupled IPC layer.
|
||||
* Kept standalone (no ` import 'electron'`) so it can be unit-tested with
|
||||
* `node --test` — same pattern as connection-config.ts / backend-probes.ts.
|
||||
* main.ts requires these and wires them into the electron-coupled IPC layer.
|
||||
*
|
||||
* The three modes mirror the CLI's options exactly:
|
||||
* - 'gui' → remove ONLY the Chat GUI, keep the agent + all user data.
|
||||
@@ -23,10 +23,10 @@
|
||||
* app bundle (locked on macOS/Windows while the process is alive). So we hand
|
||||
* the work to a detached child that waits for this app's PID to exit, runs the
|
||||
* Python uninstall, then removes the app bundle — then the app quits. Same
|
||||
* shape as the self-update swap-and-relaunch flow already in main.cjs.
|
||||
* shape as the self-update swap-and-relaunch flow already in main.ts.
|
||||
*/
|
||||
|
||||
const path = require('node:path')
|
||||
import path from 'node:path'
|
||||
|
||||
const UNINSTALL_MODES = ['gui', 'lite', 'full']
|
||||
|
||||
@@ -41,6 +41,7 @@ function uninstallArgsForMode(mode) {
|
||||
if (!UNINSTALL_MODES.includes(mode)) {
|
||||
throw new Error(`Unknown uninstall mode: ${mode}`)
|
||||
}
|
||||
|
||||
return ['-m', 'hermes_cli.uninstall', '--mode', mode]
|
||||
}
|
||||
|
||||
@@ -65,9 +66,12 @@ function modeRemovesUserData(mode) {
|
||||
* Returns null when we can't confidently identify a removable bundle (e.g.
|
||||
* running from a dev checkout, or a system-package install we must not rmtree).
|
||||
*/
|
||||
function resolveRemovableAppPath(execPath, platform, env = {}) {
|
||||
function resolveRemovableAppPath(execPath, platform, env: any = {}) {
|
||||
const exe = String(execPath || '')
|
||||
if (!exe) return null
|
||||
|
||||
if (!exe) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Use the path flavor that matches the TARGET platform, not the host running
|
||||
// this code — so the Windows branch parses backslash paths correctly even
|
||||
@@ -79,22 +83,37 @@ function resolveRemovableAppPath(execPath, platform, env = {}) {
|
||||
const macOsDir = p.dirname(exe) // …/Contents/MacOS
|
||||
const contents = p.dirname(macOsDir) // …/Contents
|
||||
const appBundle = p.dirname(contents) // …/Hermes.app
|
||||
if (appBundle.endsWith('.app')) return appBundle
|
||||
|
||||
if (appBundle.endsWith('.app')) {
|
||||
return appBundle
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
// NSIS per-user installs Hermes.exe directly in the install dir.
|
||||
const dir = p.dirname(exe)
|
||||
if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) return dir
|
||||
|
||||
if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) {
|
||||
return dir
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Linux: an AppImage exposes its own path via the APPIMAGE env var.
|
||||
if (env.APPIMAGE) return env.APPIMAGE
|
||||
if (env.APPIMAGE) {
|
||||
return env.APPIMAGE
|
||||
}
|
||||
|
||||
// Unpacked electron-builder tree: …/linux-unpacked/hermes
|
||||
const dir = p.dirname(exe)
|
||||
if (/-unpacked$/.test(dir)) return dir
|
||||
|
||||
if (/-unpacked$/.test(dir)) {
|
||||
return dir
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -121,6 +140,7 @@ function shouldRemoveAppBundle(isPackaged, appPath) {
|
||||
*/
|
||||
function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) {
|
||||
const q = s => `'${String(s).replace(/'/g, `'\\''`)}'`
|
||||
|
||||
const lines = [
|
||||
'#!/bin/bash',
|
||||
'set -u',
|
||||
@@ -135,16 +155,21 @@ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot,
|
||||
'fi',
|
||||
`export HERMES_HOME=${q(hermesHome)}`
|
||||
]
|
||||
|
||||
if (pythonPath) {
|
||||
lines.push(`export PYTHONPATH=${q(pythonPath)}\${PYTHONPATH:+:$PYTHONPATH}`)
|
||||
}
|
||||
|
||||
lines.push(`cd ${q(agentRoot)} 2>/dev/null || true`, `${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`)
|
||||
|
||||
if (appPath) {
|
||||
lines.push(`rm -rf ${q(appPath)} || true`)
|
||||
}
|
||||
|
||||
// Self-delete the script.
|
||||
lines.push('rm -f "$0" 2>/dev/null || true')
|
||||
lines.push('')
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
@@ -180,15 +205,18 @@ function buildWindowsCleanupScript({
|
||||
// under %LOCALAPPDATA% never contain them). `&`/`^` in a path would still be
|
||||
// a problem, but Hermes install paths don't use them.
|
||||
const q = s => `"${String(s).replace(/"/g, '')}"`
|
||||
|
||||
const lines = [
|
||||
'@echo off',
|
||||
'setlocal enableextensions',
|
||||
`set "HERMES_HOME=${String(hermesHome).replace(/"/g, '')}"`,
|
||||
`set "PID=${pid}"`
|
||||
]
|
||||
|
||||
if (pythonPath) {
|
||||
lines.push(`set "PYTHONPATH=${String(pythonPath).replace(/"/g, '')};%PYTHONPATH%"`)
|
||||
}
|
||||
|
||||
lines.push(
|
||||
'set /a waited=0',
|
||||
':waitloop',
|
||||
@@ -206,6 +234,7 @@ function buildWindowsCleanupScript({
|
||||
`cd /d ${q(agentRoot)}`,
|
||||
`${q(pythonExe)} ${uninstallArgs.map(q).join(' ')}`
|
||||
)
|
||||
|
||||
if (appPath) {
|
||||
lines.push(
|
||||
'set /a tries=0',
|
||||
@@ -220,18 +249,20 @@ function buildWindowsCleanupScript({
|
||||
':rmdone'
|
||||
)
|
||||
}
|
||||
|
||||
lines.push('del "%~f0"')
|
||||
lines.push('')
|
||||
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
UNINSTALL_MODES,
|
||||
export {
|
||||
buildPosixCleanupScript,
|
||||
buildWindowsCleanupScript,
|
||||
modeRemovesAgent,
|
||||
modeRemovesUserData,
|
||||
resolveRemovableAppPath,
|
||||
shouldRemoveAppBundle,
|
||||
UNINSTALL_MODES,
|
||||
uninstallArgsForMode
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
'use strict'
|
||||
|
||||
const { session } = require('electron')
|
||||
import { session } from 'electron'
|
||||
|
||||
const EMBED_SESSION_PARTITION = 'persist:hermes-embed'
|
||||
const EMBED_REFERER = 'https://www.youtube.com/'
|
||||
|
||||
const YOUTUBE_REFERER_HOST_RE =
|
||||
/(^|\.)(youtube\.com|youtube-nocookie\.com|googlevideo\.com|ytimg\.com|youtubei\.googleapis\.com)$/i
|
||||
|
||||
@@ -23,6 +22,7 @@ function installEmbedRefererForSession(embedSession) {
|
||||
|
||||
if (!YOUTUBE_REFERER_HOST_RE.test(host)) {
|
||||
callback({ requestHeaders: details.requestHeaders })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -45,4 +45,4 @@ function installEmbedReferer() {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { installEmbedReferer }
|
||||
export { installEmbedReferer }
|
||||
@@ -1,19 +1,18 @@
|
||||
'use strict'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
import { test } from 'vitest'
|
||||
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
import { readDirForIpc } from './fs-read-dir'
|
||||
|
||||
function mkTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-fs-read-dir-'))
|
||||
}
|
||||
|
||||
function fakeDirent(name, flags = {}) {
|
||||
function fakeDirent(name, flags: any = {}) {
|
||||
return {
|
||||
name,
|
||||
isDirectory: () => Boolean(flags.directory),
|
||||
@@ -109,10 +108,12 @@ test('readDirForIpc accepts file URLs for directories', async () => {
|
||||
|
||||
test('readDirForIpc returns invalid-path for blank or non-string input', async () => {
|
||||
let readdirCalls = 0
|
||||
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => {
|
||||
readdirCalls += 1
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -126,10 +127,12 @@ test('readDirForIpc returns invalid-path for blank or non-string input', async (
|
||||
|
||||
test('readDirForIpc rejects Windows device paths before readdir', async () => {
|
||||
let readdirCalls = 0
|
||||
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => {
|
||||
readdirCalls += 1
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -224,6 +227,7 @@ test('readDirForIpc allows expanding symlink or junction directories outside the
|
||||
fs.writeFileSync(path.join(outside, 'outside.txt'), 'ok')
|
||||
|
||||
const linkPath = path.join(root, 'outside-link')
|
||||
|
||||
try {
|
||||
fs.symlinkSync(outside, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
} catch (error) {
|
||||
@@ -252,6 +256,7 @@ test('readDirForIpc stats symbolic links and unknown entries without dropping th
|
||||
const input = path.join('virtual-root')
|
||||
const resolved = path.resolve(input)
|
||||
const statCalls = []
|
||||
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => [
|
||||
@@ -266,9 +271,11 @@ test('readDirForIpc stats symbolic links and unknown entries without dropping th
|
||||
}
|
||||
|
||||
statCalls.push(fullPath)
|
||||
|
||||
if (fullPath.endsWith(`${path.sep}linked-dir`)) {
|
||||
return { isDirectory: () => true }
|
||||
}
|
||||
|
||||
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
|
||||
}
|
||||
}
|
||||
@@ -301,12 +308,15 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out
|
||||
let peak = 0
|
||||
let releaseStats
|
||||
let markFirstStatStarted
|
||||
|
||||
const statsReleased = new Promise(resolve => {
|
||||
releaseStats = resolve
|
||||
})
|
||||
|
||||
const firstStatStarted = new Promise(resolve => {
|
||||
markFirstStatStarted = resolve
|
||||
})
|
||||
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => [
|
||||
@@ -326,6 +336,7 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out
|
||||
active -= 1
|
||||
|
||||
const name = path.basename(fullPath)
|
||||
|
||||
if (name === failedName) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
'use strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { resolveDirectoryForIpc } = require('./hardening.cjs')
|
||||
import { resolveDirectoryForIpc } from './hardening'
|
||||
|
||||
const FS_READDIR_STAT_CONCURRENCY = 16
|
||||
|
||||
@@ -37,7 +36,9 @@ function direntIsSymbolicLink(dirent) {
|
||||
}
|
||||
|
||||
function shouldStatDirent(dirent) {
|
||||
if (direntIsDirectory(dirent)) return false
|
||||
if (direntIsDirectory(dirent)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return direntIsSymbolicLink(dirent) || !direntIsFile(dirent)
|
||||
}
|
||||
@@ -70,13 +71,13 @@ async function mapWithStatConcurrency(items, mapper) {
|
||||
}
|
||||
|
||||
const workerCount = Math.min(FS_READDIR_STAT_CONCURRENCY, items.length)
|
||||
const workers = Array.from({ length: workerCount }, () => runWorker())
|
||||
const workers = Array.from({ length: workerCount } as any, () => runWorker())
|
||||
await Promise.all(workers)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async function readDirForIpc(dirPath, options = {}) {
|
||||
async function readDirForIpc(dirPath, options: any = {}) {
|
||||
const fsImpl = options.fs || fs
|
||||
let resolved
|
||||
|
||||
@@ -102,6 +103,4 @@ async function readDirForIpc(dirPath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readDirForIpc
|
||||
}
|
||||
export { readDirForIpc }
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Tests for electron/gateway-ws-probe.cjs.
|
||||
* Tests for electron/gateway-ws-probe.ts.
|
||||
*
|
||||
* Run with: node --test electron/gateway-ws-probe.test.cjs
|
||||
* Run with: node --test electron/gateway-ws-probe.test.ts
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* The probe drives a real WebSocket handshake for the "Test remote" button.
|
||||
@@ -9,16 +9,21 @@
|
||||
* outcome (open, frame, error, early close, never-opens) without a network.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { probeGatewayWebSocket } from './gateway-ws-probe'
|
||||
|
||||
// Minimal WebSocket double: records listeners synchronously (the probe attaches
|
||||
// them in its executor) and exposes emit() so the test can replay events.
|
||||
function makeFakeWs() {
|
||||
function makeFakeWs(): { FakeWs: new (url: string) => any; instances: any[] } {
|
||||
const instances = []
|
||||
|
||||
class FakeWs {
|
||||
url: string
|
||||
closed = false
|
||||
listeners: Record<string, any[]> = {}
|
||||
constructor(url) {
|
||||
this.url = url
|
||||
this.listeners = {}
|
||||
@@ -32,9 +37,12 @@ function makeFakeWs() {
|
||||
this.closed = true
|
||||
}
|
||||
emit(type, event) {
|
||||
for (const fn of this.listeners[type] || []) fn(event)
|
||||
for (const fn of this.listeners[type] || []) {
|
||||
fn(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { FakeWs, instances }
|
||||
}
|
||||
|
||||
@@ -51,11 +59,13 @@ test('probe resolves ok when the socket opens and stays open', async () => {
|
||||
|
||||
test('probe resolves ok immediately when a frame arrives', async () => {
|
||||
const { FakeWs, instances } = makeFakeWs()
|
||||
|
||||
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', {
|
||||
WebSocketImpl: FakeWs,
|
||||
connectTimeoutMs: 1_000,
|
||||
readyGraceMs: 10_000 // long grace: success must come from the frame, not the timer
|
||||
})
|
||||
|
||||
instances[0].emit('open')
|
||||
instances[0].emit('message', { data: '{"jsonrpc":"2.0"}' })
|
||||
const result = await promise
|
||||
@@ -95,11 +105,13 @@ test('probe fails when the gateway accepts then immediately closes (auth rejecte
|
||||
|
||||
test('probe times out when the socket never opens', async () => {
|
||||
const { FakeWs } = makeFakeWs()
|
||||
|
||||
const result = await probeGatewayWebSocket('ws://host/api/ws?token=t', {
|
||||
WebSocketImpl: FakeWs,
|
||||
connectTimeoutMs: 20,
|
||||
readyGraceMs: 10
|
||||
})
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.reason, /Timed out/)
|
||||
})
|
||||
@@ -36,13 +36,16 @@ const DEFAULT_READY_GRACE_MS = 750
|
||||
* Attempt a live WebSocket connection and classify the outcome.
|
||||
*
|
||||
* @param {string} wsUrl - Fully-formed ws(s):// URL including the credential.
|
||||
* @param {object} [options]
|
||||
* @param {new (url: string) => any} [options.WebSocketImpl] - WebSocket ctor.
|
||||
* @param {number} [options.connectTimeoutMs]
|
||||
* @param {number} [options.readyGraceMs]
|
||||
* @returns {Promise<{ ok: boolean, reason?: string }>}
|
||||
*/
|
||||
function probeGatewayWebSocket(wsUrl, options = {}) {
|
||||
function probeGatewayWebSocket<T>(
|
||||
wsUrl: string,
|
||||
options: {
|
||||
WebSocketImpl?: any
|
||||
connectTimeoutMs?: number
|
||||
readyGraceMs?: number
|
||||
} = {}
|
||||
) {
|
||||
const WebSocketImpl = options.WebSocketImpl
|
||||
const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
|
||||
const readyGraceMs = options.readyGraceMs ?? DEFAULT_READY_GRACE_MS
|
||||
@@ -54,7 +57,7 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
return new Promise<any>(resolve => {
|
||||
let settled = false
|
||||
let opened = false
|
||||
let connectTimer = null
|
||||
@@ -66,6 +69,7 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
|
||||
clearTimeout(connectTimer)
|
||||
connectTimer = null
|
||||
}
|
||||
|
||||
if (graceTimer !== null) {
|
||||
clearTimeout(graceTimer)
|
||||
graceTimer = null
|
||||
@@ -73,14 +77,19 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
|
||||
}
|
||||
|
||||
const finish = result => {
|
||||
if (settled) return
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
clearTimers()
|
||||
|
||||
try {
|
||||
socket?.close?.()
|
||||
} catch {
|
||||
// ignore — best effort teardown
|
||||
}
|
||||
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
@@ -91,11 +100,15 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
|
||||
ok: false,
|
||||
reason: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const onOpen = () => {
|
||||
if (settled) return
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
opened = true
|
||||
// Upgrade accepted. Give the server a brief window to reject the
|
||||
// credential post-handshake (early close) before declaring success.
|
||||
@@ -118,7 +131,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
|
||||
}
|
||||
|
||||
const onClose = event => {
|
||||
if (settled) return
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (opened) {
|
||||
// Opened, then closed inside the grace window: the upgrade was accepted
|
||||
// but the session was refused (e.g. ws-ticket/token rejected, or a
|
||||
@@ -127,8 +143,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
|
||||
ok: false,
|
||||
reason: closeReason(event, 'The gateway accepted the connection then closed it (credential rejected?).')
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
finish({
|
||||
ok: false,
|
||||
reason: closeReason(event, 'The gateway closed the WebSocket before it opened.')
|
||||
@@ -154,8 +172,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
|
||||
function addListener(socket, type, handler) {
|
||||
if (typeof socket.addEventListener === 'function') {
|
||||
socket.addEventListener(type, handler)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Node's global WebSocket implements addEventListener; this fallback keeps the
|
||||
// helper usable with the `ws` package's EventEmitter shape too.
|
||||
if (typeof socket.on === 'function') {
|
||||
@@ -164,25 +184,44 @@ function addListener(socket, type, handler) {
|
||||
}
|
||||
|
||||
function extractErrorReason(event) {
|
||||
if (!event) return ''
|
||||
if (event instanceof Error) return event.message
|
||||
if (!event) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (event instanceof Error) {
|
||||
return event.message
|
||||
}
|
||||
|
||||
const err = event.error || event.message
|
||||
if (err instanceof Error) return err.message
|
||||
if (typeof err === 'string') return err
|
||||
|
||||
if (err instanceof Error) {
|
||||
return err.message
|
||||
}
|
||||
|
||||
if (typeof err === 'string') {
|
||||
return err
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function closeReason(event, fallback) {
|
||||
const code = event && typeof event.code === 'number' ? event.code : null
|
||||
const reason = event && typeof event.reason === 'string' ? event.reason.trim() : ''
|
||||
if (code && reason) return `${fallback} (code ${code}: ${reason})`
|
||||
if (code) return `${fallback} (code ${code})`
|
||||
if (reason) return `${fallback} (${reason})`
|
||||
|
||||
if (code && reason) {
|
||||
return `${fallback} (code ${code}: ${reason})`
|
||||
}
|
||||
|
||||
if (code) {
|
||||
return `${fallback} (code ${code})`
|
||||
}
|
||||
|
||||
if (reason) {
|
||||
return `${fallback} (${reason})`
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
DEFAULT_READY_GRACE_MS,
|
||||
probeGatewayWebSocket
|
||||
}
|
||||
export { DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READY_GRACE_MS, probeGatewayWebSocket }
|
||||
@@ -1,14 +1,12 @@
|
||||
'use strict'
|
||||
|
||||
// Repo-first discovery: walk bounded roots for git repos using only Node's `fs`
|
||||
// — no native addon, so it just works for anyone who pulls main (no
|
||||
// electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git`
|
||||
// (don't descend into a repo), cap depth, and skip heavy non-repo trees so the
|
||||
// first scan stays fast. Results are cached by the backend after the first run.
|
||||
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const fsp = fs.promises
|
||||
|
||||
@@ -36,14 +34,14 @@ async function mapLimit(items, limit, fn) {
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) } as any, worker))
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan `roots` (default: the home dir) for git repositories. Returns deduped
|
||||
* `{ root, label }` entries. `options.maxDepth` caps recursion (default 3).
|
||||
*/
|
||||
async function scanGitRepos(roots, options = {}) {
|
||||
async function scanGitRepos(roots, options: any = {}) {
|
||||
const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH
|
||||
const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()]
|
||||
const found = new Map()
|
||||
@@ -54,6 +52,7 @@ async function scanGitRepos(roots, options = {}) {
|
||||
}
|
||||
|
||||
let entries
|
||||
|
||||
try {
|
||||
entries = await fsp.readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
@@ -73,6 +72,7 @@ async function scanGitRepos(roots, options = {}) {
|
||||
}
|
||||
|
||||
const subdirs = []
|
||||
|
||||
for (const entry of entries) {
|
||||
// Real directories only (skip symlinks to avoid loops), no hidden dirs, no
|
||||
// known heavy trees.
|
||||
@@ -93,4 +93,4 @@ async function scanGitRepos(roots, options = {}) {
|
||||
return [...found.entries()].map(([root, label]) => ({ label, root }))
|
||||
}
|
||||
|
||||
module.exports = { scanGitRepos }
|
||||
export { scanGitRepos }
|
||||
@@ -1,9 +1,8 @@
|
||||
'use strict'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
import { test } from 'vitest'
|
||||
|
||||
const { resolveRenamePath } = require('./git-review-ops.cjs')
|
||||
import { resolveRenamePath } from './git-review-ops'
|
||||
|
||||
test('resolveRenamePath: plain path is unchanged', () => {
|
||||
assert.equal(resolveRenamePath('src/a.ts'), 'src/a.ts')
|
||||
@@ -1,37 +1,16 @@
|
||||
'use strict'
|
||||
|
||||
// Git ops backing the coding rail + Codex-style review pane. Built on `simple-git`
|
||||
// (a maintained wrapper around the system git binary — same git the rest of the
|
||||
// app shells to, no native build) so we read structured status()/diffSummary()
|
||||
// results instead of hand-parsing porcelain. Reads degrade to null/empty on a
|
||||
// non-repo / remote backend; mutations reject so the renderer can toast.
|
||||
|
||||
const { execFile } = require('node:child_process')
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
import { execFile } from 'node:child_process'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
// `simple-git` is a pure-JS runtime dep that workspace dedup hoists into the
|
||||
// repo-root node_modules. Packaged builds set `files:` in package.json, which
|
||||
// excludes node_modules from the asar, so the normal require() fails at launch
|
||||
// (issue #52735: "Cannot find module 'simple-git'"). We ship the dep's
|
||||
// closure under resources/native-deps/vendor/node_modules/ via extraResources
|
||||
// + scripts/stage-native-deps.cjs, and resolve from there when the hoisted
|
||||
// require() isn't reachable. The `vendor/` nesting matters: electron-builder
|
||||
// drops a node_modules dir at the root of an extraResources copy but keeps a
|
||||
// nested one. Dev mode never hits the fallback -- Node's normal lookup finds
|
||||
// the hoisted copy.
|
||||
let simpleGit
|
||||
try {
|
||||
simpleGit = require('simple-git')
|
||||
} catch {
|
||||
const resourcesPath = process.resourcesPath
|
||||
if (!resourcesPath) {
|
||||
throw new Error("git-review IPC: 'simple-git' not found and no resourcesPath to fall back to")
|
||||
}
|
||||
simpleGit = require(path.join(resourcesPath, 'native-deps', 'vendor', 'node_modules', 'simple-git'))
|
||||
}
|
||||
import simpleGit from 'simple-git'
|
||||
|
||||
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
|
||||
import { resolveRequestedPathForIpc } from './hardening'
|
||||
|
||||
const COMMIT_CONTEXT_DIFF_MAX_CHARS = 120_000
|
||||
const COMMIT_CONTEXT_UNTRACKED_MAX = 80
|
||||
@@ -52,7 +31,7 @@ function ghEnv(ghBin) {
|
||||
|
||||
// Run the `gh` CLI in a repo. Resolves { ok, stdout } so callers branch on
|
||||
// availability/auth without a throw. gh missing/unauthed → ok:false.
|
||||
function runGh(args, cwd, ghBin) {
|
||||
function runGh(args, cwd, ghBin): Promise<{ ok: boolean; stdout: string }> {
|
||||
return new Promise(resolve => {
|
||||
execFile(
|
||||
ghBin || 'gh',
|
||||
@@ -260,10 +239,11 @@ async function reviewList(repoPath, scope, baseRef, gitBin) {
|
||||
|
||||
const range = scope === 'branch' ? `${base}...HEAD` : base
|
||||
const summary = await git.diffSummary([range])
|
||||
|
||||
const files = summary.files.map(file => ({
|
||||
path: resolveRenamePath(file.file),
|
||||
added: file.binary ? 0 : file.insertions,
|
||||
removed: file.binary ? 0 : file.deletions,
|
||||
added: 'insertions' in file ? file.insertions : 0,
|
||||
removed: 'deletions' in file ? file.deletions : 0,
|
||||
status: 'M',
|
||||
staged: false
|
||||
}))
|
||||
@@ -291,6 +271,7 @@ async function reviewList(repoPath, scope, baseRef, gitBin) {
|
||||
git.diffSummary(['--cached']),
|
||||
git.diffSummary([])
|
||||
])
|
||||
|
||||
const stagedCounts = countsByPath(staged)
|
||||
const unstagedCounts = countsByPath(unstaged)
|
||||
|
||||
@@ -495,6 +476,7 @@ async function reviewCommitContext(repoPath, gitBin) {
|
||||
const safe = args => git.diff(args).catch(() => '')
|
||||
|
||||
let status
|
||||
|
||||
try {
|
||||
status = await git.status()
|
||||
} catch {
|
||||
@@ -510,9 +492,11 @@ async function reviewCommitContext(repoPath, gitBin) {
|
||||
|
||||
// Untracked files have no diff — list them so new files aren't invisible.
|
||||
const untracked = status.not_added || []
|
||||
|
||||
if (untracked.length > 0) {
|
||||
const visible = untracked.slice(0, COMMIT_CONTEXT_UNTRACKED_MAX)
|
||||
const omitted = untracked.length - visible.length
|
||||
|
||||
const note =
|
||||
`\n# New (untracked) files:\n${visible.map(p => `# ${p}`).join('\n')}\n` +
|
||||
(omitted > 0 ? `# ... ${omitted} more omitted\n` : '')
|
||||
@@ -607,6 +591,7 @@ async function repoStatus(repoPath, gitBin) {
|
||||
// fail soft and hide the coding rail instead of spamming IPC handler errors.
|
||||
try {
|
||||
const stat = await fs.stat(cwd)
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
return null
|
||||
}
|
||||
@@ -615,11 +600,13 @@ async function repoStatus(repoPath, gitBin) {
|
||||
}
|
||||
|
||||
let git
|
||||
|
||||
try {
|
||||
git = gitFor(cwd, gitBin)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
let status
|
||||
|
||||
try {
|
||||
@@ -630,6 +617,7 @@ async function repoStatus(repoPath, gitBin) {
|
||||
}
|
||||
|
||||
const detached = typeof status.detached === 'boolean' ? status.detached : !status.current
|
||||
|
||||
const files = status.files.map(file => ({
|
||||
path: file.path,
|
||||
staged: isStaged(file),
|
||||
@@ -671,10 +659,12 @@ async function repoStatus(repoPath, gitBin) {
|
||||
// can't stall the probe.
|
||||
try {
|
||||
const untracked = status.not_added.slice(0, 500)
|
||||
|
||||
for (let i = 0; i < untracked.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) {
|
||||
const batch = await Promise.all(
|
||||
untracked.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(path => untrackedInsertions(cwd, path))
|
||||
)
|
||||
|
||||
result.added += batch.reduce((sum, n) => sum + n, 0)
|
||||
}
|
||||
} catch {
|
||||
@@ -684,7 +674,7 @@ async function repoStatus(repoPath, gitBin) {
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export {
|
||||
branchBase,
|
||||
fileDiffVsHead,
|
||||
repoStatus,
|
||||
@@ -695,8 +685,8 @@ module.exports = {
|
||||
reviewDiff,
|
||||
reviewList,
|
||||
reviewPush,
|
||||
reviewRevParse,
|
||||
reviewRevert,
|
||||
reviewRevParse,
|
||||
reviewShipInfo,
|
||||
reviewStage,
|
||||
reviewUnstage
|
||||
@@ -1,40 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
|
||||
function mkTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-'))
|
||||
}
|
||||
|
||||
test('gitRootForIpc returns null for invalid and device paths', async () => {
|
||||
assert.equal(await gitRootForIpc(''), null)
|
||||
assert.equal(await gitRootForIpc(' '), null)
|
||||
assert.equal(await gitRootForIpc(null), null)
|
||||
assert.equal(await gitRootForIpc('\\\\?\\C:\\secret'), null)
|
||||
assert.equal(await gitRootForIpc('file:///%E0%A4%A'), null)
|
||||
})
|
||||
|
||||
test('gitRootForIpc resolves directories files missing descendants and file URLs', async t => {
|
||||
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')
|
||||
|
||||
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)
|
||||
})
|
||||
42
apps/desktop/electron/git-root.test.ts
Normal file
42
apps/desktop/electron/git-root.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { gitRootForIpc } from './git-root'
|
||||
|
||||
function mkTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-'))
|
||||
}
|
||||
|
||||
test('gitRootForIpc returns null for invalid and device paths', async () => {
|
||||
assert.equal(await gitRootForIpc(''), null)
|
||||
assert.equal(await gitRootForIpc(' '), null)
|
||||
assert.equal(await gitRootForIpc(null), null)
|
||||
assert.equal(await gitRootForIpc('\\\\?\\C:\\secret'), null)
|
||||
assert.equal(await gitRootForIpc('file:///%E0%A4%A'), null)
|
||||
})
|
||||
|
||||
test('gitRootForIpc resolves directories files missing descendants and file URLs', async () => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
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)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -1,8 +1,7 @@
|
||||
'use strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
|
||||
import { resolveRequestedPathForIpc } from './hardening'
|
||||
|
||||
function findGitRoot(start, fsImpl = fs) {
|
||||
let dir = start
|
||||
@@ -28,7 +27,7 @@ function findGitRoot(start, fsImpl = fs) {
|
||||
return null
|
||||
}
|
||||
|
||||
async function gitRootForIpc(startPath, options = {}) {
|
||||
async function gitRootForIpc(startPath, options: { fs?: typeof fs } = {}) {
|
||||
const fsImpl = options.fs || fs
|
||||
let resolved
|
||||
|
||||
@@ -48,7 +47,4 @@ async function gitRootForIpc(startPath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findGitRoot,
|
||||
gitRootForIpc
|
||||
}
|
||||
export { findGitRoot, gitRootForIpc }
|
||||
@@ -1,20 +1,19 @@
|
||||
'use strict'
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
import { test } from 'vitest'
|
||||
|
||||
const {
|
||||
import {
|
||||
addWorktree,
|
||||
ensureGitRepo,
|
||||
listBranches,
|
||||
parseWorktrees,
|
||||
sanitizeBranch,
|
||||
switchBranch
|
||||
} = require('./git-worktree-ops.cjs')
|
||||
} from './git-worktree-ops'
|
||||
|
||||
test('sanitizeBranch: spaces → hyphens, forbidden chars dropped, edges trimmed', () => {
|
||||
assert.equal(sanitizeBranch('beach vibes'), 'beach-vibes')
|
||||
@@ -1,16 +1,14 @@
|
||||
'use strict'
|
||||
|
||||
// Git-driven worktree operations for the desktop "Start work" flow: spin up a
|
||||
// fresh worktree the lightest way (`git worktree add -b`), list real worktrees,
|
||||
// and remove them. Git is the source of truth; the renderer just drives these.
|
||||
|
||||
const path = require('node:path')
|
||||
const fs = require('node:fs')
|
||||
const { execFile } = require('node:child_process')
|
||||
import { execFile } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
|
||||
import { resolveRequestedPathForIpc } from './hardening'
|
||||
|
||||
function runGit(gitBin, args, cwd) {
|
||||
function runGit(gitBin, args, cwd): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
gitBin,
|
||||
@@ -306,6 +304,7 @@ async function listBranches(repoPath, gitBin) {
|
||||
['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'],
|
||||
resolved
|
||||
)
|
||||
|
||||
const trees = await listWorktrees(resolved, gitBin)
|
||||
const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path]))
|
||||
const trunk = await defaultBranch(gitBin, resolved)
|
||||
@@ -338,7 +337,7 @@ async function switchBranch(repoPath, branch, gitBin) {
|
||||
return { branch: target }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export {
|
||||
addWorktree,
|
||||
ensureGitRepo,
|
||||
listBranches,
|
||||
@@ -1,279 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const {
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
encryptDesktopSecret,
|
||||
resolveDirectoryForIpc,
|
||||
resolveReadableFileForIpc,
|
||||
resolveRequestedPathForIpc,
|
||||
resolveTimeoutMs,
|
||||
sensitiveFileBlockReason
|
||||
} = require('./hardening.cjs')
|
||||
|
||||
async function rejectsWithCode(promise, code) {
|
||||
await assert.rejects(promise, error => {
|
||||
assert.equal(error?.code, code)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
test('resolveTimeoutMs falls back to defaults and accepts overrides', () => {
|
||||
assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
assert.equal(resolveTimeoutMs(-25), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
assert.equal(resolveTimeoutMs('2750'), 2750)
|
||||
})
|
||||
|
||||
test('encryptDesktopSecret requires available secure storage', () => {
|
||||
assert.equal(
|
||||
encryptDesktopSecret('', { isEncryptionAvailable: () => true, encryptString: () => Buffer.alloc(0) }),
|
||||
null
|
||||
)
|
||||
|
||||
assert.throws(
|
||||
() => encryptDesktopSecret('token', { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0) }),
|
||||
/Secure token storage is unavailable/
|
||||
)
|
||||
})
|
||||
|
||||
test('encryptDesktopSecret stores safeStorage base64 payload', () => {
|
||||
const secret = encryptDesktopSecret('token-123', {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: value => Buffer.from(`enc:${value}`, 'utf8')
|
||||
})
|
||||
|
||||
assert.deepEqual(secret, {
|
||||
encoding: 'safeStorage',
|
||||
value: Buffer.from('enc:token-123', 'utf8').toString('base64')
|
||||
})
|
||||
})
|
||||
|
||||
test('sensitiveFileBlockReason blocks obvious secret file patterns', () => {
|
||||
assert.match(String(sensitiveFileBlockReason('/tmp/.env')), /\.env/)
|
||||
assert.equal(sensitiveFileBlockReason('/tmp/.env.example'), null)
|
||||
assert.match(String(sensitiveFileBlockReason('/Users/me/.ssh/id_ed25519')), /SSH/)
|
||||
assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/)
|
||||
})
|
||||
|
||||
test('path helpers reject blank non-string NUL and Windows device syntax', async () => {
|
||||
await rejectsWithCode(resolveReadableFileForIpc('', { purpose: 'File preview' }), 'invalid-path')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(' ', { purpose: 'File preview' }), 'invalid-path')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(null, { purpose: 'File preview' }), 'invalid-path')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(`safe${String.fromCharCode(0)}name.txt`), 'invalid-path')
|
||||
|
||||
const devicePaths = [
|
||||
'\\\\?\\C:\\secret.txt',
|
||||
'\\\\.\\C:\\secret.txt',
|
||||
'\\\\?\\UNC\\server\\share\\secret.txt',
|
||||
'GLOBALROOT/Device/HarddiskVolumeShadowCopy1/secret.txt'
|
||||
]
|
||||
|
||||
for (const devicePath of devicePaths) {
|
||||
assert.throws(
|
||||
() => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }),
|
||||
error => {
|
||||
assert.equal(error?.code, 'device-path')
|
||||
return true
|
||||
}
|
||||
)
|
||||
await rejectsWithCode(resolveReadableFileForIpc(devicePath, { purpose: 'File preview' }), 'device-path')
|
||||
}
|
||||
|
||||
assert.throws(
|
||||
() => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }),
|
||||
error => {
|
||||
assert.equal(error?.code, 'invalid-path')
|
||||
return true
|
||||
}
|
||||
)
|
||||
await rejectsWithCode(resolveReadableFileForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), 'invalid-path')
|
||||
})
|
||||
|
||||
test('resolveRequestedPathForIpc resolves relative paths from the trimmed base directory', () => {
|
||||
const baseDir = path.join(os.tmpdir(), 'hermes-desktop-base')
|
||||
|
||||
assert.equal(
|
||||
resolveRequestedPathForIpc('notes.txt', {
|
||||
baseDir: ` ${baseDir} `,
|
||||
purpose: 'File preview'
|
||||
}),
|
||||
path.resolve(baseDir, 'notes.txt')
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveRequestedPathForIpc expands ~ to the home directory', () => {
|
||||
assert.equal(resolveRequestedPathForIpc('~', { purpose: 'Directory read' }), path.resolve(os.homedir()))
|
||||
assert.equal(
|
||||
resolveRequestedPathForIpc('~/www/project', { purpose: 'Directory read' }),
|
||||
path.resolve(os.homedir(), 'www/project')
|
||||
)
|
||||
// `~user` shorthand is NOT expanded — only the caller's own home.
|
||||
assert.equal(
|
||||
resolveRequestedPathForIpc('~other/secret', { baseDir: os.tmpdir(), purpose: 'Directory read' }),
|
||||
path.resolve(os.tmpdir(), '~other/secret')
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const textPath = path.join(tempDir, 'notes.txt')
|
||||
fs.writeFileSync(textPath, 'hello world', 'utf8')
|
||||
|
||||
const fromRelative = await resolveReadableFileForIpc('notes.txt', {
|
||||
baseDir: tempDir,
|
||||
maxBytes: 256,
|
||||
purpose: 'File preview'
|
||||
})
|
||||
assert.equal(fromRelative.resolvedPath, textPath)
|
||||
assert.equal(fromRelative.stat.size, 11)
|
||||
|
||||
const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
assert.equal(fromFileUrl.resolvedPath, textPath)
|
||||
|
||||
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', {
|
||||
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)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc blocks common sensitive files', async t => {
|
||||
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)
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
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 => {
|
||||
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})`)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file')
|
||||
})
|
||||
|
||||
test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async t => {
|
||||
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})`)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const resolved = await resolveDirectoryForIpc(linkPath)
|
||||
assert.equal(resolved.resolvedPath, linkPath)
|
||||
assert.equal(resolved.stat.isDirectory(), true)
|
||||
})
|
||||
306
apps/desktop/electron/hardening.test.ts
Normal file
306
apps/desktop/electron/hardening.test.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
encryptDesktopSecret,
|
||||
resolveDirectoryForIpc,
|
||||
resolveReadableFileForIpc,
|
||||
resolveRequestedPathForIpc,
|
||||
resolveTimeoutMs,
|
||||
sensitiveFileBlockReason
|
||||
} from './hardening'
|
||||
|
||||
async function rejectsWithCode(promise, code: string) {
|
||||
await assert.rejects(promise, (error: any) => {
|
||||
assert.equal(error?.code, code)
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
test('resolveTimeoutMs falls back to defaults and accepts overrides', () => {
|
||||
assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
assert.equal(resolveTimeoutMs(-25), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
assert.equal(resolveTimeoutMs('2750'), 2750)
|
||||
})
|
||||
|
||||
test('encryptDesktopSecret requires available secure storage', () => {
|
||||
assert.equal(
|
||||
encryptDesktopSecret('', { isEncryptionAvailable: () => true, encryptString: () => Buffer.alloc(0) }),
|
||||
null
|
||||
)
|
||||
|
||||
assert.throws(
|
||||
() => encryptDesktopSecret('token', { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0) }),
|
||||
/Secure token storage is unavailable/
|
||||
)
|
||||
})
|
||||
|
||||
test('encryptDesktopSecret stores safeStorage base64 payload', () => {
|
||||
const secret = encryptDesktopSecret('token-123', {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: value => Buffer.from(`enc:${value}`, 'utf8')
|
||||
})
|
||||
|
||||
assert.deepEqual(secret, {
|
||||
encoding: 'safeStorage',
|
||||
value: Buffer.from('enc:token-123', 'utf8').toString('base64')
|
||||
})
|
||||
})
|
||||
|
||||
test('sensitiveFileBlockReason blocks obvious secret file patterns', () => {
|
||||
assert.match(String(sensitiveFileBlockReason('/tmp/.env')), /\.env/)
|
||||
assert.equal(sensitiveFileBlockReason('/tmp/.env.example'), null)
|
||||
assert.match(String(sensitiveFileBlockReason('/Users/me/.ssh/id_ed25519')), /SSH/)
|
||||
assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/)
|
||||
})
|
||||
|
||||
test('path helpers reject blank non-string NUL and Windows device syntax', async () => {
|
||||
await rejectsWithCode(resolveReadableFileForIpc('', { purpose: 'File preview' }), 'invalid-path')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(' ', { purpose: 'File preview' }), 'invalid-path')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(null, { purpose: 'File preview' }), 'invalid-path')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(`safe${String.fromCharCode(0)}name.txt`), 'invalid-path')
|
||||
|
||||
const devicePaths = [
|
||||
'\\\\?\\C:\\secret.txt',
|
||||
'\\\\.\\C:\\secret.txt',
|
||||
'\\\\?\\UNC\\server\\share\\secret.txt',
|
||||
'GLOBALROOT/Device/HarddiskVolumeShadowCopy1/secret.txt'
|
||||
]
|
||||
|
||||
for (const devicePath of devicePaths) {
|
||||
assert.throws(
|
||||
() => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }),
|
||||
(error: any) => {
|
||||
assert.equal(error?.code, 'device-path')
|
||||
|
||||
return true
|
||||
}
|
||||
)
|
||||
await rejectsWithCode(resolveReadableFileForIpc(devicePath, { purpose: 'File preview' }), 'device-path')
|
||||
}
|
||||
|
||||
assert.throws(
|
||||
() => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }),
|
||||
(error: any) => {
|
||||
assert.equal(error?.code, 'invalid-path')
|
||||
|
||||
return true
|
||||
}
|
||||
)
|
||||
await rejectsWithCode(resolveReadableFileForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), 'invalid-path')
|
||||
})
|
||||
|
||||
test('resolveRequestedPathForIpc resolves relative paths from the trimmed base directory', () => {
|
||||
const baseDir = path.join(os.tmpdir(), 'hermes-desktop-base')
|
||||
|
||||
assert.equal(
|
||||
resolveRequestedPathForIpc('notes.txt', {
|
||||
baseDir: ` ${baseDir} `,
|
||||
purpose: 'File preview'
|
||||
}),
|
||||
path.resolve(baseDir, 'notes.txt')
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveRequestedPathForIpc expands ~ to the home directory', () => {
|
||||
assert.equal(resolveRequestedPathForIpc('~', { purpose: 'Directory read' }), path.resolve(os.homedir()))
|
||||
assert.equal(
|
||||
resolveRequestedPathForIpc('~/www/project', { purpose: 'Directory read' }),
|
||||
path.resolve(os.homedir(), 'www/project')
|
||||
)
|
||||
// `~user` shorthand is NOT expanded — only the caller's own home.
|
||||
assert.equal(
|
||||
resolveRequestedPathForIpc('~other/secret', { baseDir: os.tmpdir(), purpose: 'Directory read' }),
|
||||
path.resolve(os.tmpdir(), '~other/secret')
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc validates existence type size and sensitivity', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-'))
|
||||
|
||||
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', {
|
||||
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 () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-sensitive-'))
|
||||
|
||||
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')
|
||||
]
|
||||
|
||||
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 })
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-realpath-'))
|
||||
|
||||
try {
|
||||
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') {
|
||||
// symlink creation is not permitted on this platform — skip
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file')
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-'))
|
||||
|
||||
try {
|
||||
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')
|
||||
} 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
|
||||
}
|
||||
|
||||
const resolved = await resolveDirectoryForIpc(linkPath)
|
||||
assert.equal(resolved.resolvedPath, linkPath)
|
||||
assert.equal(resolved.stat.isDirectory(), true)
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { fileURLToPath } = require('node:url')
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const DEFAULT_FETCH_TIMEOUT_MS = 15_000
|
||||
const DATA_URL_READ_MAX_BYTES = 16 * 1024 * 1024
|
||||
@@ -13,6 +13,7 @@ const SENSITIVE_EXTENSIONS = new Set(['.kdbx', '.p12', '.pem', '.pfx'])
|
||||
function resolveTimeoutMs(timeoutMs, fallbackMs = DEFAULT_FETCH_TIMEOUT_MS) {
|
||||
const fallback =
|
||||
Number.isFinite(fallbackMs) && Number(fallbackMs) > 0 ? Math.round(Number(fallbackMs)) : DEFAULT_FETCH_TIMEOUT_MS
|
||||
|
||||
const parsed = Number(timeoutMs)
|
||||
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
@@ -62,6 +63,7 @@ function sensitiveFileBlockReason(filePath) {
|
||||
const normalized = String(filePath || '')
|
||||
.replace(/\\/g, '/')
|
||||
.toLowerCase()
|
||||
|
||||
const basename = path.basename(normalized)
|
||||
const ext = path.extname(basename)
|
||||
|
||||
@@ -87,6 +89,7 @@ function sensitiveFileBlockReason(filePath) {
|
||||
|
||||
if (basename.startsWith('.env.')) {
|
||||
const suffix = basename.slice('.env.'.length)
|
||||
|
||||
if (!SAFE_ENV_SUFFIXES.has(suffix)) {
|
||||
return `${basename} is blocked because it appears to contain environment secrets.`
|
||||
}
|
||||
@@ -107,9 +110,11 @@ function sensitiveFileBlockReason(filePath) {
|
||||
return null
|
||||
}
|
||||
|
||||
function ipcPathError(code, message) {
|
||||
const error = new Error(message)
|
||||
error.code = code
|
||||
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
|
||||
}
|
||||
|
||||
@@ -129,6 +134,7 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
|
||||
}
|
||||
|
||||
const normalized = raw.replace(/\\/g, '/').toLowerCase()
|
||||
|
||||
if (
|
||||
normalized.startsWith('//?/') ||
|
||||
normalized.startsWith('//./') ||
|
||||
@@ -141,7 +147,7 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
|
||||
return raw
|
||||
}
|
||||
|
||||
function resolveRequestedPathForIpc(filePath, options = {}) {
|
||||
function resolveRequestedPathForIpc(filePath, options: { purpose?: string; baseDir?: fs.PathOrFileDescriptor } = {}) {
|
||||
const purpose = String(options.purpose || 'File read')
|
||||
let raw = rejectUnsafePathSyntax(filePath, purpose)
|
||||
|
||||
@@ -154,17 +160,21 @@ function resolveRequestedPathForIpc(filePath, options = {}) {
|
||||
|
||||
if (/^file:/i.test(raw)) {
|
||||
let resolvedPath
|
||||
|
||||
try {
|
||||
const parsed = new URL(raw)
|
||||
|
||||
if (parsed.protocol !== 'file:') {
|
||||
throw new Error('not a file URL')
|
||||
}
|
||||
|
||||
resolvedPath = fileURLToPath(parsed)
|
||||
} catch {
|
||||
throw ipcPathError('invalid-path', `${purpose} failed: file URL is invalid.`)
|
||||
}
|
||||
|
||||
rejectUnsafePathSyntax(resolvedPath, purpose)
|
||||
|
||||
return path.resolve(resolvedPath)
|
||||
}
|
||||
|
||||
@@ -178,14 +188,16 @@ function resolveRequestedPathForIpc(filePath, options = {}) {
|
||||
return resolvedPath
|
||||
}
|
||||
|
||||
async function statForIpc(fsImpl, resolvedPath, purpose, typeLabel) {
|
||||
async function statForIpc(fsImpl: { promises: { stat: typeof fs.promises.stat } }, resolvedPath, purpose, typeLabel) {
|
||||
try {
|
||||
return await fsImpl.promises.stat(resolvedPath)
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? error.code : ''
|
||||
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`)
|
||||
}
|
||||
|
||||
throw ipcPathError(
|
||||
code || 'read-error',
|
||||
`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
@@ -201,6 +213,7 @@ async function realpathForIpc(fsImpl, resolvedPath, purpose) {
|
||||
try {
|
||||
const realPath = await fsImpl.promises.realpath(resolvedPath)
|
||||
rejectUnsafePathSyntax(realPath, purpose)
|
||||
|
||||
return realPath
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? error.code : ''
|
||||
@@ -213,12 +226,20 @@ async function realpathForIpc(fsImpl, resolvedPath, purpose) {
|
||||
|
||||
function rejectSensitiveFilePath(filePath, purpose) {
|
||||
const blockReason = sensitiveFileBlockReason(filePath)
|
||||
|
||||
if (blockReason) {
|
||||
throw ipcPathError('sensitive-file', `${purpose} blocked for sensitive file: ${blockReason}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDirectoryForIpc(dirPath, options = {}) {
|
||||
async function resolveDirectoryForIpc(
|
||||
dirPath,
|
||||
options: {
|
||||
purpose?: string
|
||||
baseDir?: fs.PathOrFileDescriptor
|
||||
fs?: { promises: { stat: typeof fs.promises.stat } }
|
||||
} = {}
|
||||
) {
|
||||
const purpose = String(options.purpose || 'Directory read')
|
||||
const fsImpl = options.fs || fs
|
||||
const resolvedPath = resolveRequestedPathForIpc(dirPath, { baseDir: options.baseDir, purpose })
|
||||
@@ -233,7 +254,16 @@ async function resolveDirectoryForIpc(dirPath, options = {}) {
|
||||
return { realPath, resolvedPath, stat }
|
||||
}
|
||||
|
||||
async function resolveReadableFileForIpc(filePath, options = {}) {
|
||||
async function resolveReadableFileForIpc(
|
||||
filePath,
|
||||
options: {
|
||||
purpose?: string
|
||||
baseDir?: fs.PathOrFileDescriptor
|
||||
fs?: typeof fs
|
||||
blockSensitive?: boolean
|
||||
maxBytes?: number
|
||||
} = {}
|
||||
) {
|
||||
const purpose = String(options.purpose || 'File read')
|
||||
const fsImpl = options.fs || fs
|
||||
const resolvedPath = resolveRequestedPathForIpc(filePath, { baseDir: options.baseDir, purpose })
|
||||
@@ -253,11 +283,13 @@ async function resolveReadableFileForIpc(filePath, options = {}) {
|
||||
}
|
||||
|
||||
const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose)
|
||||
|
||||
if (options.blockSensitive !== false) {
|
||||
rejectSensitiveFilePath(realPath, purpose)
|
||||
}
|
||||
|
||||
const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null
|
||||
|
||||
if (maxBytes && stat.size > maxBytes) {
|
||||
throw ipcPathError('EFBIG', `${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
|
||||
}
|
||||
@@ -271,15 +303,15 @@ async function resolveReadableFileForIpc(filePath, options = {}) {
|
||||
return { realPath, resolvedPath, stat }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export {
|
||||
DATA_URL_READ_MAX_BYTES,
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
TEXT_PREVIEW_SOURCE_MAX_BYTES,
|
||||
encryptDesktopSecret,
|
||||
rejectUnsafePathSyntax,
|
||||
resolveDirectoryForIpc,
|
||||
resolveReadableFileForIpc,
|
||||
resolveRequestedPathForIpc,
|
||||
resolveTimeoutMs,
|
||||
sensitiveFileBlockReason
|
||||
sensitiveFileBlockReason,
|
||||
TEXT_PREVIEW_SOURCE_MAX_BYTES
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
const {
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
createLinkTitleWindow,
|
||||
guardLinkTitleSession,
|
||||
linkTitleWindowOptions,
|
||||
readLinkTitleWindowTitle
|
||||
} = require('./link-title-window.cjs')
|
||||
} from './link-title-window'
|
||||
|
||||
function makeFakeBrowserWindow() {
|
||||
const calls = { audioMuted: [] }
|
||||
|
||||
const FakeBrowserWindow = function (options) {
|
||||
this.options = options
|
||||
this.webContents = {
|
||||
@@ -1,11 +1,9 @@
|
||||
'use strict'
|
||||
|
||||
// Hidden BrowserWindow used by tier-2 link-title resolution: when curl can't
|
||||
// read a page <title> (bot walls, JS-rendered pages), we briefly load the URL
|
||||
// in an offscreen window and read its title. That window loads arbitrary
|
||||
// user-linked pages, so it must never emit sound or trigger real downloads.
|
||||
|
||||
function linkTitleWindowOptions(partitionSession) {
|
||||
export function linkTitleWindowOptions(partitionSession) {
|
||||
return {
|
||||
show: false,
|
||||
width: 1280,
|
||||
@@ -25,7 +23,7 @@ function linkTitleWindowOptions(partitionSession) {
|
||||
// Create the offscreen title-fetch window and immediately mute it. Without the
|
||||
// mute, autoplaying media on the loaded page (e.g. a YouTube link) leaks ~2s of
|
||||
// audio every time a session containing such links is re-rendered. See #49505.
|
||||
function createLinkTitleWindow(BrowserWindow, partitionSession) {
|
||||
export function createLinkTitleWindow(BrowserWindow, partitionSession) {
|
||||
const window = new BrowserWindow(linkTitleWindowOptions(partitionSession))
|
||||
|
||||
try {
|
||||
@@ -41,7 +39,7 @@ function createLinkTitleWindow(BrowserWindow, partitionSession) {
|
||||
// Cancel any download the title-fetch window triggers. Without this, a link
|
||||
// artifact URL served with Content-Disposition: attachment auto-downloads every
|
||||
// time the Artifacts page renders and fetchLinkTitle loads it.
|
||||
function guardLinkTitleSession(partitionSession) {
|
||||
export function guardLinkTitleSession(partitionSession) {
|
||||
try {
|
||||
partitionSession.on('will-download', (_event, item) => item.cancel())
|
||||
} catch {
|
||||
@@ -52,20 +50,20 @@ function guardLinkTitleSession(partitionSession) {
|
||||
// Read the page title from a title-fetch window. Callers schedule this from
|
||||
// timers that can fire after finish() destroys the window, so every access must
|
||||
// guard isDestroyed and swallow Electron's "Object has been destroyed" throws.
|
||||
function readLinkTitleWindowTitle(window) {
|
||||
export function readLinkTitleWindowTitle(window) {
|
||||
try {
|
||||
if (!window || window.isDestroyed()) return ''
|
||||
if (!window || window.isDestroyed()) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const contents = window.webContents
|
||||
if (!contents || contents.isDestroyed()) return ''
|
||||
|
||||
if (!contents || contents.isDestroyed()) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return contents.getTitle() || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createLinkTitleWindow,
|
||||
guardLinkTitleSession,
|
||||
linkTitleWindowOptions,
|
||||
readLinkTitleWindowTitle
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* Tests for OAuth-session Electron net.request helpers.
|
||||
*
|
||||
* Run with: node --test electron/oauth-net-request.test.cjs
|
||||
* Run with: node --test electron/oauth-net-request.test.ts
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
|
||||
|
||||
test('serializeJsonBody returns undefined for absent bodies', () => {
|
||||
assert.equal(serializeJsonBody(undefined), undefined)
|
||||
@@ -21,6 +22,7 @@ test('serializeJsonBody JSON-encodes request bodies', () => {
|
||||
|
||||
test('setJsonRequestHeaders does not set Electron-restricted Content-Length', () => {
|
||||
const headers = []
|
||||
|
||||
const request = {
|
||||
setHeader(name, value) {
|
||||
headers.push([name, value])
|
||||
@@ -14,7 +14,4 @@ function setJsonRequestHeaders(request) {
|
||||
request.setHeader('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
serializeJsonBody,
|
||||
setJsonRequestHeaders
|
||||
}
|
||||
export { serializeJsonBody, setJsonRequestHeaders }
|
||||
@@ -1,30 +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.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, 'main.cjs'), '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\)/)
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
const { contextBridge, ipcRenderer, webUtils } = require('electron')
|
||||
import { contextBridge, ipcRenderer, webUtils } from 'electron'
|
||||
|
||||
contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getConnection: profile => ipcRenderer.invoke('hermes:connection', profile),
|
||||
@@ -24,12 +24,14 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
onState: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:pet-overlay:state', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:pet-overlay:state', listener)
|
||||
},
|
||||
// Main renderer subscribes to overlay control messages.
|
||||
onControl: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:pet-overlay:control', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener)
|
||||
}
|
||||
},
|
||||
@@ -41,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)
|
||||
@@ -87,6 +98,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
onChanged: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:zoom:changed', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:zoom:changed', listener)
|
||||
}
|
||||
},
|
||||
@@ -132,68 +144,88 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
const channel = `hermes:terminal:${id}:data`
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on(channel, listener)
|
||||
|
||||
return () => ipcRenderer.removeListener(channel, listener)
|
||||
},
|
||||
onExit: (id, callback) => {
|
||||
const channel = `hermes:terminal:${id}:exit`
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on(channel, listener)
|
||||
|
||||
return () => ipcRenderer.removeListener(channel, listener)
|
||||
}
|
||||
},
|
||||
onClosePreviewRequested: callback => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('hermes:close-preview-requested', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener)
|
||||
},
|
||||
onOpenUpdatesRequested: callback => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('hermes:open-updates', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:open-updates', listener)
|
||||
},
|
||||
onDeepLink: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:deep-link', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:deep-link', listener)
|
||||
},
|
||||
signalDeepLinkReady: () => ipcRenderer.invoke('hermes:deep-link-ready'),
|
||||
onWindowStateChanged: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:window-state-changed', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:window-state-changed', listener)
|
||||
},
|
||||
onFocusSession: callback => {
|
||||
const listener = (_event, sessionId) => callback(sessionId)
|
||||
ipcRenderer.on('hermes:focus-session', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:focus-session', listener)
|
||||
},
|
||||
onNotificationAction: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:notification-action', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:notification-action', listener)
|
||||
},
|
||||
onPreviewFileChanged: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:preview-file-changed', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:preview-file-changed', listener)
|
||||
},
|
||||
onBackendExit: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:backend-exit', listener)
|
||||
|
||||
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)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:power-resume', listener)
|
||||
},
|
||||
onBootProgress: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:boot-progress', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:boot-progress', listener)
|
||||
},
|
||||
// First-launch bootstrap progress -- emitted by the install.ps1 stage
|
||||
// runner in main.cjs (apps/desktop/electron/bootstrap-runner.cjs).
|
||||
// runner in main.ts (apps/desktop/electron/bootstrap-runner.ts).
|
||||
// Renderer's install overlay subscribes to live events and queries the
|
||||
// current snapshot via getBootstrapState() to recover after a devtools
|
||||
// reload mid-bootstrap.
|
||||
@@ -204,6 +236,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
onBootstrapEvent: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:bootstrap:event', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:bootstrap:event', listener)
|
||||
},
|
||||
getVersion: () => ipcRenderer.invoke('hermes:version'),
|
||||
@@ -220,6 +253,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
onProgress: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:updates:progress', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:updates:progress', listener)
|
||||
}
|
||||
},
|
||||
@@ -1,62 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const ELECTRON_DIR = __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.cjs')
|
||||
|
||||
// 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.cjs')
|
||||
|
||||
// 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,11 +1,8 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
const {
|
||||
buildSessionWindowUrl,
|
||||
chatWindowWebPreferences,
|
||||
createSessionWindowRegistry
|
||||
} = require('./session-windows.cjs')
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry } from './session-windows'
|
||||
|
||||
// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a
|
||||
// test fire the 'closed' event, mirroring the slice of the Electron API the
|
||||
@@ -96,6 +93,7 @@ test('registry opens one window per session and focuses on re-open', () => {
|
||||
const registry = createSessionWindowRegistry()
|
||||
let built = 0
|
||||
const win = makeFakeWindow()
|
||||
|
||||
const factory = () => {
|
||||
built += 1
|
||||
|
||||
@@ -145,6 +143,7 @@ test('registry rebuilds a fresh window after the previous one was destroyed', ()
|
||||
|
||||
let built = 0
|
||||
const second = makeFakeWindow()
|
||||
|
||||
const result = registry.openOrFocus('s1', () => {
|
||||
built += 1
|
||||
|
||||
@@ -158,6 +157,7 @@ test('registry rebuilds a fresh window after the previous one was destroyed', ()
|
||||
test('registry ignores empty / non-string session ids', () => {
|
||||
const registry = createSessionWindowRegistry()
|
||||
let built = 0
|
||||
|
||||
const factory = () => {
|
||||
built += 1
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Secondary "session windows" — one extra OS window per chat so a user can
|
||||
// work with multiple chats side by side. The pure, Electron-free pieces live
|
||||
// here so they can be unit-tested with node --test (mirroring how the rest of
|
||||
// electron/*.cjs splits testable logic out of the main.cjs monolith).
|
||||
// electron/*.ts splits testable logic out of the main.ts monolith).
|
||||
|
||||
const { pathToFileURL } = require('node:url')
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
// Secondary windows open at the minimum usable size — a compact side panel for
|
||||
// subagent watch / cmd-click session pop-out, not a second full desktop.
|
||||
@@ -12,7 +12,7 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
|
||||
|
||||
// Shared webPreferences for every window that renders the chat transcript — the
|
||||
// primary window AND the secondary session windows. Keeping it in one place is
|
||||
// the whole point: the two BrowserWindow definitions in main.cjs used to be
|
||||
// the whole point: the two BrowserWindow definitions in main.ts used to be
|
||||
// hand-copied, and the secondary windows silently lost `backgroundThrottling:
|
||||
// false`, so a streamed answer stalled until the window regained focus.
|
||||
//
|
||||
@@ -21,7 +21,7 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
|
||||
// blurred/occluded windows. A streaming chat app must keep painting in the
|
||||
// background, so every chat window opts out. The preload path is injected
|
||||
// because it depends on the Electron entry's __dirname.
|
||||
function chatWindowWebPreferences(preloadPath) {
|
||||
function chatWindowWebPreferences(preloadPath: string) {
|
||||
return {
|
||||
preload: preloadPath,
|
||||
contextIsolation: true,
|
||||
@@ -42,7 +42,7 @@ function chatWindowWebPreferences(preloadPath) {
|
||||
// scratch window; `watch=1` marks a spectator window (e.g. a running subagent's
|
||||
// session): the renderer resumes it lazily so the gateway never builds an agent
|
||||
// just to stream into it.
|
||||
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch, newSession } = {}) {
|
||||
function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath, watch, newSession }: any = {}) {
|
||||
const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}`
|
||||
const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}`
|
||||
|
||||
@@ -115,7 +115,7 @@ function createSessionWindowRegistry() {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export {
|
||||
buildSessionWindowUrl,
|
||||
chatWindowWebPreferences,
|
||||
createSessionWindowRegistry,
|
||||
@@ -1,12 +1,13 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
const {
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
MACOS_TAHOE_DARWIN_MAJOR,
|
||||
OVERLAY_FALLBACK_WIDTH,
|
||||
macTitleBarOverlayHeight,
|
||||
nativeOverlayWidth
|
||||
} = require('./titlebar-overlay-width.cjs')
|
||||
nativeOverlayWidth,
|
||||
OVERLAY_FALLBACK_WIDTH
|
||||
} from './titlebar-overlay-width'
|
||||
|
||||
// This static reservation is only the pre-layout FALLBACK. Once laid out the
|
||||
// renderer reads the exact width from navigator.windowControlsOverlay
|
||||
@@ -1,6 +1,4 @@
|
||||
'use strict'
|
||||
|
||||
const OVERLAY_FALLBACK_WIDTH = 144
|
||||
export const OVERLAY_FALLBACK_WIDTH = 144
|
||||
|
||||
/**
|
||||
* Static pre-layout reservation (px) for the right-side native window-controls
|
||||
@@ -16,15 +14,18 @@ const OVERLAY_FALLBACK_WIDTH = 144
|
||||
*
|
||||
* @param {{ isMac?: boolean }} opts
|
||||
*/
|
||||
function nativeOverlayWidth({ isMac = false } = {}) {
|
||||
if (isMac) return 0
|
||||
export function nativeOverlayWidth({ isWindows = false, isWsl = false, isMac = false } = {}) {
|
||||
if (isMac) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return OVERLAY_FALLBACK_WIDTH
|
||||
}
|
||||
|
||||
// macOS Tahoe ships as Darwin 25 (Sequoia is 24); the Darwin number is truthful,
|
||||
// unlike the product version which macOS reports as 16 or 26 depending on the
|
||||
// build SDK.
|
||||
const MACOS_TAHOE_DARWIN_MAJOR = 25
|
||||
export const MACOS_TAHOE_DARWIN_MAJOR = 25
|
||||
|
||||
/**
|
||||
* Height (px) to pass to `titleBarOverlay` on macOS. Tahoe (Darwin 25+)
|
||||
@@ -36,8 +37,6 @@ const MACOS_TAHOE_DARWIN_MAJOR = 25
|
||||
*
|
||||
* @param {{ darwinMajor?: number, titlebarHeight?: number }} opts
|
||||
*/
|
||||
function macTitleBarOverlayHeight({ darwinMajor = 0, titlebarHeight = 0 } = {}) {
|
||||
export function macTitleBarOverlayHeight({ darwinMajor = 0, titlebarHeight = 0 } = {}) {
|
||||
return darwinMajor >= MACOS_TAHOE_DARWIN_MAJOR ? 0 : titlebarHeight
|
||||
}
|
||||
|
||||
module.exports = { MACOS_TAHOE_DARWIN_MAJOR, OVERLAY_FALLBACK_WIDTH, macTitleBarOverlayHeight, nativeOverlayWidth }
|
||||
@@ -1,7 +1,8 @@
|
||||
'use strict'
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { resolveBehindCount, shouldCountCommits } from './update-count'
|
||||
|
||||
// FAIL-BEFORE: pre-fix the function did `Number.parseInt(countStr) || 0`
|
||||
// unconditionally, so a shallow checkout with no merge-base surfaced the bogus
|
||||
@@ -1,5 +1,3 @@
|
||||
'use strict'
|
||||
|
||||
// Whether `git rev-list HEAD..origin/<branch> --count` produces a meaningful
|
||||
// number worth computing. On a SHALLOW checkout (installer clones with
|
||||
// --depth 1) the local history often shares no merge-base with the freshly
|
||||
@@ -19,10 +17,14 @@ function shouldCountCommits({ isShallow, hasMergeBase }) {
|
||||
// (developers / Docker dev images) keep the exact count path unchanged.
|
||||
function resolveBehindCount({ countStr, currentSha, targetSha, isShallow, hasMergeBase }) {
|
||||
if (!shouldCountCommits({ isShallow, hasMergeBase })) {
|
||||
if (currentSha && targetSha && currentSha === targetSha) return 0
|
||||
if (currentSha && targetSha && currentSha === targetSha) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return 1 // behind by an unknown amount — show a generic "update available"
|
||||
}
|
||||
|
||||
return Number.parseInt(countStr, 10) || 0
|
||||
}
|
||||
|
||||
module.exports = { resolveBehindCount, shouldCountCommits }
|
||||
export { resolveBehindCount, shouldCountCommits }
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Tests for electron/update-marker.cjs — the in-app update mutual-exclusion
|
||||
* Tests for electron/update-marker.ts — the in-app update mutual-exclusion
|
||||
* marker that prevents a desktop relaunched mid-update from spawning a backend
|
||||
* the updater then kills in a loop (#50238).
|
||||
*
|
||||
* Run with: node --test electron/update-marker.test.cjs
|
||||
* Run with: node --test electron/update-marker.test.ts
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* Why this matters: the gate must (a) report a live update only when the
|
||||
@@ -12,16 +12,24 @@
|
||||
* strand future launches, and (c) self-heal by deleting a stale marker file.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
import fs from 'fs'
|
||||
import assert from 'node:assert/strict'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
|
||||
const { markerPath, isPidAlive, readLiveUpdateMarker, writeUpdateMarker, UPDATE_MARKER_MAX_AGE_MS } = require('./update-marker.cjs')
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
isPidAlive,
|
||||
markerPath,
|
||||
readLiveUpdateMarker,
|
||||
UPDATE_MARKER_MAX_AGE_MS,
|
||||
writeUpdateMarker
|
||||
} from './update-marker'
|
||||
|
||||
function tmpHome(tag) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-marker-${tag}-`))
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
@@ -29,10 +37,12 @@ function writeMarker(home, pid, startedAtSec) {
|
||||
fs.writeFileSync(markerPath(home), `${pid}\n${startedAtSec}`)
|
||||
}
|
||||
|
||||
const ALIVE = () => true // injected kill that "succeeds" => pid alive
|
||||
const DEAD = () => {
|
||||
const ALIVE: typeof process.kill = () => true // injected kill that "succeeds" => pid alive
|
||||
|
||||
const DEAD: typeof process.kill = () => {
|
||||
const err = new Error('no such process')
|
||||
err.code = 'ESRCH'
|
||||
|
||||
;(err as any).code = 'ESRCH'
|
||||
throw err
|
||||
}
|
||||
|
||||
@@ -85,9 +95,11 @@ 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.code = 'EPERM'
|
||||
|
||||
;(err as any).code = 'EPERM'
|
||||
throw err
|
||||
}
|
||||
|
||||
assert.equal(isPidAlive(4242, eperm), true)
|
||||
})
|
||||
|
||||
@@ -16,20 +16,20 @@
|
||||
*
|
||||
* This module holds the PURE, side-effect-light logic (path, pid liveness,
|
||||
* parse + staleness) so it is unit-testable without booting Electron. The
|
||||
* polling/boot-progress wrapper lives in main.cjs where the boot-progress and
|
||||
* polling/boot-progress wrapper lives in main.ts where the boot-progress and
|
||||
* log sinks are.
|
||||
*/
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
// Even with a live-looking PID, never treat a marker older than this as a live
|
||||
// update. A full update (git pull + pip + desktop rebuild) is minutes, not tens
|
||||
// of minutes; past this the marker is almost certainly stale (e.g. the OS
|
||||
// recycled the pid onto an unrelated process), so the gate self-heals.
|
||||
const UPDATE_MARKER_MAX_AGE_MS = 20 * 60 * 1000
|
||||
export const UPDATE_MARKER_MAX_AGE_MS = 20 * 60 * 1000
|
||||
|
||||
function markerPath(hermesHome) {
|
||||
export function markerPath(hermesHome) {
|
||||
return path.join(hermesHome, '.hermes-update-in-progress')
|
||||
}
|
||||
|
||||
@@ -37,10 +37,14 @@ function markerPath(hermesHome) {
|
||||
// not deliver a signal — it just probes existence/permission. ESRCH => dead;
|
||||
// EPERM => alive but owned by another user (still "alive" for our purposes).
|
||||
// Injectable `kill` keeps it unit-testable.
|
||||
function isPidAlive(pid, kill = process.kill.bind(process)) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false
|
||||
export function isPidAlive(pid, kill: typeof process.kill = process.kill.bind(process)) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
kill(pid, 0)
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
return Boolean(err && err.code === 'EPERM')
|
||||
@@ -59,9 +63,21 @@ function isPidAlive(pid, kill = process.kill.bind(process)) {
|
||||
* Pure-ish: file I/O against the given path, plus an injectable pid probe and
|
||||
* clock for tests.
|
||||
*/
|
||||
function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPDATE_MARKER_MAX_AGE_MS } = {}) {
|
||||
export function readLiveUpdateMarker(
|
||||
hermesHome,
|
||||
{
|
||||
kill,
|
||||
now = Date.now,
|
||||
maxAgeMs = UPDATE_MARKER_MAX_AGE_MS
|
||||
}: {
|
||||
now?: () => number
|
||||
maxAgeMs?: number
|
||||
kill?: typeof process.kill
|
||||
} = {}
|
||||
) {
|
||||
const file = markerPath(hermesHome)
|
||||
let raw
|
||||
|
||||
try {
|
||||
raw = fs.readFileSync(file, 'utf8')
|
||||
} catch {
|
||||
@@ -80,8 +96,10 @@ function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPD
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return { pid, ageMs }
|
||||
}
|
||||
|
||||
@@ -107,9 +125,10 @@ function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPD
|
||||
* If the updater never starts (spawn failure) the marker still contains a
|
||||
* real PID, so `readLiveUpdateMarker` will self-heal once that PID exits.
|
||||
*/
|
||||
function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) {
|
||||
export function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) {
|
||||
const file = markerPath(hermesHome)
|
||||
const startedAt = Math.floor(now() / 1000)
|
||||
|
||||
try {
|
||||
fs.writeFileSync(file, `${pid}\n${startedAt}\n`, 'utf8')
|
||||
} catch {
|
||||
@@ -117,11 +136,3 @@ function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) {
|
||||
// updater will write its own when it reaches run_update.
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
UPDATE_MARKER_MAX_AGE_MS,
|
||||
markerPath,
|
||||
isPidAlive,
|
||||
readLiveUpdateMarker,
|
||||
writeUpdateMarker
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Tests for electron/update-rebuild.cjs — the retry-once policy for the desktop
|
||||
* Tests for electron/update-rebuild.ts — the retry-once policy for the desktop
|
||||
* `--build-only` rebuild during self-update.
|
||||
*
|
||||
* Run with: node --test electron/update-rebuild.test.cjs
|
||||
* Run with: node --test electron/update-rebuild.test.ts
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* Why this matters: a first rebuild can return nonzero on a still-settling tree
|
||||
@@ -12,10 +12,11 @@
|
||||
* success, and must run at most twice.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
const { shouldRetryRebuild, runRebuildWithRetry } = require('./update-rebuild.cjs')
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { runRebuildWithRetry, shouldRetryRebuild } from './update-rebuild'
|
||||
|
||||
test('shouldRetryRebuild retries only on a non-success exit', () => {
|
||||
assert.equal(shouldRetryRebuild(0), false)
|
||||
@@ -25,30 +26,39 @@ test('shouldRetryRebuild retries only on a non-success exit', () => {
|
||||
|
||||
test('a clean first rebuild runs once and does not retry', async () => {
|
||||
const codes = []
|
||||
|
||||
const result = await runRebuildWithRetry(attempt => {
|
||||
codes.push(attempt)
|
||||
|
||||
return Promise.resolve({ code: 0 })
|
||||
})
|
||||
|
||||
assert.deepEqual(codes, [0])
|
||||
assert.equal(result.code, 0)
|
||||
})
|
||||
|
||||
test('a failed first rebuild retries once and succeeds', async () => {
|
||||
const codes = []
|
||||
|
||||
const result = await runRebuildWithRetry(attempt => {
|
||||
codes.push(attempt)
|
||||
|
||||
return Promise.resolve({ code: attempt === 0 ? 1 : 0 })
|
||||
})
|
||||
|
||||
assert.deepEqual(codes, [0, 1])
|
||||
assert.equal(result.code, 0)
|
||||
})
|
||||
|
||||
test('a rebuild that keeps failing runs at most twice and reports the failure', async () => {
|
||||
const codes = []
|
||||
|
||||
const result = await runRebuildWithRetry(attempt => {
|
||||
codes.push(attempt)
|
||||
|
||||
return Promise.resolve({ code: 1, error: 'rebuild-failed' })
|
||||
})
|
||||
|
||||
assert.deepEqual(codes, [0, 1])
|
||||
assert.equal(result.code, 1)
|
||||
assert.equal(result.error, 'rebuild-failed')
|
||||
@@ -1,5 +1,3 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* Retry-once policy for the desktop `--build-only` rebuild during self-update.
|
||||
*
|
||||
@@ -20,10 +18,12 @@ function shouldRetryRebuild(code) {
|
||||
*/
|
||||
async function runRebuildWithRetry(rebuild) {
|
||||
let result = await rebuild(0)
|
||||
|
||||
if (shouldRetryRebuild(result.code)) {
|
||||
result = await rebuild(1)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = { shouldRetryRebuild, runRebuildWithRetry }
|
||||
export { runRebuildWithRetry, shouldRetryRebuild }
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Tests for electron/update-relaunch.cjs — the pure decision + script helpers
|
||||
* Tests for electron/update-relaunch.ts — the pure decision + script helpers
|
||||
* behind the Linux in-app update relaunch (#45205).
|
||||
*
|
||||
* Run with: node --test electron/update-relaunch.test.cjs
|
||||
* Run with: node --test electron/update-relaunch.test.ts
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* What this locks (review acceptance criteria for PR #45205):
|
||||
@@ -17,24 +17,25 @@
|
||||
* (keep a working window) unless a non-interactive fallback applies.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const {
|
||||
unpackedDirName,
|
||||
resolveUnpackedRelease,
|
||||
decideRelaunchOutcome,
|
||||
sandboxPreflight,
|
||||
sandboxFallbackFromEnv,
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
buildRelaunchScript,
|
||||
collectRelaunchArgs,
|
||||
collectRelaunchEnv,
|
||||
buildRelaunchScript,
|
||||
shellQuote
|
||||
} = require('./update-relaunch.cjs')
|
||||
decideRelaunchOutcome,
|
||||
resolveUnpackedRelease,
|
||||
sandboxFallbackFromEnv,
|
||||
sandboxPreflight,
|
||||
shellQuote,
|
||||
unpackedDirName
|
||||
} from './update-relaunch'
|
||||
|
||||
const ROOT = '/home/u/.hermes/hermes-agent'
|
||||
const UNPACKED = path.join(ROOT, 'apps', 'desktop', 'release', 'linux-unpacked')
|
||||
@@ -91,6 +92,7 @@ test('decideRelaunchOutcome: only under-unpacked + sandbox-ok relaunches', () =>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fakeStat = (uid, mode) => () => ({ uid, mode })
|
||||
|
||||
const throwStat = () => {
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
@@ -150,6 +152,7 @@ test('collectRelaunchArgs drops Electron internals, keeps user/launcher args', (
|
||||
'--profile=work', // app flag — keep
|
||||
'--remote-debugging-port=9222' // internal — drop
|
||||
]
|
||||
|
||||
assert.deepEqual(collectRelaunchArgs(argv), ['--no-sandbox', 'hermes://open/agent/42', '--profile=work'])
|
||||
assert.deepEqual(collectRelaunchArgs(undefined), [])
|
||||
})
|
||||
@@ -165,6 +168,7 @@ test('collectRelaunchEnv preserves HERMES_HOME + HERMES_DESKTOP_* + sandbox opt-
|
||||
HOME: '/home/u', // not preserved
|
||||
UNRELATED: 'x'
|
||||
}
|
||||
|
||||
assert.deepEqual(collectRelaunchEnv(env), {
|
||||
HERMES_HOME: '/home/u/.hermes',
|
||||
HERMES_DESKTOP_REMOTE_URL: 'http://box:9119',
|
||||
@@ -207,6 +211,7 @@ test('buildRelaunchScript embeds pid/exec/args/env/cwd and is valid bash', () =>
|
||||
// It must be syntactically valid bash (`bash -n`). Write to a temp file and lint.
|
||||
const tmp = path.join(os.tmpdir(), `hermes-relaunch-test-${Date.now()}.sh`)
|
||||
fs.writeFileSync(tmp, script)
|
||||
|
||||
try {
|
||||
execFileSync('bash', ['-n', tmp], { stdio: 'pipe' })
|
||||
} finally {
|
||||
@@ -222,13 +227,16 @@ test('buildRelaunchScript with no args/env still lints clean', () => {
|
||||
env: {},
|
||||
cwd: ''
|
||||
})
|
||||
|
||||
const tmp = path.join(os.tmpdir(), `hermes-relaunch-test2-${Date.now()}.sh`)
|
||||
fs.writeFileSync(tmp, script)
|
||||
|
||||
try {
|
||||
execFileSync('bash', ['-n', tmp], { stdio: 'pipe' })
|
||||
} finally {
|
||||
fs.rmSync(tmp, { force: true })
|
||||
}
|
||||
|
||||
// exec line has no trailing args.
|
||||
assert.match(script, /exec '\/opt\/Hermes\/Hermes'\n/)
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user