mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-11 04:08:01 +08:00
Compare commits
358 Commits
hermes/gat
...
fix/memory
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f24b1bf985 | ||
|
|
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 | ||
|
|
f64e4f4f57 | ||
|
|
48788032da | ||
|
|
f5ef7ee9da | ||
|
|
ecc6725855 | ||
|
|
8a573bb6e7 | ||
|
|
24e9ed73c2 | ||
|
|
e6077af279 | ||
|
|
862aee4956 | ||
|
|
a208b7eeb4 | ||
|
|
6695640c1d | ||
|
|
2e1982f83d | ||
|
|
4d7f8ade3e | ||
|
|
9de9c25f62 | ||
|
|
c30c9753b6 | ||
|
|
f9eca7e15f | ||
|
|
4f620a0bbc | ||
|
|
6015ee5d2a | ||
|
|
543f069093 | ||
|
|
75de0057bc | ||
|
|
d9a4b5a5e5 | ||
|
|
4b184cbe54 | ||
|
|
4e4a69cbf7 | ||
|
|
8d66e78844 | ||
|
|
5633fa19b8 | ||
|
|
bf7639138e | ||
|
|
0263f1d12e | ||
|
|
6ca3d701fc | ||
|
|
db9e3e4ef9 | ||
|
|
3e7ade418d | ||
|
|
c3808cfc14 | ||
|
|
551f00109d | ||
|
|
8a726e91ba | ||
|
|
b062083d0a | ||
|
|
838d50495f | ||
|
|
2d4fd1d52f | ||
|
|
a6203839b4 | ||
|
|
bae3954f4f | ||
|
|
ea0b42c43a | ||
|
|
6c731fe591 | ||
|
|
86c5febdd1 | ||
|
|
5089c84dbf | ||
|
|
4638f3b433 | ||
|
|
1f6836cd81 | ||
|
|
743c116fb2 | ||
|
|
f99e9f0d27 | ||
|
|
086596ca2b | ||
|
|
79f4f78fa4 | ||
|
|
c3d2be073a | ||
|
|
e10e4bca82 | ||
|
|
41166bbe0d | ||
|
|
e5ac169c28 | ||
|
|
0ecfbc9890 | ||
|
|
d5a5ea8640 | ||
|
|
117f49b7d4 | ||
|
|
36308f0667 | ||
|
|
f304f41266 | ||
|
|
a0b14a3126 | ||
|
|
37a4cf9000 | ||
|
|
0e04d14209 | ||
|
|
5e51b123f3 | ||
|
|
b4289200ba | ||
|
|
b1500af277 | ||
|
|
94b4ac118a | ||
|
|
e0176cbd47 | ||
|
|
f76899facf | ||
|
|
b172e03c20 | ||
|
|
ab07e06521 | ||
|
|
4bd6fce1c1 | ||
|
|
271130af56 | ||
|
|
a730156626 | ||
|
|
49dd0b1cb5 | ||
|
|
a80e5e72bc | ||
|
|
b598f8e69b | ||
|
|
9a322726ae | ||
|
|
53edf6f983 | ||
|
|
2a14205ff4 | ||
|
|
b2d6a512d5 | ||
|
|
e9da629800 | ||
|
|
fa651375be | ||
|
|
d43863f005 | ||
|
|
437052f039 | ||
|
|
2985d16be0 | ||
|
|
985e19c110 | ||
|
|
ee66ff2790 | ||
|
|
8ce3c2f991 | ||
|
|
4c3a388cba | ||
|
|
b9d9b8aad6 | ||
|
|
acfefa4fda | ||
|
|
51dd5695ec | ||
|
|
f3c27e30eb | ||
|
|
91885a32b3 | ||
|
|
2e42bb2da5 | ||
|
|
4ca61869cb | ||
|
|
1c473bc6a6 | ||
|
|
d33becd877 | ||
|
|
8ff162fb2d | ||
|
|
2bc6e1a74b | ||
|
|
009b42d008 | ||
|
|
2acbdd1848 | ||
|
|
3c63ed3a3c | ||
|
|
cc0aa18fe7 | ||
|
|
afb5808d8c | ||
|
|
685f527d6b | ||
|
|
a1e6ea7d71 | ||
|
|
4f6313eadc | ||
|
|
c6a3d412d4 | ||
|
|
4a99571d54 | ||
|
|
f8723c4781 | ||
|
|
491689784e | ||
|
|
1deeaf71ab | ||
|
|
0d9ed9214d | ||
|
|
9c272a306e | ||
|
|
b899ffd1ea | ||
|
|
9420f1acb6 | ||
|
|
ba865e4038 | ||
|
|
569b78c1f9 | ||
|
|
b2c66681c4 | ||
|
|
2718179134 | ||
|
|
aaeba213d9 | ||
|
|
4aaaa206aa | ||
|
|
ce038a0e05 | ||
|
|
7647eff360 | ||
|
|
f341cadb71 | ||
|
|
e0bca1cbe2 | ||
|
|
87be36c240 | ||
|
|
b8ce583e05 | ||
|
|
87b65e24a7 | ||
|
|
d1c8c03416 | ||
|
|
8fc1cb754b | ||
|
|
a796e0b796 | ||
|
|
7ff86f4458 | ||
|
|
c0adfd4a67 | ||
|
|
299d5c6603 | ||
|
|
fc02b1c276 | ||
|
|
144457d801 | ||
|
|
c2c73605e0 | ||
|
|
2e30a5e628 | ||
|
|
179ca25a38 | ||
|
|
8a7d0790df | ||
|
|
3c8130a826 | ||
|
|
2c5762f575 | ||
|
|
713e50e7d2 | ||
|
|
e7082ea99f | ||
|
|
62972060ca | ||
|
|
07d93413e5 | ||
|
|
6d3d9d0baf | ||
|
|
e7a6d676c8 | ||
|
|
33a529538d | ||
|
|
11516f3cc3 | ||
|
|
b75783e6dc | ||
|
|
7445df1505 | ||
|
|
ae878e1aee | ||
|
|
043e71f1f4 | ||
|
|
4b9d9b205b | ||
|
|
76979a0869 | ||
|
|
249c69b958 | ||
|
|
088b989442 | ||
|
|
f1fde49e45 | ||
|
|
d297568299 | ||
|
|
2726c21383 | ||
|
|
5eac665252 | ||
|
|
182256206a | ||
|
|
444dc0da89 | ||
|
|
536ffedbf4 | ||
|
|
586aae4bf1 | ||
|
|
ef599aa7f0 | ||
|
|
130e2337c2 | ||
|
|
c59b300865 | ||
|
|
b2213ba870 | ||
|
|
45f5a6e659 | ||
|
|
ba03c5ab27 | ||
|
|
1c702aa73e | ||
|
|
05cbddc012 | ||
|
|
91bcfff479 | ||
|
|
8f80a982a6 | ||
|
|
d42e9b1788 | ||
|
|
f69e3aadf1 | ||
|
|
830165473e | ||
|
|
b3bee33ab3 | ||
|
|
4b4f058860 | ||
|
|
4131ec380b | ||
|
|
70c6ae609e | ||
|
|
dd7198e71d | ||
|
|
5de42325db | ||
|
|
60391d0eef | ||
|
|
fff2408961 | ||
|
|
bdca94e749 | ||
|
|
0b6df665a9 | ||
|
|
948993cd62 | ||
|
|
370a489fb4 | ||
|
|
5e685999af | ||
|
|
8cc1ca4ce2 | ||
|
|
78ee0aa367 | ||
|
|
d4bcd93bb9 | ||
|
|
586acf5307 | ||
|
|
4f008b6412 | ||
|
|
5e5191b9fa | ||
|
|
2ebf9a90b7 | ||
|
|
b24ff550c3 | ||
|
|
1ea0bbbb0d | ||
|
|
94cdd56b82 | ||
|
|
91c68bf834 | ||
|
|
51e6ef5fca | ||
|
|
5431bf2921 | ||
|
|
077419b220 | ||
|
|
7dfd5077ce | ||
|
|
409560a7d9 | ||
|
|
f0f8c84d1b |
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
|
||||
|
||||
|
||||
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
@@ -221,10 +221,17 @@ jobs:
|
||||
|
||||
- name: Save baseline cache (main only)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: cp ci-timings.json ci-timings-baseline.json
|
||||
run: |
|
||||
# Degraded runs (API rate-limited) produce no ci-timings.json —
|
||||
# skip rather than fail, and never cache an empty baseline.
|
||||
if [ -f ci-timings.json ]; then
|
||||
cp ci-timings.json ci-timings-baseline.json
|
||||
else
|
||||
echo "No timings JSON this run — skipping baseline update"
|
||||
fi
|
||||
|
||||
- name: Upload baseline to cache (main only)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && hashFiles('ci-timings-baseline.json') != ''
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ci-timings-baseline.json
|
||||
|
||||
@@ -491,7 +491,7 @@ The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes
|
||||
|
||||
### Electron Desktop Chat App (`apps/desktop/`)
|
||||
|
||||
A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared` — `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.cjs` + `backendSupportsServe()` in `main.cjs`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`.
|
||||
A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared` — `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI entirely: `serve` sets `headless_backend=True`, so `cmd_dashboard` skips `_build_web_ui` AND exports `HERMES_SERVE_HEADLESS=1` so `mount_spa()` disables the SPA even if a stray `web_dist/` exists — only the JSON-RPC/WS/API surface is reachable). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.cjs` + `backendSupportsServe()` in `main.cjs`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`.
|
||||
|
||||
**Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline:
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "hermes-agent",
|
||||
"name": "Hermes Agent",
|
||||
"version": "0.18.0",
|
||||
"version": "0.18.2",
|
||||
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
|
||||
"repository": "https://github.com/NousResearch/hermes-agent",
|
||||
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
|
||||
@@ -9,7 +9,7 @@
|
||||
"license": "MIT",
|
||||
"distribution": {
|
||||
"uvx": {
|
||||
"package": "hermes-agent[acp]==0.18.0",
|
||||
"package": "hermes-agent[acp]==0.18.2",
|
||||
"args": ["hermes-acp"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any, Optional
|
||||
import httpx
|
||||
|
||||
from agent.anthropic_adapter import _is_oauth_token, resolve_anthropic_token
|
||||
from hermes_cli.auth import _read_codex_tokens, resolve_codex_runtime_credentials
|
||||
from hermes_cli.auth import AuthError, _read_codex_tokens, resolve_codex_runtime_credentials
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -436,20 +436,78 @@ def _resolve_codex_usage_url(base_url: str) -> str:
|
||||
return normalized + "/api/codex/usage"
|
||||
|
||||
|
||||
def _fetch_codex_account_usage() -> Optional[AccountUsageSnapshot]:
|
||||
creds = resolve_codex_runtime_credentials(refresh_if_expiring=True)
|
||||
token_data = _read_codex_tokens()
|
||||
tokens = token_data.get("tokens") or {}
|
||||
account_id = str(tokens.get("account_id", "") or "").strip() or None
|
||||
def _resolve_codex_usage_credentials(
|
||||
base_url: Optional[str],
|
||||
api_key: Optional[str],
|
||||
) -> tuple[str, str, Optional[str]]:
|
||||
"""Resolve Codex quota credentials from the native runtime path.
|
||||
|
||||
Prefer explicit live-agent credentials, then the legacy singleton OAuth
|
||||
state, then the credential pool. Hermes's native OAuth setup now stores
|
||||
device-code logins in the pool, so quota diagnostics must not depend only
|
||||
on the older singleton store.
|
||||
"""
|
||||
explicit_key = str(api_key or "").strip()
|
||||
if explicit_key:
|
||||
return explicit_key, str(base_url or "").strip(), None
|
||||
|
||||
# Tier 2: the native runtime resolver. It ALREADY falls back to the
|
||||
# credential pool when the singleton is empty (see
|
||||
# ``resolve_codex_runtime_credentials`` — issue #32992), so in a pool-only
|
||||
# setup this returns a usable ``source="credential_pool"`` token.
|
||||
#
|
||||
# Only ``AuthError`` ("no creds" / rate-limited) is caught so tier 3 can
|
||||
# run: a broad ``except Exception`` would (a) mask a transient refresh /
|
||||
# network failure and silently hand back a DIFFERENT pool account's usage,
|
||||
# and (b) hide genuine programming errors. A refresh/network error must
|
||||
# propagate — the outer ``fetch_account_usage`` guard fails open (shows
|
||||
# nothing this turn) rather than reporting the wrong account.
|
||||
#
|
||||
# The ``account_id`` (for the ``ChatGPT-Account-Id`` header) is read
|
||||
# best-effort: a partial/missing singleton token store must not sink an
|
||||
# otherwise-usable resolver credential and force a header-less pool fallback.
|
||||
try:
|
||||
creds = resolve_codex_runtime_credentials(refresh_if_expiring=True)
|
||||
account_id: Optional[str] = None
|
||||
try:
|
||||
token_data = _read_codex_tokens()
|
||||
tokens = token_data.get("tokens") or {}
|
||||
account_id = str(tokens.get("account_id", "") or "").strip() or None
|
||||
except AuthError:
|
||||
# Pool-only creds carry no singleton account_id; header is optional.
|
||||
logger.debug("codex ▸ /usage account_id read failed (best-effort)", exc_info=True)
|
||||
return creds["api_key"], str(creds.get("base_url", "") or "").strip(), account_id
|
||||
except AuthError:
|
||||
logger.debug("codex ▸ /usage runtime resolver returned no creds; trying pool", exc_info=True)
|
||||
|
||||
# Tier 3: direct pool select. Reached only when the resolver itself raises
|
||||
# AuthError (e.g. singleton missing AND its own pool read found nothing at
|
||||
# resolve time, but a pool entry is usable now). Pool credentials have no
|
||||
# account_id concept, so the ChatGPT-Account-Id header is intentionally
|
||||
# omitted here.
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
pool = load_pool("openai-codex")
|
||||
entry = pool.select()
|
||||
if entry is None:
|
||||
raise RuntimeError("No available openai-codex credential in credential pool")
|
||||
return entry.runtime_api_key, str(entry.runtime_base_url or base_url or "").strip(), None
|
||||
|
||||
|
||||
def _fetch_codex_account_usage(
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
) -> Optional[AccountUsageSnapshot]:
|
||||
token, resolved_base_url, account_id = _resolve_codex_usage_credentials(base_url, api_key)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {creds['api_key']}",
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "codex-cli",
|
||||
}
|
||||
if account_id:
|
||||
headers["ChatGPT-Account-Id"] = account_id
|
||||
with httpx.Client(timeout=15.0) as client:
|
||||
response = client.get(_resolve_codex_usage_url(creds.get("base_url", "")), headers=headers)
|
||||
response = client.get(_resolve_codex_usage_url(resolved_base_url), headers=headers)
|
||||
response.raise_for_status()
|
||||
payload = response.json() or {}
|
||||
rate_limit = payload.get("rate_limit") or {}
|
||||
@@ -628,7 +686,7 @@ def fetch_account_usage(
|
||||
return None
|
||||
try:
|
||||
if normalized == "openai-codex":
|
||||
return _fetch_codex_account_usage()
|
||||
return _fetch_codex_account_usage(base_url=base_url, api_key=api_key)
|
||||
if normalized == "anthropic":
|
||||
return _fetch_anthropic_account_usage()
|
||||
if normalized == "openrouter":
|
||||
|
||||
@@ -68,24 +68,118 @@ def _ra():
|
||||
return run_agent
|
||||
|
||||
|
||||
def _build_codex_gpt55_autoraise_notice(autoraise: Dict[str, float]) -> str:
|
||||
"""Build the one-time notice shown when Codex gpt-5.5 raises compaction.
|
||||
def _build_codex_gpt5_autoraise_notice(autoraise: Dict[str, Any]) -> str:
|
||||
"""Build the one-time notice shown when Codex gpt-5.x raises compaction.
|
||||
|
||||
``autoraise`` is ``{"from": <old_ratio>, "to": <new_ratio>}``. The same
|
||||
text is printed inline for CLI users and replayed via ``status_callback``
|
||||
for gateway users, so it must be self-contained and include the exact
|
||||
opt-back-out command.
|
||||
``autoraise`` is ``{"model": <slug>, "from": <old_ratio>, "to": <new_ratio>}``.
|
||||
The same text is printed inline for CLI users and replayed via
|
||||
``status_callback`` for gateway users, so it must be self-contained and
|
||||
include the exact opt-back-out command.
|
||||
"""
|
||||
model = str(autoraise.get("model") or "gpt-5.4/5.5").strip().lower().rsplit("/", 1)[-1]
|
||||
# gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5/5.6 family
|
||||
# is capped at 272K by the Codex OAuth backend.
|
||||
cap = "128K" if model.startswith("gpt-5.3-codex-spark") else "272K"
|
||||
from_pct = int(round(autoraise["from"] * 100))
|
||||
to_pct = int(round(autoraise["to"] * 100))
|
||||
return (
|
||||
f"ℹ Codex gpt-5.5 caps context at 272K, so auto-compaction was raised "
|
||||
f"ℹ Codex {model} caps context at {cap}, so auto-compaction was raised "
|
||||
f"to {to_pct}% (from {from_pct}%) to use more of the window before "
|
||||
f"summarizing.\n"
|
||||
f" Opt back out: hermes config set compression.codex_gpt55_autoraise false"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_compression_threshold(
|
||||
global_threshold: float,
|
||||
model_cthresh: Optional[float],
|
||||
*,
|
||||
model: Optional[str] = None,
|
||||
is_codex_autoraise: bool,
|
||||
) -> tuple[float, Optional[Dict[str, Any]]]:
|
||||
"""Combine the user's global compaction threshold with a per-model override.
|
||||
|
||||
Returns ``(effective_threshold, autoraise_notice)``. ``autoraise_notice`` is
|
||||
``{"model": <slug>, "from": <old>, "to": <new>}`` only when a Codex
|
||||
autoraise (gpt-5.4/5.5 272K family or gpt-5.3-codex-spark) actually raises
|
||||
the threshold, otherwise ``None``.
|
||||
|
||||
The Codex overrides are *autoraises*: they must never LOWER a higher
|
||||
user-configured threshold. A user who already set ``compression.threshold``
|
||||
above the raised value deliberately keeps more raw context, and silently
|
||||
dropping them would both waste usable window and contradict the feature's
|
||||
purpose (use more of the window). Other overrides (e.g. Arcee Trinity)
|
||||
keep their existing unconditional behaviour.
|
||||
"""
|
||||
if model_cthresh is None:
|
||||
return global_threshold, None
|
||||
if is_codex_autoraise:
|
||||
if model_cthresh <= global_threshold + 1e-9:
|
||||
# Autoraise never lowers; keep the user's higher/equal threshold.
|
||||
return global_threshold, None
|
||||
return model_cthresh, {
|
||||
"model": model,
|
||||
"from": global_threshold,
|
||||
"to": model_cthresh,
|
||||
}
|
||||
return model_cthresh, None
|
||||
|
||||
|
||||
def _codex_gpt55_autoraise_notice_marker():
|
||||
"""Path to the per-profile marker recording that the autoraise notice ran.
|
||||
|
||||
Lives under ``$HERMES_HOME`` (which is profile-scoped) alongside the other
|
||||
internal markers like ``.container-mode`` — so it is not a user-facing config
|
||||
key, and every profile tracks its own notice state independently.
|
||||
"""
|
||||
return get_hermes_home() / ".codex_gpt55_autoraise_notice"
|
||||
|
||||
|
||||
def _codex_gpt55_autoraise_notice_state(autoraise: Dict[str, Any]) -> str:
|
||||
"""Stable identity for one autoraise notice, keyed on what it displays.
|
||||
|
||||
Uses the model slug plus the same from→to percentages the notice text
|
||||
shows, so an unchanged threshold stays silent across restarts while a
|
||||
later change (the user edits their global ``threshold``, or switches to a
|
||||
different autoraised Codex model) re-notifies once.
|
||||
"""
|
||||
model = str(autoraise.get("model") or "").strip().lower().rsplit("/", 1)[-1]
|
||||
from_pct = int(round(float(autoraise["from"]) * 100))
|
||||
to_pct = int(round(float(autoraise["to"]) * 100))
|
||||
return f"{model}:{from_pct}:{to_pct}"
|
||||
|
||||
|
||||
def _codex_gpt55_autoraise_notice_seen(autoraise: Dict[str, Any]) -> bool:
|
||||
"""True if this exact autoraise notice was already shown for this profile.
|
||||
|
||||
A missing/unreadable marker (or one recording a different threshold) reads
|
||||
as unseen, so the notice shows.
|
||||
"""
|
||||
try:
|
||||
current = _codex_gpt55_autoraise_notice_state(autoraise)
|
||||
return _codex_gpt55_autoraise_notice_marker().read_text(
|
||||
encoding="utf-8"
|
||||
).strip() == current
|
||||
except (OSError, KeyError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _record_codex_gpt55_autoraise_notice(autoraise: Dict[str, Any]) -> None:
|
||||
"""Persist that the autoraise notice was shown for this profile/config state.
|
||||
|
||||
Best-effort: a read-only or missing ``$HERMES_HOME`` just means the notice
|
||||
may show again next init, which is preferable to breaking agent init.
|
||||
"""
|
||||
try:
|
||||
marker = _codex_gpt55_autoraise_notice_marker()
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
marker.write_text(
|
||||
_codex_gpt55_autoraise_notice_state(autoraise), encoding="utf-8"
|
||||
)
|
||||
except (OSError, KeyError, TypeError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _normalized_custom_base_url(value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
@@ -1409,14 +1503,14 @@ def init_agent(
|
||||
if not isinstance(_compression_cfg, dict):
|
||||
_compression_cfg = {}
|
||||
compression_threshold = float(_compression_cfg.get("threshold", 0.50))
|
||||
# Per-model/route compaction-threshold override. Codex gpt-5.5 raises to
|
||||
# 85% (the Codex backend caps the window at 272K, so the default 50% would
|
||||
# compact at ~136K — half the usable context). Gated by an opt-out config
|
||||
# flag so the user can fall back to the global threshold; when the override
|
||||
# fires we stash a one-time notification (replayed on the first turn) that
|
||||
# tells the user what changed and how to revert. The notice has its own
|
||||
# display gate so users can keep the threshold autoraise without getting
|
||||
# the banner on gateway turns.
|
||||
# Per-model/route compaction-threshold override. Codex gpt-5.4 / gpt-5.5
|
||||
# raise to 85% (the Codex backend caps both families at 272K, so the
|
||||
# default 50% would compact at ~136K — half the usable context). Gated by
|
||||
# an opt-out config flag so the user can fall back to the global threshold;
|
||||
# when the override fires we stash a one-time notification (replayed on the
|
||||
# first turn) that tells the user what changed and how to revert. The
|
||||
# notice has its own display gate so users can keep the threshold
|
||||
# autoraise without getting the banner on gateway turns.
|
||||
_codex_gpt55_autoraise = str(
|
||||
_compression_cfg.get("codex_gpt55_autoraise", True)
|
||||
).lower() in {"true", "1", "yes"}
|
||||
@@ -1427,28 +1521,30 @@ def init_agent(
|
||||
try:
|
||||
from agent.auxiliary_client import (
|
||||
_compression_threshold_for_model as _cthresh_fn,
|
||||
_is_codex_gpt55 as _is_codex_gpt55_fn,
|
||||
_is_codex_gpt54_or_gpt55 as _is_codex_gpt54_or_gpt55_fn,
|
||||
_is_codex_spark as _is_codex_spark_fn,
|
||||
)
|
||||
_model_cthresh = _cthresh_fn(
|
||||
agent.model,
|
||||
agent.provider,
|
||||
allow_codex_gpt55_autoraise=_codex_gpt55_autoraise,
|
||||
)
|
||||
if _model_cthresh is not None:
|
||||
_prev_threshold = compression_threshold
|
||||
compression_threshold = _model_cthresh
|
||||
# Notify only for the Codex gpt-5.5 autoraise (the Arcee Trinity
|
||||
# override is a long-standing silent default). Skip the notice when
|
||||
# the user's global threshold already meets/exceeds the raised
|
||||
# value, since nothing actually changed for them.
|
||||
if (
|
||||
_is_codex_gpt55_fn(agent.model, agent.provider)
|
||||
and _model_cthresh > _prev_threshold + 1e-9
|
||||
):
|
||||
agent._compression_threshold_autoraised = {
|
||||
"from": _prev_threshold,
|
||||
"to": _model_cthresh,
|
||||
}
|
||||
# The Codex autoraises (gpt-5.4/5.5 272K family and gpt-5.3-codex-spark)
|
||||
# apply only when they RAISE (never lower a user's higher global
|
||||
# threshold). The notice is populated only when it actually fires, and
|
||||
# carries the model slug so the banner names the right family. Arcee
|
||||
# Trinity keeps its long-standing unconditional behaviour.
|
||||
compression_threshold, agent._compression_threshold_autoraised = (
|
||||
_resolve_compression_threshold(
|
||||
compression_threshold,
|
||||
_model_cthresh,
|
||||
model=agent.model,
|
||||
is_codex_autoraise=(
|
||||
_is_codex_gpt54_or_gpt55_fn(agent.model, agent.provider)
|
||||
or _is_codex_spark_fn(agent.model, agent.provider)
|
||||
),
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"}
|
||||
@@ -1474,6 +1570,16 @@ def init_agent(
|
||||
compression_in_place = is_truthy_value(
|
||||
_compression_cfg.get("in_place"), default=False
|
||||
)
|
||||
codex_app_server_auto_compaction = str(
|
||||
_compression_cfg.get("codex_app_server_auto", "native") or "native"
|
||||
).lower()
|
||||
if codex_app_server_auto_compaction not in {"native", "hermes", "off"}:
|
||||
_ra().logger.warning(
|
||||
"Invalid compression.codex_app_server_auto=%r; using 'native'. "
|
||||
"Valid values are: native, hermes, off.",
|
||||
codex_app_server_auto_compaction,
|
||||
)
|
||||
codex_app_server_auto_compaction = "native"
|
||||
|
||||
# Read optional explicit context_length override for the auxiliary
|
||||
# compression model. Custom endpoints often cannot report this via
|
||||
@@ -1677,6 +1783,12 @@ def init_agent(
|
||||
|
||||
if _selected_engine is not None:
|
||||
agent.context_compressor = _selected_engine
|
||||
# External engines own compaction policy: the host compression
|
||||
# threshold (including the Codex gpt-5.5 autoraise above) only
|
||||
# configures the built-in ContextCompressor and never reaches the
|
||||
# plugin, so the autoraise notice would announce a change that does
|
||||
# not apply. Drop it. (#44439)
|
||||
agent._compression_threshold_autoraised = None
|
||||
# Resolve context_length for plugin engines — mirrors switch_model() path
|
||||
from agent.model_metadata import get_model_context_length
|
||||
_plugin_ctx_len = get_model_context_length(
|
||||
@@ -1722,6 +1834,7 @@ def init_agent(
|
||||
pass
|
||||
agent.compression_enabled = compression_enabled
|
||||
agent.compression_in_place = compression_in_place
|
||||
agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction
|
||||
|
||||
# Reject models whose context window is below the minimum required
|
||||
# for reliable tool-calling workflows (64K tokens).
|
||||
@@ -1899,29 +2012,53 @@ def init_agent(
|
||||
agent._ollama_num_ctx,
|
||||
)
|
||||
|
||||
# Codex gpt-5.x autoraise notice: show at most once per profile/config
|
||||
# state. Without the persisted marker the notice re-fires on every agent
|
||||
# init — and the gateway rebuilds the agent per inbound message, so Discord
|
||||
# etc. saw it repeatedly (#54432). A change in the raised threshold (or the
|
||||
# autoraised model) updates the marker state and re-notifies once. The
|
||||
# config display gate (compression.codex_gpt55_autoraise_notice) still
|
||||
# suppresses the banner entirely without disabling the threshold autoraise.
|
||||
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
|
||||
_show_autoraise_notice = (
|
||||
bool(_autoraise)
|
||||
and compression_enabled
|
||||
and _codex_gpt55_autoraise_notice
|
||||
and not _codex_gpt55_autoraise_notice_seen(_autoraise)
|
||||
)
|
||||
|
||||
if not agent.quiet_mode:
|
||||
if compression_enabled:
|
||||
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(compression_threshold*100)}% = {agent.context_compressor.threshold_tokens:,})")
|
||||
# Report the active engine's own threshold — for a plugin engine
|
||||
# the host compression_threshold is not in effect, and mixing the
|
||||
# two printed a percent that contradicted the token count. (#44439)
|
||||
_active_threshold_pct = getattr(
|
||||
agent.context_compressor, "threshold_percent", compression_threshold
|
||||
)
|
||||
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(_active_threshold_pct*100)}% = {agent.context_compressor.threshold_tokens:,})")
|
||||
else:
|
||||
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (auto-compression disabled)")
|
||||
# One-time notice when the Codex gpt-5.5 autoraise kicked in, with the
|
||||
# exact opt-back-out command. Printed inline at startup for CLI users;
|
||||
# gateway users get the same text replayed via _compression_warning on
|
||||
# turn 1 (set below, after the warning slot is initialized).
|
||||
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
|
||||
if _autoraise and compression_enabled and _codex_gpt55_autoraise_notice:
|
||||
print(_build_codex_gpt55_autoraise_notice(_autoraise))
|
||||
# Notice with the exact opt-back-out command. Printed inline at startup
|
||||
# for CLI users; gateway users get the same text replayed via
|
||||
# _compression_warning on turn 1 (set below).
|
||||
if _show_autoraise_notice:
|
||||
print(_build_codex_gpt5_autoraise_notice(_autoraise))
|
||||
|
||||
# Check immediately so CLI users see the warning at startup.
|
||||
# Gateway status_callback is not yet wired, so any warning is stored
|
||||
# in _compression_warning and replayed in the first run_conversation().
|
||||
agent._compression_warning = None
|
||||
# Gateway parity for the Codex gpt-5.5 autoraise notice: the startup print
|
||||
# Gateway parity for the Codex gpt-5.x autoraise notice: the startup print
|
||||
# above only reaches the CLI, so stash the same text here to be replayed
|
||||
# through status_callback on the first turn (Telegram/Discord/Slack/etc.).
|
||||
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
|
||||
if _autoraise and compression_enabled and _codex_gpt55_autoraise_notice:
|
||||
agent._compression_warning = _build_codex_gpt55_autoraise_notice(_autoraise)
|
||||
if _show_autoraise_notice:
|
||||
agent._compression_warning = _build_codex_gpt5_autoraise_notice(_autoraise)
|
||||
|
||||
# Mark shown so repeated inits in this profile (e.g. every gateway message)
|
||||
# stay silent. Recorded once, whether the notice went to the CLI print or
|
||||
# the gateway replay slot.
|
||||
if _show_autoraise_notice:
|
||||
_record_codex_gpt55_autoraise_notice(_autoraise)
|
||||
# Lazy feasibility check: deferred to the first turn that approaches the
|
||||
# compression threshold. Running it eagerly here costs ~400ms cold (network
|
||||
# probe of the auxiliary provider chain + /models lookup) on every agent
|
||||
|
||||
@@ -729,7 +729,14 @@ def recover_with_credential_pool(
|
||||
# that seeded the pool.
|
||||
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
|
||||
pool_provider = (getattr(pool, "provider", "") or "").strip().lower()
|
||||
if current_provider and pool_provider and current_provider != pool_provider:
|
||||
# Guard: skip credential pool recovery when the pool is scoped to a
|
||||
# different provider than the agent. Only guard when the pool has a
|
||||
# known provider — an empty pool provider means "unscoped" (applies to
|
||||
# any provider). An empty agent provider is treated as a mismatch
|
||||
# because swapping the pool's credentials would set base_url/api_key
|
||||
# without fixing the empty provider field, leaving the agent in a
|
||||
# corrupted state (provider="" model="").
|
||||
if pool_provider and current_provider != pool_provider:
|
||||
# Custom endpoints use two naming conventions for the SAME provider:
|
||||
# the agent carries the generic ``custom`` label while the pool is
|
||||
# keyed ``custom:<name>`` (see CUSTOM_POOL_PREFIX). A literal string
|
||||
@@ -1268,6 +1275,12 @@ def restore_primary_runtime(agent) -> bool:
|
||||
agent._fallback_activated = False
|
||||
agent._fallback_index = 0
|
||||
|
||||
# Reset the stale-call circuit breaker (#58962): the streak measured
|
||||
# the FALLBACK provider we're leaving; the restored primary deserves
|
||||
# a fresh stream attempt before the breaker can trip again.
|
||||
from agent.chat_completion_helpers import _reset_stale_streak
|
||||
_reset_stale_streak(agent)
|
||||
|
||||
# Undo the fallback's identity rewrite so the prompt is
|
||||
# byte-identical to the stored copy again (prefix cache match).
|
||||
from agent.chat_completion_helpers import rewrite_prompt_model_identity
|
||||
@@ -1551,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
|
||||
@@ -1564,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
|
||||
@@ -1792,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"):
|
||||
@@ -1912,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",
|
||||
@@ -1985,6 +2031,14 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
||||
# ── Invalidate cached system prompt so it rebuilds next turn ──
|
||||
agent._cached_system_prompt = None
|
||||
|
||||
# ── Reset the cross-turn stale-call circuit breaker (#58962) ──
|
||||
# The breaker's error text tells the user to "switch models ... then
|
||||
# retry"; without this reset the streak stays latched and the freshly
|
||||
# selected (healthy) provider would keep short-circuiting before any
|
||||
# stream is even attempted.
|
||||
from agent.chat_completion_helpers import _reset_stale_streak
|
||||
_reset_stale_streak(agent)
|
||||
|
||||
# ── Update _primary_runtime so the change persists across turns ──
|
||||
_cc = agent.context_compressor if hasattr(agent, "context_compressor") and agent.context_compressor else None
|
||||
agent._primary_runtime = {
|
||||
@@ -2094,12 +2148,12 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
||||
except Exception as _mw_err:
|
||||
logger.debug("tool_request middleware error: %s", _mw_err)
|
||||
|
||||
# Check plugin hooks for a block directive before executing anything.
|
||||
# Check plugin hooks for a block or approval directive before executing.
|
||||
block_message: Optional[str] = None
|
||||
if not pre_tool_block_checked:
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
block_message = get_pre_tool_call_block_message(
|
||||
from hermes_cli.plugins import resolve_pre_tool_block
|
||||
block_message = resolve_pre_tool_block(
|
||||
function_name,
|
||||
function_args,
|
||||
task_id=effective_task_id or "",
|
||||
@@ -2110,7 +2164,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
||||
middleware_trace=list(_tool_middleware_trace),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
block_message = None
|
||||
if block_message is not None:
|
||||
result = json.dumps({"error": block_message}, ensure_ascii=False)
|
||||
try:
|
||||
|
||||
@@ -1390,7 +1390,8 @@ _OAUTH_TOKEN_URL = _OAUTH_TOKEN_URLS[0]
|
||||
_OAUTH_TOKEN_USER_AGENT = "axios/1.7.9"
|
||||
_OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
|
||||
_OAUTH_SCOPES = "org:create_api_key user:profile user:inference"
|
||||
_HERMES_OAUTH_FILE = get_hermes_home() / ".anthropic_oauth.json"
|
||||
def _get_hermes_oauth_file() -> Path:
|
||||
return get_hermes_home() / ".anthropic_oauth.json"
|
||||
|
||||
|
||||
def _generate_pkce() -> tuple:
|
||||
@@ -1538,9 +1539,10 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
|
||||
|
||||
def read_hermes_oauth_credentials() -> Optional[Dict[str, Any]]:
|
||||
"""Read Hermes-managed OAuth credentials from ~/.hermes/.anthropic_oauth.json."""
|
||||
if _HERMES_OAUTH_FILE.exists():
|
||||
oauth_file = _get_hermes_oauth_file()
|
||||
if oauth_file.exists():
|
||||
try:
|
||||
data = json.loads(_HERMES_OAUTH_FILE.read_text(encoding="utf-8"))
|
||||
data = json.loads(oauth_file.read_text(encoding="utf-8"))
|
||||
if data.get("accessToken"):
|
||||
return data
|
||||
except (json.JSONDecodeError, OSError, IOError) as e:
|
||||
|
||||
@@ -314,33 +314,72 @@ 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.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.5
|
||||
# sessions use the window they actually have.
|
||||
_CODEX_GPT55_COMPACTION_THRESHOLD = 0.85
|
||||
# ``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
|
||||
# native 128K context window. The default 50% compaction trigger fires at
|
||||
# ~64K — wasting half the usable window, often before the session has enough
|
||||
# turns to summarize meaningfully. We raise the trigger to 70% (~90K) so
|
||||
# spark sessions use more of the window before summarization, while still
|
||||
# leaving ~38K headroom for the summary and continued conversation before
|
||||
# the 128K hard limit.
|
||||
_CODEX_SPARK_COMPACTION_THRESHOLD = 0.70
|
||||
|
||||
|
||||
def _is_codex_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool:
|
||||
"""True for gpt-5.5 accessed through the ChatGPT Codex OAuth backend.
|
||||
def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool:
|
||||
"""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.5-pro`` and dated snapshots
|
||||
(``gpt-5.5-2026-04-23``) are matched via prefix so the override tracks the
|
||||
family 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":
|
||||
return False
|
||||
bare = (model or "").strip().lower().rsplit("/", 1)[-1]
|
||||
return bare == "gpt-5.5" or bare.startswith("gpt-5.5-") or bare.startswith("gpt-5.5.")
|
||||
return (
|
||||
bare == "gpt-5.4"
|
||||
or bare.startswith("gpt-5.4-")
|
||||
or bare.startswith("gpt-5.4.")
|
||||
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.")
|
||||
)
|
||||
|
||||
|
||||
def _is_codex_spark(model: Optional[str], provider: Optional[str] = None) -> bool:
|
||||
"""True for ``gpt-5.3-codex-spark`` on the ChatGPT Codex OAuth backend.
|
||||
|
||||
The model is Codex-OAuth-only (ChatGPT Pro entitlement) with a native
|
||||
128K context window. Only the Codex OAuth route (provider
|
||||
``openai-codex``) is matched — the slug is not available on other
|
||||
routes.
|
||||
"""
|
||||
prov = (provider or "").strip().lower()
|
||||
if prov != "openai-codex":
|
||||
return False
|
||||
bare = (model or "").strip().lower().rsplit("/", 1)[-1]
|
||||
return bare == "gpt-5.3-codex-spark"
|
||||
|
||||
|
||||
def _fixed_temperature_for_model(
|
||||
@@ -379,18 +418,27 @@ def _compression_threshold_for_model(
|
||||
|
||||
Per-model/route overrides:
|
||||
- Arcee Trinity Large Thinking → 0.75 (preserve reasoning context).
|
||||
- gpt-5.5 on the Codex OAuth route → 0.85, because Codex caps the window
|
||||
at 272K and the default 50% trigger would compact at ~136K. Gated by
|
||||
``allow_codex_gpt55_autoraise`` 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
|
||||
opt-out flag: 128K is the model's native window, so the raise is
|
||||
unambiguously correct.
|
||||
|
||||
Returns a float in (0, 1] to override the global ``compression.threshold``
|
||||
config value, or ``None`` to leave the user's config value unchanged.
|
||||
"""
|
||||
if _is_arcee_trinity_thinking(model):
|
||||
return 0.75
|
||||
if allow_codex_gpt55_autoraise and _is_codex_gpt55(model, provider):
|
||||
return _CODEX_GPT55_COMPACTION_THRESHOLD
|
||||
if allow_codex_gpt55_autoraise and _is_codex_gpt54_or_gpt55(model, provider):
|
||||
return _CODEX_GPT54_GPT55_COMPACTION_THRESHOLD
|
||||
if _is_codex_spark(model, provider):
|
||||
return _CODEX_SPARK_COMPACTION_THRESHOLD
|
||||
return None
|
||||
|
||||
# Default auxiliary models for direct API-key providers (cheap/fast for side tasks)
|
||||
@@ -1317,6 +1365,96 @@ class AsyncAnthropicAuxiliaryClient:
|
||||
self._real_client = sync_wrapper._real_client
|
||||
|
||||
|
||||
class _BedrockCompletionsAdapter:
|
||||
"""Translates ``chat.completions.create(**kwargs)`` into Bedrock Converse."""
|
||||
|
||||
def __init__(self, region: str, model: str):
|
||||
self._region = region
|
||||
self._model = model
|
||||
|
||||
def create(self, **kwargs) -> Any:
|
||||
from agent.bedrock_adapter import call_converse
|
||||
|
||||
messages = kwargs.get("messages", [])
|
||||
model = kwargs.get("model", self._model)
|
||||
max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens")
|
||||
# OpenAI accepts ``stop`` as str or list; Converse requires a list.
|
||||
stop = kwargs.get("stop")
|
||||
if isinstance(stop, str):
|
||||
stop = [stop]
|
||||
if kwargs.get("tool_choice") is not None:
|
||||
# Converse's toolChoice isn't wired through call_converse();
|
||||
# no in-tree auxiliary caller passes tool_choice today. Surface
|
||||
# the drop instead of silently ignoring it.
|
||||
logger.debug(
|
||||
"BedrockAuxiliaryClient: tool_choice=%r not supported by the "
|
||||
"Converse shim — ignored.", kwargs.get("tool_choice"),
|
||||
)
|
||||
if kwargs.get("stream"):
|
||||
# Converse streaming isn't wired through this shim. Return a
|
||||
# complete response instead — call_llm's streaming consumer
|
||||
# detects a final object and downgrades to non-live output.
|
||||
logger.debug(
|
||||
"BedrockAuxiliaryClient: stream=True requested for %s — "
|
||||
"returning a complete response (Converse shim does not "
|
||||
"stream); caller downgrades to non-streaming.",
|
||||
model,
|
||||
)
|
||||
return call_converse(
|
||||
region=self._region,
|
||||
model=model,
|
||||
messages=messages,
|
||||
tools=kwargs.get("tools"),
|
||||
max_tokens=int(max_tokens) if max_tokens else 4096,
|
||||
temperature=kwargs.get("temperature"),
|
||||
top_p=kwargs.get("top_p"),
|
||||
stop_sequences=stop,
|
||||
)
|
||||
|
||||
|
||||
class _BedrockChatShim:
|
||||
def __init__(self, adapter: "_BedrockCompletionsAdapter"):
|
||||
self.completions = adapter
|
||||
|
||||
|
||||
class BedrockAuxiliaryClient:
|
||||
"""OpenAI-client-compatible wrapper over AWS Bedrock Converse API."""
|
||||
|
||||
def __init__(self, region: str, model: str):
|
||||
self._region = region
|
||||
self._model = model
|
||||
adapter = _BedrockCompletionsAdapter(region, model)
|
||||
self.chat = _BedrockChatShim(adapter)
|
||||
self.api_key = "aws-sdk"
|
||||
self.base_url = f"https://bedrock-runtime.{region}.amazonaws.com"
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _AsyncBedrockCompletionsAdapter:
|
||||
def __init__(self, sync_adapter: _BedrockCompletionsAdapter):
|
||||
self._sync = sync_adapter
|
||||
|
||||
async def create(self, **kwargs) -> Any:
|
||||
import asyncio
|
||||
return await asyncio.to_thread(self._sync.create, **kwargs)
|
||||
|
||||
|
||||
class _AsyncBedrockChatShim:
|
||||
def __init__(self, adapter: _AsyncBedrockCompletionsAdapter):
|
||||
self.completions = adapter
|
||||
|
||||
|
||||
class AsyncBedrockAuxiliaryClient:
|
||||
def __init__(self, sync_wrapper: "BedrockAuxiliaryClient"):
|
||||
sync_adapter = sync_wrapper.chat.completions
|
||||
async_adapter = _AsyncBedrockCompletionsAdapter(sync_adapter)
|
||||
self.chat = _AsyncBedrockChatShim(async_adapter)
|
||||
self.api_key = sync_wrapper.api_key
|
||||
self.base_url = sync_wrapper.base_url
|
||||
|
||||
|
||||
def _endpoint_speaks_anthropic_messages(base_url: str) -> bool:
|
||||
"""True if the endpoint at ``base_url`` speaks the Anthropic Messages
|
||||
protocol instead of OpenAI chat.completions.
|
||||
@@ -1372,6 +1510,8 @@ def _maybe_wrap_anthropic(
|
||||
# Already wrapped — don't double-wrap.
|
||||
if _safe_isinstance(client_obj, AnthropicAuxiliaryClient):
|
||||
return client_obj
|
||||
if _safe_isinstance(client_obj, BedrockAuxiliaryClient):
|
||||
return client_obj
|
||||
# Other specialized adapters we should never re-dispatch.
|
||||
if _safe_isinstance(client_obj, CodexAuxiliaryClient):
|
||||
return client_obj
|
||||
@@ -3349,6 +3489,21 @@ def _refresh_provider_credentials(provider: str) -> bool:
|
||||
"""Refresh short-lived credentials for OAuth-backed auxiliary providers."""
|
||||
normalized = _normalize_aux_provider(provider)
|
||||
try:
|
||||
if normalized == "copilot":
|
||||
from hermes_cli.copilot_auth import (
|
||||
_jwt_cache,
|
||||
_token_fingerprint,
|
||||
exchange_copilot_token,
|
||||
resolve_copilot_token,
|
||||
)
|
||||
|
||||
raw_token, _source = resolve_copilot_token()
|
||||
if not str(raw_token or "").strip():
|
||||
return False
|
||||
_jwt_cache.pop(_token_fingerprint(raw_token), None)
|
||||
exchange_copilot_token(raw_token)
|
||||
_evict_cached_clients(normalized)
|
||||
return True
|
||||
if normalized == "openai-codex":
|
||||
from hermes_cli.auth import resolve_codex_runtime_credentials
|
||||
|
||||
@@ -3403,6 +3558,152 @@ def _refresh_provider_credentials(provider: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _auth_refresh_provider_for_route(
|
||||
resolved_provider: Optional[str],
|
||||
client_base_url: str,
|
||||
) -> str:
|
||||
"""Return the provider whose short-lived credentials should be refreshed.
|
||||
|
||||
Auto-routed auxiliary calls keep ``resolved_provider == "auto"`` even
|
||||
after _get_cached_client() selects a concrete backend. Infer the backend
|
||||
from the selected client's base URL so auth refresh works for auto →
|
||||
Copilot/Codex/Anthropic/Nous routes too. (#20832)
|
||||
"""
|
||||
normalized = _normalize_aux_provider(resolved_provider)
|
||||
if normalized and normalized != "auto":
|
||||
return normalized
|
||||
if base_url_host_matches(client_base_url, "api.githubcopilot.com"):
|
||||
return "copilot"
|
||||
if base_url_host_matches(client_base_url, "chatgpt.com"):
|
||||
return "openai-codex"
|
||||
if base_url_host_matches(client_base_url, "api.anthropic.com"):
|
||||
return "anthropic"
|
||||
if base_url_host_matches(client_base_url, "inference-api.nousresearch.com"):
|
||||
return "nous"
|
||||
return normalized
|
||||
|
||||
|
||||
def _call_fallback_candidate_sync(
|
||||
fb_client: Any,
|
||||
fb_model: Optional[str],
|
||||
fb_label: str,
|
||||
*,
|
||||
task: Optional[str],
|
||||
messages: list,
|
||||
temperature: Optional[float],
|
||||
max_tokens: Optional[int],
|
||||
tools: Optional[list],
|
||||
effective_timeout: float,
|
||||
effective_extra_body: dict,
|
||||
) -> Optional[Any]:
|
||||
"""Call one fallback candidate with stale-credential recovery.
|
||||
|
||||
A fallback candidate can itself carry a stale credential (e.g. an expired
|
||||
``ANTHROPIC_TOKEN`` picked up by ``_try_anthropic``). Before this helper,
|
||||
such a 401 propagated out of the fallback site and aborted the auxiliary
|
||||
task (for compression: a 60s cooldown + context marker) even though other
|
||||
healthy candidates remained. Live case: a Codex-timeout → Anthropic
|
||||
fallback 401-looped five times in one session (mattalachia debug dump,
|
||||
Jul 2026).
|
||||
|
||||
On an auth error: refresh the candidate's provider credentials and retry
|
||||
once with a rebuilt client; if the retry also auth-fails (non-refreshable
|
||||
expired token), mark the provider unhealthy and return ``None`` so the
|
||||
caller can continue to the next fallback layer. Non-auth errors raise.
|
||||
"""
|
||||
fb_base = str(getattr(fb_client, "base_url", "") or "")
|
||||
fb_kwargs = _build_call_kwargs(
|
||||
fb_label, fb_model, messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout,
|
||||
extra_body=effective_extra_body, base_url=fb_base)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
fb_client.chat.completions.create(**fb_kwargs), task)
|
||||
except Exception as fb_err:
|
||||
if not _is_auth_error(fb_err):
|
||||
raise
|
||||
fb_provider = _auth_refresh_provider_for_route(fb_label, fb_base)
|
||||
if fb_provider not in {"auto", "", None} and _refresh_provider_credentials(fb_provider):
|
||||
retry_client, retry_model = _get_cached_client(fb_provider, fb_model)
|
||||
if retry_client is not None:
|
||||
retry_kwargs = _build_call_kwargs(
|
||||
fb_provider, retry_model or fb_model, messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout,
|
||||
extra_body=effective_extra_body,
|
||||
base_url=str(getattr(retry_client, "base_url", "") or fb_base))
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
retry_client.chat.completions.create(**retry_kwargs), task)
|
||||
except Exception as retry_err:
|
||||
if not _is_auth_error(retry_err):
|
||||
raise
|
||||
# Refresh unavailable or the refreshed credential still 401s —
|
||||
# the token is dead (expired setup token with no refresh token).
|
||||
# Quarantine the candidate so subsequent chain walks skip it, and
|
||||
# let the caller move on instead of aborting the whole task.
|
||||
_mark_provider_unhealthy(fb_provider or fb_label)
|
||||
logger.warning(
|
||||
"Auxiliary %s: fallback candidate %s has a stale/unrefreshable "
|
||||
"credential (%s) — skipping to next fallback",
|
||||
task or "call", fb_label, fb_err,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _call_fallback_candidate_async(
|
||||
fb_client: Any,
|
||||
fb_model: Optional[str],
|
||||
fb_label: str,
|
||||
*,
|
||||
task: Optional[str],
|
||||
messages: list,
|
||||
temperature: Optional[float],
|
||||
max_tokens: Optional[int],
|
||||
tools: Optional[list],
|
||||
effective_timeout: float,
|
||||
effective_extra_body: dict,
|
||||
) -> Optional[Any]:
|
||||
"""Async mirror of :func:`_call_fallback_candidate_sync`."""
|
||||
fb_base = str(getattr(fb_client, "base_url", "") or "")
|
||||
fb_kwargs = _build_call_kwargs(
|
||||
fb_label, fb_model, messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout,
|
||||
extra_body=effective_extra_body, base_url=fb_base)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await fb_client.chat.completions.create(**fb_kwargs), task)
|
||||
except Exception as fb_err:
|
||||
if not _is_auth_error(fb_err):
|
||||
raise
|
||||
fb_provider = _auth_refresh_provider_for_route(fb_label, fb_base)
|
||||
if fb_provider not in {"auto", "", None} and _refresh_provider_credentials(fb_provider):
|
||||
retry_client, retry_model = _get_cached_client(
|
||||
fb_provider, fb_model, async_mode=True)
|
||||
if retry_client is not None:
|
||||
retry_kwargs = _build_call_kwargs(
|
||||
fb_provider, retry_model or fb_model, messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout,
|
||||
extra_body=effective_extra_body,
|
||||
base_url=str(getattr(retry_client, "base_url", "") or fb_base))
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await retry_client.chat.completions.create(**retry_kwargs), task)
|
||||
except Exception as retry_err:
|
||||
if not _is_auth_error(retry_err):
|
||||
raise
|
||||
_mark_provider_unhealthy(fb_provider or fb_label)
|
||||
logger.warning(
|
||||
"Auxiliary %s (async): fallback candidate %s has a stale/unrefreshable "
|
||||
"credential (%s) — skipping to next fallback",
|
||||
task or "call", fb_label, fb_err,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _try_payment_fallback(
|
||||
failed_provider: str,
|
||||
task: str = None,
|
||||
@@ -4004,6 +4305,8 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
|
||||
return AsyncCodexAuxiliaryClient(sync_client), model
|
||||
if isinstance(sync_client, AnthropicAuxiliaryClient):
|
||||
return AsyncAnthropicAuxiliaryClient(sync_client), model
|
||||
if isinstance(sync_client, BedrockAuxiliaryClient):
|
||||
return AsyncBedrockAuxiliaryClient(sync_client), model
|
||||
try:
|
||||
from agent.gemini_native_adapter import GeminiNativeClient, AsyncGeminiNativeClient
|
||||
|
||||
@@ -4151,7 +4454,15 @@ def resolve_provider_client(
|
||||
# main_model also empty), the branches still hit their own
|
||||
# missing-credentials returns and ``_resolve_auto`` falls through to
|
||||
# the Step-2 chain as before.
|
||||
if not model:
|
||||
#
|
||||
# Prefer explicit caller model, then provider-scoped aux model, then main model.
|
||||
# Do NOT pre-fill a blank ``auto`` request from the config/main default here.
|
||||
# ``auto`` has its own main-runtime resolver below; pre-filling first can pair
|
||||
# a stale configured model with a live fallback provider (e.g. Claude model
|
||||
# sent to Codex after the main lane fell back to gpt-5.5). Let _resolve_auto()
|
||||
# return the actual current runtime model when the caller did not explicitly
|
||||
# request one. (# compression-current-model)
|
||||
if not model and provider != "auto":
|
||||
model = _get_aux_model_for_provider(provider) or _read_main_model() or model
|
||||
|
||||
def _needs_codex_wrap(client_obj, base_url_str: str, model_str: str) -> bool:
|
||||
@@ -4735,10 +5046,14 @@ def resolve_provider_client(
|
||||
else (client, final_model))
|
||||
|
||||
elif pconfig.auth_type == "aws_sdk":
|
||||
# AWS SDK providers (Bedrock) — use the Anthropic Bedrock client via
|
||||
# boto3's credential chain (IAM roles, SSO, env vars, instance metadata).
|
||||
# AWS SDK providers (Bedrock) — Claude models use the Anthropic Bedrock
|
||||
# SDK (prompt caching, thinking); non-Claude models use Converse API.
|
||||
try:
|
||||
from agent.bedrock_adapter import has_aws_credentials, resolve_bedrock_region
|
||||
from agent.bedrock_adapter import (
|
||||
has_aws_credentials,
|
||||
is_anthropic_bedrock_model,
|
||||
resolve_bedrock_region,
|
||||
)
|
||||
from agent.anthropic_adapter import build_anthropic_bedrock_client
|
||||
except ImportError:
|
||||
logger.warning("resolve_provider_client: bedrock requested but "
|
||||
@@ -4753,17 +5068,26 @@ def resolve_provider_client(
|
||||
region = resolve_bedrock_region()
|
||||
default_model = "anthropic.claude-haiku-4-5-20251001-v1:0"
|
||||
final_model = _normalize_resolved_model(model or default_model, provider)
|
||||
try:
|
||||
real_client = build_anthropic_bedrock_client(region)
|
||||
except ImportError as exc:
|
||||
logger.warning("resolve_provider_client: cannot create Bedrock "
|
||||
"client: %s", exc)
|
||||
return None, None
|
||||
client = AnthropicAuxiliaryClient(
|
||||
real_client, final_model, api_key="aws-sdk",
|
||||
base_url=f"https://bedrock-runtime.{region}.amazonaws.com",
|
||||
)
|
||||
logger.debug("resolve_provider_client: bedrock (%s, %s)", final_model, region)
|
||||
base_url = f"https://bedrock-runtime.{region}.amazonaws.com"
|
||||
|
||||
if is_anthropic_bedrock_model(final_model):
|
||||
try:
|
||||
real_client = build_anthropic_bedrock_client(region)
|
||||
except ImportError as exc:
|
||||
logger.warning("resolve_provider_client: cannot create Bedrock "
|
||||
"client: %s", exc)
|
||||
return None, None
|
||||
client = AnthropicAuxiliaryClient(
|
||||
real_client, final_model, api_key="aws-sdk",
|
||||
base_url=base_url,
|
||||
)
|
||||
logger.debug("resolve_provider_client: bedrock anthropic (%s, %s)",
|
||||
final_model, region)
|
||||
else:
|
||||
client = BedrockAuxiliaryClient(region, final_model)
|
||||
logger.debug("resolve_provider_client: bedrock converse (%s, %s)",
|
||||
final_model, region)
|
||||
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
|
||||
@@ -5657,6 +5981,17 @@ def _resolve_task_provider_model(
|
||||
|
||||
_DEFAULT_AUX_TIMEOUT = 30.0
|
||||
|
||||
# Compression summarises large conversation histories; a reasoning auxiliary
|
||||
# model (e.g. Codex / GPT-5.5) can legitimately take longer than the default
|
||||
# ``auxiliary.compression.timeout`` (120 s), causing the stream to time out and
|
||||
# the compressor to fall back to the deterministic context marker (#54915).
|
||||
# This is a bounded *floor* applied only to config-derived compression timeouts
|
||||
# — it does not affect other auxiliary tasks and does not override an explicit
|
||||
# per-call ``timeout=``. A floor is harmless for fast compression models
|
||||
# (they finish before the deadline) and is a minimum, so a higher config value
|
||||
# is kept unchanged.
|
||||
_COMPRESSION_TIMEOUT_FLOOR_SECONDS = 300.0
|
||||
|
||||
|
||||
def _get_auxiliary_task_config(task: str) -> Dict[str, Any]:
|
||||
"""Return the config dict for auxiliary.<task>, or {} when unavailable.
|
||||
@@ -5716,6 +6051,23 @@ def _get_task_timeout(task: str, default: float = _DEFAULT_AUX_TIMEOUT) -> float
|
||||
return default
|
||||
|
||||
|
||||
def _effective_aux_timeout(task: str, timeout: Optional[float]) -> float:
|
||||
"""Resolve the effective timeout for an auxiliary LLM call.
|
||||
|
||||
Uses the caller-provided ``timeout`` when given; otherwise reads
|
||||
``auxiliary.{task}.timeout`` from config via :func:`_get_task_timeout`.
|
||||
For the ``compression`` task only, applies a bounded floor so a reasoning
|
||||
model summarising a large context is not cut off by the default timeout
|
||||
(#54915). The floor is intentionally skipped when the caller passes an
|
||||
explicit ``timeout=`` — explicit per-call deadlines are always honoured —
|
||||
and it is a minimum (``max``), so a config value already above it is kept.
|
||||
"""
|
||||
effective = timeout if timeout is not None else _get_task_timeout(task)
|
||||
if timeout is None and task == "compression":
|
||||
effective = max(effective, _COMPRESSION_TIMEOUT_FLOOR_SECONDS)
|
||||
return effective
|
||||
|
||||
|
||||
def _get_task_extra_body(task: str) -> Dict[str, Any]:
|
||||
"""Read auxiliary.<task>.extra_body and return a shallow copy when valid."""
|
||||
task_config = _get_auxiliary_task_config(task)
|
||||
@@ -6150,7 +6502,7 @@ def call_llm(
|
||||
f"No LLM provider configured for task={task} provider={resolved_provider}. "
|
||||
f"Run: hermes setup")
|
||||
|
||||
effective_timeout = timeout if timeout is not None else _get_task_timeout(task)
|
||||
effective_timeout = _effective_aux_timeout(task, timeout)
|
||||
|
||||
# Log what we're about to do — makes auxiliary operations visible
|
||||
_base_info = str(getattr(client, "base_url", resolved_base_url) or "")
|
||||
@@ -6389,18 +6741,24 @@ def call_llm(
|
||||
refreshed_client.chat.completions.create(**kwargs), task)
|
||||
|
||||
# ── Auth refresh retry ───────────────────────────────────────
|
||||
auth_refresh_provider = _auth_refresh_provider_for_route(
|
||||
resolved_provider, _base_info)
|
||||
if (_is_auth_error(first_err)
|
||||
and resolved_provider not in {"auto", "", None}
|
||||
and auth_refresh_provider not in {"auto", "", None}
|
||||
and not client_is_nous):
|
||||
if _refresh_provider_credentials(resolved_provider):
|
||||
if _refresh_provider_credentials(auth_refresh_provider):
|
||||
if auth_refresh_provider != _normalize_aux_provider(resolved_provider):
|
||||
# The stale client is cached under the route label
|
||||
# (e.g. "auto"), not the concrete backend we refreshed.
|
||||
_evict_cached_clients(resolved_provider)
|
||||
logger.info(
|
||||
"Auxiliary %s: refreshed %s credentials after auth error, retrying",
|
||||
task or "call", resolved_provider,
|
||||
task or "call", auth_refresh_provider,
|
||||
)
|
||||
return _retry_same_provider_sync(
|
||||
task=task,
|
||||
resolved_provider=resolved_provider,
|
||||
resolved_model=resolved_model,
|
||||
resolved_provider=auth_refresh_provider,
|
||||
resolved_model=resolved_model or final_model,
|
||||
resolved_base_url=resolved_base_url,
|
||||
resolved_api_key=resolved_api_key,
|
||||
resolved_api_mode=resolved_api_mode,
|
||||
@@ -6570,14 +6928,28 @@ def call_llm(
|
||||
resolved_provider, task, reason=reason)
|
||||
|
||||
if fb_client is not None:
|
||||
fb_kwargs = _build_call_kwargs(
|
||||
fb_label, fb_model, messages,
|
||||
fb_resp = _call_fallback_candidate_sync(
|
||||
fb_client, fb_model, fb_label,
|
||||
task=task, messages=messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout,
|
||||
extra_body=effective_extra_body,
|
||||
base_url=str(getattr(fb_client, "base_url", "") or ""))
|
||||
return _validate_llm_response(
|
||||
fb_client.chat.completions.create(**fb_kwargs), task)
|
||||
tools=tools, effective_timeout=effective_timeout,
|
||||
effective_extra_body=effective_extra_body)
|
||||
if fb_resp is not None:
|
||||
return fb_resp
|
||||
# The candidate had a stale/unrefreshable credential and was
|
||||
# quarantined — walk the discovery chain once more; unhealthy
|
||||
# entries are skipped so the next viable candidate serves.
|
||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||
resolved_provider, task, reason="stale fallback credential")
|
||||
if fb_client is not None:
|
||||
fb_resp = _call_fallback_candidate_sync(
|
||||
fb_client, fb_model, fb_label,
|
||||
task=task, messages=messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, effective_timeout=effective_timeout,
|
||||
effective_extra_body=effective_extra_body)
|
||||
if fb_resp is not None:
|
||||
return fb_resp
|
||||
# All fallback layers exhausted — emit a single user-visible
|
||||
# warning so the operator knows aux task is about to fail.
|
||||
# (#26882) The error itself is re-raised below.
|
||||
@@ -6739,7 +7111,7 @@ async def async_call_llm(
|
||||
f"No LLM provider configured for task={task} provider={resolved_provider}. "
|
||||
f"Run: hermes setup")
|
||||
|
||||
effective_timeout = timeout if timeout is not None else _get_task_timeout(task)
|
||||
effective_timeout = _effective_aux_timeout(task, timeout)
|
||||
|
||||
# Pass the client's actual base_url (not just resolved_base_url) so
|
||||
# endpoint-specific temperature overrides can distinguish
|
||||
@@ -6917,18 +7289,24 @@ async def async_call_llm(
|
||||
await refreshed_client.chat.completions.create(**kwargs), task)
|
||||
|
||||
# ── Auth refresh retry (mirrors sync call_llm) ───────────────
|
||||
auth_refresh_provider = _auth_refresh_provider_for_route(
|
||||
resolved_provider, _client_base)
|
||||
if (_is_auth_error(first_err)
|
||||
and resolved_provider not in {"auto", "", None}
|
||||
and auth_refresh_provider not in {"auto", "", None}
|
||||
and not client_is_nous):
|
||||
if _refresh_provider_credentials(resolved_provider):
|
||||
if _refresh_provider_credentials(auth_refresh_provider):
|
||||
if auth_refresh_provider != _normalize_aux_provider(resolved_provider):
|
||||
# The stale client is cached under the route label
|
||||
# (e.g. "auto"), not the concrete backend we refreshed.
|
||||
_evict_cached_clients(resolved_provider)
|
||||
logger.info(
|
||||
"Auxiliary %s (async): refreshed %s credentials after auth error, retrying",
|
||||
task or "call", resolved_provider,
|
||||
task or "call", auth_refresh_provider,
|
||||
)
|
||||
return await _retry_same_provider_async(
|
||||
task=task,
|
||||
resolved_provider=resolved_provider,
|
||||
resolved_model=resolved_model,
|
||||
resolved_provider=auth_refresh_provider,
|
||||
resolved_model=resolved_model or final_model,
|
||||
resolved_base_url=resolved_base_url,
|
||||
resolved_api_key=resolved_api_key,
|
||||
resolved_api_mode=resolved_api_mode,
|
||||
@@ -7055,20 +7433,34 @@ async def async_call_llm(
|
||||
resolved_provider, task, reason=reason)
|
||||
|
||||
if fb_client is not None:
|
||||
fb_kwargs = _build_call_kwargs(
|
||||
fb_label, fb_model, messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout,
|
||||
extra_body=effective_extra_body,
|
||||
base_url=str(getattr(fb_client, "base_url", "") or ""))
|
||||
# Convert sync fallback client to async
|
||||
async_fb, async_fb_model = _to_async_client(
|
||||
fb_client, fb_model or "", is_vision=(task == "vision")
|
||||
)
|
||||
if async_fb_model and async_fb_model != fb_kwargs.get("model"):
|
||||
fb_kwargs["model"] = async_fb_model
|
||||
return _validate_llm_response(
|
||||
await async_fb.chat.completions.create(**fb_kwargs), task)
|
||||
fb_resp = await _call_fallback_candidate_async(
|
||||
async_fb, async_fb_model or fb_model, fb_label,
|
||||
task=task, messages=messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, effective_timeout=effective_timeout,
|
||||
effective_extra_body=effective_extra_body)
|
||||
if fb_resp is not None:
|
||||
return fb_resp
|
||||
# Stale/unrefreshable candidate credential — quarantined; walk
|
||||
# the discovery chain once more (unhealthy entries skipped).
|
||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||
resolved_provider, task, reason="stale fallback credential")
|
||||
if fb_client is not None:
|
||||
async_fb, async_fb_model = _to_async_client(
|
||||
fb_client, fb_model or "", is_vision=(task == "vision")
|
||||
)
|
||||
fb_resp = await _call_fallback_candidate_async(
|
||||
async_fb, async_fb_model or fb_model, fb_label,
|
||||
task=task, messages=messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, effective_timeout=effective_timeout,
|
||||
effective_extra_body=effective_extra_body)
|
||||
if fb_resp is not None:
|
||||
return fb_resp
|
||||
# All fallback layers exhausted — warn before re-raising. (#26882)
|
||||
logger.warning(
|
||||
"Auxiliary %s (async): %s on %s and all fallbacks exhausted "
|
||||
|
||||
@@ -171,6 +171,52 @@ def _env_float(name: str, default: float) -> float:
|
||||
return default
|
||||
|
||||
|
||||
# ── Cross-turn stale-call circuit breaker (#58962) ─────────────────────
|
||||
# A session wedged against an unresponsive provider hits the stale detector
|
||||
# on every call and loops forever (observed: 494 consecutive failures over
|
||||
# 3+ days, each burning the full stale timeout × retries with no response).
|
||||
# The agent carries ``_consecutive_stale_streams``: incremented on every
|
||||
# stale kill, reset only when a call actually completes (or when the
|
||||
# provider is swapped — switch_model / try_activate_fallback /
|
||||
# restore_primary_runtime — since the streak measured the OLD provider).
|
||||
# Past the give-up threshold, calls abort immediately with an actionable
|
||||
# error instead of re-waiting out the stale timeout.
|
||||
|
||||
def _stale_streak(agent) -> int:
|
||||
try:
|
||||
return int(getattr(agent, "_consecutive_stale_streams", 0) or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _bump_stale_streak(agent) -> None:
|
||||
try:
|
||||
agent._consecutive_stale_streams = _stale_streak(agent) + 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _reset_stale_streak(agent) -> None:
|
||||
try:
|
||||
agent._consecutive_stale_streams = 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _check_stale_giveup(agent) -> None:
|
||||
"""Raise immediately when the consecutive-stale streak is past the
|
||||
give-up threshold — no network attempt, no stale-timeout wait."""
|
||||
_giveup = env_int("HERMES_STREAM_STALE_GIVEUP", 5)
|
||||
_streak = _stale_streak(agent)
|
||||
if _giveup > 0 and _streak >= _giveup:
|
||||
raise RuntimeError(
|
||||
"Provider has been unresponsive (no response received) for "
|
||||
f"{_streak} consecutive stale attempts — aborting this call to "
|
||||
"avoid an indefinite stall. Switch models or start a new "
|
||||
"session, then retry."
|
||||
)
|
||||
|
||||
|
||||
def interruptible_api_call(agent, api_kwargs: dict):
|
||||
"""
|
||||
Run the API call in a background thread so the main conversation loop
|
||||
@@ -186,6 +232,13 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
provider fallback.
|
||||
"""
|
||||
result = {"response": None, "error": None}
|
||||
|
||||
# Cross-turn stale-call circuit breaker (#58962) — non-streaming sibling
|
||||
# of the guard in interruptible_streaming_api_call. Quiet-mode /
|
||||
# subagent / no-stream-consumer sessions take THIS path, and a wedged
|
||||
# unattended session here has the same infinite stale-retry class.
|
||||
_check_stale_giveup(agent)
|
||||
|
||||
request_client_holder = {"client": None, "owner_tid": None}
|
||||
request_client_lock = threading.Lock()
|
||||
# Request-local cancellation flag. Distinct from agent._interrupt_requested
|
||||
@@ -557,6 +610,9 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
_close_request_client_once("stale_call_kill")
|
||||
except Exception:
|
||||
pass
|
||||
# Circuit breaker (#58962): count the stale kill. See the
|
||||
# canonical comment block above ``_stale_streak()``.
|
||||
_bump_stale_streak(agent)
|
||||
agent._touch_activity(
|
||||
f"stale non-streaming call killed after {int(_elapsed)}s"
|
||||
)
|
||||
@@ -599,6 +655,10 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
raise InterruptedError("Agent interrupted during API call")
|
||||
if result["error"] is not None:
|
||||
raise result["error"]
|
||||
# Success — clear the circuit breaker (#58962): the provider proved
|
||||
# responsive. See the canonical comment block above ``_stale_streak()``.
|
||||
if result["response"] is not None:
|
||||
_reset_stale_streak(agent)
|
||||
return result["response"]
|
||||
|
||||
|
||||
@@ -1339,6 +1399,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
|
||||
@@ -1484,10 +1545,25 @@ 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,
|
||||
)
|
||||
# Reset the stale-call circuit breaker (#58962): the streak measured
|
||||
# the OLD provider's unresponsiveness. Carrying it over would
|
||||
# short-circuit the freshly activated fallback before it gets a
|
||||
# single stream attempt.
|
||||
_reset_stale_streak(agent)
|
||||
return True
|
||||
except Exception as e:
|
||||
if fb_provider == "nous":
|
||||
@@ -1525,8 +1601,10 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
|
||||
# hand-builds messages and calls chat.completions.create() directly,
|
||||
# bypassing the transport — so mirror that sanitization here:
|
||||
# tool_name (SQLite FTS bookkeeping), the codex_* reasoning carriers,
|
||||
# timestamp (preserved on gateway user replay entries for the
|
||||
# stale-confirmation expiry check — #47868 rejection class),
|
||||
# and every Hermes-internal underscore-prefixed scaffolding key.
|
||||
for schema_foreign in ("tool_name", "codex_reasoning_items", "codex_message_items"):
|
||||
for schema_foreign in ("tool_name", "codex_reasoning_items", "codex_message_items", "timestamp"):
|
||||
api_msg.pop(schema_foreign, None)
|
||||
for internal_key in [k for k in api_msg if isinstance(k, str) and k.startswith("_")]:
|
||||
api_msg.pop(internal_key, None)
|
||||
@@ -1879,11 +1957,27 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
t.join(timeout=0.3)
|
||||
if agent._interrupt_requested:
|
||||
raise InterruptedError("Agent interrupted during Bedrock API call")
|
||||
# Worker exited before the poll loop observed the interrupt flag. The
|
||||
# Bedrock stream callback breaks out and returns a PARTIAL response
|
||||
# without raising on interrupt (see bedrock_adapter.py
|
||||
# stream_converse_with_callbacks / on_interrupt_check), so result[
|
||||
# "response"] is populated with error=None and the in-loop raise above
|
||||
# never fires. Re-check here so /stop is not silently swallowed on the
|
||||
# Bedrock path — mirrors the post-worker guard on the main streaming
|
||||
# loop. (#59999 area)
|
||||
if agent._interrupt_requested:
|
||||
raise InterruptedError("Agent interrupted during Bedrock API call (post-worker)")
|
||||
if result["error"] is not None:
|
||||
raise result["error"]
|
||||
return result["response"]
|
||||
|
||||
result = {"response": None, "error": None, "partial_tool_names": []}
|
||||
|
||||
# Cross-turn stale-stream circuit breaker (#58962) — see the canonical
|
||||
# comment block above ``_stale_streak()``. Raises past the give-up
|
||||
# threshold instead of burning another stale-timeout×retries cycle.
|
||||
_check_stale_giveup(agent)
|
||||
|
||||
request_client_holder = {"client": None, "diag": None, "owner_tid": None}
|
||||
request_client_lock = threading.Lock()
|
||||
# Request-local cancellation flag — see interruptible_api_call for the full
|
||||
@@ -2861,6 +2955,9 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
_close_request_client_once("stale_stream_kill")
|
||||
except Exception:
|
||||
pass
|
||||
# Circuit breaker (#58962): count the stale kill. See the
|
||||
# canonical comment block above ``_stale_streak()``.
|
||||
_bump_stale_streak(agent)
|
||||
# Rebuild the primary client too — its connection pool
|
||||
# may hold dead sockets from the same provider outage.
|
||||
if agent.api_mode == "anthropic_messages":
|
||||
@@ -2900,6 +2997,13 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
except Exception:
|
||||
pass
|
||||
raise InterruptedError("Agent interrupted during streaming API call")
|
||||
# Worker thread exited before the main thread's poll loop could check
|
||||
# the interrupt flag. If the worker returned early due to an interrupt
|
||||
# (e.g. _call_anthropic() detected _interrupt_requested and returned
|
||||
# None), the InterruptedError above was never raised. Re-check the
|
||||
# flag here so /stop is not silently swallowed. (#59999 area)
|
||||
if agent._interrupt_requested:
|
||||
raise InterruptedError("Agent interrupted during streaming API call (post-worker)")
|
||||
if result["error"] is not None:
|
||||
if deltas_were_sent["yes"]:
|
||||
# Streaming failed AFTER some tokens were already delivered to
|
||||
@@ -2983,8 +3087,16 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
)
|
||||
if _content_filter_terminated:
|
||||
_stub._content_filter_terminated = True
|
||||
# Partial-stream stub: chunks WERE received (deltas fired), so
|
||||
# the provider is demonstrably responsive — clear the circuit
|
||||
# breaker (#58962) just like the full-success return below.
|
||||
_reset_stale_streak(agent)
|
||||
return _stub
|
||||
raise result["error"]
|
||||
# Success — clear the circuit breaker (#58962): the provider proved
|
||||
# responsive. See the canonical comment block above ``_stale_streak()``.
|
||||
if result["response"] is not None:
|
||||
_reset_stale_streak(agent)
|
||||
return result["response"]
|
||||
|
||||
# ── Provider fallback ──────────────────────────────────────────────────
|
||||
|
||||
@@ -228,6 +228,76 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _record_codex_app_server_compaction(
|
||||
agent,
|
||||
turn,
|
||||
*,
|
||||
approx_tokens: int | None = None,
|
||||
force: bool = False,
|
||||
) -> bool:
|
||||
"""Record a Codex-native context compaction boundary in Hermes state.
|
||||
|
||||
The app-server owns the compacted thread context, so Hermes should not
|
||||
rewrite local transcript rows here; state.db records the boundary via the
|
||||
session event/usage counters while preserving the visible transcript.
|
||||
"""
|
||||
if not force and not getattr(turn, "compacted", False):
|
||||
return False
|
||||
|
||||
thread_id = getattr(turn, "thread_id", None) or ""
|
||||
turn_id = getattr(turn, "turn_id", None) or ""
|
||||
logger.info(
|
||||
"codex app-server compaction observed: session=%s thread=%s turn=%s force=%s",
|
||||
getattr(agent, "session_id", None) or "none",
|
||||
thread_id,
|
||||
turn_id,
|
||||
force,
|
||||
)
|
||||
if not force:
|
||||
try:
|
||||
from agent.conversation_compression import COMPACTION_STATUS
|
||||
|
||||
agent._emit_status(COMPACTION_STATUS)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
compressor = getattr(agent, "context_compressor", None)
|
||||
if compressor is not None:
|
||||
compressor.compression_count = getattr(
|
||||
compressor, "compression_count", 0
|
||||
) + 1
|
||||
compressor.last_compression_rough_tokens = approx_tokens or 0
|
||||
if not getattr(turn, "token_usage_last", None):
|
||||
compressor.last_prompt_tokens = -1
|
||||
compressor.last_completion_tokens = 0
|
||||
compressor.awaiting_real_usage_after_compression = True
|
||||
|
||||
agent._last_compaction_in_place = False
|
||||
try:
|
||||
if getattr(agent, "event_callback", None):
|
||||
agent.event_callback(
|
||||
"session:compress",
|
||||
{
|
||||
"platform": getattr(agent, "platform", None) or "",
|
||||
"session_id": getattr(agent, "session_id", None) or "",
|
||||
"old_session_id": "",
|
||||
"in_place": False,
|
||||
"compression_count": getattr(
|
||||
compressor, "compression_count", 0
|
||||
)
|
||||
if compressor is not None
|
||||
else 0,
|
||||
"runtime": "codex_app_server",
|
||||
"thread_id": thread_id,
|
||||
"turn_id": turn_id,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("event_callback error on codex session:compress", exc_info=True)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def run_codex_app_server_turn(
|
||||
agent,
|
||||
*,
|
||||
@@ -393,6 +463,7 @@ def run_codex_app_server_turn(
|
||||
agent._iters_since_skill = (
|
||||
getattr(agent, "_iters_since_skill", 0) + turn.tool_iterations
|
||||
)
|
||||
_record_codex_app_server_compaction(agent, turn)
|
||||
usage_result = _record_codex_app_server_usage(agent, turn)
|
||||
api_calls = 1
|
||||
|
||||
|
||||
@@ -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`'\")\]}<>]+")
|
||||
|
||||
@@ -298,6 +310,32 @@ def _content_length_for_budget(raw_content: Any) -> int:
|
||||
return total
|
||||
|
||||
|
||||
def _serialized_length_for_budget(value: Any) -> int:
|
||||
"""Return a stable char-length for non-content replay/metadata fields."""
|
||||
if value is None or value == "":
|
||||
return 0
|
||||
if isinstance(value, str):
|
||||
return len(value)
|
||||
try:
|
||||
return len(json.dumps(value, ensure_ascii=False, sort_keys=True, default=str))
|
||||
except (TypeError, ValueError):
|
||||
return len(str(value))
|
||||
|
||||
|
||||
# Provider replay/metadata fields that ride the wire on every request but are
|
||||
# invisible to ``msg["content"]``/``msg["tool_calls"]`` accounting. Codex
|
||||
# Responses sessions in particular carry ``codex_reasoning_items`` blobs of
|
||||
# ``encrypted_content`` that can dominate the serialized session (a measured
|
||||
# 214-turn session held ~115K tokens / 27% of its payload there — #55572).
|
||||
_REPLAY_BUDGET_KEYS = (
|
||||
"reasoning",
|
||||
"reasoning_content",
|
||||
"reasoning_details",
|
||||
"codex_reasoning_items",
|
||||
"codex_message_items",
|
||||
)
|
||||
|
||||
|
||||
def _estimate_msg_budget_tokens(msg: dict) -> int:
|
||||
"""Token estimate for one message in the tail-protection budget walks.
|
||||
|
||||
@@ -308,12 +346,23 @@ def _estimate_msg_budget_tokens(msg: dict) -> int:
|
||||
4-tool-call turn measures ~73 vs ~1,090 real tokens), so the protected
|
||||
tail overshot ``tail_token_budget`` and compression became ineffective.
|
||||
See issue #28053.
|
||||
|
||||
Also counts provider replay fields (``codex_reasoning_items`` etc. —
|
||||
see ``_REPLAY_BUDGET_KEYS``). The preflight "should I compress?"
|
||||
estimator sees the full message shape, so the tail walk must use the
|
||||
same size class; otherwise an assistant message with tiny visible
|
||||
content but large hidden replay blobs is protected as if it were small,
|
||||
the post-compression session stays near the context limit, and
|
||||
compaction re-fires continuously (#55572). Accounting-only: replay
|
||||
fields are never mutated or pruned here.
|
||||
"""
|
||||
content_len = _content_length_for_budget(msg.get("content") or "")
|
||||
tokens = content_len // _CHARS_PER_TOKEN + 10 # +10 for role/key overhead
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if isinstance(tc, dict):
|
||||
tokens += len(str(tc)) // _CHARS_PER_TOKEN
|
||||
for key in _REPLAY_BUDGET_KEYS:
|
||||
tokens += _serialized_length_for_budget(msg.get(key)) // _CHARS_PER_TOKEN
|
||||
return tokens
|
||||
|
||||
|
||||
@@ -846,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)
|
||||
@@ -908,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,
|
||||
@@ -995,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
|
||||
@@ -1373,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":
|
||||
@@ -1843,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:
|
||||
@@ -1883,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())
|
||||
|
||||
@@ -465,6 +465,21 @@ def compress_context(
|
||||
prompt — the session is NOT rotated. Callers should detect the
|
||||
no-op via ``len(returned) == len(input)`` and stop the retry loop.
|
||||
"""
|
||||
# Codex app-server sessions: the codex agent owns the real thread context;
|
||||
# Hermes' summarizer would only rewrite a local mirror without shrinking
|
||||
# the actual thread (#36801). Route compaction to the app server's own
|
||||
# thread/compact mechanism. Behavior is controlled by
|
||||
# ``compression.codex_app_server_auto`` (native|hermes|off).
|
||||
if getattr(agent, "api_mode", None) == "codex_app_server":
|
||||
return _compress_context_via_codex_app_server(
|
||||
agent,
|
||||
messages,
|
||||
system_message,
|
||||
approx_tokens=approx_tokens,
|
||||
task_id=task_id,
|
||||
force=force,
|
||||
)
|
||||
|
||||
# Lazy feasibility check — run the auxiliary-provider probe + context
|
||||
# length lookup just-in-time on the first compression attempt instead of
|
||||
# at AIAgent.__init__. Saves ~400ms cold off every short session that
|
||||
@@ -971,6 +986,122 @@ def compress_context(
|
||||
_release_lock()
|
||||
|
||||
|
||||
def _compress_context_via_codex_app_server(
|
||||
agent: Any,
|
||||
messages: list,
|
||||
system_message: Optional[str],
|
||||
*,
|
||||
approx_tokens: Optional[int] = None,
|
||||
task_id: str = "default",
|
||||
force: bool = False,
|
||||
) -> Tuple[list, str]:
|
||||
"""Route compaction to Codex app-server for Codex-owned threads.
|
||||
|
||||
Hermes' normal compressor rewrites the local OpenAI-style transcript.
|
||||
That does not shrink the actual Codex app-server thread context. For this
|
||||
runtime, ask Codex to compact its own thread and keep Hermes' transcript
|
||||
unchanged.
|
||||
"""
|
||||
auto_mode = str(
|
||||
getattr(agent, "codex_app_server_auto_compaction", "native") or "native"
|
||||
).lower()
|
||||
if auto_mode not in {"native", "hermes", "off"}:
|
||||
auto_mode = "native"
|
||||
if not force and auto_mode != "hermes":
|
||||
logger.info(
|
||||
"codex app-server compaction skipped: mode=%s force=false "
|
||||
"(session=%s messages=%d tokens=~%s)",
|
||||
auto_mode,
|
||||
getattr(agent, "session_id", None) or "none",
|
||||
len(messages),
|
||||
f"{approx_tokens:,}" if approx_tokens else "unknown",
|
||||
)
|
||||
existing_prompt = getattr(agent, "_cached_system_prompt", None)
|
||||
if not existing_prompt:
|
||||
existing_prompt = agent._build_system_prompt(system_message)
|
||||
return messages, existing_prompt
|
||||
|
||||
codex_session = getattr(agent, "_codex_session", None)
|
||||
if codex_session is None:
|
||||
logger.info(
|
||||
"codex app-server compaction skipped: no active codex thread "
|
||||
"(session=%s messages=%d tokens=~%s)",
|
||||
getattr(agent, "session_id", None) or "none",
|
||||
len(messages),
|
||||
f"{approx_tokens:,}" if approx_tokens else "unknown",
|
||||
)
|
||||
existing_prompt = getattr(agent, "_cached_system_prompt", None)
|
||||
if not existing_prompt:
|
||||
existing_prompt = agent._build_system_prompt(system_message)
|
||||
return messages, existing_prompt
|
||||
|
||||
logger.info(
|
||||
"codex app-server compaction started: session=%s messages=%d tokens=~%s",
|
||||
getattr(agent, "session_id", None) or "none",
|
||||
len(messages),
|
||||
f"{approx_tokens:,}" if approx_tokens else "unknown",
|
||||
)
|
||||
try:
|
||||
agent._emit_status(COMPACTION_STATUS)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = codex_session.compact_thread()
|
||||
if getattr(result, "should_retire", False):
|
||||
try:
|
||||
codex_session.close()
|
||||
except Exception:
|
||||
pass
|
||||
agent._codex_session = None
|
||||
|
||||
if getattr(result, "error", None):
|
||||
try:
|
||||
agent._emit_warning(
|
||||
f"⚠ Codex app-server compaction failed: {result.error}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
existing_prompt = getattr(agent, "_cached_system_prompt", None)
|
||||
if not existing_prompt:
|
||||
existing_prompt = agent._build_system_prompt(system_message)
|
||||
return messages, existing_prompt
|
||||
|
||||
try:
|
||||
from agent.codex_runtime import (
|
||||
_record_codex_app_server_compaction,
|
||||
_record_codex_app_server_usage,
|
||||
)
|
||||
|
||||
_record_codex_app_server_compaction(
|
||||
agent,
|
||||
result,
|
||||
approx_tokens=approx_tokens,
|
||||
force=True,
|
||||
)
|
||||
if getattr(result, "token_usage_last", None):
|
||||
_record_codex_app_server_usage(agent, result)
|
||||
except Exception:
|
||||
logger.debug("codex compaction bookkeeping failed", exc_info=True)
|
||||
|
||||
try:
|
||||
from tools.file_tools import reset_file_dedup
|
||||
|
||||
reset_file_dedup(task_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
"codex app-server compaction done: session=%s thread=%s turn=%s",
|
||||
getattr(agent, "session_id", None) or "none",
|
||||
getattr(result, "thread_id", None) or "",
|
||||
getattr(result, "turn_id", None) or "",
|
||||
)
|
||||
existing_prompt = getattr(agent, "_cached_system_prompt", None)
|
||||
if not existing_prompt:
|
||||
existing_prompt = agent._build_system_prompt(system_message)
|
||||
return messages, existing_prompt
|
||||
|
||||
|
||||
def try_shrink_image_parts_in_messages(
|
||||
api_messages: list,
|
||||
*,
|
||||
|
||||
@@ -58,7 +58,12 @@ from agent.model_metadata import (
|
||||
)
|
||||
from agent.process_bootstrap import _install_safe_stdio
|
||||
from agent.prompt_caching import apply_anthropic_cache_control
|
||||
from agent.retry_utils import adaptive_rate_limit_backoff, jittered_backoff
|
||||
from agent.retry_utils import (
|
||||
adaptive_rate_limit_backoff,
|
||||
is_zai_coding_overload_error,
|
||||
jittered_backoff,
|
||||
zai_coding_overload_retry_ceiling,
|
||||
)
|
||||
from agent.trajectory import has_incomplete_scratchpad
|
||||
from agent.usage_pricing import estimate_usage_cost, normalize_usage
|
||||
from hermes_constants import PARTIAL_STREAM_STUB_ID
|
||||
@@ -3142,6 +3147,18 @@ def run_conversation(
|
||||
FailoverReason.timeout,
|
||||
FailoverReason.overloaded,
|
||||
}
|
||||
# Z.AI Coding Plan GLM-5.2 overload 429s classify as
|
||||
# `overloaded` (to spare the credential pool), but `overloaded`
|
||||
# is excluded from `is_rate_limited` — the gate for the adaptive
|
||||
# Z.AI backoff below. Detect the overload directly so its
|
||||
# long-backoff schedule runs, and raise the retry ceiling so the
|
||||
# long tier (30/60/90/120s) is reachable. See
|
||||
# zai_coding_overload_retry_ceiling() for the ceiling rationale.
|
||||
_is_zai_coding_overload = is_zai_coding_overload_error(
|
||||
base_url=str(_base), model=_model, error=api_error
|
||||
)
|
||||
if _is_zai_coding_overload:
|
||||
max_retries = max(max_retries, zai_coding_overload_retry_ceiling())
|
||||
_should_fallback = (
|
||||
is_rate_limited
|
||||
or (_is_transport_failure and retry_count >= 2)
|
||||
@@ -4092,7 +4109,7 @@ def run_conversation(
|
||||
pass
|
||||
wait_time = _retry_after if _retry_after else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0)
|
||||
_backoff_policy = None
|
||||
if is_rate_limited and not _retry_after:
|
||||
if (is_rate_limited or _is_zai_coding_overload) and not _retry_after:
|
||||
wait_time, _backoff_policy = adaptive_rate_limit_backoff(
|
||||
retry_count,
|
||||
base_url=str(_base),
|
||||
@@ -4100,13 +4117,14 @@ def run_conversation(
|
||||
error=api_error,
|
||||
default_wait=wait_time,
|
||||
)
|
||||
if is_rate_limited:
|
||||
if is_rate_limited or _is_zai_coding_overload:
|
||||
_policy_note = ""
|
||||
if _backoff_policy == "zai_coding_overload_long":
|
||||
_policy_note = " (Z.AI Coding overload adaptive long backoff)"
|
||||
elif _backoff_policy == "zai_coding_overload_short":
|
||||
_policy_note = " (Z.AI Coding overload short retry)"
|
||||
_rate_limit_status = f"⏱️ Rate limited. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries}){_policy_note}..."
|
||||
_wait_reason = "Provider overloaded" if _is_zai_coding_overload and not is_rate_limited else "Rate limited"
|
||||
_rate_limit_status = f"⏱️ {_wait_reason}. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries}){_policy_note}..."
|
||||
# Normal retries are buffered to avoid noisy transient chatter. Long
|
||||
# Z.AI Coding waits are different: they can last minutes, so surface
|
||||
# progress immediately instead of making the TUI look frozen.
|
||||
@@ -5044,8 +5062,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 (
|
||||
|
||||
@@ -515,6 +515,16 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
|
||||
msg = msg[:17] + "..."
|
||||
return f"to {target}: \"{msg}\""
|
||||
|
||||
if tool_name == "skill_view":
|
||||
name = _oneline(str(args.get("name") or ""))
|
||||
file_path = args.get("file_path")
|
||||
if file_path:
|
||||
file_path = _oneline(str(file_path))
|
||||
preview = f"{name} → {file_path}" if name else file_path
|
||||
else:
|
||||
preview = name
|
||||
return _truncate_preview(preview, max_len) if preview else None
|
||||
|
||||
key = primary_args.get(tool_name)
|
||||
if not key:
|
||||
for fallback_key in ("query", "text", "command", "path", "name", "prompt", "code", "goal"):
|
||||
@@ -1384,7 +1394,11 @@ def get_cute_tool_message(
|
||||
if tool_name == "skills_list":
|
||||
return _wrap(f"┊ 📚 skills list {args.get('category', 'all')} {dur}")
|
||||
if tool_name == "skill_view":
|
||||
return _wrap(f"┊ 📚 skill {_trunc(args.get('name', ''), 30)} {dur}")
|
||||
label = args.get("name", "")
|
||||
file_path = args.get("file_path")
|
||||
if file_path:
|
||||
label = f"{label} → {file_path}" if label else str(file_path)
|
||||
return _wrap(f"┊ 📚 skill {_trunc(label, 44)} {dur}")
|
||||
if tool_name == "image_generate":
|
||||
return _wrap(f"┊ 🎨 create {_trunc(args.get('prompt', ''), 35)} {dur}")
|
||||
if tool_name == "text_to_speech":
|
||||
|
||||
@@ -348,17 +348,15 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
|
||||
pip_target.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
logger.info("[install] pip install --target %s %s", pip_target, pkg)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "--target", str(pip_target), "--quiet", pkg],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
from hermes_cli.tools_config import _pip_install
|
||||
|
||||
proc = _pip_install(
|
||||
["--target", str(pip_target), "--quiet", pkg],
|
||||
timeout=300,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
logger.warning(
|
||||
"[install] pip install failed for %s: %s", pkg, proc.stderr.strip()[:500]
|
||||
"[install] pip install failed for %s: %s", pkg, (proc.stderr or "").strip()[:500]
|
||||
)
|
||||
return None
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
|
||||
@@ -30,7 +30,7 @@ import logging
|
||||
import re
|
||||
import inspect
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from concurrent.futures import Future, ThreadPoolExecutor, wait
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
@@ -368,6 +368,16 @@ class MemoryManager:
|
||||
# _submit_background() and the sync_all/queue_prefetch_all rationale.
|
||||
self._sync_executor: Optional[ThreadPoolExecutor] = None
|
||||
self._sync_executor_lock = threading.Lock()
|
||||
# Futures are tracked by durability class so shutdown can give writes
|
||||
# a bounded FIFO drain, then explicitly report anything abandoned.
|
||||
self._background_futures: Dict[Future, str] = {}
|
||||
self._shutting_down = False
|
||||
self._shutdown_drain_state: Dict[str, Any] = {
|
||||
"status": "not_started",
|
||||
"abandoned_writes": 0,
|
||||
"abandoned_prefetches": 0,
|
||||
"active_tasks": 0,
|
||||
}
|
||||
|
||||
# -- Registration --------------------------------------------------------
|
||||
|
||||
@@ -539,7 +549,7 @@ class MemoryManager:
|
||||
provider.name, e,
|
||||
)
|
||||
|
||||
self._submit_background(_run)
|
||||
self._submit_background(_run, kind="prefetch")
|
||||
|
||||
# -- Sync ----------------------------------------------------------------
|
||||
|
||||
@@ -615,46 +625,57 @@ class MemoryManager:
|
||||
|
||||
# -- Background dispatch -------------------------------------------------
|
||||
|
||||
def _submit_background(self, fn) -> None:
|
||||
"""Run ``fn`` on the manager's background worker.
|
||||
|
||||
The executor is created lazily and shared across calls. If the
|
||||
executor can't be created or has already been shut down, ``fn``
|
||||
runs inline as a last-resort fallback — losing the async benefit
|
||||
but never losing the write itself. ``fn`` must do its own
|
||||
per-provider error handling; this wrapper only guards executor
|
||||
plumbing.
|
||||
"""
|
||||
def _submit_background(self, fn, *, kind: str = "write") -> None:
|
||||
"""Queue ``fn`` on the serialized worker and track its durability class."""
|
||||
executor = self._get_sync_executor()
|
||||
if executor is None:
|
||||
# Executor unavailable (shut down / creation failed) — run
|
||||
# inline rather than drop the work. Slow, but correct.
|
||||
if self._shutting_down:
|
||||
logger.warning("Memory manager is shutting down; rejecting late %s task", kind)
|
||||
return
|
||||
# Creation failure outside shutdown: preserve the historical
|
||||
# fail-safe behavior and run the operation inline.
|
||||
try:
|
||||
fn()
|
||||
except Exception as e: # pragma: no cover - fn guards internally
|
||||
logger.debug("Inline memory background task failed: %s", e)
|
||||
return
|
||||
try:
|
||||
executor.submit(fn)
|
||||
# Make submit+tracking atomic with the shutdown snapshot. The
|
||||
# callback is attached after releasing the lock because an already
|
||||
# completed future invokes callbacks synchronously.
|
||||
with self._sync_executor_lock:
|
||||
if self._shutting_down:
|
||||
logger.warning("Memory manager is shutting down; rejecting late %s task", kind)
|
||||
return
|
||||
future = executor.submit(fn)
|
||||
self._background_futures[future] = kind
|
||||
future.add_done_callback(self._forget_background_future)
|
||||
except RuntimeError:
|
||||
# Executor was shut down between the get and the submit
|
||||
# (teardown race). Fall back to inline.
|
||||
if self._shutting_down:
|
||||
logger.warning("Memory manager shut down during %s submission; task rejected", kind)
|
||||
return
|
||||
try:
|
||||
fn()
|
||||
except Exception as e: # pragma: no cover - fn guards internally
|
||||
logger.debug("Inline memory background task failed: %s", e)
|
||||
|
||||
def _forget_background_future(self, future: Future) -> None:
|
||||
with self._sync_executor_lock:
|
||||
self._background_futures.pop(future, None)
|
||||
|
||||
def _get_sync_executor(self) -> Optional[ThreadPoolExecutor]:
|
||||
"""Lazily create the single-worker background executor."""
|
||||
if self._shutting_down:
|
||||
return None
|
||||
if self._sync_executor is not None:
|
||||
return self._sync_executor
|
||||
with self._sync_executor_lock:
|
||||
if self._shutting_down:
|
||||
return None
|
||||
if self._sync_executor is None:
|
||||
try:
|
||||
# Daemon workers (see tools.daemon_pool): a provider wedged
|
||||
# on a network call must never block interpreter exit —
|
||||
# stdlib ThreadPoolExecutor's atexit hook would join it
|
||||
# unconditionally even after shutdown(wait=False).
|
||||
# on a network call must never block interpreter exit.
|
||||
from tools.daemon_pool import DaemonThreadPoolExecutor
|
||||
self._sync_executor = DaemonThreadPoolExecutor(
|
||||
max_workers=1,
|
||||
@@ -783,6 +804,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,
|
||||
@@ -1020,51 +1090,66 @@ class MemoryManager:
|
||||
provider.name, e,
|
||||
)
|
||||
|
||||
def _drain_sync_executor(self) -> None:
|
||||
"""Shut down the background executor, waiting briefly for drain.
|
||||
|
||||
Bounded by ``_SYNC_DRAIN_TIMEOUT_S``: a wedged provider must never
|
||||
hang process/session teardown. We stop accepting new work and
|
||||
cancel anything still queued, then wait at most the drain timeout
|
||||
for the currently-running task on a watcher thread. The worker is
|
||||
daemon, so an over-running task dies with the interpreter.
|
||||
"""
|
||||
@property
|
||||
def shutdown_drain_state(self) -> Dict[str, Any]:
|
||||
"""Snapshot of the most recent bounded shutdown drain outcome."""
|
||||
with self._sync_executor_lock:
|
||||
return dict(self._shutdown_drain_state)
|
||||
|
||||
def _drain_sync_executor(self) -> None:
|
||||
"""Give queued FIFO work a bounded chance, then abandon explicitly."""
|
||||
with self._sync_executor_lock:
|
||||
self._shutting_down = True
|
||||
executor = self._sync_executor
|
||||
self._sync_executor = None
|
||||
tracked = dict(self._background_futures)
|
||||
self._shutdown_drain_state = {
|
||||
"status": "draining" if executor is not None else "drained",
|
||||
"abandoned_writes": 0,
|
||||
"abandoned_prefetches": 0,
|
||||
"active_tasks": sum(not future.done() for future in tracked),
|
||||
}
|
||||
if executor is None:
|
||||
return
|
||||
try:
|
||||
# Stop accepting new work and drop anything still queued, but
|
||||
# do NOT block here — cancel_futures cancels not-yet-started
|
||||
# tasks; the in-flight one keeps running on its daemon thread.
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
except TypeError:
|
||||
# Older Python without cancel_futures kwarg.
|
||||
try:
|
||||
executor.shutdown(wait=False)
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.debug("Memory sync executor shutdown failed: %s", e)
|
||||
return
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.debug("Memory sync executor shutdown failed: %s", e)
|
||||
return
|
||||
# Give an in-flight sync a bounded chance to finish on a watcher
|
||||
# thread so we don't block the caller past the drain timeout.
|
||||
drainer = threading.Thread(
|
||||
target=lambda: self._bounded_executor_wait(executor),
|
||||
daemon=True,
|
||||
name="mem-sync-drain",
|
||||
)
|
||||
drainer.start()
|
||||
drainer.join(timeout=_SYNC_DRAIN_TIMEOUT_S)
|
||||
|
||||
@staticmethod
|
||||
def _bounded_executor_wait(executor: ThreadPoolExecutor) -> None:
|
||||
try:
|
||||
executor.shutdown(wait=True)
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.debug("Memory sync executor drain wait failed: %s", e)
|
||||
# shutdown(wait=False) closes submission without touching the FIFO.
|
||||
# Waiting on the tracked futures lets the real single-worker executor
|
||||
# run every queued write/boundary task in order up to the deadline.
|
||||
executor.shutdown(wait=False, cancel_futures=False)
|
||||
_, pending = wait(tuple(tracked), timeout=_SYNC_DRAIN_TIMEOUT_S)
|
||||
if not pending:
|
||||
with self._sync_executor_lock:
|
||||
self._shutdown_drain_state.update(status="drained", active_tasks=0)
|
||||
return
|
||||
|
||||
abandoned_writes = 0
|
||||
abandoned_prefetches = 0
|
||||
active_tasks = 0
|
||||
for future in pending:
|
||||
kind = tracked[future]
|
||||
if future.cancel():
|
||||
if kind == "prefetch":
|
||||
abandoned_prefetches += 1
|
||||
else:
|
||||
abandoned_writes += 1
|
||||
else:
|
||||
active_tasks += 1
|
||||
|
||||
with self._sync_executor_lock:
|
||||
self._shutdown_drain_state.update(
|
||||
status="timed_out",
|
||||
abandoned_writes=abandoned_writes,
|
||||
abandoned_prefetches=abandoned_prefetches,
|
||||
active_tasks=active_tasks,
|
||||
)
|
||||
logger.warning(
|
||||
"Memory shutdown drain timed out after %.2fs; abandoning %d queued "
|
||||
"memory write(s) and %d queued prefetch(es); %d active task(s) remain detached",
|
||||
_SYNC_DRAIN_TIMEOUT_S,
|
||||
abandoned_writes,
|
||||
abandoned_prefetches,
|
||||
active_tasks,
|
||||
)
|
||||
|
||||
def initialize_all(self, session_id: str, **kwargs) -> None:
|
||||
"""Initialize all providers.
|
||||
|
||||
@@ -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]] = {}
|
||||
@@ -1013,13 +1083,23 @@ def _load_context_cache() -> Dict[str, int]:
|
||||
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")
|
||||
|
||||
@@ -138,3 +138,120 @@ def sanitize_replay_history(
|
||||
if not agent_history:
|
||||
return agent_history
|
||||
return strip_dangling_tool_call_tail(strip_interrupted_tool_tails(agent_history))
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Stale dangerous-confirmation text expiry (#59607)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
# How long a high-risk confirmation phrase remains valid.
|
||||
# Short on purpose: dangerous side effects should not survive any restart
|
||||
# or session resumption gap. The user can always re-confirm if needed.
|
||||
_DANGEROUS_CONFIRMATION_EXPIRY_SECONDS = 60.0
|
||||
|
||||
# Confirmation phrases that unlock destructive host actions.
|
||||
# Substring match (case-insensitive) so that user variants (e.g. trailing
|
||||
# punctuation, additional context) still match. Add new patterns here when
|
||||
# new high-risk actions are introduced.
|
||||
_DANGEROUS_CONFIRMATION_PATTERNS: tuple = (
|
||||
"confirm forced restart",
|
||||
"confirm forced reboot",
|
||||
"confirm shutdown",
|
||||
"confirm reboot",
|
||||
"confirm power off",
|
||||
"yes, delete everything",
|
||||
"confirm wipe",
|
||||
"confirm factory reset",
|
||||
# i18n variants observed in the original incident
|
||||
"確認強制重開機",
|
||||
"確認強制重開",
|
||||
"確認重啟",
|
||||
)
|
||||
|
||||
# Replacement text for an expired confirmation. Redacting in place (rather
|
||||
# than deleting the message) preserves strict user/assistant role
|
||||
# alternation in the replayed history.
|
||||
_EXPIRED_CONFIRMATION_SENTINEL = (
|
||||
"[A high-risk confirmation previously given here has EXPIRED and must "
|
||||
"not be acted on. Ask the user to re-confirm explicitly before "
|
||||
"performing any destructive action.]"
|
||||
)
|
||||
|
||||
|
||||
def is_dangerous_confirmation(content: Any) -> bool:
|
||||
"""Return True if a user-message text matches a known dangerous confirmation.
|
||||
|
||||
Used by ``strip_stale_dangerous_confirmations`` to decide which
|
||||
transcript rows to expire. Substring + case-insensitive so that
|
||||
``"Please confirm forced restart, the host is critical"`` still matches.
|
||||
"""
|
||||
if not isinstance(content, str):
|
||||
return False
|
||||
text = content.strip().lower()
|
||||
return any(pattern in text for pattern in _DANGEROUS_CONFIRMATION_PATTERNS)
|
||||
|
||||
|
||||
def strip_stale_dangerous_confirmations(
|
||||
agent_history: List[Dict[str, Any]],
|
||||
*,
|
||||
now: float,
|
||||
expiry_seconds: float = _DANGEROUS_CONFIRMATION_EXPIRY_SECONDS,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Expire stale dangerous-confirmation text in user messages (#59607).
|
||||
|
||||
When a high-risk side effect (e.g. host restart via ``shutdown.exe``)
|
||||
runs, the user's plain-text confirmation phrase is persisted in the
|
||||
conversation transcript. If the host restart killed the gateway
|
||||
process before the assistant's tool result was written, the
|
||||
transcript tail ends on the assistant's text response — and the
|
||||
dangerous confirmation text remains in the user role.
|
||||
|
||||
On the next inbound message — possibly a casual "are you there?" from
|
||||
the user minutes later — the LLM sees the stale confirmation and may
|
||||
interpret the new turn as a fresh re-confirmation, re-executing the
|
||||
destructive action. This is the failure mode reported in #59607.
|
||||
|
||||
Expired confirmations are REDACTED IN PLACE, not removed: deleting a
|
||||
user message from the incident tail (``user(confirm) →
|
||||
assistant("OK, restarting")``) would leave two consecutive assistant
|
||||
messages, violating the strict role-alternation invariant providers
|
||||
enforce. The message survives with its role intact; only the trigger
|
||||
text is replaced by a sentinel that tells the model the confirmation
|
||||
has expired.
|
||||
|
||||
Messages without a timestamp are left untouched (backward
|
||||
compatibility: legacy transcripts and in-memory test scaffolding have
|
||||
no timestamps). User messages that contain dangerous confirmation
|
||||
text but are within the expiry window are also left untouched — they
|
||||
represent a fresh confirmation that has not yet been acted on.
|
||||
|
||||
Complements 75ed07ace (which strips the *assistant* side of the
|
||||
broken tail) by handling the *user* side: a stale plain-text
|
||||
confirmation that the assistant has not yet responded to in a way
|
||||
the resume logic recognises.
|
||||
"""
|
||||
if not agent_history:
|
||||
return agent_history
|
||||
|
||||
cleaned: List[Dict[str, Any]] = []
|
||||
for msg in agent_history:
|
||||
if (
|
||||
isinstance(msg, dict)
|
||||
and msg.get("role") == "user"
|
||||
and is_dangerous_confirmation(msg.get("content", ""))
|
||||
):
|
||||
ts = msg.get("timestamp")
|
||||
if ts is not None and (now - float(ts)) > expiry_seconds:
|
||||
logger.debug(
|
||||
"Redacting stale dangerous-confirmation text in user "
|
||||
"message (age=%.1fs, expiry=%.1fs): %r",
|
||||
now - float(ts),
|
||||
expiry_seconds,
|
||||
(msg.get("content") or "")[:80],
|
||||
)
|
||||
redacted = dict(msg)
|
||||
redacted["content"] = _EXPIRED_CONFIRMATION_SENTINEL
|
||||
cleaned.append(redacted)
|
||||
continue
|
||||
cleaned.append(msg)
|
||||
return cleaned
|
||||
|
||||
@@ -24,6 +24,14 @@ _jitter_lock = threading.Lock()
|
||||
# not sit silent for 20+ minutes.
|
||||
_ZAI_CODING_OVERLOAD_LONG_BACKOFF = (30.0, 60.0, 90.0, 120.0)
|
||||
|
||||
# Number of initial short retries before the adaptive long-backoff tier kicks
|
||||
# in. Shared by ``adaptive_rate_limit_backoff`` (which walks the long table
|
||||
# starting at attempt ``short_attempts + 1``) and
|
||||
# ``zai_coding_overload_retry_ceiling`` (which sizes the retry loop so every
|
||||
# long-tier entry is reachable). Keeping it a single module constant prevents
|
||||
# the two from silently desyncing if the short-retry count is ever tuned.
|
||||
_ZAI_CODING_OVERLOAD_SHORT_ATTEMPTS = 3
|
||||
|
||||
|
||||
def jittered_backoff(
|
||||
attempt: int,
|
||||
@@ -104,7 +112,7 @@ def adaptive_rate_limit_backoff(
|
||||
model: str | None,
|
||||
error: Any,
|
||||
default_wait: float,
|
||||
short_attempts: int = 3,
|
||||
short_attempts: int = _ZAI_CODING_OVERLOAD_SHORT_ATTEMPTS,
|
||||
) -> tuple[float, str | None]:
|
||||
"""Provider-aware rate-limit backoff.
|
||||
|
||||
@@ -127,3 +135,20 @@ def adaptive_rate_limit_backoff(
|
||||
# A smaller jitter ratio keeps long waits readable while still avoiding
|
||||
# synchronized retry storms across concurrent Hermes sessions.
|
||||
return jittered_backoff(1, base_delay=base_delay, max_delay=base_delay, jitter_ratio=0.2), "zai_coding_overload_long"
|
||||
|
||||
|
||||
def zai_coding_overload_retry_ceiling(short_attempts: int = _ZAI_CODING_OVERLOAD_SHORT_ATTEMPTS) -> int:
|
||||
"""Retry-loop ceiling needed for the full Z.AI overload backoff schedule.
|
||||
|
||||
The adaptive policy runs ``short_attempts`` short retries, then walks the
|
||||
long-backoff table one entry per subsequent attempt. The retry loop gives
|
||||
up as soon as ``retry_count >= ceiling`` — and that check runs *before* the
|
||||
attempt's backoff is computed — so the ceiling must sit one past the final
|
||||
long-backoff entry for every long tier to actually execute.
|
||||
|
||||
With the default ``api_max_retries`` (3) equal to ``short_attempts`` (3),
|
||||
the loop always gave up before reaching the long tier, leaving the whole
|
||||
long-backoff schedule as dead code. Callers extend the ceiling to this
|
||||
value for Z.AI Coding overload 429s so the 30/60/90/120s waits run.
|
||||
"""
|
||||
return short_attempts + len(_ZAI_CODING_OVERLOAD_LONG_BACKOFF) + 1
|
||||
|
||||
@@ -224,6 +224,15 @@ def register_from_config(
|
||||
if not isinstance(cfg, dict):
|
||||
return []
|
||||
|
||||
# Safe mode (--safe-mode / HERMES_SAFE_MODE=1): shell hooks are user
|
||||
# customizations too — skip registration entirely so a troubleshooting
|
||||
# run fires zero user-configured code (plugins, MCP, AND hooks).
|
||||
from utils import env_var_enabled
|
||||
|
||||
if env_var_enabled("HERMES_SAFE_MODE"):
|
||||
logger.info("HERMES_SAFE_MODE=1 — shell-hook registration skipped")
|
||||
return []
|
||||
|
||||
effective_accept = _resolve_effective_accept(cfg, accept_hooks)
|
||||
|
||||
specs = _parse_hooks_block(cfg.get("hooks"))
|
||||
|
||||
@@ -143,37 +143,9 @@ def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tu
|
||||
|
||||
try:
|
||||
from tools.skills_tool import SKILLS_DIR, skill_view
|
||||
from agent.skill_utils import get_external_skills_dirs
|
||||
from agent.skill_utils import normalize_skill_lookup_name
|
||||
|
||||
identifier_path = Path(raw_identifier).expanduser()
|
||||
if identifier_path.is_absolute():
|
||||
normalized = None
|
||||
trusted_roots = [SKILLS_DIR]
|
||||
try:
|
||||
trusted_roots.extend(get_external_skills_dirs())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Prefer the lexical path under a trusted skill root before
|
||||
# resolving symlinks. Slash-command discovery can legitimately
|
||||
# find a skill via ~/.hermes/skills/<name> where <name> is a
|
||||
# symlink to a checked-out skill elsewhere. Resolving first turns
|
||||
# that trusted visible path into an arbitrary absolute path that
|
||||
# skill_view() refuses to load.
|
||||
for root in trusted_roots:
|
||||
try:
|
||||
normalized = str(identifier_path.relative_to(root))
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if normalized is None:
|
||||
try:
|
||||
normalized = str(identifier_path.resolve().relative_to(SKILLS_DIR.resolve()))
|
||||
except Exception:
|
||||
normalized = raw_identifier
|
||||
else:
|
||||
normalized = raw_identifier.lstrip("/")
|
||||
normalized = normalize_skill_lookup_name(raw_identifier)
|
||||
|
||||
loaded_skill = json.loads(
|
||||
skill_view(normalized, task_id=task_id, preprocess=False)
|
||||
|
||||
@@ -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
|
||||
@@ -507,6 +511,63 @@ def get_all_skills_dirs() -> List[Path]:
|
||||
return dirs
|
||||
|
||||
|
||||
def normalize_skill_lookup_name(identifier: str) -> str:
|
||||
"""Normalize a skill identifier to a ``skill_view()``-safe relative path.
|
||||
|
||||
Slash commands and cron jobs may store absolute paths to skills that live
|
||||
under ``~/.hermes/skills/`` (including via symlinks) or configured
|
||||
``skills.external_dirs``. ``skill_view()`` rejects absolute names for
|
||||
security, so callers must translate trusted absolute paths to their
|
||||
relative form first.
|
||||
"""
|
||||
raw_identifier = (identifier or "").strip()
|
||||
if not raw_identifier:
|
||||
return raw_identifier
|
||||
|
||||
identifier_path = Path(raw_identifier).expanduser()
|
||||
if not identifier_path.is_absolute():
|
||||
return raw_identifier.lstrip("/")
|
||||
|
||||
# Look the primary skills root up on tools.skills_tool at CALL time
|
||||
# (not via get_skills_dir()): callers and tests patch
|
||||
# ``tools.skills_tool.SKILLS_DIR`` and skill_view() itself resolves
|
||||
# against that module attribute, so normalization must agree with the
|
||||
# exact root skill_view() will enforce. Import deferred to avoid a
|
||||
# module cycle (tools.skills_tool imports agent.skill_utils).
|
||||
try:
|
||||
from tools import skills_tool as _skills_tool
|
||||
primary_root = Path(_skills_tool.SKILLS_DIR)
|
||||
except Exception:
|
||||
primary_root = get_skills_dir()
|
||||
|
||||
trusted_roots = [primary_root]
|
||||
try:
|
||||
trusted_roots.extend(get_external_skills_dirs())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Prefer the lexical path under a trusted skill root before resolving
|
||||
# symlinks. Slash-command discovery can legitimately find a skill via
|
||||
# ~/.hermes/skills/<name> where <name> is a symlink to a checked-out
|
||||
# skill elsewhere. Resolving first turns that trusted visible path into
|
||||
# an arbitrary absolute path that skill_view() refuses to load.
|
||||
for root in trusted_roots:
|
||||
try:
|
||||
return str(identifier_path.relative_to(root))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
try:
|
||||
return str(identifier_path.resolve().relative_to(primary_root.resolve()))
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Skill identifier %r is an absolute path outside trusted skills "
|
||||
"roots — passing through unchanged (skill_view will reject it)",
|
||||
raw_identifier,
|
||||
)
|
||||
return raw_identifier
|
||||
|
||||
|
||||
def _resolve_for_skill_ownership(path) -> Path:
|
||||
path_obj = path if isinstance(path, Path) else Path(str(path))
|
||||
try:
|
||||
@@ -730,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
|
||||
@@ -740,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)
|
||||
|
||||
|
||||
@@ -415,8 +415,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
)
|
||||
else:
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
block_message = get_pre_tool_call_block_message(
|
||||
from hermes_cli.plugins import resolve_pre_tool_block
|
||||
block_message = resolve_pre_tool_block(
|
||||
function_name,
|
||||
function_args,
|
||||
task_id=effective_task_id or "",
|
||||
@@ -1034,8 +1034,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
_block_error_type = "tool_scope_block"
|
||||
else:
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
_block_msg = get_pre_tool_call_block_message(
|
||||
from hermes_cli.plugins import resolve_pre_tool_block
|
||||
_block_msg = resolve_pre_tool_block(
|
||||
function_name,
|
||||
function_args,
|
||||
task_id=effective_task_id or "",
|
||||
|
||||
398
agent/trace_upload.py
Normal file
398
agent/trace_upload.py
Normal file
@@ -0,0 +1,398 @@
|
||||
"""Upload a Hermes session transcript to Hugging Face as an agent trace.
|
||||
|
||||
Hermes stores sessions in its own SQLite store (``hermes_state.SessionDB``),
|
||||
so we reconstruct the conversation and emit it in the **Claude Code JSONL**
|
||||
shape — one of the three formats the Hugging Face Agent Trace Viewer
|
||||
auto-detects (Claude Code / Codex / Pi). No dataset-side preprocessing is
|
||||
needed; the Hub tags the dataset ``agent-traces`` and opens it in the viewer.
|
||||
|
||||
Docs: https://huggingface.co/docs/hub/agent-traces
|
||||
|
||||
Design notes
|
||||
------------
|
||||
* **Zero LLM turn.** This is a deterministic export — it never spends a
|
||||
model call. The ``hermes trace upload`` subcommand calls
|
||||
:func:`upload_session_trace` directly.
|
||||
* **Private by default.** Traces can contain prompts, tool output, local
|
||||
paths, and secrets. The dataset is created private and every text body
|
||||
is passed through Hermes' secret redactor (``force=True``) unless the
|
||||
caller explicitly opts out with ``redact=False``.
|
||||
* **Never raises.** Returns a user-facing status string so command
|
||||
handlers can echo it straight back to the user. Programmatic callers
|
||||
that need the URL can use :func:`build_trace_jsonl` + :func:`_do_upload`
|
||||
directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_DATASET_NAME = "hermes-traces"
|
||||
_HERMES_VERSION = "hermes-agent"
|
||||
_REDACTION_BLOCKED_MESSAGE = (
|
||||
"Trace upload blocked: secret redaction failed, so the transcript may "
|
||||
"still contain credentials or other sensitive data. Fix the redactor or "
|
||||
"rerun with --no-redact only after manually reviewing the transcript."
|
||||
)
|
||||
|
||||
|
||||
class TraceRedactionError(RuntimeError):
|
||||
"""Raised when a trace cannot be safely redacted before upload."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conversion: Hermes OpenAI-format messages -> Claude Code JSONL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
||||
|
||||
|
||||
def _redact(text: Any, enabled: bool) -> Any:
|
||||
"""Redact secrets from a string body when redaction is enabled.
|
||||
|
||||
Non-strings pass through untouched. Uses Hermes' shared redactor with
|
||||
``force=True`` so an upload always scrubs known secret shapes even if
|
||||
the user disabled log redaction globally.
|
||||
"""
|
||||
if not enabled or not isinstance(text, str) or not text:
|
||||
return text
|
||||
try:
|
||||
from agent.redact import redact_sensitive_text
|
||||
return redact_sensitive_text(text, force=True)
|
||||
except Exception as exc:
|
||||
logger.warning("Trace upload redaction failed; refusing upload", exc_info=True)
|
||||
raise TraceRedactionError(_REDACTION_BLOCKED_MESSAGE) from exc
|
||||
|
||||
|
||||
def _content_to_blocks(content: Any, redact: bool) -> List[Dict[str, Any]]:
|
||||
"""Normalize a message ``content`` field into Anthropic content blocks."""
|
||||
if content is None:
|
||||
return []
|
||||
if isinstance(content, str):
|
||||
return [{"type": "text", "text": _redact(content, redact)}]
|
||||
if isinstance(content, list):
|
||||
blocks: List[Dict[str, Any]] = []
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
ptype = part.get("type")
|
||||
if ptype == "text":
|
||||
blocks.append({"type": "text", "text": _redact(part.get("text", ""), redact)})
|
||||
elif ptype in ("image_url", "image"):
|
||||
# Keep a placeholder; the viewer renders text turns and we
|
||||
# don't want to inline base64 blobs into a trace.
|
||||
blocks.append({"type": "text", "text": "[image omitted]"})
|
||||
else:
|
||||
blocks.append({"type": "text", "text": _redact(json.dumps(part), redact)})
|
||||
else:
|
||||
blocks.append({"type": "text", "text": _redact(str(part), redact)})
|
||||
return blocks
|
||||
return [{"type": "text", "text": _redact(json.dumps(content), redact)}]
|
||||
|
||||
|
||||
def _tool_calls_to_blocks(tool_calls: Any, redact: bool) -> List[Dict[str, Any]]:
|
||||
"""Convert OpenAI tool_calls into Anthropic ``tool_use`` content blocks."""
|
||||
blocks: List[Dict[str, Any]] = []
|
||||
if not isinstance(tool_calls, list):
|
||||
return blocks
|
||||
for tc in tool_calls:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
fn = tc.get("function") or {}
|
||||
name = fn.get("name") or tc.get("name") or "tool"
|
||||
raw_args = fn.get("arguments")
|
||||
if isinstance(raw_args, str):
|
||||
try:
|
||||
parsed = json.loads(raw_args) if raw_args.strip() else {}
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
parsed = {"_raw": raw_args}
|
||||
elif isinstance(raw_args, dict):
|
||||
parsed = raw_args
|
||||
else:
|
||||
parsed = {}
|
||||
if redact:
|
||||
try:
|
||||
parsed = json.loads(_redact(json.dumps(parsed), redact))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
logger.warning("Trace upload redacted tool arguments are not valid JSON; refusing upload")
|
||||
raise TraceRedactionError(_REDACTION_BLOCKED_MESSAGE)
|
||||
blocks.append({
|
||||
"type": "tool_use",
|
||||
"id": tc.get("id") or f"toolu_{uuid.uuid4().hex[:16]}",
|
||||
"name": name,
|
||||
"input": parsed,
|
||||
})
|
||||
return blocks
|
||||
|
||||
|
||||
def build_trace_jsonl(
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
session_id: str,
|
||||
model: str = "",
|
||||
cwd: str = "",
|
||||
redact: bool = True,
|
||||
) -> str:
|
||||
"""Render Hermes conversation messages as Claude Code JSONL text.
|
||||
|
||||
Each non-system message becomes one JSONL line in the Claude Code
|
||||
transcript shape the HF Agent Trace Viewer auto-detects:
|
||||
|
||||
* ``user`` / ``tool`` -> ``{"type": "user", "message": {...}}``
|
||||
* ``assistant`` -> ``{"type": "assistant", "message": {...}}``
|
||||
with ``content`` blocks (text + ``tool_use``).
|
||||
|
||||
Tool results are emitted as user turns carrying a ``tool_result``
|
||||
block keyed by ``tool_call_id`` — the same way Claude Code records
|
||||
them. Turns are linked via ``uuid`` / ``parentUuid``.
|
||||
"""
|
||||
lines: List[str] = []
|
||||
parent: Optional[str] = None
|
||||
base_ts = _now_iso()
|
||||
git_branch = ""
|
||||
try:
|
||||
import subprocess
|
||||
if cwd:
|
||||
r = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True, text=True, timeout=3, cwd=cwd,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
git_branch = r.stdout.strip()
|
||||
except Exception:
|
||||
git_branch = ""
|
||||
|
||||
def _common(turn_uuid: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"parentUuid": parent,
|
||||
"isSidechain": False,
|
||||
"userType": "external",
|
||||
"cwd": cwd or os.getcwd(),
|
||||
"sessionId": session_id,
|
||||
"version": _HERMES_VERSION,
|
||||
"gitBranch": git_branch,
|
||||
"uuid": turn_uuid,
|
||||
"timestamp": base_ts,
|
||||
}
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role")
|
||||
if role == "system":
|
||||
continue
|
||||
turn_uuid = str(uuid.uuid4())
|
||||
|
||||
if role == "assistant":
|
||||
blocks = _content_to_blocks(msg.get("content"), redact)
|
||||
blocks.extend(_tool_calls_to_blocks(msg.get("tool_calls"), redact))
|
||||
if not blocks:
|
||||
blocks = [{"type": "text", "text": ""}]
|
||||
entry = _common(turn_uuid)
|
||||
entry["type"] = "assistant"
|
||||
entry["message"] = {
|
||||
"role": "assistant",
|
||||
"model": model or "unknown",
|
||||
"content": blocks,
|
||||
}
|
||||
lines.append(json.dumps(entry, ensure_ascii=False))
|
||||
parent = turn_uuid
|
||||
continue
|
||||
|
||||
if role == "tool":
|
||||
tool_use_id = msg.get("tool_call_id") or msg.get("tool_name") or "tool"
|
||||
result_content = _redact(
|
||||
msg.get("content") if isinstance(msg.get("content"), str)
|
||||
else json.dumps(msg.get("content")),
|
||||
redact,
|
||||
)
|
||||
entry = _common(turn_uuid)
|
||||
entry["type"] = "user"
|
||||
entry["message"] = {
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_use_id,
|
||||
"content": result_content,
|
||||
}],
|
||||
}
|
||||
lines.append(json.dumps(entry, ensure_ascii=False))
|
||||
parent = turn_uuid
|
||||
continue
|
||||
|
||||
# Default: user (and any unknown role) -> user turn.
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
message_content: Any = _redact(content, redact)
|
||||
else:
|
||||
message_content = _content_to_blocks(content, redact)
|
||||
entry = _common(turn_uuid)
|
||||
entry["type"] = "user"
|
||||
entry["message"] = {"role": "user", "content": message_content}
|
||||
lines.append(json.dumps(entry, ensure_ascii=False))
|
||||
parent = turn_uuid
|
||||
|
||||
return "\n".join(lines) + ("\n" if lines else "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Upload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_hf_token() -> Optional[str]:
|
||||
"""Return the user's Hugging Face token from the usual env vars."""
|
||||
for var in ("HF_TOKEN", "HUGGINGFACE_HUB_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HUGGINGFACE_TOKEN"):
|
||||
val = os.getenv(var)
|
||||
if val and val.strip():
|
||||
return val.strip()
|
||||
return None
|
||||
|
||||
|
||||
_NO_TOKEN_MESSAGE = (
|
||||
"Can't upload — no Hugging Face token is available. To set it up:\n"
|
||||
"\n"
|
||||
"1. Create a token with WRITE access at https://huggingface.co/settings/tokens\n"
|
||||
" (New token -> type \"Write\" -> copy it).\n"
|
||||
"2. Add it to your environment as HF_TOKEN (e.g. in ~/.hermes/.env):\n"
|
||||
" HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxx\n"
|
||||
"3. Run /upload-trace again (or `hermes trace upload`)."
|
||||
)
|
||||
|
||||
|
||||
def _do_upload(
|
||||
jsonl: str,
|
||||
*,
|
||||
token: str,
|
||||
session_id: str,
|
||||
dataset_name: str = DEFAULT_DATASET_NAME,
|
||||
private: bool = True,
|
||||
) -> str:
|
||||
"""Create (idempotently) the private dataset and push the trace file.
|
||||
|
||||
Returns a user-facing status string. Never raises.
|
||||
"""
|
||||
try:
|
||||
from tools import lazy_deps
|
||||
lazy_deps.ensure("tool.trace_upload", prompt=False)
|
||||
except Exception:
|
||||
# lazy-install unavailable/declined — fall through to the import,
|
||||
# which surfaces the install hint below if the package is missing.
|
||||
pass
|
||||
try:
|
||||
from huggingface_hub import HfApi
|
||||
except ImportError:
|
||||
return ("Hugging Face upload needs the `huggingface_hub` package "
|
||||
"(`pip install huggingface_hub`).")
|
||||
|
||||
api = HfApi(token=token)
|
||||
try:
|
||||
who = api.whoami()
|
||||
user = who.get("name") if isinstance(who, dict) else None
|
||||
except Exception as e:
|
||||
logger.warning("HF whoami failed: %s", e)
|
||||
return ("Your Hugging Face token was rejected (whoami failed). "
|
||||
"Make sure it has WRITE access and isn't expired.")
|
||||
if not user:
|
||||
return "Could not resolve your Hugging Face username from the token."
|
||||
|
||||
repo_id = f"{user}/{dataset_name}"
|
||||
try:
|
||||
api.create_repo(
|
||||
repo_id=repo_id, repo_type="dataset", private=private, exist_ok=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("HF create_repo failed for %s: %s", repo_id, e)
|
||||
return f"Could not create/access dataset {repo_id}: {e}"
|
||||
|
||||
path_in_repo = f"sessions/{session_id}.jsonl"
|
||||
try:
|
||||
api.upload_file(
|
||||
path_or_fileobj=jsonl.encode("utf-8"),
|
||||
path_in_repo=path_in_repo,
|
||||
repo_id=repo_id,
|
||||
repo_type="dataset",
|
||||
commit_message=f"add session trace {session_id}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("HF upload_file failed for %s: %s", repo_id, e)
|
||||
return f"Upload to Hugging Face failed: {e}"
|
||||
|
||||
return (f"Uploaded -> https://huggingface.co/datasets/{repo_id}/blob/main/{path_in_repo}\n"
|
||||
f"View in the trace viewer: https://huggingface.co/datasets/{repo_id}")
|
||||
|
||||
|
||||
def load_session_messages(
|
||||
session_id: str, db_path=None
|
||||
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
|
||||
"""Load a session's conversation + metadata from the SQLite store.
|
||||
|
||||
Returns ``(messages, meta)``. ``meta`` is ``{}`` when the session row is
|
||||
missing (messages may still be present for a live, untitled session).
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB(db_path=db_path) if db_path else SessionDB()
|
||||
resolved = db.resolve_session_id(session_id) or session_id
|
||||
meta = db.get_session(resolved) or {}
|
||||
messages = db.get_messages_as_conversation(resolved)
|
||||
return messages, meta
|
||||
|
||||
|
||||
def upload_session_trace(
|
||||
session_id: str,
|
||||
*,
|
||||
model: str = "",
|
||||
cwd: str = "",
|
||||
redact: bool = True,
|
||||
private: bool = True,
|
||||
dataset_name: str = DEFAULT_DATASET_NAME,
|
||||
db_path=None,
|
||||
token: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Top-level entry point used by the CLI/gateway/subcommand.
|
||||
|
||||
Loads the session, converts it to Claude Code JSONL, and uploads it to
|
||||
the user's private ``{user}/hermes-traces`` dataset. Returns a
|
||||
user-facing status string and never raises.
|
||||
"""
|
||||
if not session_id:
|
||||
return "No active session to upload."
|
||||
|
||||
token = token or _resolve_hf_token()
|
||||
if not token:
|
||||
return _NO_TOKEN_MESSAGE
|
||||
|
||||
try:
|
||||
messages, meta = load_session_messages(session_id, db_path=db_path)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load session %s for trace upload: %s", session_id, e)
|
||||
return f"Could not load session {session_id}: {e}"
|
||||
|
||||
if not messages:
|
||||
return "No transcript to upload for this session yet."
|
||||
|
||||
resolved_model = model or meta.get("model") or ""
|
||||
try:
|
||||
jsonl = build_trace_jsonl(
|
||||
messages,
|
||||
session_id=session_id,
|
||||
model=resolved_model,
|
||||
cwd=cwd,
|
||||
redact=redact,
|
||||
)
|
||||
except TraceRedactionError:
|
||||
return _REDACTION_BLOCKED_MESSAGE
|
||||
if not jsonl.strip():
|
||||
return "No transcript content to upload for this session."
|
||||
|
||||
return _do_upload(
|
||||
jsonl,
|
||||
token=token,
|
||||
session_id=session_id,
|
||||
dataset_name=dataset_name,
|
||||
private=private,
|
||||
)
|
||||
@@ -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]]:
|
||||
|
||||
@@ -75,6 +75,7 @@ class TurnResult:
|
||||
token_usage_last: Optional[dict[str, Any]] = None
|
||||
token_usage_total: Optional[dict[str, Any]] = None
|
||||
model_context_window: Optional[int] = None
|
||||
compacted: bool = False
|
||||
# Hint to the caller that the underlying codex subprocess is likely
|
||||
# wedged (turn-level timeout fired, post-tool watchdog tripped, or
|
||||
# token-refresh failure killed the child). The caller should retire
|
||||
@@ -505,6 +506,7 @@ class CodexAppServerSession:
|
||||
if pending is None:
|
||||
break
|
||||
_apply_token_usage_notification(result, pending)
|
||||
_apply_compaction_notification(result, pending)
|
||||
self._track_pending_file_change(pending)
|
||||
proj = projector.project(pending)
|
||||
if proj.messages:
|
||||
@@ -541,6 +543,7 @@ class CodexAppServerSession:
|
||||
logger.debug("on_event callback raised", exc_info=True)
|
||||
|
||||
_apply_token_usage_notification(result, note)
|
||||
_apply_compaction_notification(result, note)
|
||||
|
||||
# Track in-progress fileChange items so the approval bridge
|
||||
# can surface a real change summary when codex requests
|
||||
@@ -632,6 +635,154 @@ class CodexAppServerSession:
|
||||
|
||||
return result
|
||||
|
||||
def compact_thread(
|
||||
self,
|
||||
*,
|
||||
turn_timeout: float = 600.0,
|
||||
notification_poll_timeout: float = 0.25,
|
||||
) -> TurnResult:
|
||||
"""Trigger Codex-native history compaction for the current thread.
|
||||
|
||||
`thread/compact/start` returns immediately; the actual compaction
|
||||
progress streams through the same turn/item notifications as a normal
|
||||
turn. We wait for the matching `turn/completed` so callers can treat a
|
||||
successful return as a completed compaction boundary.
|
||||
"""
|
||||
result = TurnResult()
|
||||
try:
|
||||
self.ensure_started()
|
||||
except (CodexAppServerError, TimeoutError) as exc:
|
||||
result.error = self._format_error_with_stderr(
|
||||
"codex app-server startup failed", exc
|
||||
)
|
||||
result.should_retire = True
|
||||
return result
|
||||
|
||||
assert self._client is not None and self._thread_id is not None
|
||||
result.thread_id = self._thread_id
|
||||
self._interrupt_event.clear()
|
||||
projector = CodexEventProjector()
|
||||
|
||||
try:
|
||||
self._client.request(
|
||||
"thread/compact/start",
|
||||
{"threadId": self._thread_id},
|
||||
timeout=10,
|
||||
)
|
||||
except CodexAppServerError as exc:
|
||||
stderr_blob = "\n".join(self._client.stderr_tail(40))
|
||||
hint = _classify_oauth_failure(exc.message, stderr_blob)
|
||||
if hint is not None:
|
||||
result.error = hint
|
||||
result.should_retire = True
|
||||
else:
|
||||
result.error = self._format_error_with_stderr(
|
||||
"thread/compact/start failed", exc
|
||||
)
|
||||
return result
|
||||
except TimeoutError as exc:
|
||||
stderr_blob = "\n".join(self._client.stderr_tail(40))
|
||||
hint = _classify_oauth_failure(stderr_blob)
|
||||
result.error = hint or self._format_error_with_stderr(
|
||||
"thread/compact/start timed out", exc
|
||||
)
|
||||
result.should_retire = True
|
||||
return result
|
||||
|
||||
deadline = time.monotonic() + turn_timeout
|
||||
turn_complete = False
|
||||
|
||||
while time.monotonic() < deadline and not turn_complete:
|
||||
if self._interrupt_event.is_set():
|
||||
self._issue_interrupt(result.turn_id)
|
||||
result.interrupted = True
|
||||
break
|
||||
|
||||
if not self._client.is_alive():
|
||||
stderr_blob = "\n".join(self._client.stderr_tail(60))
|
||||
hint = _classify_oauth_failure(stderr_blob)
|
||||
if hint is not None:
|
||||
result.error = hint
|
||||
else:
|
||||
result.error = self._format_error_with_stderr(
|
||||
"codex app-server subprocess exited unexpectedly",
|
||||
tail_lines=20,
|
||||
)
|
||||
result.should_retire = True
|
||||
break
|
||||
|
||||
sreq = self._client.take_server_request(timeout=0)
|
||||
if sreq is not None:
|
||||
self._handle_server_request(sreq)
|
||||
continue
|
||||
|
||||
note = self._client.take_notification(
|
||||
timeout=notification_poll_timeout
|
||||
)
|
||||
if note is None:
|
||||
continue
|
||||
|
||||
method = note.get("method", "")
|
||||
if self._on_event is not None:
|
||||
try:
|
||||
self._on_event(note)
|
||||
except Exception: # pragma: no cover - display callback
|
||||
logger.debug("on_event callback raised", exc_info=True)
|
||||
|
||||
_apply_token_usage_notification(result, note)
|
||||
_apply_compaction_notification(result, note)
|
||||
self._track_pending_file_change(note)
|
||||
|
||||
projection = projector.project(note)
|
||||
if projection.messages:
|
||||
result.projected_messages.extend(projection.messages)
|
||||
if projection.is_tool_iteration:
|
||||
result.tool_iterations += 1
|
||||
if projection.final_text is not None:
|
||||
result.final_text = projection.final_text
|
||||
if _has_turn_aborted_marker(projection.final_text):
|
||||
turn_complete = True
|
||||
result.interrupted = True
|
||||
result.error = (
|
||||
result.error or "codex reported turn_aborted"
|
||||
)
|
||||
|
||||
if method == "turn/started":
|
||||
turn_obj = (note.get("params") or {}).get("turn") or {}
|
||||
result.turn_id = turn_obj.get("id") or result.turn_id
|
||||
elif method == "turn/completed":
|
||||
turn_complete = True
|
||||
turn_obj = (note.get("params") or {}).get("turn") or {}
|
||||
result.turn_id = turn_obj.get("id") or result.turn_id
|
||||
turn_status = turn_obj.get("status")
|
||||
if turn_status and turn_status not in {"completed", "interrupted"}:
|
||||
err_obj = turn_obj.get("error")
|
||||
if err_obj:
|
||||
err_msg = _format_responses_error(err_obj, str(turn_status))
|
||||
stderr_blob = "\n".join(
|
||||
self._client.stderr_tail(40)
|
||||
)
|
||||
hint = _classify_oauth_failure(err_msg, stderr_blob)
|
||||
if hint is not None:
|
||||
result.error = hint
|
||||
result.should_retire = True
|
||||
else:
|
||||
result.error = self._format_error_with_stderr(
|
||||
f"compact turn ended status={turn_status}",
|
||||
err_msg,
|
||||
)
|
||||
|
||||
if not turn_complete and not result.interrupted:
|
||||
self._issue_interrupt(result.turn_id)
|
||||
result.interrupted = True
|
||||
if not result.error:
|
||||
result.error = self._format_error_with_stderr(
|
||||
f"compact turn timed out after {turn_timeout}s"
|
||||
)
|
||||
result.should_retire = True
|
||||
|
||||
return result
|
||||
|
||||
# ---------- internals ----------
|
||||
|
||||
def _issue_interrupt(self, turn_id: Optional[str]) -> None:
|
||||
@@ -845,6 +996,38 @@ def _apply_token_usage_notification(result: TurnResult, note: dict) -> None:
|
||||
result.model_context_window = window
|
||||
|
||||
|
||||
def _apply_compaction_notification(result: TurnResult, note: dict) -> None:
|
||||
"""Capture Codex-native context compaction boundaries.
|
||||
|
||||
Recent app-server builds expose compaction as a ContextCompaction item.
|
||||
Older builds also emit the deprecated thread/compacted notification. Both
|
||||
mean the underlying Codex thread history has been compacted.
|
||||
"""
|
||||
if not isinstance(note, dict):
|
||||
return
|
||||
method = note.get("method") or ""
|
||||
params = note.get("params") or {}
|
||||
if not isinstance(params, dict):
|
||||
return
|
||||
|
||||
if method == "thread/compacted":
|
||||
result.compacted = True
|
||||
result.thread_id = params.get("threadId") or result.thread_id
|
||||
result.turn_id = params.get("turnId") or result.turn_id
|
||||
return
|
||||
|
||||
if method not in {"item/started", "item/completed"}:
|
||||
return
|
||||
|
||||
item = params.get("item") or {}
|
||||
if not isinstance(item, dict) or item.get("type") != "contextCompaction":
|
||||
return
|
||||
|
||||
result.compacted = True
|
||||
result.thread_id = params.get("threadId") or result.thread_id
|
||||
result.turn_id = params.get("turnId") or result.turn_id
|
||||
|
||||
|
||||
def _approval_choice_to_codex_decision(choice: str) -> str:
|
||||
"""Map Hermes approval choices onto codex's CommandExecutionApprovalDecision
|
||||
/ FileChangeApprovalDecision wire values.
|
||||
|
||||
@@ -369,6 +369,20 @@ def build_turn_context(
|
||||
lambda _tokens: False,
|
||||
)
|
||||
_preflight_deferred = _defer_preflight(_preflight_tokens)
|
||||
# Codex app-server threads are compacted by the codex agent itself;
|
||||
# Hermes only initiates compaction in "hermes" mode (#36801).
|
||||
_codex_native_auto = (
|
||||
getattr(agent, "api_mode", None) == "codex_app_server"
|
||||
and str(
|
||||
getattr(
|
||||
agent,
|
||||
"codex_app_server_auto_compaction",
|
||||
"native",
|
||||
)
|
||||
or "native"
|
||||
).lower()
|
||||
in {"native", "off"}
|
||||
)
|
||||
|
||||
if not _preflight_deferred:
|
||||
_last = _compressor.last_prompt_tokens
|
||||
@@ -397,6 +411,12 @@ def build_turn_context(
|
||||
int(_compression_cooldown.get("remaining_seconds", 0.0)),
|
||||
agent.session_id or "none",
|
||||
)
|
||||
elif _codex_native_auto:
|
||||
logger.info(
|
||||
"Skipping Hermes preflight compression for codex app-server "
|
||||
"(mode=%s); Hermes will not start thread compaction here.",
|
||||
getattr(agent, "codex_app_server_auto_compaction", "native"),
|
||||
)
|
||||
elif _compressor.should_compress(_preflight_tokens):
|
||||
logger.info(
|
||||
"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
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 +61,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)
|
||||
})
|
||||
@@ -17,8 +17,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 +29,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 +45,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,15 @@
|
||||
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'
|
||||
import test from 'node:test'
|
||||
|
||||
const {
|
||||
POSIX_SANE_PATH_ENTRIES,
|
||||
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,15 @@ 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 +64,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 +73,17 @@ 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 +94,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 +111,12 @@ function buildDesktopBackendEnv({
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
POSIX_SANE_PATH_ENTRIES,
|
||||
export {
|
||||
appendUniquePathEntries,
|
||||
buildDesktopBackendEnv,
|
||||
buildDesktopBackendPath,
|
||||
delimiterForPlatform,
|
||||
normalizeHermesHomeRoot,
|
||||
pathEnvKey
|
||||
pathEnvKey,
|
||||
POSIX_SANE_PATH_ENTRIES
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
/**
|
||||
* 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'
|
||||
import test from 'node:test'
|
||||
|
||||
const { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
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 +67,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,34 @@
|
||||
* 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'
|
||||
import test from 'node:test'
|
||||
|
||||
const {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -86,6 +91,13 @@ test('resolves with the announced port', async () => {
|
||||
assert.equal(await p, 54321)
|
||||
})
|
||||
|
||||
test('resolves with a HERMES_BACKEND_READY port (headless `serve`)', async () => {
|
||||
const child = makeFakeChild()
|
||||
const p = waitForDashboardPort(child, 1000)
|
||||
child.stdout.emit('data', 'HERMES_BACKEND_READY port=43210\n')
|
||||
assert.equal(await p, 43210)
|
||||
})
|
||||
|
||||
test('parses the port even when the line arrives split across chunks', async () => {
|
||||
const child = makeFakeChild()
|
||||
const p = waitForDashboardPort(child, 1000)
|
||||
@@ -132,6 +144,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'),
|
||||
@@ -141,6 +154,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)
|
||||
@@ -151,6 +165,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, '{')
|
||||
@@ -165,6 +180,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)
|
||||
@@ -177,6 +193,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)
|
||||
@@ -189,6 +206,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,6 +1,9 @@
|
||||
const fs = require('node:fs')
|
||||
import fs from 'node:fs'
|
||||
|
||||
const _READY_RE = /^HERMES_DASHBOARD_READY port=(\d+)/m
|
||||
// `hermes serve` announces HERMES_BACKEND_READY; the legacy `hermes dashboard`
|
||||
// backend announces HERMES_DASHBOARD_READY. Accept either so the desktop spawn
|
||||
// works against both the headless backend and old/dashboard runtimes.
|
||||
const _READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\d+)/m
|
||||
|
||||
// The announcement clock starts the instant the backend process is spawned —
|
||||
// before uvicorn binds its socket. On a cold install the child must first
|
||||
@@ -23,15 +26,17 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch a child process's stdout for the `HERMES_DASHBOARD_READY port=<N>`
|
||||
* line that web_server.py prints after uvicorn binds its socket.
|
||||
* Watch a child process's stdout for the `HERMES_(BACKEND|DASHBOARD)_READY
|
||||
* port=<N>` line that web_server.py prints after uvicorn binds its socket.
|
||||
*
|
||||
* Returns the parsed port. Rejects if:
|
||||
* - the child exits before emitting the line
|
||||
@@ -52,7 +57,9 @@ 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)
|
||||
@@ -63,13 +70,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
|
||||
}
|
||||
}
|
||||
@@ -96,11 +106,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
|
||||
@@ -113,16 +127,22 @@ 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)
|
||||
@@ -147,25 +167,36 @@ 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,12 @@
|
||||
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'
|
||||
import test from 'node:test'
|
||||
|
||||
const {
|
||||
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 +83,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,10 @@
|
||||
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'
|
||||
import test from 'node:test'
|
||||
|
||||
const {
|
||||
runBootstrap,
|
||||
resolveInstallScript,
|
||||
installedAgentInstallScript,
|
||||
cachedScriptPath
|
||||
} = require('./bootstrap-runner.cjs')
|
||||
import { cachedScriptPath, installedAgentInstallScript, resolveInstallScript, runBootstrap } from './bootstrap-runner'
|
||||
|
||||
const SCRIPT_NAME = process.platform === 'win32' ? 'install.ps1' : 'install.sh'
|
||||
|
||||
@@ -22,6 +17,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 +38,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')
|
||||
|
||||
@@ -59,6 +56,7 @@ test('installedAgentInstallScript resolves the installer in the agent checkout',
|
||||
|
||||
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 +64,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 +81,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 +91,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 +118,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.ts'
|
||||
* 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,11 +32,11 @@
|
||||
* 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'
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
|
||||
@@ -46,6 +44,7 @@ function hiddenWindowsChildOptions(options = {}) {
|
||||
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
|
||||
return options
|
||||
}
|
||||
|
||||
return { ...options, windowsHide: true }
|
||||
}
|
||||
|
||||
@@ -71,10 +70,14 @@ 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,10 +93,14 @@ 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
|
||||
@@ -110,6 +117,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 +137,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 +151,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 +181,7 @@ function downloadInstallScript(commit, destPath) {
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
@@ -174,6 +191,7 @@ function downloadInstallScript(commit, destPath) {
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
@@ -187,11 +205,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 +224,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 +241,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 +262,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 +299,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 +357,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 +382,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 +398,44 @@ 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 +450,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 +474,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 +490,36 @@ 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 })
|
||||
})
|
||||
})
|
||||
@@ -456,48 +534,60 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
|
||||
// instead of falling back to install.ps1's default ($Branch = "main").
|
||||
function buildPinArgs(installStamp) {
|
||||
const args = []
|
||||
|
||||
if (installStamp && installStamp.commit) {
|
||||
args.push('-Commit', installStamp.commit)
|
||||
}
|
||||
|
||||
if (installStamp && installStamp.branch) {
|
||||
args.push('-Branch', installStamp.branch)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
function buildPosixPinArgs({ installStamp, activeRoot, hermesHome }) {
|
||||
const args = ['--dir', activeRoot, '--hermes-home', hermesHome]
|
||||
|
||||
if (installStamp && installStamp.branch) {
|
||||
args.push('--branch', installStamp.branch)
|
||||
}
|
||||
|
||||
if (installStamp && installStamp.commit) {
|
||||
args.push('--commit', installStamp.commit)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, activeRoot, installStamp }) {
|
||||
const isPosix = installerKind === 'posix'
|
||||
|
||||
const args = isPosix
|
||||
? ['--manifest', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })]
|
||||
: ['-Manifest', ...buildPinArgs(installStamp)]
|
||||
|
||||
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 +595,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 +606,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,6 +618,7 @@ function parseStageResult(stdout) {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -533,6 +627,7 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
|
||||
emit({ type: 'stage', name: stage.name, state: 'running' })
|
||||
|
||||
const isPosix = installerKind === 'posix'
|
||||
|
||||
const args = isPosix
|
||||
? [
|
||||
'--stage',
|
||||
@@ -542,6 +637,7 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
|
||||
...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })
|
||||
]
|
||||
: ['-Stage', stage.name, '-NonInteractive', '-Json', ...buildPinArgs(installStamp)]
|
||||
|
||||
const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, {
|
||||
emit,
|
||||
stageName: stage.name,
|
||||
@@ -554,6 +650,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 +665,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 +693,9 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
|
||||
json,
|
||||
error: json.reason || `exit code ${result.code}`
|
||||
}
|
||||
|
||||
emit(ev)
|
||||
|
||||
return ev
|
||||
}
|
||||
|
||||
@@ -603,6 +708,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 +725,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 +739,7 @@ async function runBootstrap(opts) {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false, cancelled: true }
|
||||
}
|
||||
|
||||
@@ -646,8 +753,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`)
|
||||
@@ -677,6 +787,7 @@ async function runBootstrap(opts) {
|
||||
activeRoot,
|
||||
installStamp
|
||||
})
|
||||
|
||||
emit({
|
||||
type: 'manifest',
|
||||
stages: manifest.stages,
|
||||
@@ -690,8 +801,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,
|
||||
@@ -702,9 +815,11 @@ async function runBootstrap(opts) {
|
||||
abortSignal,
|
||||
installStamp
|
||||
})
|
||||
|
||||
if (ev.state === 'failed') {
|
||||
emit({ type: 'failed', stage: stage.name, error: ev.error || 'stage failed' })
|
||||
return { ok: false, failedStage: stage.name, error: ev.error }
|
||||
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 +828,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 +846,12 @@ async function runBootstrap(opts) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
runBootstrap,
|
||||
export {
|
||||
cachedScriptPath,
|
||||
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,26 @@
|
||||
* and the OAuth session-cookie detector.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const {
|
||||
import {
|
||||
AT_COOKIE_VARIANTS,
|
||||
RT_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
cookiesHaveSession,
|
||||
normalizeRemoteBaseUrl,
|
||||
normAuthMode,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
RT_COOKIE_VARIANTS,
|
||||
tokenPreview
|
||||
} = require('./connection-config.cjs')
|
||||
} from './connection-config'
|
||||
|
||||
// --- connectionScopeKey / normAuthMode ---
|
||||
|
||||
@@ -73,6 +73,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',
|
||||
@@ -365,6 +366,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 +378,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
|
||||
@@ -45,6 +45,7 @@ function normalizeRemoteBaseUrl(rawUrl) {
|
||||
}
|
||||
|
||||
let parsed
|
||||
|
||||
try {
|
||||
parsed = new URL(value)
|
||||
} catch (error) {
|
||||
@@ -83,7 +84,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 +94,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 +106,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 +123,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)
|
||||
}
|
||||
|
||||
@@ -148,17 +156,19 @@ function normAuthMode(mode) {
|
||||
*
|
||||
* 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
|
||||
* 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') {
|
||||
return null
|
||||
}
|
||||
|
||||
const url = String(entry.url || '').trim()
|
||||
|
||||
if (!url) {
|
||||
return null
|
||||
}
|
||||
@@ -172,18 +182,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 +237,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 +264,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 +285,27 @@ 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 = {
|
||||
export {
|
||||
AT_COOKIE_VARIANTS,
|
||||
RT_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
cookiesHaveSession,
|
||||
normalizeRemoteBaseUrl,
|
||||
normAuthMode,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
RT_COOKIE_VARIANTS,
|
||||
tokenPreview
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
/**
|
||||
* 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'
|
||||
import test from 'node:test'
|
||||
|
||||
const {
|
||||
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 +39,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 +102,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 +131,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,19 @@
|
||||
* cleanup-script builders (POSIX + Windows).
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const {
|
||||
UNINSTALL_MODES,
|
||||
import {
|
||||
buildPosixCleanupScript,
|
||||
buildWindowsCleanupScript,
|
||||
modeRemovesAgent,
|
||||
modeRemovesUserData,
|
||||
resolveRemovableAppPath,
|
||||
shouldRemoveAppBundle,
|
||||
UNINSTALL_MODES,
|
||||
uninstallArgsForMode
|
||||
} = require('./desktop-uninstall.cjs')
|
||||
} from './desktop-uninstall'
|
||||
|
||||
// --- uninstallArgsForMode ---
|
||||
|
||||
@@ -132,6 +132,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 +153,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 +170,7 @@ test('buildPosixCleanupScript omits PYTHONPATH when pythonPath is null (gui)', (
|
||||
appPath: null,
|
||||
hermesHome: '/h'
|
||||
})
|
||||
|
||||
assert.doesNotMatch(script, /export PYTHONPATH/)
|
||||
})
|
||||
|
||||
@@ -181,6 +184,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 +200,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 +217,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 +244,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,36 @@ 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 +139,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 +154,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 +204,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 +233,7 @@ function buildWindowsCleanupScript({
|
||||
`cd /d ${q(agentRoot)}`,
|
||||
`${q(pythonExe)} ${uninstallArgs.map(q).join(' ')}`
|
||||
)
|
||||
|
||||
if (appPath) {
|
||||
lines.push(
|
||||
'set /a tries=0',
|
||||
@@ -220,18 +248,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,17 @@
|
||||
'use strict'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
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 { 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 +107,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 +126,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 +226,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 +255,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 +270,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 +307,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 +335,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,20 @@
|
||||
* 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'
|
||||
import test from 'node:test'
|
||||
|
||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
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 +36,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 +58,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 +104,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,18 @@ 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 +99,14 @@ 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 +129,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 +141,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 +170,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 +182,43 @@ 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,7 @@
|
||||
'use strict'
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
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,13 +1,11 @@
|
||||
'use strict'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
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')
|
||||
import { gitRootForIpc } from './git-root'
|
||||
|
||||
function mkTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-'))
|
||||
@@ -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,18 @@
|
||||
'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'
|
||||
import test from 'node:test'
|
||||
|
||||
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')
|
||||
|
||||
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,11 +1,11 @@
|
||||
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 assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const {
|
||||
import {
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
encryptDesktopSecret,
|
||||
resolveDirectoryForIpc,
|
||||
@@ -13,11 +13,12 @@ const {
|
||||
resolveRequestedPathForIpc,
|
||||
resolveTimeoutMs,
|
||||
sensitiveFileBlockReason
|
||||
} = require('./hardening.cjs')
|
||||
} from './hardening'
|
||||
|
||||
async function rejectsWithCode(promise, code) {
|
||||
await assert.rejects(promise, error => {
|
||||
async function rejectsWithCode(promise, code: string) {
|
||||
await assert.rejects(promise, (error: any) => {
|
||||
assert.equal(error?.code, code)
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -76,8 +77,9 @@ test('path helpers reject blank non-string NUL and Windows device syntax', async
|
||||
for (const devicePath of devicePaths) {
|
||||
assert.throws(
|
||||
() => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }),
|
||||
error => {
|
||||
(error: any) => {
|
||||
assert.equal(error?.code, 'device-path')
|
||||
|
||||
return true
|
||||
}
|
||||
)
|
||||
@@ -86,8 +88,9 @@ test('path helpers reject blank non-string NUL and Windows device syntax', async
|
||||
|
||||
assert.throws(
|
||||
() => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }),
|
||||
error => {
|
||||
(error: any) => {
|
||||
assert.equal(error?.code, 'invalid-path')
|
||||
|
||||
return true
|
||||
}
|
||||
)
|
||||
@@ -131,19 +134,23 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity',
|
||||
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(
|
||||
@@ -184,9 +191,11 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity',
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -229,8 +238,10 @@ test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', as
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`symlink creation is not permitted on this platform (${error.code})`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -268,8 +279,10 @@ test('resolveDirectoryForIpc accepts directory symlinks or junctions', async t =
|
||||
} 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
|
||||
}
|
||||
|
||||
@@ -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,10 @@ 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 +133,7 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
|
||||
}
|
||||
|
||||
const normalized = raw.replace(/\\/g, '/').toLowerCase()
|
||||
|
||||
if (
|
||||
normalized.startsWith('//?/') ||
|
||||
normalized.startsWith('//./') ||
|
||||
@@ -141,7 +146,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 +159,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 +187,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 +212,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 +225,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 +253,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 +282,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 +302,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,16 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const {
|
||||
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,19 @@ 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,13 @@
|
||||
/**
|
||||
* 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'
|
||||
import test from 'node:test'
|
||||
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
|
||||
|
||||
test('serializeJsonBody returns undefined for absent bodies', () => {
|
||||
assert.equal(serializeJsonBody(undefined), undefined)
|
||||
@@ -21,6 +21,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 }
|
||||
33
apps/desktop/electron/oauth-session-request.test.ts
Normal file
33
apps/desktop/electron/oauth-session-request.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Regression coverage for the OAuth-session Electron net.request path.
|
||||
*
|
||||
* Electron net rejects manual Content-Length/Host headers with
|
||||
* net::ERR_INVALID_ARGUMENT. Node HTTP helpers may still set Content-Length;
|
||||
* this guard is scoped to fetchJsonViaOauthSession only.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8')
|
||||
|
||||
function extractFetchJsonViaOauthSession() {
|
||||
const start = source.indexOf('function fetchJsonViaOauthSession')
|
||||
const end = source.indexOf('// Mint a single-use WS ticket', start)
|
||||
assert.notEqual(start, -1, 'fetchJsonViaOauthSession should exist')
|
||||
assert.notEqual(end, -1, 'fetchJsonViaOauthSession boundary should exist')
|
||||
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
test('OAuth Electron net request does not set forbidden Content-Length header', () => {
|
||||
const fn = extractFetchJsonViaOauthSession()
|
||||
|
||||
assert.match(fn, /electronNet\.request/)
|
||||
assert.doesNotMatch(fn, /setHeader\(['"]Content-Length['"]/)
|
||||
assert.match(fn, /request\.write\(body\)/)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
@@ -78,6 +80,19 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir),
|
||||
pickDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:pick')
|
||||
},
|
||||
zoom: {
|
||||
// Current zoom of this window, as { level, percent }.
|
||||
get: () => ipcRenderer.invoke('hermes:zoom:get'),
|
||||
setPercent: percent => ipcRenderer.send('hermes:zoom:set-percent', percent),
|
||||
// Fires on every zoom change, including the Ctrl/Cmd +/-/0 shortcuts,
|
||||
// so the settings UI can stay in sync with the keyboard.
|
||||
onChanged: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:zoom:changed', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:zoom:changed', listener)
|
||||
}
|
||||
},
|
||||
revealLogs: () => ipcRenderer.invoke('hermes:logs:reveal'),
|
||||
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
|
||||
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
|
||||
@@ -120,68 +135,80 @@ 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)
|
||||
},
|
||||
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.
|
||||
@@ -192,6 +219,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'),
|
||||
@@ -208,6 +236,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,11 +1,11 @@
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
const ELECTRON_DIR = __dirname
|
||||
const ELECTRON_DIR = import.meta.dirname
|
||||
|
||||
function readElectronFile(name) {
|
||||
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
|
||||
@@ -17,7 +17,7 @@ function readElectronFile(name) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('prepareProfileDeleteRequest returns the torn-down profile name', () => {
|
||||
const source = readElectronFile('main.cjs')
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
// Locate the function definition and its closing brace.
|
||||
const fnStart = source.indexOf('async function prepareProfileDeleteRequest(')
|
||||
@@ -36,7 +36,7 @@ test('prepareProfileDeleteRequest returns the torn-down profile name', () => {
|
||||
})
|
||||
|
||||
test('hermes:api handler routes profile-delete requests to the primary backend', () => {
|
||||
const source = readElectronFile('main.cjs')
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
// The handler must capture prepareProfileDeleteRequest's return value.
|
||||
assert.match(
|
||||
@@ -1,11 +1,7 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const {
|
||||
buildSessionWindowUrl,
|
||||
chatWindowWebPreferences,
|
||||
createSessionWindowRegistry
|
||||
} = require('./session-windows.cjs')
|
||||
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 +92,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 +142,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 +156,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,12 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const {
|
||||
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,7 @@
|
||||
'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 'node:test'
|
||||
|
||||
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,23 @@
|
||||
* 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 test from 'node:test'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
|
||||
const { markerPath, isPidAlive, readLiveUpdateMarker, writeUpdateMarker, UPDATE_MARKER_MAX_AGE_MS } = require('./update-marker.cjs')
|
||||
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 +36,11 @@ 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 +93,10 @@ 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,10 @@
|
||||
* success, and must run at most twice.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const { shouldRetryRebuild, runRebuildWithRetry } = require('./update-rebuild.cjs')
|
||||
import { runRebuildWithRetry, shouldRetryRebuild } from './update-rebuild'
|
||||
|
||||
test('shouldRetryRebuild retries only on a non-success exit', () => {
|
||||
assert.equal(shouldRetryRebuild(0), false)
|
||||
@@ -25,30 +25,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,24 @@
|
||||
* (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'
|
||||
import test from 'node:test'
|
||||
|
||||
const {
|
||||
unpackedDirName,
|
||||
resolveUnpackedRelease,
|
||||
decideRelaunchOutcome,
|
||||
sandboxPreflight,
|
||||
sandboxFallbackFromEnv,
|
||||
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 +91,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 +151,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 +167,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 +210,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 +226,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/)
|
||||
})
|
||||
@@ -1,12 +1,10 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* update-relaunch.cjs — pure decision + script-generation helpers for the
|
||||
* update-relaunch.ts — pure decision + script-generation helpers for the
|
||||
* Linux in-app update relaunch (#45205).
|
||||
*
|
||||
* Extracted from main.cjs's `applyUpdatesPosixInApp` so the security- and
|
||||
* Extracted from main.ts's `applyUpdatesPosixInApp` so the security- and
|
||||
* correctness-critical "do we relaunch, or land on a manual terminal state?"
|
||||
* decision is unit-testable without booting Electron (main.cjs
|
||||
* decision is unit-testable without booting Electron (main.ts
|
||||
* `require('electron')` at load).
|
||||
*
|
||||
* Background
|
||||
@@ -37,12 +35,18 @@
|
||||
* the closeable manual-restart terminal state instead.
|
||||
*/
|
||||
|
||||
const path = require('node:path')
|
||||
import path from 'node:path'
|
||||
|
||||
// Map process.platform → electron-builder's `release/<dir>-unpacked` name.
|
||||
function unpackedDirName(platform) {
|
||||
if (platform === 'darwin') return 'mac-unpacked' // not used (mac swaps bundles)
|
||||
if (platform === 'win32') return 'win-unpacked'
|
||||
if (platform === 'darwin') {
|
||||
return 'mac-unpacked'
|
||||
} // not used (mac swaps bundles)
|
||||
|
||||
if (platform === 'win32') {
|
||||
return 'win-unpacked'
|
||||
}
|
||||
|
||||
return 'linux-unpacked'
|
||||
}
|
||||
|
||||
@@ -56,15 +60,19 @@ function unpackedDirName(platform) {
|
||||
* `.../release/linux-unpacked-evil` can't masquerade as `.../release/linux-unpacked`.
|
||||
*/
|
||||
function resolveUnpackedRelease(execPath, updateRoot, platform) {
|
||||
if (!execPath || !updateRoot) return null
|
||||
if (!execPath || !updateRoot) {
|
||||
return null
|
||||
}
|
||||
const releaseDir = path.join(updateRoot, 'apps', 'desktop', 'release')
|
||||
const unpacked = path.join(releaseDir, unpackedDirName(platform))
|
||||
const normalizedExec = path.resolve(String(execPath))
|
||||
// execPath must be the unpacked dir itself or a descendant of it.
|
||||
const withSep = unpacked.endsWith(path.sep) ? unpacked : unpacked + path.sep
|
||||
|
||||
if (normalizedExec === unpacked || normalizedExec.startsWith(withSep)) {
|
||||
return unpacked
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -81,8 +89,14 @@ function resolveUnpackedRelease(execPath, updateRoot, platform) {
|
||||
* app. Closeable manual-restart terminal state.
|
||||
*/
|
||||
function decideRelaunchOutcome({ underUnpacked, sandboxOk }) {
|
||||
if (!underUnpacked) return 'guiSkew'
|
||||
if (!sandboxOk) return 'manual'
|
||||
if (!underUnpacked) {
|
||||
return 'guiSkew'
|
||||
}
|
||||
|
||||
if (!sandboxOk) {
|
||||
return 'manual'
|
||||
}
|
||||
|
||||
return 'relaunch'
|
||||
}
|
||||
|
||||
@@ -99,9 +113,12 @@ function decideRelaunchOutcome({ underUnpacked, sandboxOk }) {
|
||||
* `statSync` is injectable so this is testable without a real setuid file.
|
||||
*/
|
||||
function sandboxPreflight(unpackedDir, statSync) {
|
||||
if (!unpackedDir) return { ok: false, reason: 'no-unpacked-dir', path: null }
|
||||
if (!unpackedDir) {
|
||||
return { ok: false, reason: 'no-unpacked-dir', path: null }
|
||||
}
|
||||
const sandboxPath = path.join(unpackedDir, 'chrome-sandbox')
|
||||
let st
|
||||
|
||||
try {
|
||||
st = statSync(sandboxPath)
|
||||
} catch {
|
||||
@@ -109,15 +126,22 @@ function sandboxPreflight(unpackedDir, statSync) {
|
||||
// sandbox; nothing to block the relaunch.
|
||||
return { ok: true, reason: 'no-sandbox-helper', path: sandboxPath }
|
||||
}
|
||||
|
||||
const ownedByRoot = st.uid === 0
|
||||
const hasSetuid = (st.mode & 0o4000) !== 0
|
||||
|
||||
if (ownedByRoot && hasSetuid) {
|
||||
return { ok: true, reason: 'launchable', path: sandboxPath }
|
||||
}
|
||||
|
||||
if (!ownedByRoot && !hasSetuid) {
|
||||
return { ok: false, reason: 'not-root-not-setuid', path: sandboxPath }
|
||||
}
|
||||
if (!ownedByRoot) return { ok: false, reason: 'not-root', path: sandboxPath }
|
||||
|
||||
if (!ownedByRoot) {
|
||||
return { ok: false, reason: 'not-root', path: sandboxPath }
|
||||
}
|
||||
|
||||
return { ok: false, reason: 'not-setuid', path: sandboxPath }
|
||||
}
|
||||
|
||||
@@ -126,7 +150,7 @@ function sandboxPreflight(unpackedDir, statSync) {
|
||||
* environment. The reviewer asked us to integrate with any existing
|
||||
* `--no-sandbox` / chrome-sandbox handling. A repo grep found NO existing
|
||||
* non-interactive sandbox fallback in the desktop app (the only chrome-sandbox
|
||||
* reference is documentation in scripts/before-pack.cjs). The one signal that
|
||||
* reference is documentation in scripts/before-pack.ts). The one signal that
|
||||
* DOES exist is the standard Electron escape hatch: ELECTRON_DISABLE_SANDBOX=1
|
||||
* (and the equivalent `--no-sandbox` already present in the launch args). If
|
||||
* the user has set that, the rebuilt binary will start even with a broken
|
||||
@@ -137,8 +161,15 @@ function sandboxPreflight(unpackedDir, statSync) {
|
||||
*/
|
||||
function sandboxFallbackFromEnv(env, launchArgs) {
|
||||
const disable = String((env && env.ELECTRON_DISABLE_SANDBOX) || '').trim()
|
||||
if (disable === '1' || disable.toLowerCase() === 'true') return true
|
||||
if (Array.isArray(launchArgs) && launchArgs.some(a => a === '--no-sandbox')) return true
|
||||
|
||||
if (disable === '1' || disable.toLowerCase() === 'true') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Array.isArray(launchArgs) && launchArgs.some(a => a === '--no-sandbox')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -176,9 +207,15 @@ const INTERNAL_ARG_PREFIXES = [
|
||||
* the exec path itself; there is no entry-script arg as in a dev run).
|
||||
*/
|
||||
function collectRelaunchArgs(argv) {
|
||||
if (!Array.isArray(argv)) return []
|
||||
if (!Array.isArray(argv)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return argv.filter(arg => {
|
||||
if (typeof arg !== 'string' || arg.length === 0) return false
|
||||
if (typeof arg !== 'string' || arg.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !INTERNAL_ARG_PREFIXES.some(prefix =>
|
||||
prefix.endsWith('=') ? arg.startsWith(prefix) : arg === prefix || arg.startsWith(prefix + '=')
|
||||
)
|
||||
@@ -197,13 +234,21 @@ const PRESERVED_ENV_PREFIXES = ['HERMES_DESKTOP_']
|
||||
|
||||
function collectRelaunchEnv(env) {
|
||||
const out = {}
|
||||
if (!env || typeof env !== 'object') return out
|
||||
|
||||
if (!env || typeof env !== 'object') {
|
||||
return out
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value == null) continue
|
||||
if (value == null) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (PRESERVED_ENV_KEYS.includes(key) || PRESERVED_ENV_PREFIXES.some(p => key.startsWith(p))) {
|
||||
out[key] = String(value)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -223,8 +268,10 @@ function buildRelaunchScript({ pid, execPath, args, env, cwd }) {
|
||||
const exports = Object.entries(env || {})
|
||||
.map(([k, v]) => `export ${k}=${shellQuote(v)}`)
|
||||
.join('\n')
|
||||
|
||||
const quotedArgs = (args || []).map(shellQuote).join(' ')
|
||||
const cwdLine = cwd ? `cd ${shellQuote(cwd)} 2>/dev/null || true` : ''
|
||||
|
||||
// NOTE: `exec` replaces the watcher process with the relaunched app, so the
|
||||
// re-exec inherits exactly the env/cwd we set above.
|
||||
return `#!/bin/bash
|
||||
@@ -249,17 +296,17 @@ exec ${shellQuote(execPath)}${quotedArgs ? ' ' + quotedArgs : ''}
|
||||
`
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
unpackedDirName,
|
||||
resolveUnpackedRelease,
|
||||
decideRelaunchOutcome,
|
||||
sandboxPreflight,
|
||||
sandboxFallbackFromEnv,
|
||||
export {
|
||||
buildRelaunchScript,
|
||||
collectRelaunchArgs,
|
||||
collectRelaunchEnv,
|
||||
buildRelaunchScript,
|
||||
shellQuote,
|
||||
decideRelaunchOutcome,
|
||||
INTERNAL_ARG_PREFIXES,
|
||||
PRESERVED_ENV_KEYS,
|
||||
PRESERVED_ENV_PREFIXES
|
||||
PRESERVED_ENV_PREFIXES,
|
||||
resolveUnpackedRelease,
|
||||
sandboxFallbackFromEnv,
|
||||
sandboxPreflight,
|
||||
shellQuote,
|
||||
unpackedDirName
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Tests for electron/update-remote.cjs — the remote-detection helpers that
|
||||
* Tests for electron/update-remote.ts — the remote-detection helpers that
|
||||
* keep passive update checks off the SSH origin for official installs.
|
||||
*
|
||||
* Run with: node --test electron/update-remote.test.cjs
|
||||
* Run with: node --test electron/update-remote.test.ts
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* Why this matters: a public install can carry
|
||||
@@ -15,16 +15,16 @@
|
||||
* never prompts and should keep the normal fetch path).
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const {
|
||||
OFFICIAL_REPO_HTTPS_URL,
|
||||
OFFICIAL_REPO_CANONICAL,
|
||||
import {
|
||||
canonicalGitHubRemote,
|
||||
isOfficialSshRemote,
|
||||
isSshRemote,
|
||||
isOfficialSshRemote
|
||||
} = require('./update-remote.cjs')
|
||||
OFFICIAL_REPO_CANONICAL,
|
||||
OFFICIAL_REPO_HTTPS_URL
|
||||
} from './update-remote'
|
||||
|
||||
test('canonicalGitHubRemote normalizes SSH and HTTPS forms to the same value', () => {
|
||||
assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
|
||||
@@ -8,8 +8,8 @@
|
||||
* which needs no auth and cannot prompt. Active update/apply flows are left
|
||||
* unchanged.
|
||||
*
|
||||
* Extracted from main.cjs so the security-critical remote detection is unit
|
||||
* testable without booting Electron (main.cjs requires('electron') at load).
|
||||
* Extracted from main.ts so the security-critical remote detection is unit
|
||||
* testable without booting Electron (main.ts requires('electron') at load).
|
||||
*/
|
||||
|
||||
const OFFICIAL_REPO_HTTPS_URL = 'https://github.com/NousResearch/hermes-agent.git'
|
||||
@@ -19,8 +19,11 @@ const OFFICIAL_REPO_CANONICAL = 'github.com/nousresearch/hermes-agent'
|
||||
// no trailing slash, no .git suffix) so SSH and HTTPS forms of the same repo
|
||||
// compare equal.
|
||||
function canonicalGitHubRemote(url) {
|
||||
if (!url) return ''
|
||||
if (!url) {
|
||||
return ''
|
||||
}
|
||||
let value = String(url).trim()
|
||||
|
||||
if (value.startsWith('git@github.com:')) {
|
||||
value = `github.com/${value.slice('git@github.com:'.length)}`
|
||||
} else if (value.startsWith('ssh://git@github.com/')) {
|
||||
@@ -28,13 +31,21 @@ function canonicalGitHubRemote(url) {
|
||||
} else {
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
if (parsed.hostname && parsed.pathname) value = `${parsed.hostname}${parsed.pathname}`
|
||||
|
||||
if (parsed.hostname && parsed.pathname) {
|
||||
value = `${parsed.hostname}${parsed.pathname}`
|
||||
}
|
||||
} catch {
|
||||
// Leave non-URL forms unchanged.
|
||||
}
|
||||
}
|
||||
|
||||
value = value.trim().replace(/\/+$/, '')
|
||||
if (value.endsWith('.git')) value = value.slice(0, -4)
|
||||
|
||||
if (value.endsWith('.git')) {
|
||||
value = value.slice(0, -4)
|
||||
}
|
||||
|
||||
return value.toLowerCase()
|
||||
}
|
||||
|
||||
@@ -42,6 +53,7 @@ function isSshRemote(url) {
|
||||
const value = String(url || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
|
||||
return value.startsWith('git@') || value.startsWith('ssh://')
|
||||
}
|
||||
|
||||
@@ -49,10 +61,4 @@ function isOfficialSshRemote(url) {
|
||||
return isSshRemote(url) && canonicalGitHubRemote(url) === OFFICIAL_REPO_CANONICAL
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
OFFICIAL_REPO_HTTPS_URL,
|
||||
OFFICIAL_REPO_CANONICAL,
|
||||
canonicalGitHubRemote,
|
||||
isSshRemote,
|
||||
isOfficialSshRemote
|
||||
}
|
||||
export { canonicalGitHubRemote, isOfficialSshRemote, isSshRemote, OFFICIAL_REPO_CANONICAL, OFFICIAL_REPO_HTTPS_URL }
|
||||
@@ -1,9 +1,7 @@
|
||||
'use strict'
|
||||
import assert from 'node:assert'
|
||||
import test from 'node:test'
|
||||
|
||||
const assert = require('node:assert')
|
||||
const test = require('node:test')
|
||||
|
||||
const { __testing, extractThemes, readCentralDirectory } = require('./vscode-marketplace.cjs')
|
||||
import { __testing, extractThemes, readCentralDirectory } from './vscode-marketplace'
|
||||
|
||||
// Build a minimal zip with stored (uncompressed) entries so the test controls
|
||||
// the bytes exactly — exercises the central-directory reader + theme extraction
|
||||
@@ -72,6 +70,7 @@ test('extractThemes reads contributed color themes (resolving ./ paths)', () =>
|
||||
themes: [{ label: 'Dracula', uiTheme: 'vs-dark', path: './themes/dracula.json' }]
|
||||
}
|
||||
})
|
||||
|
||||
const themeJson = JSON.stringify({ name: 'Dracula', type: 'dark', colors: { 'editor.background': '#282a36' } })
|
||||
|
||||
const zip = makeZip([
|
||||
@@ -1,5 +1,3 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* VS Code Marketplace color-theme fetcher (main process).
|
||||
*
|
||||
@@ -14,8 +12,8 @@
|
||||
* zip library into the desktop bundle for a feature this small.
|
||||
*/
|
||||
|
||||
const https = require('node:https')
|
||||
const zlib = require('node:zlib')
|
||||
import https from 'node:https'
|
||||
import zlib from 'node:zlib'
|
||||
|
||||
const GALLERY_QUERY_URL = 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery'
|
||||
const VSIX_ASSET_TYPE = 'Microsoft.VisualStudio.Services.VSIXPackage'
|
||||
@@ -30,7 +28,7 @@ function request(
|
||||
url,
|
||||
{ method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {},
|
||||
redirectsLeft = MAX_REDIRECTS
|
||||
) {
|
||||
): Promise<Buffer<ArrayBuffer>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(url, { method, headers }, res => {
|
||||
const status = res.statusCode ?? 0
|
||||
@@ -102,6 +100,7 @@ async function resolveExtension(id) {
|
||||
// IncludeCategoryAndTags | IncludeLatestVersionOnly = 914.
|
||||
flags: 914
|
||||
})
|
||||
|
||||
const extension = json?.results?.[0]?.extensions?.[0]
|
||||
|
||||
if (!extension) {
|
||||
@@ -127,6 +126,7 @@ async function resolveExtension(id) {
|
||||
/** POST an ExtensionQuery payload and return the parsed gallery response. */
|
||||
async function queryGallery(payload, { maxBytes = 4 * 1024 * 1024 } = {}) {
|
||||
const body = JSON.stringify(payload)
|
||||
|
||||
const raw = await request(GALLERY_QUERY_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -332,10 +332,6 @@ async function fetchMarketplaceThemes(id) {
|
||||
return { extensionId: trimmed, displayName, themes }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchMarketplaceThemes,
|
||||
searchMarketplaceThemes,
|
||||
extractThemes,
|
||||
readCentralDirectory,
|
||||
__testing: { themeEntryName, looksLikeIconTheme }
|
||||
}
|
||||
const __testing = { themeEntryName, looksLikeIconTheme }
|
||||
|
||||
export { __testing, extractThemes, fetchMarketplaceThemes, readCentralDirectory, searchMarketplaceThemes }
|
||||
@@ -4,19 +4,19 @@
|
||||
* clamping, and the debounce that collapses mid-drag write storms.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const {
|
||||
DEFAULT_WIDTH,
|
||||
DEFAULT_HEIGHT,
|
||||
MIN_WIDTH,
|
||||
MIN_HEIGHT,
|
||||
sanitizeWindowState,
|
||||
onScreen,
|
||||
import {
|
||||
computeWindowOptions,
|
||||
debounce
|
||||
} = require('./window-state.cjs')
|
||||
debounce,
|
||||
DEFAULT_HEIGHT,
|
||||
DEFAULT_WIDTH,
|
||||
MIN_HEIGHT,
|
||||
MIN_WIDTH,
|
||||
onScreen,
|
||||
sanitizeWindowState
|
||||
} from './window-state'
|
||||
|
||||
// A single 1920×1080 monitor (work area trimmed for the taskbar).
|
||||
const PRIMARY = [{ workArea: { x: 0, y: 0, width: 1920, height: 1040 } }]
|
||||
@@ -121,6 +121,7 @@ test('computeWindowOptions does not clamp when displays are unknown', () => {
|
||||
test('debounce coalesces a burst into one trailing run', t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
let calls = 0
|
||||
|
||||
const d = debounce(() => {
|
||||
calls += 1
|
||||
}, 250)
|
||||
@@ -138,6 +139,7 @@ test('debounce coalesces a burst into one trailing run', t => {
|
||||
test('debounce.flush runs now and cancels the pending timer', t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
let calls = 0
|
||||
|
||||
const d = debounce(() => {
|
||||
calls += 1
|
||||
}, 250)
|
||||
@@ -2,7 +2,7 @@
|
||||
* Pure geometry helpers for window-state.json — restoring the main window's
|
||||
* size, position, and maximized flag across launches. Side-effect-free so the
|
||||
* part that actually matters (rejecting garbage + off-screen bounds) is
|
||||
* unit-testable without booting Electron; main.cjs owns the file I/O and the
|
||||
* unit-testable without booting Electron; main.ts owns the file I/O and the
|
||||
* live `screen` displays.
|
||||
*/
|
||||
|
||||
@@ -21,41 +21,66 @@ const MIN_VISIBLE = 48
|
||||
const finite = v => typeof v === 'number' && Number.isFinite(v)
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, hi))
|
||||
|
||||
interface SanitizedWindowState {
|
||||
width: number
|
||||
height: number
|
||||
isMaximized: boolean
|
||||
x?: number
|
||||
y?: number
|
||||
}
|
||||
|
||||
// Parse raw JSON → clean state, or null if garbage. width/height are required
|
||||
// and floored; x/y survive only as a finite pair; isMaximized is strict.
|
||||
function sanitizeWindowState(raw) {
|
||||
if (!raw || typeof raw !== 'object' || !finite(raw.width) || !finite(raw.height)) return null
|
||||
function sanitizeWindowState(raw?: any): SanitizedWindowState | null {
|
||||
if (!raw || typeof raw !== 'object' || !finite(raw.width) || !finite(raw.height)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const state = {
|
||||
const state: SanitizedWindowState = {
|
||||
width: Math.max(MIN_WIDTH, Math.round(raw.width)),
|
||||
height: Math.max(MIN_HEIGHT, Math.round(raw.height)),
|
||||
isMaximized: raw.isMaximized === true
|
||||
}
|
||||
|
||||
if (finite(raw.x) && finite(raw.y)) {
|
||||
state.x = Math.round(raw.x)
|
||||
state.y = Math.round(raw.y)
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
// True when `bounds` overlaps some display's work area by ≥ MIN_VISIBLE on both
|
||||
// axes. `displays` is Electron's screen.getAllDisplays() shape.
|
||||
function onScreen(bounds, displays) {
|
||||
if (!Array.isArray(displays)) return false
|
||||
if (!Array.isArray(displays)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return displays.some(({ workArea: a } = {}) => {
|
||||
if (!a) return false
|
||||
if (!a) {
|
||||
return false
|
||||
}
|
||||
const x = Math.min(bounds.x + bounds.width, a.x + a.width) - Math.max(bounds.x, a.x)
|
||||
const y = Math.min(bounds.y + bounds.height, a.y + a.height) - Math.max(bounds.y, a.y)
|
||||
|
||||
return x >= MIN_VISIBLE && y >= MIN_VISIBLE
|
||||
})
|
||||
}
|
||||
|
||||
interface WindowOptions {
|
||||
width: number
|
||||
height: number
|
||||
x?: number
|
||||
y?: number
|
||||
}
|
||||
|
||||
// Sanitized state (or null) → BrowserWindow size/position options. Always sets
|
||||
// width/height, capped to the largest current display so a size saved on a
|
||||
// since-disconnected bigger monitor can't exceed any screen the user now has.
|
||||
// Sets x/y only when still on-screen; otherwise Electron centers the window.
|
||||
function computeWindowOptions(state, displays) {
|
||||
const opts = {
|
||||
function computeWindowOptions(state, displays): WindowOptions {
|
||||
const opts: WindowOptions = {
|
||||
width: finite(state?.width) ? state.width : DEFAULT_WIDTH,
|
||||
height: finite(state?.height) ? state.height : DEFAULT_HEIGHT
|
||||
}
|
||||
@@ -67,6 +92,7 @@ function computeWindowOptions(state, displays) {
|
||||
: m,
|
||||
{ width: 0, height: 0 }
|
||||
)
|
||||
|
||||
if (cap.width && cap.height) {
|
||||
opts.width = clamp(opts.width, MIN_WIDTH, cap.width)
|
||||
opts.height = clamp(opts.height, MIN_HEIGHT, cap.height)
|
||||
@@ -81,6 +107,7 @@ function computeWindowOptions(state, displays) {
|
||||
opts.x = state.x
|
||||
opts.y = state.y
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
@@ -89,6 +116,7 @@ function computeWindowOptions(state, displays) {
|
||||
// cancels the pending timer — used on close, before the window is gone.
|
||||
function debounce(fn, delayMs) {
|
||||
let timer = null
|
||||
|
||||
const debounced = () => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
@@ -96,22 +124,24 @@ function debounce(fn, delayMs) {
|
||||
fn()
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
debounced.flush = () => {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
fn()
|
||||
}
|
||||
|
||||
return debounced
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_WIDTH,
|
||||
export {
|
||||
computeWindowOptions,
|
||||
debounce,
|
||||
DEFAULT_HEIGHT,
|
||||
MIN_WIDTH,
|
||||
DEFAULT_WIDTH,
|
||||
MIN_HEIGHT,
|
||||
MIN_VISIBLE,
|
||||
sanitizeWindowState,
|
||||
MIN_WIDTH,
|
||||
onScreen,
|
||||
computeWindowOptions,
|
||||
debounce
|
||||
sanitizeWindowState
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
'use strict'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const ELECTRON_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
const ELECTRON_DIR = __dirname
|
||||
// TODO FIXME these tests all grep source code for specific things. This is an antipattern.
|
||||
// Tests should NEVER read src, only assert behavior.
|
||||
|
||||
function readElectronFile(name) {
|
||||
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
|
||||
@@ -24,9 +26,9 @@ function requireHiddenChildOptions(source, needle) {
|
||||
}
|
||||
|
||||
test('desktop background child processes opt into hidden Windows consoles', () => {
|
||||
const source = readElectronFile('main.cjs')
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
|
||||
assert.match(source, /function hiddenWindowsChildOptions\(options: any = \{\}\)/)
|
||||
|
||||
requireHiddenChildOptions(source, "execFileSync(\n 'reg'")
|
||||
requireHiddenChildOptions(source, /execFileSync\(\s*pyExe/)
|
||||
@@ -45,7 +47,7 @@ test('desktop background child processes opt into hidden Windows consoles', () =
|
||||
})
|
||||
|
||||
test('desktop backend launches console python so child consoles are inherited, not pythonw', () => {
|
||||
const source = readElectronFile('main.cjs')
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
// The flash fix is structural: the backend runs as a console-subsystem
|
||||
// python.exe under hiddenWindowsChildOptions() (-> CREATE_NO_WINDOW), so it
|
||||
@@ -75,7 +77,7 @@ test('desktop backend launches console python so child consoles are inherited, n
|
||||
})
|
||||
|
||||
test('desktop backend teardown tree-kills Windows backend descendants', () => {
|
||||
const source = readElectronFile('main.cjs')
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
const helperIndex = source.indexOf('function stopBackendChild(child)')
|
||||
assert.notEqual(helperIndex, -1, 'missing backend teardown helper')
|
||||
@@ -98,7 +100,7 @@ test('desktop backend teardown tree-kills Windows backend descendants', () => {
|
||||
})
|
||||
|
||||
test('intentional or interactive desktop child processes stay documented', () => {
|
||||
const source = readElectronFile('main.cjs')
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
assert.match(source, /windowsHide: false/)
|
||||
assert.match(source, /handOffWindowsBootstrapRecovery/)
|
||||
@@ -109,7 +111,7 @@ test('intentional or interactive desktop child processes stay documented', () =>
|
||||
})
|
||||
|
||||
test('bootstrap PowerShell runner hides Windows console children', () => {
|
||||
const source = readElectronFile('bootstrap-runner.cjs')
|
||||
const source = readElectronFile('bootstrap-runner.ts')
|
||||
|
||||
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
|
||||
requireHiddenChildOptions(source, /spawn\(\s*ps,\s*fullArgs/)
|
||||
@@ -1,9 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
// Regression guards for Windows `hermes` resolution in main.cjs.
|
||||
// Regression guards for Windows `hermes` resolution in main.ts.
|
||||
//
|
||||
// main.cjs has no module.exports, so these follow the repo's source-assertion
|
||||
// test pattern (see windows-child-process.test.cjs). They pin the two Windows
|
||||
// main.ts has no module.exports, so these follow the repo's source-assertion
|
||||
// test pattern (see windows-child-process.test.ts). They pin the two Windows
|
||||
// resolution bugs that caused desktop reinstall loops:
|
||||
// 1. findOnPath() tried the empty extension FIRST, so an extensionless
|
||||
// Git-Bash `hermes` shim shadowed the real hermes.cmd/hermes.exe; the
|
||||
@@ -20,13 +18,16 @@
|
||||
// Retry / "Repair install" resolved the same dead interpreter instead of
|
||||
// falling through to the bootstrap installer.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
function readMain() {
|
||||
return fs.readFileSync(path.join(__dirname, 'main.cjs'), 'utf8').replace(/\r\n/g, '\n')
|
||||
return fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8').replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
test('findOnPath tries PATHEXT extensions before the bare (empty) name on Windows', () => {
|
||||
@@ -66,7 +67,7 @@ test('Windows bootstrap recovery chooses --update when any real-install signal i
|
||||
test('unwrapWindowsVenvHermesCommand smoke-tests the venv python before trusting it', () => {
|
||||
const source = readMain()
|
||||
const fnStart = source.indexOf('function unwrapWindowsVenvHermesCommand(')
|
||||
assert.notEqual(fnStart, -1, 'unwrapWindowsVenvHermesCommand must exist in main.cjs')
|
||||
assert.notEqual(fnStart, -1, 'unwrapWindowsVenvHermesCommand must exist in main.ts')
|
||||
// Slice out just the function body (up to the next top-level function decl)
|
||||
const fnEnd = source.indexOf('\nfunction ', fnStart + 1)
|
||||
const body = source.slice(fnStart, fnEnd === -1 ? undefined : fnEnd)
|
||||
@@ -1,7 +1,7 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { test } = require('node:test')
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
const { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } = require('./windows-user-env.cjs')
|
||||
import { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } from './windows-user-env'
|
||||
|
||||
// ── parseRegQueryValue ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -42,25 +42,32 @@ test('expandWindowsEnvRefs leaves literal paths and unknown refs intact', () =>
|
||||
|
||||
test('readWindowsUserEnvVar returns null off Windows without spawning', () => {
|
||||
let spawned = false
|
||||
|
||||
const exec = () => {
|
||||
spawned = true
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'linux', exec }), null)
|
||||
assert.equal(spawned, false)
|
||||
})
|
||||
|
||||
test('readWindowsUserEnvVar queries HKCU\\Environment and expands the value', () => {
|
||||
const calls = []
|
||||
|
||||
const exec = (cmd, args) => {
|
||||
calls.push([cmd, args])
|
||||
|
||||
return 'HKEY_CURRENT_USER\\Environment\r\n HERMES_HOME REG_EXPAND_SZ %DRIVE%\\Hermes\r\n'
|
||||
}
|
||||
|
||||
const value = readWindowsUserEnvVar('HERMES_HOME', {
|
||||
platform: 'win32',
|
||||
env: { DRIVE: 'F:' },
|
||||
exec
|
||||
})
|
||||
|
||||
assert.equal(value, 'F:\\Hermes')
|
||||
assert.deepEqual(calls, [['reg', ['query', 'HKCU\\Environment', '/v', 'HERMES_HOME']]])
|
||||
})
|
||||
@@ -69,6 +76,7 @@ test('readWindowsUserEnvVar returns null when reg exits non-zero (value missing)
|
||||
const exec = () => {
|
||||
throw new Error('reg exited 1')
|
||||
}
|
||||
|
||||
assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null)
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user