Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffe5558bd0 | ||
|
|
af8b917dab | ||
|
|
9ca11b35d5 | ||
|
|
ca1fb32c26 | ||
|
|
7583aedacd | ||
|
|
14fee4f112 | ||
|
|
98528c78c1 | ||
|
|
d880b5be09 | ||
|
|
ca8c78e588 | ||
|
|
1a3e608524 | ||
|
|
db204ae203 | ||
|
|
72eb42d9ec | ||
|
|
947e21b3d6 | ||
|
|
d41427504e | ||
|
|
06268f11cc | ||
|
|
3cd1bd971f | ||
|
|
ec46f5912e | ||
|
|
6bf55a473e | ||
|
|
8a9ded5b21 | ||
|
|
3da44dbda7 | ||
|
|
ef5e48f3fd | ||
|
|
2a82519b0d | ||
|
|
397d492b3e | ||
|
|
b459bac02c | ||
|
|
3278b423d5 | ||
|
|
9ab9c923da | ||
|
|
b0d234f068 | ||
|
|
c8e80cd0bf | ||
|
|
ad69d3edc7 | ||
|
|
b1e399de95 | ||
|
|
439f53cab8 | ||
|
|
899ee8c23d | ||
|
|
7309f3bef7 | ||
|
|
736dc0fd86 | ||
|
|
6b77fd2a0f | ||
|
|
46c16b9288 |
@@ -108,6 +108,12 @@ docs/superpowers/*
|
||||
# logs, and per-session caches are never artifacts of the codebase.
|
||||
.hermes/
|
||||
|
||||
# Desktop/bootstrap install marker written into the managed checkout root by the
|
||||
# bootstrap installer. It is Hermes-managed runtime state, never a code change —
|
||||
# ignore it so `hermes update`'s `git stash push --include-untracked` does not
|
||||
# treat it as a local edit and autostash it on every run (#38529).
|
||||
.hermes-bootstrap-complete
|
||||
|
||||
# Tool Search live-test harness output — non-deterministic model transcripts,
|
||||
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
|
||||
scripts/out/
|
||||
|
||||
@@ -2720,6 +2720,61 @@ def run_conversation(
|
||||
# compress history and retry, not abort immediately.
|
||||
status_code = getattr(api_error, "status_code", None)
|
||||
|
||||
# ── Respect disabled auto-compaction on overflow ──────
|
||||
# Ported from anomalyco/opencode#30749. When the user has
|
||||
# turned auto-compaction off (``compression.enabled: false``),
|
||||
# NO automatic compaction trigger may fire — including the
|
||||
# provider/request-size overflow recovery paths below
|
||||
# (long-context-tier 429, 413 payload-too-large, and
|
||||
# context-overflow). Without this guard the proactive
|
||||
# threshold path correctly honours the setting (see the
|
||||
# preflight check and the post-response ``should_compress``
|
||||
# gate) but a provider overflow error would still silently
|
||||
# compress + rotate the session, bypassing the user's
|
||||
# explicit choice. Surface a terminal error instead so the
|
||||
# user can compact manually (``/compress``), start fresh
|
||||
# (``/new``), switch to a larger-context model, or reduce
|
||||
# attachments. Forced compaction via ``/compress``
|
||||
# (``force=True``) is unaffected — it never reaches this loop.
|
||||
_overflow_reasons = {
|
||||
FailoverReason.long_context_tier,
|
||||
FailoverReason.payload_too_large,
|
||||
FailoverReason.context_overflow,
|
||||
}
|
||||
if (
|
||||
classified.reason in _overflow_reasons
|
||||
and not getattr(agent, "compression_enabled", True)
|
||||
):
|
||||
agent._flush_status_buffer()
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}❌ Context overflow, but auto-compaction is disabled "
|
||||
f"(compression.enabled: false).",
|
||||
force=True,
|
||||
)
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} 💡 Run /compress to compact manually, /new to start fresh, "
|
||||
f"switch to a larger-context model, or reduce attachments.",
|
||||
force=True,
|
||||
)
|
||||
logger.error(
|
||||
f"{agent.log_prefix}Context overflow ({classified.reason.value}) with "
|
||||
f"auto-compaction disabled — not compressing."
|
||||
)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
return {
|
||||
"messages": messages,
|
||||
"completed": False,
|
||||
"api_calls": api_call_count,
|
||||
"error": (
|
||||
"Context overflow and auto-compaction is disabled "
|
||||
"(compression.enabled: false). Run /compress to compact manually, "
|
||||
"/new to start fresh, or switch to a larger-context model."
|
||||
),
|
||||
"partial": True,
|
||||
"failed": True,
|
||||
"compaction_disabled": True,
|
||||
}
|
||||
|
||||
# ── Anthropic Sonnet long-context tier gate ───────────
|
||||
# Anthropic returns HTTP 429 "Extra usage is required for
|
||||
# long context requests" when a Claude Max (or similar)
|
||||
|
||||
@@ -33,6 +33,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
|
||||
# Published max output-token ceiling shared by every current Gemini text model
|
||||
# (2.5 + 3.x: flash, flash-lite, pro). Used as the default when the caller
|
||||
# passes max_tokens=None, because Gemini's native API otherwise applies a low
|
||||
# internal default and truncates output (unlike OpenAI-compat endpoints where
|
||||
# an omitted limit means full budget).
|
||||
GEMINI_DEFAULT_MAX_OUTPUT_TOKENS = 65535
|
||||
|
||||
|
||||
def is_native_gemini_base_url(base_url: str) -> bool:
|
||||
"""Return True when the endpoint speaks Gemini's native REST API."""
|
||||
@@ -414,6 +421,18 @@ def build_gemini_request(
|
||||
generation_config["temperature"] = temperature
|
||||
if max_tokens is not None:
|
||||
generation_config["maxOutputTokens"] = max_tokens
|
||||
else:
|
||||
# Gemini's native generateContent does NOT treat an omitted
|
||||
# maxOutputTokens as "use the model's full output budget" — it applies
|
||||
# a low internal default and the model stops early with
|
||||
# finishReason=MAX_TOKENS, truncating tool calls mid-stream (Hermes
|
||||
# then retries 3× and refuses the incomplete call). Every current
|
||||
# Gemini text model (2.5 + 3.x, flash / flash-lite / pro) caps at
|
||||
# 65,535 output tokens, so default to that ceiling when the caller
|
||||
# passes None ("unlimited"). See the OpenAI-compat path where omitting
|
||||
# the field genuinely means full budget — that assumption does not
|
||||
# hold on the native API.
|
||||
generation_config["maxOutputTokens"] = GEMINI_DEFAULT_MAX_OUTPUT_TOKENS
|
||||
if top_p is not None:
|
||||
generation_config["topP"] = top_p
|
||||
if stop:
|
||||
|
||||
@@ -571,7 +571,28 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
api_kwargs[k] = v
|
||||
|
||||
if extra_body:
|
||||
api_kwargs["extra_body"] = extra_body
|
||||
# Native Gemini (generativelanguage.googleapis.com, non-/openai)
|
||||
# speaks Google's REST schema, not OpenAI's. OpenAI-style extra_body
|
||||
# keys (tags, reasoning, provider, plugins, …) are unknown fields
|
||||
# there and Gemini rejects the whole request with a non-retryable
|
||||
# HTTP 400 ("Invalid JSON payload received. Unknown name 'tags'").
|
||||
# This happens when a profile that emits extra_body (e.g. the Nous
|
||||
# profile's portal `tags`) is active but the resolved endpoint is a
|
||||
# Gemini base_url — typical when only Google credentials are set and
|
||||
# a fallback/aux call lands on Gemini. The native client only reads
|
||||
# thinking_config from extra_body, so drop everything else here.
|
||||
try:
|
||||
from agent.gemini_native_adapter import is_native_gemini_base_url
|
||||
_native_gemini = is_native_gemini_base_url(params.get("base_url"))
|
||||
except Exception:
|
||||
_native_gemini = False
|
||||
if _native_gemini:
|
||||
extra_body = {
|
||||
k: v for k, v in extra_body.items()
|
||||
if k in ("thinking_config", "thinkingConfig")
|
||||
}
|
||||
if extra_body:
|
||||
api_kwargs["extra_body"] = extra_body
|
||||
|
||||
return api_kwargs
|
||||
|
||||
|
||||
@@ -171,12 +171,19 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
let child_env = update_child_env(&install_root);
|
||||
let mut update_args: Vec<String> =
|
||||
vec!["update".into(), "--yes".into(), "--gateway".into()];
|
||||
// --force skips `hermes update`'s Windows running-exe guard (which would
|
||||
// `sys.exit(2)` and dead-end the handoff). By contract the desktop has
|
||||
// already exited and waited for the venv shim to unlock before launching
|
||||
// us, and wait_for_venv_free below force-kills any straggler — so by the
|
||||
// time `hermes update` runs there is no legitimate hermes.exe to protect,
|
||||
// and the guard would only produce a false "Hermes is still running" stop.
|
||||
update_args.push("--force".into());
|
||||
update_args.push("--branch".into());
|
||||
update_args.push(update_branch);
|
||||
|
||||
emit_stage(&app, "update", StageState::Running, None, None);
|
||||
let started = Instant::now();
|
||||
let update = run_streamed(
|
||||
let mut update = run_streamed(
|
||||
&app,
|
||||
&hermes,
|
||||
&update_args,
|
||||
@@ -185,6 +192,38 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
Some("update"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Retry-once for the update-boundary crash. `hermes update` lazily imports
|
||||
// the FRESHLY PULLED modules, but the dependency-install step still runs the
|
||||
// already-in-memory pre-pull code for one invocation. A release that changed
|
||||
// an updater-path contract across that boundary (e.g. #39780's `_UvResult`,
|
||||
// whose `__iter__` injected a bool into the argv and crashed Windows
|
||||
// `list2cmdline` with `TypeError: sequence item 1: expected str instance,
|
||||
// bool found`, fixed in #39820) therefore kills the FIRST update on the
|
||||
// parked population — even though the fix is already on disk by then. A
|
||||
// second `hermes update` runs clean because the now-current module is loaded
|
||||
// from the start. Rather than make the parked user click Update twice (and
|
||||
// stare at a scary crash first), retry once automatically. Skip the retry
|
||||
// for the concurrent-instance guard (exit 2) — that's a "close Hermes" state
|
||||
// a retry can't fix.
|
||||
if !matches!(update.exit_code, Some(0) | Some(UPDATE_EXIT_CONCURRENT)) {
|
||||
emit_log(
|
||||
&app,
|
||||
Some("update"),
|
||||
LogStream::Stdout,
|
||||
"[update] first update attempt failed; retrying once (the fix it just \
|
||||
pulled loads on the second run)…",
|
||||
);
|
||||
update = run_streamed(
|
||||
&app,
|
||||
&hermes,
|
||||
&update_args,
|
||||
&install_root,
|
||||
&child_env,
|
||||
Some("update"),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let update_ms = started.elapsed().as_millis() as u64;
|
||||
|
||||
match update.exit_code {
|
||||
@@ -366,18 +405,77 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
// Last resort: a backend hermes.exe (or a grandchild it spawned)
|
||||
// is still holding the shim. The desktop should have reaped its
|
||||
// tree before handing off, but SIGTERM races / detached
|
||||
// grandchildren / AV handles can leave a straggler. Rather than
|
||||
// "proceed anyway" straight into uv's "Access is denied", force-kill
|
||||
// every hermes.exe except ourselves, then give the OS a beat to
|
||||
// unload the image.
|
||||
emit_log(
|
||||
app,
|
||||
Some("update"),
|
||||
LogStream::Stdout,
|
||||
"[update] timed out waiting for Hermes to exit; proceeding anyway",
|
||||
"[update] Hermes still holding the venv shim; force-killing stragglers…",
|
||||
);
|
||||
force_kill_other_hermes();
|
||||
tokio::time::sleep(Duration::from_millis(800)).await;
|
||||
if !is_locked(&shim) {
|
||||
emit_log(
|
||||
app,
|
||||
Some("update"),
|
||||
LogStream::Stdout,
|
||||
"[update] venv shim freed after force-kill",
|
||||
);
|
||||
} else {
|
||||
emit_log(
|
||||
app,
|
||||
Some("update"),
|
||||
LogStream::Stdout,
|
||||
"[update] venv shim still locked; proceeding (--force + quarantine will handle it)",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(DESKTOP_EXIT_POLL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Force-kill any `hermes.exe` other than this process. Windows-only; a no-op
|
||||
/// elsewhere (POSIX has no mandatory-lock contention). We can't selectively
|
||||
/// target "the backend" by PID here — the desktop already exited and we never
|
||||
/// knew its children — so we kill the whole `hermes.exe` image tree via
|
||||
/// taskkill, excluding our own PID.
|
||||
///
|
||||
/// Safe w.r.t. our own update child: this runs inside `wait_for_venv_free`,
|
||||
/// which completes BEFORE we spawn `venv\Scripts\hermes.exe update`. At this
|
||||
/// point no update-driven hermes.exe exists yet, so the only hermes.exe images
|
||||
/// are stragglers from the old desktop — exactly what we want gone. (`/FI PID
|
||||
/// ne <self>` also spares this Tauri process, though it isn't named
|
||||
/// hermes.exe.)
|
||||
fn force_kill_other_hermes() {
|
||||
if !cfg!(target_os = "windows") {
|
||||
return;
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let my_pid = std::process::id();
|
||||
// /FI excludes our own PID; /T kills the tree; /F forces.
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.args([
|
||||
"/F",
|
||||
"/T",
|
||||
"/IM",
|
||||
"hermes.exe",
|
||||
"/FI",
|
||||
&format!("PID ne {my_pid}"),
|
||||
])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort lock probe: try to open the file for read+write. On Windows an
|
||||
/// exclusively-held running .exe refuses the open with a sharing violation.
|
||||
/// On Unix this almost always succeeds (no mandatory locking), which is fine —
|
||||
|
||||
@@ -24,12 +24,6 @@
|
||||
|
||||
### Install with Hermes (recommended)
|
||||
|
||||
Add `--include-desktop` to the [one-line installer](../../README.md#quick-install) and it sets up the agent and builds the desktop app in one go:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --include-desktop
|
||||
```
|
||||
|
||||
Already have the Hermes CLI? Just run:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -18,11 +18,24 @@
|
||||
* this via the public `/api/status` field `auth_required: true`.
|
||||
*/
|
||||
|
||||
// Bare + prefixed variants of the access-token cookie the gateway may set,
|
||||
// Bare + prefixed variants of the session cookies the gateway may set,
|
||||
// depending on its deploy shape (HTTPS direct → __Host-, behind a path prefix
|
||||
// → __Secure-, loopback HTTP → bare). Mirrors
|
||||
// hermes_cli/dashboard_auth/cookies.py.
|
||||
//
|
||||
// Two cookies are in play (see that module):
|
||||
// - hermes_session_at: the OAuth access token. Short-lived (~15 min); its
|
||||
// Max-Age tracks the access-token TTL, so the cookie jar drops it the
|
||||
// instant the AT expires.
|
||||
// - hermes_session_rt: the OAuth refresh token. Long-lived (24h rotating,
|
||||
// reuse-detected — Portal NAS #293 / hermes #37247). When the AT cookie
|
||||
// has lapsed but the RT cookie is still present, the gateway middleware
|
||||
// transparently rotates a fresh AT on the next authenticated request
|
||||
// (POST /api/auth/ws-ticket), so the session is still LIVE even with no
|
||||
// AT cookie. A liveness check that looked only at the AT cookie would
|
||||
// force a needless full re-login every ~15 min — hence cookiesHaveLiveSession.
|
||||
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
|
||||
const RT_COOKIE_VARIANTS = ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt']
|
||||
|
||||
function normalizeRemoteBaseUrl(rawUrl) {
|
||||
const value = String(rawUrl || '').trim()
|
||||
@@ -118,6 +131,41 @@ async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
|
||||
return buildGatewayWsUrl(baseUrl, token)
|
||||
}
|
||||
|
||||
// Normalize a profile name to a connection scope key, or null for the global
|
||||
// (default) connection. Shared by the resolver and the IPC layer.
|
||||
function connectionScopeKey(profile) {
|
||||
return String(profile ?? '').trim() || null
|
||||
}
|
||||
|
||||
// Coerce a remote auth mode to one of the two supported values ('token' default).
|
||||
function normAuthMode(mode) {
|
||||
return mode === 'oauth' ? 'oauth' : 'token'
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a profile's explicit remote override from a connection config, or null
|
||||
* when it has none (so the caller falls back to env → global remote → local).
|
||||
*
|
||||
* The config may carry a `profiles` map keyed by name; an entry counts as an
|
||||
* override only with `mode === 'remote'` and a non-empty `url`. Pure: `token`
|
||||
* is the raw stored secret; main.cjs decrypts it. Returns
|
||||
* `{ 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
|
||||
}
|
||||
|
||||
return { url, authMode: normAuthMode(entry.authMode), token: entry.token }
|
||||
}
|
||||
|
||||
function tokenPreview(value) {
|
||||
const raw = String(value || '')
|
||||
|
||||
@@ -150,22 +198,56 @@ function resolveAuthMode(inputAuthMode, existingAuthMode) {
|
||||
}
|
||||
|
||||
/**
|
||||
* True if any cookie in `cookies` is a hermes session access-token cookie
|
||||
* True if any cookie in `cookies` is a hermes session ACCESS-token cookie
|
||||
* with a non-empty value. `cookies` is an array of {name, value} (the shape
|
||||
* Electron's session.cookies.get returns).
|
||||
*
|
||||
* Note: this is AT-only. A session whose AT cookie has lapsed but whose RT
|
||||
* cookie is still alive is STILL connectable (the gateway refreshes the AT on
|
||||
* the next request) — use `cookiesHaveLiveSession` for a connectivity/display
|
||||
* check. `cookiesHaveSession` remains exported for callers that specifically
|
||||
* need to know whether an unexpired access token is present right now.
|
||||
*/
|
||||
function cookiesHaveSession(cookies) {
|
||||
if (!Array.isArray(cookies)) return false
|
||||
return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the cookie jar holds a credential that can yield an authenticated
|
||||
* request — EITHER a live access-token cookie OR a refresh-token cookie. The
|
||||
* RT cookie outlives the AT cookie (24h vs ~15min), and the gateway middleware
|
||||
* transparently rotates a fresh AT from the RT on the next authenticated
|
||||
* request. Gating connectivity on the AT alone would force a full IDP
|
||||
* re-login every ~15 min even though a valid 24h RT is sitting in the jar.
|
||||
*
|
||||
* This answers "should we even attempt to connect / show as signed in?", not
|
||||
* "is the access token unexpired?". The authoritative liveness check is still
|
||||
* the actual ws-ticket mint at connect time (which surfaces a true 401 when
|
||||
* the RT is also dead/revoked).
|
||||
*/
|
||||
function cookiesHaveLiveSession(cookies) {
|
||||
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 = {
|
||||
AT_COOKIE_VARIANTS,
|
||||
RT_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
tokenPreview
|
||||
|
||||
@@ -15,16 +15,81 @@ const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
AT_COOKIE_VARIANTS,
|
||||
RT_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
tokenPreview
|
||||
} = require('./connection-config.cjs')
|
||||
|
||||
// --- connectionScopeKey / normAuthMode ---
|
||||
|
||||
test('connectionScopeKey trims to a name or null for the global scope', () => {
|
||||
assert.equal(connectionScopeKey(' coder '), 'coder')
|
||||
assert.equal(connectionScopeKey(''), null)
|
||||
assert.equal(connectionScopeKey(null), null)
|
||||
assert.equal(connectionScopeKey(undefined), null)
|
||||
})
|
||||
|
||||
test('normAuthMode coerces to token unless explicitly oauth', () => {
|
||||
assert.equal(normAuthMode('oauth'), 'oauth')
|
||||
assert.equal(normAuthMode('token'), 'token')
|
||||
assert.equal(normAuthMode(undefined), 'token')
|
||||
assert.equal(normAuthMode('weird'), 'token')
|
||||
})
|
||||
|
||||
// --- profileRemoteOverride ---
|
||||
|
||||
test('profileRemoteOverride returns null when no profile is given', () => {
|
||||
const config = { profiles: { coder: { mode: 'remote', url: 'https://x' } } }
|
||||
assert.equal(profileRemoteOverride(config, ''), null)
|
||||
assert.equal(profileRemoteOverride(config, null), null)
|
||||
assert.equal(profileRemoteOverride(config, undefined), null)
|
||||
})
|
||||
|
||||
test('profileRemoteOverride returns null when the profile has no entry', () => {
|
||||
const config = { profiles: { coder: { mode: 'remote', url: 'https://x' } } }
|
||||
assert.equal(profileRemoteOverride(config, 'writer'), null)
|
||||
})
|
||||
|
||||
test('profileRemoteOverride ignores local or url-less profile entries', () => {
|
||||
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'local', url: 'https://x' } } }, 'p'), null)
|
||||
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'remote', url: '' } } }, 'p'), null)
|
||||
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'remote' } } }, 'p'), null)
|
||||
})
|
||||
|
||||
test('profileRemoteOverride returns the per-profile remote with defaulted auth mode', () => {
|
||||
const config = {
|
||||
profiles: {
|
||||
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',
|
||||
token: { value: 'sek' }
|
||||
})
|
||||
})
|
||||
|
||||
test('profileRemoteOverride preserves an explicit oauth auth mode', () => {
|
||||
const config = { profiles: { coder: { mode: 'remote', url: 'https://x', authMode: 'oauth' } } }
|
||||
assert.equal(profileRemoteOverride(config, 'coder').authMode, 'oauth')
|
||||
})
|
||||
|
||||
test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
|
||||
assert.equal(profileRemoteOverride({}, 'coder'), null)
|
||||
assert.equal(profileRemoteOverride({ profiles: null }, 'coder'), null)
|
||||
assert.equal(profileRemoteOverride(null, 'coder'), null)
|
||||
})
|
||||
|
||||
// --- normalizeRemoteBaseUrl ---
|
||||
|
||||
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
|
||||
@@ -131,7 +196,10 @@ test('cookiesHaveSession is false for an empty value', () => {
|
||||
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: '' }]), false)
|
||||
})
|
||||
|
||||
test('cookiesHaveSession ignores unrelated cookies', () => {
|
||||
test('cookiesHaveSession ignores unrelated cookies (AT-only by design)', () => {
|
||||
// cookiesHaveSession is deliberately access-token-only — a lone RT cookie
|
||||
// is NOT an access token, so this returns false. Connectivity callers must
|
||||
// use cookiesHaveLiveSession instead (see below).
|
||||
assert.equal(cookiesHaveSession([{ name: 'hermes_session_rt', value: 'x' }]), false)
|
||||
assert.equal(cookiesHaveSession([{ name: 'other', value: 'x' }]), false)
|
||||
})
|
||||
@@ -146,6 +214,56 @@ test('AT_COOKIE_VARIANTS covers all three deploy shapes', () => {
|
||||
assert.deepEqual(AT_COOKIE_VARIANTS, ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at'])
|
||||
})
|
||||
|
||||
test('RT_COOKIE_VARIANTS covers all three deploy shapes', () => {
|
||||
assert.deepEqual(RT_COOKIE_VARIANTS, ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt'])
|
||||
})
|
||||
|
||||
// --- cookiesHaveLiveSession (AT or RT — the connectivity check) ---
|
||||
|
||||
test('cookiesHaveLiveSession is true for a live access-token cookie', () => {
|
||||
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_at', value: 'x' }]), true)
|
||||
assert.equal(cookiesHaveLiveSession([{ name: '__Host-hermes_session_at', value: 'x' }]), true)
|
||||
assert.equal(cookiesHaveLiveSession([{ name: '__Secure-hermes_session_at', value: 'x' }]), true)
|
||||
})
|
||||
|
||||
test('cookiesHaveLiveSession is true for an RT cookie even with NO access-token cookie', () => {
|
||||
// This is the bug-fix case: the AT cookie has lapsed (dropped from the jar)
|
||||
// but the 24h RT cookie is still alive. The session is still connectable —
|
||||
// the gateway rotates a fresh AT from the RT on the next request.
|
||||
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_rt', value: 'x' }]), true)
|
||||
assert.equal(cookiesHaveLiveSession([{ name: '__Host-hermes_session_rt', value: 'x' }]), true)
|
||||
assert.equal(cookiesHaveLiveSession([{ name: '__Secure-hermes_session_rt', value: 'x' }]), true)
|
||||
})
|
||||
|
||||
test('cookiesHaveLiveSession is true when both AT and RT are present', () => {
|
||||
assert.equal(
|
||||
cookiesHaveLiveSession([
|
||||
{ name: 'hermes_session_at', value: 'a' },
|
||||
{ name: 'hermes_session_rt', value: 'r' }
|
||||
]),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('cookiesHaveLiveSession is false for empty values', () => {
|
||||
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_at', value: '' }]), false)
|
||||
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_rt', value: '' }]), false)
|
||||
assert.equal(
|
||||
cookiesHaveLiveSession([
|
||||
{ name: 'hermes_session_at', value: '' },
|
||||
{ name: 'hermes_session_rt', value: '' }
|
||||
]),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('cookiesHaveLiveSession is false for unrelated cookies and non-arrays', () => {
|
||||
assert.equal(cookiesHaveLiveSession([{ name: 'other', value: 'x' }]), false)
|
||||
assert.equal(cookiesHaveLiveSession(null), false)
|
||||
assert.equal(cookiesHaveLiveSession(undefined), false)
|
||||
assert.equal(cookiesHaveLiveSession([]), false)
|
||||
})
|
||||
|
||||
// --- tokenPreview ---
|
||||
|
||||
test('tokenPreview returns null for empty', () => {
|
||||
|
||||
+360
-93
@@ -32,8 +32,12 @@ const {
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
tokenPreview
|
||||
@@ -1309,6 +1313,111 @@ function resolveUpdaterBinary() {
|
||||
return fileExists(candidate) ? candidate : null
|
||||
}
|
||||
|
||||
// Path to the venv shim whose lock decides whether `hermes update` can write
|
||||
// fresh entry points. On Windows this is the file the running backend
|
||||
// `hermes.exe` holds open; on POSIX it's never mandatory-locked.
|
||||
function venvHermesShimPath(updateRoot) {
|
||||
return IS_WINDOWS
|
||||
? path.join(updateRoot, 'venv', 'Scripts', 'hermes.exe')
|
||||
: path.join(updateRoot, 'venv', 'bin', 'hermes')
|
||||
}
|
||||
|
||||
// Best-effort lock probe mirroring the Rust updater's is_locked(): a running
|
||||
// .exe on Windows refuses an O_RDWR open with a sharing violation. On POSIX
|
||||
// this practically always succeeds (no mandatory locking), so it returns false
|
||||
// — correct, since the shim-contention brick is Windows-only.
|
||||
function isShimLocked(shimPath) {
|
||||
if (!IS_WINDOWS) return false
|
||||
let fd
|
||||
try {
|
||||
fd = fs.openSync(shimPath, 'r+')
|
||||
return false
|
||||
} catch (err) {
|
||||
// ENOENT ⇒ not there ⇒ nothing locking it. Anything else (EBUSY/EPERM/
|
||||
// EACCES) on Windows means a live handle holds it.
|
||||
return err && err.code !== 'ENOENT'
|
||||
} finally {
|
||||
if (fd !== undefined) {
|
||||
try {
|
||||
fs.closeSync(fd)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Force-kill the entire process TREE rooted at each PID. Node's child.kill()
|
||||
// only signals the direct child, so on Windows a backend `hermes.exe` that
|
||||
// spawned its own grandchildren (a `hermes` REPL, a pty terminal session, the
|
||||
// gateway) would survive and keep the venv shim locked. taskkill /T /F reaps
|
||||
// the whole tree synchronously. Windows-only: this is called solely from the
|
||||
// Windows shim-unlock path, and the backend is NOT spawned detached (so it's
|
||||
// not a process-group leader — a POSIX negative-pgid kill would be meaningless
|
||||
// here anyway). POSIX teardown stays with the existing before-quit SIGTERM.
|
||||
function forceKillProcessTree(pid) {
|
||||
if (!IS_WINDOWS) return
|
||||
if (!Number.isInteger(pid) || pid <= 0) return
|
||||
try {
|
||||
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
|
||||
} catch {
|
||||
// Already gone, or no permission — best effort; the unlock wait below is
|
||||
// the real gate.
|
||||
}
|
||||
}
|
||||
|
||||
// Before handing off the update on Windows, the desktop MUST stop every backend
|
||||
// it spawned and WAIT for the venv shim to actually unlock. The old code did
|
||||
// `hermesProcess.kill('SIGTERM')` + `app.quit()` fire-and-forget: SIGTERM on
|
||||
// Windows doesn't reap the backend's grandchildren, and quit didn't wait for
|
||||
// teardown, so the updater raced a still-locked `hermes.exe`, the quarantine
|
||||
// rename failed, uv's `pip install` hit "Access is denied", and the git path
|
||||
// bailed into a full ZIP re-download that ALSO couldn't write the locked shim —
|
||||
// a half-applied install (ryanc's update.log). Here we tree-kill the primary +
|
||||
// pool backends and poll the shim until it's writable (or a bounded timeout),
|
||||
// so by the time we spawn the updater the lock is genuinely gone.
|
||||
//
|
||||
// Windows-only: the venv-shim mandatory lock is a Windows phenomenon. On
|
||||
// macOS/Linux there's no REPLACE-on-running-exe block, the existing before-quit
|
||||
// SIGTERM + app.quit() teardown already works (the macOS path is flawless), and
|
||||
// aggressively SIGKILL-ing the backend here would be an untested behavior change
|
||||
// for no benefit. So we no-op off Windows and leave that path exactly as it was.
|
||||
async function releaseBackendLockForUpdate(updateRoot) {
|
||||
if (!IS_WINDOWS) return { unlocked: true }
|
||||
|
||||
// Collect every backend PID the desktop owns: primary window backend + pool.
|
||||
const pids = []
|
||||
if (hermesProcess && Number.isInteger(hermesProcess.pid)) pids.push(hermesProcess.pid)
|
||||
for (const entry of backendPool.values()) {
|
||||
if (entry.process && Number.isInteger(entry.process.pid)) pids.push(entry.process.pid)
|
||||
}
|
||||
|
||||
// Graceful first (lets Python flush), then tree-kill to catch grandchildren.
|
||||
if (hermesProcess && !hermesProcess.killed) {
|
||||
try {
|
||||
hermesProcess.kill('SIGTERM')
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
stopAllPoolBackends()
|
||||
for (const pid of pids) forceKillProcessTree(pid)
|
||||
|
||||
const shim = venvHermesShimPath(updateRoot)
|
||||
const deadlineMs = Date.now() + 15000
|
||||
while (Date.now() < deadlineMs) {
|
||||
if (!isShimLocked(shim)) {
|
||||
rememberLog('[updates] venv shim unlocked; safe to hand off the update')
|
||||
return { unlocked: true }
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
}
|
||||
// Timed out: the updater's own wait_for_venv_free + force-kill is the second
|
||||
// line of defense, and we pass --force so the guard won't dead-end. Log it.
|
||||
rememberLog('[updates] venv shim still locked after 15s; handing off anyway (updater will force)')
|
||||
return { unlocked: false }
|
||||
}
|
||||
|
||||
// applyUpdates — hand off to the installer's --update flow, then exit.
|
||||
//
|
||||
// The desktop is a pure consumer: it does NOT git pull / pip install / rebuild
|
||||
@@ -1375,6 +1484,12 @@ async function applyUpdates(opts = {}) {
|
||||
}
|
||||
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
|
||||
|
||||
// Stop our own backend(s) and wait for the venv shim to unlock BEFORE we
|
||||
// spawn the updater. Without this the updater races a still-locked
|
||||
// hermes.exe (held by the backend child / its grandchildren) and the update
|
||||
// bricks. See releaseBackendLockForUpdate for the full failure analysis.
|
||||
await releaseBackendLockForUpdate(updateRoot)
|
||||
|
||||
// Detached so the updater outlives this process — it needs us GONE before
|
||||
// `hermes update` will run (the venv shim is locked while we live).
|
||||
const child = spawn(updater, updaterArgs, {
|
||||
@@ -3167,8 +3282,16 @@ function installMediaPermissions() {
|
||||
// * WebSocket upgrades require a single-use ``?ticket=`` minted at
|
||||
// ``POST /api/auth/ws-ticket`` (cookie-authed). The legacy ``?token=``
|
||||
// path is unconditionally rejected by gated gateways.
|
||||
// * Nous Portal contract v1 issues NO refresh token; the access cookie has
|
||||
// a ~15-min TTL. On 401 we must re-run the login round trip.
|
||||
// * Nous Portal now issues a 24h ROTATING, reuse-detected refresh token
|
||||
// alongside the ~15-min access token (Portal NAS #293 / hermes #37247).
|
||||
// Both are set as HttpOnly cookies (``hermes_session_at`` ~15 min,
|
||||
// ``hermes_session_rt`` 24h). When the AT cookie lapses but the RT cookie
|
||||
// is still alive, the gateway middleware transparently rotates a fresh AT
|
||||
// on the next authenticated request — so connectivity must NOT be gated on
|
||||
// the AT cookie alone. We probe liveness by actually minting a ws-ticket
|
||||
// (which triggers that server-side refresh) and treat a real 401 as
|
||||
// "needs re-login"; the AT-or-RT cookie presence check is only a cheap
|
||||
// "is the user signed in at all?" gate / display signal.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const OAUTH_SESSION_PARTITION = 'persist:hermes-remote-oauth'
|
||||
@@ -3179,8 +3302,9 @@ function getOauthSession() {
|
||||
return oauthSession
|
||||
}
|
||||
|
||||
// Bare + prefixed variants of the access-token cookie live in
|
||||
// connection-config.cjs (cookiesHaveSession). See that module for details.
|
||||
// Bare + prefixed variants of the session cookies live in
|
||||
// connection-config.cjs (cookiesHaveSession / cookiesHaveLiveSession). See
|
||||
// that module for details.
|
||||
|
||||
async function hasOauthSessionCookie(baseUrl) {
|
||||
const sess = getOauthSession()
|
||||
@@ -3201,6 +3325,30 @@ async function hasOauthSessionCookie(baseUrl) {
|
||||
}
|
||||
}
|
||||
|
||||
// Like hasOauthSessionCookie, but returns true when EITHER a live access-token
|
||||
// cookie OR a (longer-lived) refresh-token cookie is present. This is the right
|
||||
// "is the user signed in at all?" check: an expired AT with a live RT is still
|
||||
// a connectable session because the gateway rotates a fresh AT server-side on
|
||||
// the next authenticated request. Gating on the AT alone forces a needless full
|
||||
// re-login every ~15 min. Used for the Settings "connected" indicator and as a
|
||||
// cheap early-out before attempting a network round-trip in resolveRemoteBackend.
|
||||
async function hasLiveOauthSession(baseUrl) {
|
||||
const sess = getOauthSession()
|
||||
if (!sess) return false
|
||||
const parsed = new URL(baseUrl)
|
||||
try {
|
||||
const cookies = await sess.cookies.get({ url: baseUrl })
|
||||
return cookiesHaveLiveSession(cookies)
|
||||
} catch {
|
||||
try {
|
||||
const cookies = await sess.cookies.get({ domain: parsed.hostname })
|
||||
return cookiesHaveLiveSession(cookies)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function clearOauthSession(baseUrl) {
|
||||
const sess = getOauthSession()
|
||||
if (!sess) return
|
||||
@@ -3447,6 +3595,38 @@ function decryptDesktopSecret(secret) {
|
||||
return value
|
||||
}
|
||||
|
||||
// Validate + normalize the per-profile remote overrides map read from disk.
|
||||
// Drops malformed names/entries and keeps only the recognized fields so a
|
||||
// hand-edited or stale connection.json can't inject junk into resolution.
|
||||
function sanitizeConnectionProfiles(raw) {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return {}
|
||||
}
|
||||
|
||||
const out = {}
|
||||
for (const [name, entry] of Object.entries(raw)) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
continue
|
||||
}
|
||||
if (name !== 'default' && !PROFILE_NAME_RE.test(name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const cleaned = { mode: entry.mode === 'remote' ? 'remote' : 'local' }
|
||||
const url = String(entry.url || '').trim()
|
||||
if (url) {
|
||||
cleaned.url = url
|
||||
}
|
||||
cleaned.authMode = normAuthMode(entry.authMode)
|
||||
if (entry.token && typeof entry.token === 'object') {
|
||||
cleaned.token = entry.token
|
||||
}
|
||||
out[name] = cleaned
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function readDesktopConnectionConfig() {
|
||||
// Check if file changed on disk since last read (e.g. modified by another
|
||||
// process or an external tool). Our own writes update the cache inline
|
||||
@@ -3462,7 +3642,7 @@ function readDesktopConnectionConfig() {
|
||||
return connectionConfigCache
|
||||
}
|
||||
|
||||
let config = { mode: 'local', remote: {} }
|
||||
let config = { mode: 'local', remote: {}, profiles: {} }
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(DESKTOP_CONNECTION_CONFIG_PATH, 'utf8')
|
||||
@@ -3476,7 +3656,11 @@ function readDesktopConnectionConfig() {
|
||||
remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
|
||||
config = {
|
||||
mode: parsed.mode === 'remote' ? 'remote' : 'local',
|
||||
remote
|
||||
remote,
|
||||
// Per-profile remote overrides: each profile may point at its own
|
||||
// backend (local spawn or its own remote URL). Preserved verbatim so
|
||||
// profileRemoteOverride() can resolve them; normalized lazily on save.
|
||||
profiles: sanitizeConnectionProfiles(parsed.profiles)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -3528,104 +3712,114 @@ function writeActiveDesktopProfile(name) {
|
||||
return value || null
|
||||
}
|
||||
|
||||
async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) {
|
||||
const remoteToken = decryptDesktopSecret(config.remote?.token)
|
||||
const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token'
|
||||
const remoteUrl = String(config.remote?.url || '')
|
||||
// Sanitize a connection config into the renderer-facing shape. With no
|
||||
// `profile` this describes the global/default connection (the existing
|
||||
// behavior); with a `profile` it describes that profile's per-profile remote
|
||||
// override (or an empty "local/inherit" view when the profile has none).
|
||||
async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig(), profile = null) {
|
||||
const key = connectionScopeKey(profile)
|
||||
const scoped = key ? config.profiles?.[key] || null : null
|
||||
const block = key ? scoped || {} : config.remote || {}
|
||||
|
||||
const remoteToken = decryptDesktopSecret(block.token)
|
||||
const authMode = normAuthMode(block.authMode)
|
||||
const remoteUrl = String(block.url || '')
|
||||
const mode = (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local'
|
||||
|
||||
let remoteOauthConnected = false
|
||||
if (authMode === 'oauth' && remoteUrl) {
|
||||
try {
|
||||
remoteOauthConnected = await hasOauthSessionCookie(remoteUrl)
|
||||
// Display signal: treat a live RT cookie as "connected" even if the AT
|
||||
// cookie has lapsed — the gateway refreshes the AT on the next request,
|
||||
// so the session is still usable. The authoritative liveness check is
|
||||
// the ws-ticket mint in resolveRemoteBackend at actual connect time.
|
||||
remoteOauthConnected = await hasLiveOauthSession(remoteUrl)
|
||||
} catch {
|
||||
remoteOauthConnected = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mode: config.mode === 'remote' ? 'remote' : 'local',
|
||||
mode,
|
||||
// Echo the scope back so the UI knows which profile (if any) this reflects.
|
||||
profile: key,
|
||||
remoteAuthMode: authMode,
|
||||
remoteOauthConnected,
|
||||
remoteUrl,
|
||||
remoteTokenPreview: tokenPreview(remoteToken),
|
||||
remoteTokenSet: Boolean(remoteToken),
|
||||
envOverride: Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
|
||||
// The env override only forces the global/primary connection; a per-profile
|
||||
// scope is never overridden by HERMES_DESKTOP_REMOTE_URL.
|
||||
envOverride: key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
|
||||
}
|
||||
}
|
||||
|
||||
// Build + validate a `{ url, authMode, token }` remote block. OAuth gateways
|
||||
// authenticate via the login-window session cookie (verified at connect time in
|
||||
// resolveRemoteBackend), so only token-auth remotes require a saved token.
|
||||
function buildRemoteBlock(remoteUrl, authMode, token) {
|
||||
if (authMode !== 'oauth' && !decryptDesktopSecret(token)) {
|
||||
throw new Error('Remote gateway session token is required.')
|
||||
}
|
||||
return { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token }
|
||||
}
|
||||
|
||||
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) {
|
||||
const persistToken = options.persistToken !== false
|
||||
const key = connectionScopeKey(input.profile)
|
||||
const mode = input.mode === 'remote' ? 'remote' : 'local'
|
||||
const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim()
|
||||
|
||||
// The block being edited: a per-profile entry or the global remote block.
|
||||
const existingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {}
|
||||
const remoteUrl = String(input.remoteUrl ?? existingBlock.url ?? '').trim()
|
||||
// authMode: explicit input wins; otherwise inherit the saved value, default 'token'.
|
||||
const authMode = resolveAuthMode(input.remoteAuthMode, existing.remote?.authMode)
|
||||
const authMode = resolveAuthMode(input.remoteAuthMode, existingBlock.authMode)
|
||||
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
|
||||
const existingToken = existing.remote?.token
|
||||
const nextRemote = {
|
||||
url: remoteUrl,
|
||||
authMode,
|
||||
token: incomingToken
|
||||
? persistToken
|
||||
? encryptDesktopSecret(incomingToken)
|
||||
: { encoding: 'plain', value: incomingToken }
|
||||
: existingToken
|
||||
}
|
||||
const nextToken = incomingToken
|
||||
? persistToken
|
||||
? encryptDesktopSecret(incomingToken)
|
||||
: { encoding: 'plain', value: incomingToken }
|
||||
: existingBlock.token
|
||||
|
||||
if (mode === 'remote') {
|
||||
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
|
||||
|
||||
// OAuth gateways authenticate via the session cookie established by the
|
||||
// login window, NOT a static token — so no token is required here. The
|
||||
// cookie presence is verified at connect time (resolveRemoteBackend).
|
||||
if (authMode !== 'oauth' && !decryptDesktopSecret(nextRemote.token)) {
|
||||
throw new Error('Remote gateway session token is required.')
|
||||
if (key) {
|
||||
// Per-profile scope: a remote entry pins this profile to its own backend; a
|
||||
// local entry clears the override so the profile inherits the default.
|
||||
const profiles = { ...(existing.profiles || {}) }
|
||||
if (mode === 'remote') {
|
||||
profiles[key] = { mode: 'remote', ...buildRemoteBlock(remoteUrl, authMode, nextToken) }
|
||||
} else {
|
||||
delete profiles[key]
|
||||
}
|
||||
} else if (remoteUrl) {
|
||||
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
|
||||
return { mode: existing.mode === 'remote' ? 'remote' : 'local', remote: existing.remote || {}, profiles }
|
||||
}
|
||||
|
||||
return { mode, remote: nextRemote }
|
||||
const nextRemote =
|
||||
mode === 'remote'
|
||||
? buildRemoteBlock(remoteUrl, authMode, nextToken)
|
||||
: { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken }
|
||||
|
||||
// Preserve per-profile overrides when saving the global connection.
|
||||
return { mode, remote: nextRemote, profiles: existing.profiles || {} }
|
||||
}
|
||||
|
||||
async function resolveRemoteBackend() {
|
||||
const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
|
||||
const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
|
||||
|
||||
if (rawEnvUrl) {
|
||||
if (!rawEnvToken) {
|
||||
throw new Error(
|
||||
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
|
||||
'Both must be provided to connect to a remote Hermes backend.'
|
||||
)
|
||||
}
|
||||
|
||||
const baseUrl = normalizeRemoteBaseUrl(rawEnvUrl)
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'remote',
|
||||
source: 'env',
|
||||
authMode: 'token',
|
||||
token: rawEnvToken,
|
||||
wsUrl: buildGatewayWsUrl(baseUrl, rawEnvToken)
|
||||
}
|
||||
}
|
||||
|
||||
const config = readDesktopConnectionConfig()
|
||||
|
||||
if (config.mode !== 'remote') {
|
||||
return null
|
||||
}
|
||||
|
||||
const baseUrl = normalizeRemoteBaseUrl(config.remote?.url)
|
||||
const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token'
|
||||
// Build a remote backend connection descriptor from an already-resolved remote
|
||||
// config. Handles both auth models (OAuth ws-ticket vs static session token)
|
||||
// and is shared by the per-profile, env, and global resolution paths. `token`
|
||||
// is the DECRYPTED static token (or null in OAuth mode). `source` is a label
|
||||
// for diagnostics ('profile' | 'env' | 'settings').
|
||||
async function buildRemoteConnection(rawUrl, authMode, token, source) {
|
||||
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
|
||||
|
||||
if (authMode === 'oauth') {
|
||||
// OAuth gateway: auth comes from the session cookie in the OAuth partition.
|
||||
// Verify the cookie is present, then mint a single-use WS ticket (the
|
||||
// gateway rejects ?token= in gated mode). A missing cookie / 401 means the
|
||||
// user needs to (re-)log in via Settings → Gateway.
|
||||
if (!(await hasOauthSessionCookie(baseUrl))) {
|
||||
// OAuth gateway: auth comes from the session cookies in the OAuth
|
||||
// partition. Liveness is NOT "is the access-token cookie present?" —
|
||||
// Portal issues a 24h rotating refresh token (hermes #37247), and the
|
||||
// gateway middleware transparently rotates a fresh ~15-min access token
|
||||
// from it on the next authenticated request. So a session with an expired
|
||||
// AT cookie but a live RT cookie is still perfectly connectable. We
|
||||
// early-out only when neither cookie is present, then mint a ws-ticket as
|
||||
// the authoritative liveness check.
|
||||
if (!(await hasLiveOauthSession(baseUrl))) {
|
||||
const err = new Error(
|
||||
'Remote Hermes gateway uses OAuth, but you are not signed in. ' +
|
||||
'Open Settings → Gateway and click "Sign in", or switch back to Local.'
|
||||
@@ -3649,7 +3843,7 @@ async function resolveRemoteBackend() {
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'remote',
|
||||
source: 'settings',
|
||||
source,
|
||||
authMode: 'oauth',
|
||||
// No static token in OAuth mode; REST is cookie-authed via the partition.
|
||||
token: null,
|
||||
@@ -3657,8 +3851,6 @@ async function resolveRemoteBackend() {
|
||||
}
|
||||
}
|
||||
|
||||
const token = decryptDesktopSecret(config.remote?.token)
|
||||
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
'Remote Hermes gateway is selected, but no session token is saved. ' +
|
||||
@@ -3669,13 +3861,54 @@ async function resolveRemoteBackend() {
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'remote',
|
||||
source: 'settings',
|
||||
source,
|
||||
authMode: 'token',
|
||||
token,
|
||||
wsUrl: buildGatewayWsUrl(baseUrl, token)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the remote backend for a given profile, or null when that profile
|
||||
// should run a LOCAL backend. Precedence:
|
||||
// 1. explicit per-profile remote override (connection.json `profiles[name]`)
|
||||
// 2. env override (HERMES_DESKTOP_REMOTE_URL/_TOKEN) — applies app-wide
|
||||
// 3. global remote (connection.json `mode: 'remote'`)
|
||||
// A null/empty profile resolves the env/global remote, so legacy callers and
|
||||
// the connection test (which pass no profile) are unchanged.
|
||||
async function resolveRemoteBackend(profile) {
|
||||
const config = readDesktopConnectionConfig()
|
||||
|
||||
// 1. Per-profile override — "a profile with its own remote host". Wins even
|
||||
// over the env override so an explicitly-configured profile always
|
||||
// reaches its intended backend.
|
||||
const override = profileRemoteOverride(config, profile)
|
||||
if (override) {
|
||||
const token = override.authMode === 'oauth' ? null : decryptDesktopSecret(override.token)
|
||||
return buildRemoteConnection(override.url, override.authMode, token, 'profile')
|
||||
}
|
||||
|
||||
// 2. Env override (global, token-auth only).
|
||||
const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
|
||||
const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
|
||||
if (rawEnvUrl) {
|
||||
if (!rawEnvToken) {
|
||||
throw new Error(
|
||||
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
|
||||
'Both must be provided to connect to a remote Hermes backend.'
|
||||
)
|
||||
}
|
||||
return buildRemoteConnection(rawEnvUrl, 'token', rawEnvToken, 'env')
|
||||
}
|
||||
|
||||
// 3. Global remote.
|
||||
if (config.mode !== 'remote') {
|
||||
return null
|
||||
}
|
||||
const authMode = normAuthMode(config.remote?.authMode)
|
||||
const token = authMode === 'oauth' ? null : decryptDesktopSecret(config.remote?.token)
|
||||
return buildRemoteConnection(config.remote?.url, authMode, token, 'settings')
|
||||
}
|
||||
|
||||
async function probeRemoteAuthMode(rawUrl) {
|
||||
// Determine how a remote gateway expects callers to authenticate, WITHOUT
|
||||
// sending any credentials. ``/api/status`` is public on every Hermes
|
||||
@@ -3743,6 +3976,11 @@ async function probeRemoteAuthMode(rawUrl) {
|
||||
|
||||
async function testDesktopConnectionConfig(input = {}) {
|
||||
const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false })
|
||||
const key = connectionScopeKey(input.profile)
|
||||
// The block under test: a per-profile entry or the global remote. Coerce has
|
||||
// already normalized the URL and resolved token inheritance for the scope.
|
||||
const block = key ? config.profiles?.[key] || null : config.remote
|
||||
const wantRemote = block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block)
|
||||
// ``/api/status`` is public on every gateway (no creds needed), so a
|
||||
// reachability test works for local, token, and oauth modes alike — we only
|
||||
// need a base URL. For a remote config we normalize the URL from the input;
|
||||
@@ -3750,17 +3988,17 @@ async function testDesktopConnectionConfig(input = {}) {
|
||||
let baseUrl
|
||||
let token = null
|
||||
let authMode = 'token'
|
||||
if (config.mode === 'remote') {
|
||||
baseUrl = normalizeRemoteBaseUrl(config.remote.url)
|
||||
authMode = config.remote.authMode === 'oauth' ? 'oauth' : 'token'
|
||||
if (wantRemote && block?.url) {
|
||||
baseUrl = normalizeRemoteBaseUrl(block.url)
|
||||
authMode = normAuthMode(block.authMode)
|
||||
if (authMode !== 'oauth') {
|
||||
token = decryptDesktopSecret(config.remote.token)
|
||||
token = decryptDesktopSecret(block.token)
|
||||
}
|
||||
} else {
|
||||
const remote = (await resolveRemoteBackend()) || (await startHermes())
|
||||
const remote = (await resolveRemoteBackend(key)) || (await startHermes())
|
||||
baseUrl = remote.baseUrl
|
||||
token = remote.token
|
||||
authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
|
||||
authMode = normAuthMode(remote.authMode)
|
||||
}
|
||||
const status = await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 })
|
||||
|
||||
@@ -3932,10 +4170,21 @@ function startPoolIdleReaper() {
|
||||
// local-spawn portion of startHermes() but without the boot-progress UI,
|
||||
// bootstrap, or remote handling (those belong to the primary backend only).
|
||||
async function spawnPoolBackend(profile, entry) {
|
||||
// Remote deployments are single-tenant; profiles only apply to local backends.
|
||||
const remote = await resolveRemoteBackend()
|
||||
// A profile may point at its OWN remote backend (connection.json
|
||||
// `profiles[name]`), or inherit the app-wide remote (env / global settings).
|
||||
// In either case there is no local child to spawn — we just verify the
|
||||
// remote is reachable and hand back its connection descriptor. The pool
|
||||
// entry keeps `entry.process === null`, which stopPoolBackend/evict already
|
||||
// tolerate.
|
||||
const remote = await resolveRemoteBackend(profile)
|
||||
if (remote) {
|
||||
throw new Error('Profiles are unavailable when connected to a remote Hermes backend.')
|
||||
await waitForHermes(remote.baseUrl, remote.token)
|
||||
return {
|
||||
...remote,
|
||||
profile,
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
}
|
||||
|
||||
const port = await pickPort()
|
||||
@@ -4036,7 +4285,9 @@ async function startHermes() {
|
||||
|
||||
connectionPromise = (async () => {
|
||||
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
|
||||
const remote = await resolveRemoteBackend()
|
||||
// Resolve for the desktop's primary profile so a per-profile remote
|
||||
// override on the active profile is honored (falls back to env / global).
|
||||
const remote = await resolveRemoteBackend(primaryProfileKey())
|
||||
if (remote) {
|
||||
await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24)
|
||||
await waitForHermes(remote.baseUrl, remote.token)
|
||||
@@ -4373,7 +4624,9 @@ ipcMain.handle('hermes:bootstrap:cancel', async () => {
|
||||
})
|
||||
ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState)
|
||||
ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState())
|
||||
ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig())
|
||||
ipcMain.handle('hermes:connection-config:get', async (_event, profile) =>
|
||||
sanitizeDesktopConnectionConfig(readDesktopConnectionConfig(), profile)
|
||||
)
|
||||
ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload))
|
||||
ipcMain.handle('hermes:connection-config:probe', async (_event, rawUrl) => probeRemoteAuthMode(rawUrl))
|
||||
ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => {
|
||||
@@ -4388,22 +4641,36 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) =>
|
||||
ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => {
|
||||
const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : ''
|
||||
await clearOauthSession(baseUrl || undefined)
|
||||
return { ok: true, connected: baseUrl ? await hasOauthSessionCookie(baseUrl) : false }
|
||||
// Report against the SAME liveness notion the Settings indicator uses
|
||||
// (AT-or-RT) so a logout that left any session cookie behind is reflected
|
||||
// as still-connected rather than silently signed-out.
|
||||
return { ok: true, connected: baseUrl ? await hasLiveOauthSession(baseUrl) : false }
|
||||
})
|
||||
ipcMain.handle('hermes:connection-config:save', async (_event, payload) => {
|
||||
const config = coerceDesktopConnectionConfig(payload)
|
||||
writeDesktopConnectionConfig(config)
|
||||
|
||||
return sanitizeDesktopConnectionConfig(config)
|
||||
return sanitizeDesktopConnectionConfig(config, payload?.profile)
|
||||
})
|
||||
ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => {
|
||||
const config = coerceDesktopConnectionConfig(payload)
|
||||
writeDesktopConnectionConfig(config)
|
||||
|
||||
await teardownPrimaryBackendAndWait()
|
||||
const key = connectionScopeKey(payload?.profile)
|
||||
|
||||
mainWindow?.reload()
|
||||
return sanitizeDesktopConnectionConfig(config)
|
||||
if (key && key !== primaryProfileKey()) {
|
||||
// Editing a NON-primary profile's connection: don't disturb the window's
|
||||
// primary backend. Drop the profile's pooled backend so the next switch
|
||||
// re-resolves against the new remote/local target.
|
||||
stopPoolBackend(key)
|
||||
} else {
|
||||
// Global connection, or the primary profile's connection: re-home the
|
||||
// window backend by tearing it down and reloading the renderer.
|
||||
await teardownPrimaryBackendAndWait()
|
||||
mainWindow?.reload()
|
||||
}
|
||||
|
||||
return sanitizeDesktopConnectionConfig(config, payload?.profile)
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:profile:get', async () => ({ profile: readActiveDesktopProfile() }))
|
||||
|
||||
@@ -5,7 +5,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
|
||||
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
|
||||
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
|
||||
getConnectionConfig: () => ipcRenderer.invoke('hermes:connection-config:get'),
|
||||
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
|
||||
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
|
||||
applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),
|
||||
testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"react": "^19.2.5",
|
||||
"react-arborist": "^3.5.0",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"react-shiki": "^0.9.3",
|
||||
"remark-math": "^6.0.0",
|
||||
"shiki": "^4.0.2",
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
unpinSession
|
||||
} from '../store/layout'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
|
||||
import { $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
|
||||
import { $activeGatewayProfile, $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentCwd,
|
||||
@@ -506,6 +506,25 @@ export function DesktopController() {
|
||||
startFreshSessionDraft()
|
||||
}, [freshSessionRequest, startFreshSessionDraft])
|
||||
|
||||
// Swapping the live gateway to another profile must re-pull that profile's
|
||||
// global model + active-profile pill. Both are nanostores, so the blanket
|
||||
// invalidateQueries() the profile store fires on swap doesn't touch them —
|
||||
// without this the statusbar keeps showing the previous profile's model
|
||||
// (the "forgets the LLM setting" report). gatewayState stays 'open' across a
|
||||
// swap (background sockets persist), so the open→open effect won't re-run.
|
||||
const activeGatewayProfile = useStore($activeGatewayProfile)
|
||||
const lastGatewayProfileRef = useRef(activeGatewayProfile)
|
||||
|
||||
useEffect(() => {
|
||||
if (activeGatewayProfile === lastGatewayProfileRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastGatewayProfileRef.current = activeGatewayProfile
|
||||
void refreshCurrentModel()
|
||||
void refreshActiveProfile()
|
||||
}, [activeGatewayProfile, refreshCurrentModel])
|
||||
|
||||
const composer = useComposerActions({
|
||||
activeSessionId,
|
||||
currentCwd,
|
||||
|
||||
@@ -453,15 +453,31 @@ export function useSessionActions({
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
|
||||
void requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
|
||||
.then(usage => {
|
||||
if (isCurrentResume() && usage) {
|
||||
setCurrentUsage(current => ({ ...current, ...usage }))
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
try {
|
||||
const usage = await requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
|
||||
|
||||
return
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (usage) {
|
||||
setCurrentUsage(current => ({ ...current, ...usage }))
|
||||
}
|
||||
|
||||
return
|
||||
} catch {
|
||||
// The cached runtime id was minted by a prior backend instance. A
|
||||
// pooled profile backend that gets idle-reaped (pruneSecondaryGateways)
|
||||
// and respawned across a profile swap mints fresh ids, so this mapping
|
||||
// now 404s ("session not found"). Drop it and fall through to a full
|
||||
// resume that rebinds a live runtime id.
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
}
|
||||
|
||||
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
|
||||
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
|
||||
}
|
||||
}
|
||||
|
||||
setFreshDraftReady(false)
|
||||
|
||||
@@ -241,7 +241,8 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
|
||||
'memory.provider': ['', 'builtin', 'honcho'],
|
||||
'stt.elevenlabs.model_id': ['scribe_v2', 'scribe_v1'],
|
||||
'stt.local.model': ['tiny', 'base', 'small', 'medium', 'large-v3'],
|
||||
'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']
|
||||
'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'],
|
||||
'updates.non_interactive_local_changes': ['stash', 'discard']
|
||||
}
|
||||
|
||||
export const FIELD_LABELS: Record<string, string> = {
|
||||
@@ -309,7 +310,8 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
'delegation.max_iterations': 'Subagent Turn Limit',
|
||||
'delegation.max_concurrent_children': 'Parallel Subagents',
|
||||
'delegation.child_timeout_seconds': 'Subagent Timeout',
|
||||
'delegation.reasoning_effort': 'Subagent Reasoning Effort'
|
||||
'delegation.reasoning_effort': 'Subagent Reasoning Effort',
|
||||
'updates.non_interactive_local_changes': 'In-App Update Local Changes'
|
||||
}
|
||||
|
||||
export const FIELD_DESCRIPTIONS: Record<string, string> = {
|
||||
@@ -336,7 +338,9 @@ export const FIELD_DESCRIPTIONS: Record<string, string> = {
|
||||
'voice.auto_tts': 'Automatically speak assistant responses.',
|
||||
'stt.enabled': 'Enable local or provider-backed speech transcription.',
|
||||
'stt.elevenlabs.language_code': 'Optional ISO-639-3 language code. Blank lets ElevenLabs auto-detect.',
|
||||
'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.'
|
||||
'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.',
|
||||
'updates.non_interactive_local_changes':
|
||||
'When Hermes updates itself from the app (no terminal prompt), keep local source edits (stash) or throw them away (discard). Terminal updates always ask.'
|
||||
}
|
||||
|
||||
// Curated desktop config surface: only fields a user might tune from the app.
|
||||
@@ -449,7 +453,8 @@ export const SECTIONS: DesktopConfigSection[] = [
|
||||
'delegation.max_iterations',
|
||||
'delegation.max_concurrent_children',
|
||||
'delegation.child_timeout_seconds',
|
||||
'delegation.reasoning_effort'
|
||||
'delegation.reasoning_effort',
|
||||
'updates.non_interactive_local_changes'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -6,6 +7,7 @@ import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global
|
||||
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $profiles, refreshActiveProfile } from '@/store/profile'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
|
||||
@@ -74,6 +76,23 @@ function ModeCard({
|
||||
)
|
||||
}
|
||||
|
||||
function ScopeChip({ active, label, onSelect }: { active: boolean; label: string; onSelect: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-1 text-[length:var(--conversation-caption-font-size)] transition',
|
||||
active
|
||||
? 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary) text-(--ui-text-primary)'
|
||||
: 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover)'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function GatewaySettings() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -83,6 +102,16 @@ export function GatewaySettings() {
|
||||
const [remoteToken, setRemoteToken] = useState('')
|
||||
const [lastTest, setLastTest] = useState<null | string>(null)
|
||||
|
||||
// Connection scope: null = the global/default connection (the original
|
||||
// behavior); a profile name = that profile's per-profile remote override, so
|
||||
// each profile can point at its own backend.
|
||||
const [scope, setScope] = useState<null | string>(null)
|
||||
const profiles = useStore($profiles)
|
||||
|
||||
useEffect(() => {
|
||||
void refreshActiveProfile()
|
||||
}, [])
|
||||
|
||||
// Auth-mode probe: as the user types a remote URL we ask the gateway (via
|
||||
// its public /api/status) whether it gates with OAuth or a static session
|
||||
// token, so we can show the right control (login button vs token box).
|
||||
@@ -100,8 +129,14 @@ export function GatewaySettings() {
|
||||
return () => void (cancelled = true)
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
// Clear scope-local entry state so a token from one scope can't leak into
|
||||
// the next when switching profiles.
|
||||
setRemoteToken('')
|
||||
setLastTest(null)
|
||||
|
||||
desktop
|
||||
.getConnectionConfig()
|
||||
.getConnectionConfig(scope)
|
||||
.then(config => {
|
||||
if (cancelled) {
|
||||
return
|
||||
@@ -117,7 +152,7 @@ export function GatewaySettings() {
|
||||
})
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
}, [scope])
|
||||
|
||||
// Debounced probe of the entered remote URL. Only runs in remote mode with a
|
||||
// syntactically plausible URL. The probe result drives whether we render the
|
||||
@@ -223,6 +258,10 @@ export function GatewaySettings() {
|
||||
return providers.length > 0 && providers.every(p => p.supportsPassword)
|
||||
}, [probe])
|
||||
|
||||
// The 'default' profile uses the global ("All profiles") connection, so the
|
||||
// per-profile scopes are the named, non-default profiles.
|
||||
const namedProfiles = useMemo(() => profiles.filter(profile => profile.name !== 'default'), [profiles])
|
||||
|
||||
const oauthConnected = state.remoteOauthConnected
|
||||
|
||||
const canUseRemote = useMemo(() => {
|
||||
@@ -239,6 +278,7 @@ export function GatewaySettings() {
|
||||
|
||||
const payload = () => ({
|
||||
mode: state.mode,
|
||||
profile: scope ?? undefined,
|
||||
remoteAuthMode: authMode,
|
||||
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
|
||||
remoteUrl: trimmedUrl
|
||||
@@ -296,6 +336,7 @@ export function GatewaySettings() {
|
||||
// oauth mode is persisted, without yet flipping the live connection.
|
||||
const saved = await window.hermesDesktop.saveConnectionConfig({
|
||||
mode: state.mode,
|
||||
profile: scope ?? undefined,
|
||||
remoteAuthMode: 'oauth',
|
||||
remoteUrl: trimmedUrl
|
||||
})
|
||||
@@ -305,7 +346,7 @@ export function GatewaySettings() {
|
||||
const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)
|
||||
|
||||
if (result.connected) {
|
||||
const refreshed = await window.hermesDesktop.getConnectionConfig()
|
||||
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
|
||||
setState(refreshed)
|
||||
notify({ kind: 'success', title: 'Signed in', message: `Connected to ${providerLabel}.` })
|
||||
} else {
|
||||
@@ -327,7 +368,7 @@ export function GatewaySettings() {
|
||||
|
||||
try {
|
||||
await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined)
|
||||
const refreshed = await window.hermesDesktop.getConnectionConfig()
|
||||
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
|
||||
setState(refreshed)
|
||||
notify({ kind: 'success', title: 'Signed out', message: 'Cleared the remote gateway session.' })
|
||||
} catch (err) {
|
||||
@@ -357,6 +398,7 @@ export function GatewaySettings() {
|
||||
try {
|
||||
const result = await window.hermesDesktop.testConnectionConfig({
|
||||
mode: 'remote',
|
||||
profile: scope ?? undefined,
|
||||
remoteAuthMode: authMode,
|
||||
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
|
||||
remoteUrl: trimmedUrl
|
||||
@@ -395,10 +437,35 @@ export function GatewaySettings() {
|
||||
</div>
|
||||
<p className="mt-2 max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to control
|
||||
an already-running Hermes backend on another machine or behind a trusted proxy.
|
||||
an already-running Hermes backend on another machine or behind a trusted proxy. Pick a profile below to give it
|
||||
its own remote host.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{namedProfiles.length > 0 ? (
|
||||
<div className="mb-5 grid gap-2">
|
||||
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
|
||||
Applies to
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<ScopeChip active={scope === null} label="All profiles" onSelect={() => setScope(null)} />
|
||||
{namedProfiles.map(profile => (
|
||||
<ScopeChip
|
||||
active={scope === profile.name}
|
||||
key={profile.name}
|
||||
label={profile.name}
|
||||
onSelect={() => setScope(profile.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{scope === null
|
||||
? 'Default connection for every profile that has no override of its own.'
|
||||
: `Connection used only when “${scope}” is the active profile. Set it to Local to inherit the default.`}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.envOverride ? (
|
||||
<div className="mb-5 flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-[length:var(--conversation-caption-font-size)] text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
|
||||
@@ -8,6 +8,7 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec
|
||||
return {
|
||||
envOverride: false,
|
||||
mode: 'remote',
|
||||
profile: null,
|
||||
remoteAuthMode: 'oauth',
|
||||
remoteOauthConnected: false,
|
||||
remoteTokenPreview: null,
|
||||
|
||||
Vendored
+7
-1
@@ -12,7 +12,7 @@ declare global {
|
||||
touchBackend: (profile?: string | null) => Promise<{ ok: boolean }>
|
||||
getGatewayWsUrl: (profile?: null | string) => Promise<string>
|
||||
getBootProgress: () => Promise<DesktopBootProgress>
|
||||
getConnectionConfig: () => Promise<DesktopConnectionConfig>
|
||||
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
|
||||
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
||||
applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
||||
testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult>
|
||||
@@ -190,6 +190,9 @@ export interface DesktopActiveProfile {
|
||||
export interface DesktopConnectionConfig {
|
||||
envOverride: boolean
|
||||
mode: 'local' | 'remote'
|
||||
// The profile this config describes, or null for the global/default
|
||||
// connection. Per-profile entries let a profile point at its own backend.
|
||||
profile: null | string
|
||||
remoteAuthMode: 'oauth' | 'token'
|
||||
remoteOauthConnected: boolean
|
||||
remoteTokenPreview: string | null
|
||||
@@ -199,6 +202,9 @@ export interface DesktopConnectionConfig {
|
||||
|
||||
export interface DesktopConnectionConfigInput {
|
||||
mode: 'local' | 'remote'
|
||||
// When set, the save/apply/test targets this profile's per-profile remote
|
||||
// override instead of the global connection.
|
||||
profile?: null | string
|
||||
remoteAuthMode?: 'oauth' | 'token'
|
||||
remoteToken?: string
|
||||
remoteUrl?: string
|
||||
|
||||
@@ -871,7 +871,7 @@ delegation:
|
||||
max_iterations: 50 # Max tool-calling turns per child (default: 50)
|
||||
# max_concurrent_children: 3 # Max parallel child agents per batch (default: 3, floor: 1, no ceiling).
|
||||
# WARNING: values above 10 multiply API cost linearly.
|
||||
# max_spawn_depth: 1 # Delegation tree depth cap (range: 1-3, default: 1 = flat).
|
||||
# max_spawn_depth: 1 # Delegation tree depth (floor 1, no ceiling; default: 1 = flat).
|
||||
# Raise to 2 to allow workers to spawn their own subagents.
|
||||
# Requires role="orchestrator" on intermediate agents.
|
||||
# orchestrator_enabled: true # Kill switch for role="orchestrator" children (default: true).
|
||||
|
||||
@@ -13094,6 +13094,16 @@ class HermesCLI:
|
||||
_welcome_color = "#FFF8DC"
|
||||
self._console_print(f"[{_welcome_color}]{_welcome_text}[/]")
|
||||
|
||||
# Warm the /model picker's provider-models cache off-thread during this
|
||||
# idle window (banner shown, user about to type). The no-args picker
|
||||
# otherwise blocks ~1-2s on serial /v1/models fetches the first time
|
||||
# it's opened in a session. Fire-and-forget, guarded once-per-process.
|
||||
try:
|
||||
from hermes_cli.model_switch import prewarm_picker_cache_async
|
||||
prewarm_picker_cache_async()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Redaction opt-out warning (#17691): ON by default, loud when off.
|
||||
# The redactor snapshots its state at import time so any toggle now
|
||||
# won't affect the running process — we just want the operator to
|
||||
|
||||
@@ -3016,6 +3016,17 @@ class BasePlatformAdapter(ABC):
|
||||
expanded = os.path.expanduser(raw)
|
||||
if os.path.isfile(expanded):
|
||||
found.append((raw, expanded))
|
||||
else:
|
||||
# The reply mentions a deliverable-looking path that does not
|
||||
# exist on disk, so it is silently dropped from native delivery.
|
||||
# This is the most common reason a promised file never arrives
|
||||
# (the model said "here's your file" but never wrote it, or
|
||||
# referenced the wrong path). Log it so the gap is visible in
|
||||
# gateway.log rather than vanishing without a trace.
|
||||
logger.info(
|
||||
"Skipping bare file path in reply (no file on disk): %s",
|
||||
_log_safe_path(raw),
|
||||
)
|
||||
|
||||
# Deduplicate by expanded path, preserving discovery order
|
||||
seen: set = set()
|
||||
|
||||
@@ -4073,7 +4073,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return SendResult(success=True, message_id=str(msg.message_id))
|
||||
except Exception as e:
|
||||
print(f"[{self.name}] Failed to send document: {e}")
|
||||
logger.warning("[%s] Failed to send document: %s", self.name, e, exc_info=True)
|
||||
return await super().send_document(chat_id, file_path, caption, file_name, reply_to, metadata=metadata)
|
||||
|
||||
async def send_video(
|
||||
@@ -4120,7 +4120,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return SendResult(success=True, message_id=str(msg.message_id))
|
||||
except Exception as e:
|
||||
print(f"[{self.name}] Failed to send video: {e}")
|
||||
logger.warning("[%s] Failed to send video: %s", self.name, e, exc_info=True)
|
||||
return await super().send_video(chat_id, video_path, caption, reply_to, metadata=metadata)
|
||||
|
||||
async def send_image(
|
||||
|
||||
+59
-2
@@ -11998,13 +11998,24 @@ class GatewayRunner:
|
||||
self._save_voice_modes()
|
||||
if adapter:
|
||||
self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True)
|
||||
return t("gateway.voice.enabled_short")
|
||||
toggle_line = t("gateway.voice.enabled_short")
|
||||
else:
|
||||
self._voice_mode[voice_key] = "off"
|
||||
self._save_voice_modes()
|
||||
if adapter:
|
||||
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
|
||||
return t("gateway.voice.disabled_short")
|
||||
toggle_line = t("gateway.voice.disabled_short")
|
||||
# Bare /voice still toggles, but append an explainer so users
|
||||
# discover the on/off/tts/status subcommands (and, on Discord,
|
||||
# live voice-channel join/leave). The toggle result is shown
|
||||
# first via the {toggle} placeholder.
|
||||
supports_voice_channels = adapter is not None and hasattr(
|
||||
adapter, "join_voice_channel"
|
||||
)
|
||||
channels = (
|
||||
t("gateway.voice.help_channels") if supports_voice_channels else ""
|
||||
)
|
||||
return t("gateway.voice.help", toggle=toggle_line, channels=channels)
|
||||
|
||||
async def _handle_voice_channel_join(self, event: MessageEvent) -> str:
|
||||
"""Join the user's current Discord voice channel."""
|
||||
@@ -17024,6 +17035,47 @@ class GatewayRunner:
|
||||
last_progress_msg = [None] # Track last message for dedup
|
||||
repeat_count = [0] # How many times the same message repeated
|
||||
|
||||
# ── Discord voice "verbal ack before tool calls" ────────────────
|
||||
# When the bot is in a voice channel with the continuous mixer
|
||||
# installed (discord.voice_fx.enabled), speak a short phrase ("let me
|
||||
# look into that") over the ambient idle bed on the FIRST tool call of
|
||||
# the turn. Fires from tool_start_callback (independent of the
|
||||
# tool-progress text gate), at most once per turn. No-op on every
|
||||
# other platform / when not in a voice channel.
|
||||
_voice_ack_fired = [False]
|
||||
_voice_ack_guild: List[Optional[int]] = [None]
|
||||
if source.platform == Platform.DISCORD:
|
||||
_va = self.adapters.get(Platform.DISCORD)
|
||||
# source.chat_id is the linked text channel; resolve the guild whose
|
||||
# voice connection is bound to it (mirrors DiscordAdapter.play_tts).
|
||||
_vtc = getattr(_va, "_voice_text_channels", None)
|
||||
if isinstance(_vtc, dict) and hasattr(_va, "voice_mixer_active"):
|
||||
for _gid, _tc in _vtc.items():
|
||||
if str(_tc) == str(source.chat_id) and _va.voice_mixer_active(_gid):
|
||||
_voice_ack_guild[0] = _gid
|
||||
break
|
||||
_voice_ack_loop = asyncio.get_running_loop()
|
||||
|
||||
def voice_ack_callback(call_id, tool_name, args):
|
||||
"""tool_start_callback: speak a one-time ack in the voice channel."""
|
||||
if _voice_ack_fired[0] or _voice_ack_guild[0] is None:
|
||||
return
|
||||
if not _run_still_current():
|
||||
return
|
||||
_voice_ack_fired[0] = True
|
||||
_adapter = self.adapters.get(Platform.DISCORD)
|
||||
if _adapter is None or not hasattr(_adapter, "play_ack_in_voice"):
|
||||
return
|
||||
try:
|
||||
safe_schedule_threadsafe(
|
||||
_adapter.play_ack_in_voice(_voice_ack_guild[0]),
|
||||
_voice_ack_loop,
|
||||
logger=logger,
|
||||
log_message="voice ack scheduling error",
|
||||
)
|
||||
except Exception as _ack_err:
|
||||
logger.debug("voice ack schedule failed: %s", _ack_err)
|
||||
|
||||
# Auto-cleanup of temporary progress bubbles (Telegram + any adapter
|
||||
# that implements ``delete_message``). When enabled via
|
||||
# ``display.platforms.<platform>.cleanup_progress: true``, message IDs
|
||||
@@ -17834,6 +17886,11 @@ class GatewayRunner:
|
||||
# Per-message state — callbacks and reasoning config change every
|
||||
# turn and must not be baked into the cached agent constructor.
|
||||
agent.tool_progress_callback = progress_callback if tool_progress_enabled else None
|
||||
# Discord voice verbal-ack hook (fires once per turn on first tool
|
||||
# call; armed only when in a voice channel with the mixer running).
|
||||
agent.tool_start_callback = (
|
||||
voice_ack_callback if _voice_ack_guild[0] is not None else None
|
||||
)
|
||||
agent.step_callback = _step_callback_sync if _hooks_ref.loaded_hooks else None
|
||||
agent.stream_delta_callback = _stream_delta_cb
|
||||
agent.interim_assistant_callback = _interim_assistant_cb if _want_interim_messages else None
|
||||
|
||||
+7
-3
@@ -227,7 +227,10 @@ def _read_json_file(path: Path) -> Optional[dict[str, Any]]:
|
||||
return None
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
except (OSError, UnicodeDecodeError):
|
||||
# OSError: file vanished or permission flipped between exists() and
|
||||
# read. UnicodeDecodeError: file holds non-UTF-8 / binary garbage
|
||||
# (a truncated or clobbered status file). Either way it's unusable.
|
||||
return None
|
||||
if not raw:
|
||||
return None
|
||||
@@ -249,8 +252,9 @@ def _read_pid_record(pid_path: Optional[Path] = None) -> Optional[dict]:
|
||||
|
||||
try:
|
||||
raw = pid_path.read_text().strip()
|
||||
except OSError:
|
||||
# File was deleted between exists() and read_text(), or permission flipped.
|
||||
except (OSError, UnicodeDecodeError):
|
||||
# File was deleted between exists() and read_text(), permission
|
||||
# flipped, or it holds non-UTF-8 / binary garbage.
|
||||
return None
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
@@ -225,6 +225,25 @@ def check_for_updates() -> Optional[int]:
|
||||
cache_file = hermes_home / ".update_check"
|
||||
embedded_rev = os.environ.get("HERMES_REVISION") or None
|
||||
|
||||
# Docker images have no working tree to count commits against — the
|
||||
# published image excludes `.git` (see .dockerignore) and sets no
|
||||
# HERMES_REVISION (that's nix-only). Without this guard the checks below
|
||||
# fall through to `check_via_pypi()`, whose PyPI-version mismatch flag (1)
|
||||
# then gets rendered by the CLI banner and the TUI badge as a phantom
|
||||
# "1 commit behind" — even though no git repo or commit math is involved,
|
||||
# and `hermes update` correctly refuses to run in-place inside the
|
||||
# container anyway. The dashboard's REST `/api/hermes/update/check`
|
||||
# endpoint already short-circuits docker the same way (web_server.py);
|
||||
# mirror that here so the banner/TUI surfaces agree. Returning None makes
|
||||
# both the Rich banner (build_welcome_banner) and the Ink badge
|
||||
# (branding.tsx, guarded on `typeof === 'number' && > 0`) show nothing.
|
||||
try:
|
||||
from hermes_cli.config import detect_install_method
|
||||
if detect_install_method() == "docker":
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Read cache — invalidate if the embedded rev OR installed version has
|
||||
# changed since the last check. The version guard matters for pip installs:
|
||||
# `check_via_pypi()` compares against VERSION, so a `pip install --upgrade`
|
||||
|
||||
+1
-85
@@ -1148,41 +1148,6 @@ def slack_subcommand_map() -> dict[str, str]:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Per-process cache for /model<space> LM Studio autocomplete. Probing on
|
||||
# every keystroke would block the UI; a short TTL keeps it live without
|
||||
# hammering the server.
|
||||
_LMSTUDIO_COMPLETION_CACHE: tuple[float, list[str]] | None = None
|
||||
|
||||
|
||||
def _lmstudio_completion_models() -> list[str]:
|
||||
"""Locally-loaded LM Studio models for /model autocomplete (cached, gated)."""
|
||||
global _LMSTUDIO_COMPLETION_CACHE
|
||||
# Gate: don't probe 127.0.0.1 on every keystroke for users who don't use LM Studio.
|
||||
if not (os.environ.get("LM_API_KEY") or os.environ.get("LM_BASE_URL")):
|
||||
try:
|
||||
from hermes_cli.auth import _load_auth_store
|
||||
store = _load_auth_store() or {}
|
||||
if "lmstudio" not in (store.get("providers") or {}) \
|
||||
and "lmstudio" not in (store.get("credential_pool") or {}):
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
now = time.time()
|
||||
if _LMSTUDIO_COMPLETION_CACHE and (now - _LMSTUDIO_COMPLETION_CACHE[0]) < 30.0:
|
||||
return _LMSTUDIO_COMPLETION_CACHE[1]
|
||||
try:
|
||||
from hermes_cli.models import fetch_lmstudio_models
|
||||
models = fetch_lmstudio_models(
|
||||
api_key=os.environ.get("LM_API_KEY", ""),
|
||||
base_url=os.environ.get("LM_BASE_URL") or "http://127.0.0.1:1234/v1",
|
||||
timeout=0.8,
|
||||
)
|
||||
except Exception:
|
||||
models = []
|
||||
_LMSTUDIO_COMPLETION_CACHE = (now, models)
|
||||
return models
|
||||
|
||||
|
||||
class SlashCommandCompleter(Completer):
|
||||
"""Autocomplete for built-in slash commands, subcommands, and skill commands."""
|
||||
|
||||
@@ -1599,52 +1564,6 @@ class SlashCommandCompleter(Completer):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _model_completions(self, sub_text: str, sub_lower: str):
|
||||
"""Yield completions for /model from config aliases + built-in aliases."""
|
||||
seen = set()
|
||||
# Config-based direct aliases (preferred — include provider info)
|
||||
try:
|
||||
from hermes_cli.model_switch import (
|
||||
_ensure_direct_aliases, DIRECT_ALIASES, MODEL_ALIASES,
|
||||
)
|
||||
_ensure_direct_aliases()
|
||||
for name, da in DIRECT_ALIASES.items():
|
||||
if name.startswith(sub_lower) and name != sub_lower:
|
||||
seen.add(name)
|
||||
yield Completion(
|
||||
name,
|
||||
start_position=-len(sub_text),
|
||||
display=name,
|
||||
display_meta=f"{da.model} ({da.provider})",
|
||||
)
|
||||
# Built-in catalog aliases not already covered
|
||||
for name in sorted(MODEL_ALIASES.keys()):
|
||||
if name in seen:
|
||||
continue
|
||||
if name.startswith(sub_lower) and name != sub_lower:
|
||||
identity = MODEL_ALIASES[name]
|
||||
yield Completion(
|
||||
name,
|
||||
start_position=-len(sub_text),
|
||||
display=name,
|
||||
display_meta=f"{identity.vendor}/{identity.family}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# LM Studio: surface locally-loaded models. Gated on the user actually
|
||||
# having LM Studio configured (env var or auth-store entry) so we
|
||||
# don't probe 127.0.0.1 on every keystroke for users who don't use it.
|
||||
for name in _lmstudio_completion_models():
|
||||
if name in seen:
|
||||
continue
|
||||
if name.startswith(sub_lower) and name != sub_lower:
|
||||
yield Completion(
|
||||
name,
|
||||
start_position=-len(sub_text),
|
||||
display=name,
|
||||
display_meta="LM Studio",
|
||||
)
|
||||
|
||||
def get_completions(self, document, complete_event):
|
||||
text = document.text_before_cursor
|
||||
if not text.startswith("/"):
|
||||
@@ -1668,9 +1587,6 @@ class SlashCommandCompleter(Completer):
|
||||
|
||||
# Dynamic completions for commands with runtime lists
|
||||
if " " not in sub_text:
|
||||
if base_cmd == "/model":
|
||||
yield from self._model_completions(sub_text, sub_lower)
|
||||
return
|
||||
if base_cmd == "/skin":
|
||||
yield from self._skin_completions(sub_text, sub_lower)
|
||||
return
|
||||
@@ -1788,7 +1704,7 @@ class SlashCommandAutoSuggest(AutoSuggest):
|
||||
return Suggestion(cmd_name[len(word):])
|
||||
return None
|
||||
|
||||
# Command is complete — suggest subcommands or model names
|
||||
# Command is complete — suggest subcommands
|
||||
sub_text = parts[1] if len(parts) > 1 else ""
|
||||
sub_lower = sub_text.lower()
|
||||
|
||||
|
||||
+43
-5
@@ -1677,9 +1677,9 @@ DEFAULT_CONFIG = {
|
||||
# "low", "minimal", "none" (empty = inherit parent's level)
|
||||
"max_concurrent_children": 3, # max parallel children per batch; floor of 1 enforced, no ceiling
|
||||
# Orchestrator role controls (see tools/delegate_tool.py:_get_max_spawn_depth
|
||||
# and _get_orchestrator_enabled). Values are clamped to [1, 3] with a
|
||||
# warning log if out of range.
|
||||
"max_spawn_depth": 1, # depth cap (1 = flat [default], 2 = orchestrator→leaf, 3 = three-level)
|
||||
# and _get_orchestrator_enabled). Floored at 1, no upper ceiling —
|
||||
# raise deliberately, each level multiplies API cost.
|
||||
"max_spawn_depth": 1, # depth (1 = flat [default], 2 = orchestrator→leaf, 3+ = deeper)
|
||||
"orchestrator_enabled": True, # kill switch for role="orchestrator"
|
||||
# When a subagent hits a dangerous-command approval prompt, the parent's
|
||||
# prompt_toolkit TUI owns stdin — a thread-local input() call from the
|
||||
@@ -1841,6 +1841,28 @@ DEFAULT_CONFIG = {
|
||||
# real memory cost. Default 32 MiB matches the historical hardcoded
|
||||
# cap. Set to 0 for no cap. Env override: DISCORD_MAX_ATTACHMENT_BYTES.
|
||||
"max_attachment_bytes": 33554432,
|
||||
# Voice-channel audio effects (the continuous mixer). OFF by default.
|
||||
# When enabled, the bot installs a software mixer on the outgoing voice
|
||||
# stream so a low ambient "thinking" bed, verbal acknowledgements, and
|
||||
# TTS replies can OVERLAP (ducking the ambient under speech) instead of
|
||||
# stop-and-swap — the Grok-voice-mode feel. discord.py ships no mixer;
|
||||
# this is implemented in plugins/platforms/discord/voice_mixer.py.
|
||||
"voice_fx": {
|
||||
"enabled": False, # master switch for the mixer subsystem
|
||||
"ambient_enabled": True, # play the idle "thinking" bed while tools run
|
||||
"ambient_path": "", # custom loop audio file; "" = synthesised pad
|
||||
"ambient_gain": 0.18, # idle bed loudness, 0.0–1.0
|
||||
"duck_gain": 0.06, # ambient loudness while speech plays
|
||||
"speech_gain": 1.0, # TTS / ack loudness, 0.0–1.0
|
||||
"ack_enabled": True, # speak a short phrase before the first tool call
|
||||
"ack_phrases": [ # picked at random; set [] to disable phrases
|
||||
"Let me look into that.",
|
||||
"One moment.",
|
||||
"Checking on that now.",
|
||||
"Give me a sec.",
|
||||
"On it.",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
# WhatsApp platform settings (gateway mode)
|
||||
@@ -2253,6 +2275,22 @@ DEFAULT_CONFIG = {
|
||||
# disable backups entirely, set ``pre_update_backup: false`` above
|
||||
# rather than ``backup_keep: 0``.
|
||||
"backup_keep": 5,
|
||||
# What `hermes update` does with uncommitted local changes to the
|
||||
# source tree when it runs NON-interactively — i.e. triggered from
|
||||
# the desktop/chat app or the gateway, where there's no TTY to answer
|
||||
# a restore prompt. Interactive (terminal) updates are unaffected:
|
||||
# they always stash the changes and ask whether to restore, exactly
|
||||
# as they always have.
|
||||
# "stash" — auto-stash the changes, pull, then auto-restore them
|
||||
# on top of the updated code (the safe default; nothing
|
||||
# is ever lost — conflicts are preserved in a git stash).
|
||||
# "discard" — auto-stash the changes and throw the stash away after
|
||||
# the pull. Use this only if you never intend to keep
|
||||
# local edits to the source tree on this machine.
|
||||
# Stash-and-drop (not `reset --hard` + `clean -fd`) so
|
||||
# ignored paths — node_modules, venv, build outputs —
|
||||
# are never touched.
|
||||
"non_interactive_local_changes": "stash",
|
||||
},
|
||||
|
||||
# Language Server Protocol — semantic diagnostics from real
|
||||
@@ -2382,7 +2420,7 @@ DEFAULT_CONFIG = {
|
||||
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 26,
|
||||
"_config_version": 27,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
@@ -3937,7 +3975,7 @@ _KNOWN_ROOT_KEYS = {
|
||||
"fallback_providers", "credential_pool_strategies", "toolsets",
|
||||
"agent", "terminal", "display", "compression", "delegation",
|
||||
"auxiliary", "custom_providers", "context", "memory", "gateway",
|
||||
"sessions", "streaming",
|
||||
"sessions", "streaming", "updates",
|
||||
}
|
||||
|
||||
# Valid fields inside a custom_providers list entry
|
||||
|
||||
+4
-1
@@ -81,7 +81,10 @@ def cron_list(show_all: bool = False):
|
||||
state = job.get("state", "scheduled" if job.get("enabled", True) else "paused")
|
||||
next_run = job.get("next_run_at", "?")
|
||||
|
||||
repeat_info = job.get("repeat", {})
|
||||
# `repeat` may be present-but-null in the job record (e.g. a one-shot
|
||||
# job persisted with "repeat": null), so coalesce to {} rather than
|
||||
# relying on the dict-default, which only applies to a missing key.
|
||||
repeat_info = job.get("repeat") or {}
|
||||
repeat_times = repeat_info.get("times")
|
||||
repeat_completed = repeat_info.get("completed", 0)
|
||||
repeat_str = f"{repeat_completed}/{repeat_times}" if repeat_times else "∞"
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
|
||||
Three cookies in play:
|
||||
- hermes_session_at: the OAuth access token
|
||||
(HttpOnly, lifetime = token TTL)
|
||||
(HttpOnly, lifetime = token TTL, ~15 min)
|
||||
- hermes_session_rt: the OAuth refresh token
|
||||
(HttpOnly, lifetime = 30 days)
|
||||
**DEPRECATED in OAuth contract v1** — Nous Portal
|
||||
does not issue refresh tokens; we keep the cookie
|
||||
name and clear semantics for forward compatibility
|
||||
and to flush stale cookies from old browsers.
|
||||
(HttpOnly, lifetime = 24h, ROTATING + reuse-detected)
|
||||
Nous Portal issues a rotating refresh token for the
|
||||
dashboard auth-code grant (Portal NAS #293 / hermes
|
||||
#37247). ``set_session_cookies`` writes this cookie
|
||||
whenever the provider returns a non-empty
|
||||
``refresh_token``; the middleware uses it to rotate a
|
||||
fresh access token transparently on AT expiry. A
|
||||
provider that omits the refresh token (empty string)
|
||||
degrades gracefully to access-token-only sessions —
|
||||
the RT cookie is simply not written.
|
||||
- hermes_session_pkce: short-lived PKCE state + CSRF nonce + provider
|
||||
hint (HttpOnly, lifetime = 10 minutes)
|
||||
|
||||
@@ -39,13 +44,15 @@ The setters and readers BOTH consult the active prefix because the
|
||||
cookie *name* changes — a reader that looked up the bare name when the
|
||||
setter wrote ``__Secure-hermes_session_at`` would never find the value.
|
||||
|
||||
.. deprecated:: contract v1
|
||||
``set_session_cookies`` accepts ``refresh_token=""`` (the contract-v1
|
||||
default) and silently skips writing the RT cookie in that case.
|
||||
``clear_session_cookies`` still emits a Max-Age=0 deletion for the RT
|
||||
cookie so users carrying a stale cookie from an earlier deployment get
|
||||
it cleared on logout / session expiry. The full refresh-flow machinery
|
||||
was rewritten as "401 → redirect to /login" in Phase 6.
|
||||
Refresh-token handling:
|
||||
``set_session_cookies`` accepts ``refresh_token=""`` (provider omitted
|
||||
it) and silently skips writing the RT cookie in that case, so a
|
||||
refresh-token-less provider degrades to access-token-only sessions.
|
||||
``clear_session_cookies`` always emits a Max-Age=0 deletion for the RT
|
||||
cookie on logout / session expiry so a stale cookie from an earlier
|
||||
deployment gets cleared. The transparent rotation flow ("expired AT +
|
||||
live RT → rotate server-side, else 401 → /login") lives in
|
||||
``middleware._attempt_refresh``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -66,7 +73,13 @@ PKCE_COOKIE = "hermes_session_pkce"
|
||||
# practice — a single request emits exactly one variant).
|
||||
_NAME_VARIANTS = ("__Host-", "__Secure-", "")
|
||||
|
||||
# 30 days — matches Portal's REFRESH_TOKEN_TTL_SECONDS
|
||||
# RT cookie Max-Age. Kept at 30 days as a generous upper bound on the cookie's
|
||||
# browser lifetime; Portal's actual refresh-token TTL (24h, rotating) is the
|
||||
# real authority — once the RT itself expires/rotates out, a refresh attempt
|
||||
# returns 400 → RefreshExpiredError → clean re-login, regardless of how long
|
||||
# the cookie lingers. (Not tightened to 24h here to avoid coupling the cookie
|
||||
# lifetime to a server-side TTL that can change independently; revisit if the
|
||||
# stale-cookie refresh churn ever matters.)
|
||||
_RT_MAX_AGE = 30 * 24 * 60 * 60
|
||||
_PKCE_MAX_AGE = 10 * 60
|
||||
|
||||
@@ -126,11 +139,11 @@ def set_session_cookies(
|
||||
``access_token_expires_in`` is in seconds. Use the provider's reported
|
||||
TTL for the access token.
|
||||
|
||||
``refresh_token`` is accepted for backward / forward compatibility but
|
||||
SKIPPED when empty — Nous Portal contract v1 issues no refresh tokens
|
||||
so a ``Session.refresh_token == ""`` from the provider means we don't
|
||||
persist anything. If a future contract revision starts emitting refresh
|
||||
tokens, this helper will write the RT cookie again with no other change.
|
||||
``refresh_token`` is written as the RT cookie when non-empty. Nous Portal
|
||||
issues a 24h rotating refresh token (hermes #37247); a provider that
|
||||
omits it returns ``Session.refresh_token == ""`` and we simply don't
|
||||
persist the RT cookie — the session then behaves as access-token-only
|
||||
until the AT expires. No other branch changes between the two cases.
|
||||
|
||||
``prefix`` is the normalised X-Forwarded-Prefix value (e.g. ``/hermes``)
|
||||
or ``""`` for a direct deploy. It influences both the cookie name
|
||||
|
||||
@@ -4382,6 +4382,35 @@ def _setup_standard_platform(platform: dict):
|
||||
if not prompt_yes_no(f" Reconfigure {label}?", False):
|
||||
return
|
||||
|
||||
auto_token_saved = False
|
||||
auto_owner_user_id = None
|
||||
if platform.get("key") == "telegram":
|
||||
print()
|
||||
print_info(" Telegram can be configured automatically with a managed bot:")
|
||||
print_info(" [1] Automatic (scan QR → confirm in Telegram → done)")
|
||||
print_info(" [2] Manual BotFather token")
|
||||
choice = prompt(" Choice [1/2]", default="1")
|
||||
if choice.strip() == "1":
|
||||
try:
|
||||
from hermes_cli.telegram_managed_bot import (
|
||||
auto_setup_telegram_bot_result,
|
||||
is_valid_telegram_bot_token,
|
||||
)
|
||||
except ImportError:
|
||||
print_warning(" Automatic setup is unavailable in this install.")
|
||||
else:
|
||||
result = auto_setup_telegram_bot_result()
|
||||
if result and is_valid_telegram_bot_token(result.token):
|
||||
save_env_value(token_var, result.token)
|
||||
print_success(" Saved TELEGRAM_BOT_TOKEN")
|
||||
auto_token_saved = True
|
||||
auto_owner_user_id = result.owner_user_id
|
||||
else:
|
||||
if result:
|
||||
print_warning(" Automatic setup returned an invalid Telegram token.")
|
||||
print()
|
||||
print_info(" Falling back to manual setup...")
|
||||
|
||||
allowed_val_set = None # Track if user set an allowlist (for home channel offer)
|
||||
|
||||
for var in platform["vars"]:
|
||||
@@ -4391,8 +4420,30 @@ def _setup_standard_platform(platform: dict):
|
||||
if existing and var["name"] != token_var:
|
||||
print_info(f" Current: {existing}")
|
||||
|
||||
if auto_token_saved and var["name"] == token_var:
|
||||
print_info(" Token saved by automatic setup.")
|
||||
continue
|
||||
|
||||
# Allowlist fields get special handling for the deny-by-default security model
|
||||
if var.get("is_allowlist"):
|
||||
if "TELEGRAM" in var["name"] and auto_owner_user_id:
|
||||
detected_id = str(auto_owner_user_id)
|
||||
print_success(f" Detected your Telegram user ID: {detected_id}")
|
||||
if prompt_yes_no(" Allow this Telegram account to use the bot?", True):
|
||||
extra = prompt(
|
||||
" Additional allowed user IDs (comma-separated, optional)",
|
||||
password=False,
|
||||
)
|
||||
ids = [detected_id]
|
||||
for uid in extra.replace(" ", "").split(","):
|
||||
if uid and uid not in ids:
|
||||
ids.append(uid)
|
||||
cleaned = ",".join(ids)
|
||||
save_env_value(var["name"], cleaned)
|
||||
print_success(" Saved — only these users can interact with the bot.")
|
||||
allowed_val_set = cleaned
|
||||
continue
|
||||
|
||||
print_info(" The gateway DENIES all users by default for security.")
|
||||
print_info(" Enter user IDs to create an allowlist, or leave empty")
|
||||
print_info(" and you'll be asked about open access next.")
|
||||
|
||||
+169
-213
@@ -1264,6 +1264,32 @@ def _workspace_root(dir: Path) -> Path:
|
||||
return dir
|
||||
|
||||
|
||||
def _termux_workspace_install_context(
|
||||
dir: Path, *, include_child_workspaces: bool = False
|
||||
) -> tuple[Path, tuple[str, ...]]:
|
||||
"""Return Termux-only ``(cwd, npm_args)`` for installing deps for *dir* only."""
|
||||
ws_root = _workspace_root(dir)
|
||||
if ws_root == dir:
|
||||
return dir, ()
|
||||
|
||||
try:
|
||||
workspace = dir.relative_to(ws_root).as_posix()
|
||||
except ValueError:
|
||||
return ws_root, ()
|
||||
|
||||
workspace_args: list[str] = ["--workspace", workspace]
|
||||
if include_child_workspaces:
|
||||
packages_dir = dir / "packages"
|
||||
if packages_dir.is_dir():
|
||||
for child in sorted(packages_dir.iterdir()):
|
||||
if child.is_dir() and (child / "package.json").is_file():
|
||||
workspace_args.extend(
|
||||
["--workspace", child.relative_to(ws_root).as_posix()]
|
||||
)
|
||||
workspace_args.append("--include-workspace-root=false")
|
||||
return ws_root, tuple(workspace_args)
|
||||
|
||||
|
||||
def _tui_need_npm_install(root: Path) -> bool:
|
||||
"""True when @hermes/ink is missing or node_modules is behind package-lock.json.
|
||||
|
||||
@@ -1524,16 +1550,43 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
|
||||
# 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js.
|
||||
# --dev flow: npm install if needed, then tsx src/entry.tsx.
|
||||
# npm install runs from the workspace root (where package-lock.json lives);
|
||||
# npm workspaces resolves ui-tui deps automatically.
|
||||
# Existing desktop behaviour runs npm from the workspace root. Termux
|
||||
# scopes the install to ui-tui so launch does not pull desktop/web
|
||||
# dependencies into the hot path.
|
||||
did_install = False
|
||||
if _tui_need_npm_install(tui_dir):
|
||||
termux_startup = _is_termux_startup_environment()
|
||||
termux_need_rebuild = False
|
||||
if termux_startup and not tui_dev:
|
||||
termux_need_rebuild = _tui_need_rebuild(tui_dir)
|
||||
|
||||
skip_install_for_fresh_termux_bundle = (
|
||||
termux_startup and not tui_dev and not termux_need_rebuild
|
||||
)
|
||||
if (
|
||||
not skip_install_for_fresh_termux_bundle
|
||||
and _tui_need_npm_install(tui_dir)
|
||||
):
|
||||
npm = _node_bin("npm")
|
||||
if not os.environ.get("HERMES_QUIET"):
|
||||
print("Installing TUI dependencies…")
|
||||
npm_cwd = _workspace_root(tui_dir)
|
||||
npm_workspace_args: tuple[str, ...] = ()
|
||||
if termux_startup:
|
||||
npm_cwd, npm_workspace_args = _termux_workspace_install_context(
|
||||
tui_dir,
|
||||
include_child_workspaces=True,
|
||||
)
|
||||
result = subprocess.run(
|
||||
[npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"],
|
||||
cwd=str(_workspace_root(tui_dir)),
|
||||
[
|
||||
npm,
|
||||
"install",
|
||||
*npm_workspace_args,
|
||||
"--silent",
|
||||
"--no-fund",
|
||||
"--no-audit",
|
||||
"--progress=false",
|
||||
],
|
||||
cwd=str(npm_cwd),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
@@ -1579,8 +1632,8 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
# Termux cold starts use the freshness check because esbuild startup is
|
||||
# expensive on old mobile CPUs.
|
||||
should_build = True
|
||||
if _is_termux_startup_environment():
|
||||
should_build = did_install or _tui_need_rebuild(tui_dir)
|
||||
if termux_startup:
|
||||
should_build = did_install or termux_need_rebuild
|
||||
|
||||
if should_build:
|
||||
npm = _node_bin("npm")
|
||||
@@ -7004,10 +7057,14 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
if text:
|
||||
_say(text)
|
||||
|
||||
npm_cwd = _workspace_root(web_dir)
|
||||
npm_workspace_args: tuple[str, ...] = ()
|
||||
if _is_termux_startup_environment():
|
||||
npm_cwd, npm_workspace_args = _termux_workspace_install_context(web_dir)
|
||||
r1 = _run_npm_install_deterministic(
|
||||
npm,
|
||||
_workspace_root(web_dir),
|
||||
extra_args=("--silent",),
|
||||
npm_cwd,
|
||||
extra_args=(*npm_workspace_args, "--silent"),
|
||||
)
|
||||
if r1.returncode != 0:
|
||||
_say(
|
||||
@@ -8148,59 +8205,6 @@ def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[st
|
||||
return stash_ref
|
||||
|
||||
|
||||
def _clean_managed_worktree(git_cmd: list[str], cwd: Path) -> bool:
|
||||
"""Discard working-tree dirt on a managed (non-fork) clone.
|
||||
|
||||
On a managed install (%LOCALAPPDATA%\\hermes\\hermes-agent or
|
||||
~/.hermes/hermes-agent) the user never edits the source tree, so any
|
||||
"dirty" state is pure git artifact: CRLF renormalization, npm lockfile
|
||||
churn, or files left behind when a directory was deleted upstream (e.g.
|
||||
apps/bootstrap-installer/). Stashing that dirt and re-applying it after a
|
||||
pull is actively dangerous — the stash/restore cycle has been observed to
|
||||
clobber freshly-pulled source files (apps/desktop/ deletion →
|
||||
"[UNRESOLVED_ENTRY] Cannot resolve entry module index.html").
|
||||
|
||||
For a managed clone the correct move is to throw the dirt away with
|
||||
``git reset --hard HEAD`` + ``git clean -fd`` (mirroring install.ps1's
|
||||
update path), NOT preserve it. Forks keep the stash machinery because
|
||||
their local edits are intentional.
|
||||
|
||||
Returns True if the tree was cleaned (or was already clean), False on
|
||||
a git failure (caller should fall back to the stash path).
|
||||
"""
|
||||
status = subprocess.run(
|
||||
git_cmd + ["status", "--porcelain"],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if status.returncode != 0:
|
||||
return False
|
||||
if not status.stdout.strip():
|
||||
return True
|
||||
|
||||
print("→ Discarding working-tree changes on managed clone before update...")
|
||||
reset = subprocess.run(
|
||||
git_cmd + ["reset", "--hard", "HEAD"],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if reset.returncode != 0:
|
||||
return False
|
||||
# Drop untracked files too (e.g. orphaned build artifacts), but never
|
||||
# touch ignored paths — node_modules, venv, build outputs, and the like
|
||||
# are expensive to rebuild and not git-artifact dirt.
|
||||
subprocess.run(
|
||||
git_cmd + ["clean", "-fd"],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
def _resolve_stash_selector(
|
||||
git_cmd: list[str], cwd: Path, stash_ref: str
|
||||
) -> Optional[str]:
|
||||
@@ -8341,6 +8345,54 @@ def _restore_stashed_changes(
|
||||
return True
|
||||
|
||||
|
||||
def _discard_stashed_changes(
|
||||
git_cmd: list[str],
|
||||
cwd: Path,
|
||||
stash_ref: str,
|
||||
) -> bool:
|
||||
"""Throw away a stash created before an update, without applying it.
|
||||
|
||||
Used only on a NON-interactive update when the user has set
|
||||
``updates.non_interactive_local_changes: discard`` — i.e. they've opted out
|
||||
of keeping local source edits on this machine. Drops the stash entry
|
||||
instead of re-applying it, so the working tree stays clean at the freshly
|
||||
pulled HEAD. Unlike ``git reset --hard`` + ``git clean -fd``, this only
|
||||
affects what was stashed (tracked changes + the untracked files we
|
||||
explicitly captured) — ignored paths like node_modules/venv/build outputs
|
||||
are never touched, since they were never stashed.
|
||||
|
||||
Returns True if the stash was dropped, False on a git failure (in which
|
||||
case the stash is left in place for safety).
|
||||
"""
|
||||
stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)
|
||||
if stash_selector is None:
|
||||
print(
|
||||
"⚠ Configured to discard local changes on non-interactive update, "
|
||||
"but Hermes couldn't find the stash entry to drop."
|
||||
)
|
||||
_print_stash_cleanup_guidance(stash_ref)
|
||||
return False
|
||||
|
||||
drop = subprocess.run(
|
||||
git_cmd + ["stash", "drop", stash_selector],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if drop.returncode != 0:
|
||||
print(
|
||||
"⚠ Configured to discard local changes, but Hermes couldn't drop "
|
||||
"the saved stash entry."
|
||||
)
|
||||
if drop.stderr.strip():
|
||||
print(f" {drop.stderr.strip().splitlines()[0]}")
|
||||
_print_stash_cleanup_guidance(stash_ref, stash_selector)
|
||||
return False
|
||||
|
||||
print("→ Discarded local source changes (updates.non_interactive_local_changes=discard).")
|
||||
return True
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Fork detection and upstream management for `hermes update`
|
||||
# =========================================================================
|
||||
@@ -10131,62 +10183,6 @@ def _cmd_update_pip(args):
|
||||
print("✓ Update complete! Restart hermes to use the new version.")
|
||||
|
||||
|
||||
def _should_handoff_after_pull(finalize_only: bool) -> bool:
|
||||
"""Whether to hand the post-pull steps off to a fresh subprocess on new
|
||||
code.
|
||||
|
||||
Returns False (finish in-process) when:
|
||||
- this IS already the finalize subprocess (avoid an infinite loop),
|
||||
- under pytest, so the test runner's interpreter never spawns a real
|
||||
recursive update mid-suite,
|
||||
- the ``HERMES_UPDATE_NO_HANDOFF`` escape hatch is set.
|
||||
|
||||
Cross-platform: unlike an ``os.exec*`` replacement (which on Windows
|
||||
spawns a *new* PID and breaks the desktop installer's exit-code wait on
|
||||
the original process), a child subprocess + exit-code forwarding keeps the
|
||||
parent PID intact everywhere, so this stays on for Windows too.
|
||||
"""
|
||||
if finalize_only or os.environ.get("HERMES_UPDATE_FINALIZE") == "1":
|
||||
return False
|
||||
if os.environ.get("HERMES_UPDATE_NO_HANDOFF") == "1":
|
||||
return False
|
||||
if "pytest" in sys.modules:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _handoff_update_to_refreshed_code():
|
||||
"""Finish the update in a fresh subprocess running the just-pulled code.
|
||||
|
||||
Called after a successful git pull + dependency install (so the new source
|
||||
AND its deps are on disk). Spawns ``hermes update`` again with
|
||||
``HERMES_UPDATE_FINALIZE=1`` set; that child skips the fetch/pull/snapshot
|
||||
work and this hand-off, and runs only the post-pull finalize steps with
|
||||
new code. stdin/stdout/stderr are inherited, so interactive prompts and a
|
||||
parent streaming our output both keep working.
|
||||
|
||||
Returns the child's exit code, or ``None`` if the subprocess could not be
|
||||
launched at all — in which case the caller finishes the update in-process,
|
||||
the historical behavior.
|
||||
"""
|
||||
try:
|
||||
env = dict(os.environ)
|
||||
env["HERMES_UPDATE_FINALIZE"] = "1"
|
||||
cmd = [sys.executable, "-m", "hermes_cli.main", *sys.argv[1:]]
|
||||
print("→ Finishing update with refreshed code...")
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
result = subprocess.run(cmd, cwd=PROJECT_ROOT, env=env)
|
||||
return result.returncode
|
||||
except Exception as exc: # pragma: no cover - spawn almost never fails
|
||||
logger.warning(
|
||||
"update: could not hand off to refreshed code (%s); "
|
||||
"finishing in-process",
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _cmd_update_impl(args, gateway_mode: bool):
|
||||
"""Body of ``cmd_update`` — kept separate so the wrapper can always
|
||||
restore stdio even on ``sys.exit``."""
|
||||
@@ -10198,18 +10194,31 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
)
|
||||
assume_yes = bool(getattr(args, "yes", False))
|
||||
|
||||
# Self-update hand-off marker. ``hermes update`` runs from the *old*
|
||||
# install, so its post-pull steps (dep install, config migration, gateway
|
||||
# restart) would otherwise execute stale in-memory code even though the new
|
||||
# source is already on disk. After a successful pull we re-exec into the
|
||||
# refreshed code with HERMES_UPDATE_FINALIZE=1; ``finalize_only`` is True on
|
||||
# that second pass and makes us skip the fetch/pull work and the re-exec.
|
||||
finalize_only = os.environ.get("HERMES_UPDATE_FINALIZE") == "1"
|
||||
# Whether this update is running without a human at the keyboard.
|
||||
# Interactive terminal updates always stash-and-ask (unchanged behavior);
|
||||
# only non-interactive updates (desktop/chat app, gateway, `--yes`) consult
|
||||
# the `updates.non_interactive_local_changes` config setting to decide
|
||||
# whether to auto-restore stashed local source changes or throw them away.
|
||||
_non_interactive_update = (
|
||||
gateway_mode
|
||||
or assume_yes
|
||||
or not (sys.stdin.isatty() and sys.stdout.isatty())
|
||||
)
|
||||
discard_local_changes = False
|
||||
if _non_interactive_update:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
if finalize_only:
|
||||
print("⚕ Finalizing update with refreshed code...")
|
||||
else:
|
||||
print("⚕ Updating Hermes Agent...")
|
||||
_update_cfg = (load_config() or {}).get("updates", {})
|
||||
if isinstance(_update_cfg, dict):
|
||||
_mode = str(_update_cfg.get("non_interactive_local_changes", "stash")).lower()
|
||||
discard_local_changes = _mode == "discard"
|
||||
except Exception as exc:
|
||||
# Never let a config read failure change the safe default.
|
||||
logger.debug("Could not read updates.non_interactive_local_changes: %s", exc)
|
||||
discard_local_changes = False
|
||||
|
||||
print("⚕ Updating Hermes Agent...")
|
||||
print()
|
||||
|
||||
# On Windows, abort early if another hermes.exe is holding the venv shim
|
||||
@@ -10225,10 +10234,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
sys.exit(2)
|
||||
|
||||
# Pre-update backup — runs before any git/file mutation so users can
|
||||
# always roll back to the exact state they had before this update. Skipped
|
||||
# on the finalize re-exec (the original pass already took it).
|
||||
if not finalize_only:
|
||||
_run_pre_update_backup(args)
|
||||
# always roll back to the exact state they had before this update.
|
||||
_run_pre_update_backup(args)
|
||||
|
||||
# Try git-based update first, fall back to ZIP download on Windows
|
||||
# when git file I/O is broken (antivirus, NTFS filter drivers, etc.)
|
||||
@@ -10272,21 +10279,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
if sys.platform == "win32":
|
||||
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
|
||||
|
||||
# On Windows, Git-for-Windows defaults to core.autocrlf=true, which
|
||||
# renormalizes the repo's LF-only text files to CRLF in the working tree.
|
||||
# On a managed, never-user-edited clone that makes tracked files read as
|
||||
# "locally modified", which forces an autostash on every update (and the
|
||||
# stash/restore cycle can clobber source files — see _stash_local_changes_
|
||||
# if_needed below). Pin autocrlf=false so the dirt is never created. This
|
||||
# mirrors what install.ps1's update path already does (PR #38239).
|
||||
if sys.platform == "win32" and git_dir.exists():
|
||||
subprocess.run(
|
||||
git_cmd + ["config", "core.autocrlf", "false"],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# Discard npm lockfile churn before any stash/branch logic. npm rewrites
|
||||
# tracked package-lock.json files non-deterministically at install/build
|
||||
# time (platform-specific optional deps, ideallyInert annotations, etc.),
|
||||
@@ -10364,14 +10356,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
else f"branch '{current_branch}'"
|
||||
)
|
||||
print(f" ⚠ Currently on {label} — switching to {branch} for update...")
|
||||
# Stash before checkout so uncommitted work isn't lost — but on a
|
||||
# managed (non-fork) clone there's nothing to preserve, so discard
|
||||
# git-artifact dirt instead (a dirty tree would otherwise block the
|
||||
# checkout). Forks keep the stash so their edits survive.
|
||||
if not is_fork and _clean_managed_worktree(git_cmd, PROJECT_ROOT):
|
||||
auto_stash_ref = None
|
||||
else:
|
||||
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
|
||||
# Stash before checkout so uncommitted work isn't lost
|
||||
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
|
||||
checkout_result = subprocess.run(
|
||||
git_cmd + ["checkout", branch],
|
||||
cwd=PROJECT_ROOT,
|
||||
@@ -10405,16 +10391,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
print(f" {track_result.stderr.strip().splitlines()[0]}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
# On a managed (non-fork) clone the user never edits the source
|
||||
# tree, so any dirt is git artifact (CRLF, lockfile churn,
|
||||
# upstream-deleted dirs). Throw it away rather than stash/restore
|
||||
# it — the stash/restore cycle has clobbered freshly-pulled source
|
||||
# (apps/desktop/ → "[UNRESOLVED_ENTRY] index.html"). Forks fall
|
||||
# through to the stash path so their intentional edits survive.
|
||||
if not is_fork and _clean_managed_worktree(git_cmd, PROJECT_ROOT):
|
||||
auto_stash_ref = None
|
||||
else:
|
||||
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
|
||||
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
|
||||
|
||||
prompt_for_restore = (
|
||||
auto_stash_ref is not None
|
||||
@@ -10432,11 +10409,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
)
|
||||
commit_count = int(result.stdout.strip())
|
||||
|
||||
# On the finalize re-exec the pull already happened in the original
|
||||
# pass, so origin is level with HEAD (count == 0). Don't take the
|
||||
# "Already up to date" early return — fall through and run the
|
||||
# post-pull steps (now with refreshed code).
|
||||
if commit_count == 0 and not finalize_only:
|
||||
if commit_count == 0:
|
||||
_invalidate_update_cache()
|
||||
|
||||
# Even if origin is up to date, the fork may be behind upstream
|
||||
@@ -10463,30 +10436,24 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
print("✓ Already up to date!")
|
||||
return
|
||||
|
||||
# The "found N commits" notice and pre-update snapshot belong to the
|
||||
# original pass only — the finalize re-exec sees count == 0 and the
|
||||
# snapshot was already taken before the pull.
|
||||
print(f"→ Found {commit_count} new commit(s)")
|
||||
|
||||
# Snapshot critical state (state.db, config, pairing JSONs, etc.)
|
||||
# before pulling so a user can recover if something goes wrong.
|
||||
# Issue #15733 reported missing pairing data after an update; even
|
||||
# though `git pull` can't touch $HERMES_HOME, this is cheap
|
||||
# belt-and-suspenders insurance and gives the user something to
|
||||
# restore from via `/snapshot list` / `/snapshot restore <id>`.
|
||||
pre_update_snapshot_id = None
|
||||
if not finalize_only:
|
||||
print(f"→ Found {commit_count} new commit(s)")
|
||||
try:
|
||||
from hermes_cli.backup import create_quick_snapshot
|
||||
|
||||
# Snapshot critical state (state.db, config, pairing JSONs, etc.)
|
||||
# before pulling so a user can recover if something goes wrong.
|
||||
# Issue #15733 reported missing pairing data after an update; even
|
||||
# though `git pull` can't touch $HERMES_HOME, this is cheap
|
||||
# belt-and-suspenders insurance and gives the user something to
|
||||
# restore from via `/snapshot list` / `/snapshot restore <id>`.
|
||||
try:
|
||||
from hermes_cli.backup import create_quick_snapshot
|
||||
|
||||
pre_update_snapshot_id = create_quick_snapshot(
|
||||
label="pre-update", keep=1
|
||||
)
|
||||
if pre_update_snapshot_id:
|
||||
print(f" ✓ Pre-update snapshot: {pre_update_snapshot_id}")
|
||||
except Exception as exc:
|
||||
# Never let a snapshot failure block an update.
|
||||
logger.debug("Pre-update snapshot failed: %s", exc)
|
||||
pre_update_snapshot_id = create_quick_snapshot(label="pre-update", keep=1)
|
||||
if pre_update_snapshot_id:
|
||||
print(f" ✓ Pre-update snapshot: {pre_update_snapshot_id}")
|
||||
except Exception as exc:
|
||||
# Never let a snapshot failure block an update.
|
||||
logger.debug("Pre-update snapshot failed: %s", exc)
|
||||
|
||||
print("→ Pulling updates...")
|
||||
update_succeeded = False
|
||||
@@ -10576,6 +10543,15 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
f" ℹ️ Local changes preserved in stash (ref: {auto_stash_ref})"
|
||||
)
|
||||
print(f" Restore manually with: git stash apply")
|
||||
elif discard_local_changes:
|
||||
# Non-interactive update + user opted into discarding local
|
||||
# source edits (updates.non_interactive_local_changes:
|
||||
# discard). Throw the stash away instead of re-applying it.
|
||||
_discard_stashed_changes(
|
||||
git_cmd,
|
||||
PROJECT_ROOT,
|
||||
auto_stash_ref,
|
||||
)
|
||||
else:
|
||||
_restore_stashed_changes(
|
||||
git_cmd,
|
||||
@@ -10658,26 +10634,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
|
||||
_refresh_active_lazy_features()
|
||||
|
||||
# Hand off the remaining post-pull work (node deps, web/desktop build,
|
||||
# config migration, skills sync, gateway restart) to the freshly-pulled
|
||||
# code. These are the most frequently-changed and historically most
|
||||
# fragile update steps, yet they used to run from the modules this
|
||||
# process imported at startup — so a bug fixed in the pulled version
|
||||
# still crashed here, forcing users to run ``hermes update`` twice. The
|
||||
# git pull AND dependency install are done, so the new source and its
|
||||
# deps are on disk; finish in a fresh subprocess running new code and
|
||||
# forward its exit code. On the finalize pass (or under pytest /
|
||||
# opt-out) we skip the hand-off and finish in-process — see
|
||||
# _should_handoff_after_pull.
|
||||
if _should_handoff_after_pull(finalize_only):
|
||||
handoff_rc = _handoff_update_to_refreshed_code()
|
||||
if handoff_rc is not None:
|
||||
# The child ran every remaining post-pull step on new code;
|
||||
# forward its result and stop (cmd_update's finally still
|
||||
# restores stdio on the way out).
|
||||
sys.exit(handoff_rc)
|
||||
# else: spawning the child failed — fall through and finish here.
|
||||
|
||||
_update_node_dependencies()
|
||||
_build_web_ui(PROJECT_ROOT / "web")
|
||||
|
||||
|
||||
@@ -51,12 +51,48 @@ def resolve_uv() -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def ensure_uv() -> Optional[str]:
|
||||
"""Return the managed uv path, installing it first if necessary.
|
||||
class _UvResult(str):
|
||||
"""``ensure_uv()`` return value that survives an update boundary.
|
||||
|
||||
On failure returns ``None`` (never raises) so callers can fall
|
||||
back to pip gracefully.
|
||||
``ensure_uv()``'s arity has flipped between a single path string and a
|
||||
``(path, fresh_bootstrap)`` tuple across releases. ``hermes update`` runs
|
||||
the call site from the *old*, already-imported ``hermes_cli.main`` against
|
||||
this *freshly pulled* module, so the two can disagree on how many values
|
||||
``ensure_uv()`` returns. An install parked on a 2-tuple release runs
|
||||
``uv_bin, fresh_bootstrap = ensure_uv()`` against the single-value module
|
||||
and crashes the first update: the returned path is a plain ``str``, which is
|
||||
itself iterable, so the 2-target unpack walks its characters and raises
|
||||
``ValueError: too many values to unpack (expected 2)`` (and on the failure
|
||||
path the ``None`` return raises ``TypeError: cannot unpack non-iterable
|
||||
NoneType``). This wrapper answers to both conventions:
|
||||
|
||||
uv_bin = ensure_uv() # behaves as the path str ("" when absent)
|
||||
uv_bin, fresh = ensure_uv() # unpacks as (path|None, fresh_bootstrap)
|
||||
|
||||
Missing uv is the empty string (falsy) instead of ``None`` so legacy
|
||||
2-target call sites can still unpack a failure without raising, while
|
||||
``if not uv_bin`` keeps working for single-value callers.
|
||||
|
||||
POSIX only. This wrapper is **never** returned on Windows — see
|
||||
``ensure_uv()`` for why the ``__iter__`` override is unsafe there.
|
||||
"""
|
||||
|
||||
fresh_bootstrap: bool
|
||||
|
||||
def __new__(cls, path: Optional[str], fresh: bool = False) -> "_UvResult":
|
||||
self = super().__new__(cls, path or "")
|
||||
self.fresh_bootstrap = fresh
|
||||
return self
|
||||
|
||||
def __iter__(self):
|
||||
# Tuple-unpacking hook for legacy ``uv_bin, fresh = ensure_uv()`` sites.
|
||||
# First element mirrors the historical contract: the path string, or
|
||||
# ``None`` when uv is unavailable.
|
||||
return iter(((str(self) or None), self.fresh_bootstrap))
|
||||
|
||||
|
||||
def _ensure_uv_path() -> Optional[str]:
|
||||
"""Resolve the managed uv path, installing it if necessary (plain ``str``/``None``)."""
|
||||
existing = resolve_uv()
|
||||
if existing:
|
||||
return existing
|
||||
@@ -88,6 +124,37 @@ def ensure_uv() -> Optional[str]:
|
||||
return result
|
||||
|
||||
|
||||
def ensure_uv():
|
||||
"""Return the managed uv path, installing it first if necessary.
|
||||
|
||||
On **POSIX** the result is a :class:`_UvResult` (a ``str`` subclass) that is
|
||||
both usable directly as the path *and* unpackable as
|
||||
``(path, fresh_bootstrap)`` for older call sites parked on a 2-tuple
|
||||
release — see :class:`_UvResult` for the update-boundary rationale.
|
||||
|
||||
On **Windows** we deliberately return a plain ``str``/``None`` instead.
|
||||
``subprocess`` there serializes the argv via ``subprocess.list2cmdline``,
|
||||
which iterates every entry *as a string* (``for c in arg``). The dependency
|
||||
installer passes uv straight into the command list (``[uv_bin, "pip", ...]``),
|
||||
so a ``_UvResult`` — whose ``__iter__`` yields ``(path, fresh_bootstrap)``
|
||||
rather than characters — would inject the bool into the command line and
|
||||
crash the install with ``TypeError: sequence item 1: expected str instance,
|
||||
bool found``. A plain ``str`` matches the historical Windows contract and is
|
||||
subprocess-safe. (A single value cannot satisfy both 2-target unpacking and
|
||||
Windows char-iteration: both use the iterator protocol, with contradictory
|
||||
results.)
|
||||
|
||||
On failure the result is falsy — never raises — so callers can fall back to
|
||||
pip gracefully.
|
||||
"""
|
||||
result = _ensure_uv_path()
|
||||
if platform.system() == "Windows":
|
||||
# See docstring: a str subclass with an overridden __iter__ is unsafe as
|
||||
# a Windows subprocess argument. Hand back the plain path (or None).
|
||||
return result
|
||||
return _UvResult(result)
|
||||
|
||||
|
||||
def update_managed_uv() -> Optional[str]:
|
||||
"""Run ``uv self update`` on the managed uv binary.
|
||||
|
||||
|
||||
@@ -1117,6 +1117,62 @@ def switch_model(
|
||||
# Authenticated providers listing (for /model no-args display)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Process-level guard so the picker prewarm thread is spawned at most once per
|
||||
# process — mirrors run_agent's _openrouter_prewarm_done. Without a guard a
|
||||
# long-lived process (or repeated triggers) would leak one OS thread per call.
|
||||
import threading as _threading # noqa: E402
|
||||
|
||||
_picker_prewarm_done = _threading.Event()
|
||||
|
||||
|
||||
def prewarm_picker_cache_async() -> Optional["_threading.Thread"]:
|
||||
"""Warm the provider-models disk cache in a background daemon thread.
|
||||
|
||||
The no-args ``/model`` picker calls ``list_authenticated_providers()``,
|
||||
which fetches each authenticated provider's live ``/v1/models`` list on a
|
||||
cold/stale cache. Those fetches are independent HTTP round-trips but run
|
||||
serially, so the first ``/model`` open in a session (or any open after the
|
||||
1h cache TTL expires) blocks ~1-2s on the user's critical path.
|
||||
|
||||
This pre-warms that exact path off-thread during idle session time: it
|
||||
runs ``list_authenticated_providers()`` once, which populates
|
||||
``provider_models_cache.json`` for every authed provider. By the time the
|
||||
user types ``/model``, the picker hits the warm disk cache and renders in
|
||||
~100ms.
|
||||
|
||||
Fire-and-forget. Process-level Event guard ensures it runs at most once.
|
||||
Fully exception-isolated — a slow or offline provider can never affect the
|
||||
session. Returns the spawned thread (for tests) or None if already warmed.
|
||||
"""
|
||||
if _picker_prewarm_done.is_set():
|
||||
return None
|
||||
_picker_prewarm_done.set()
|
||||
|
||||
def _warm() -> None:
|
||||
try:
|
||||
from hermes_cli.inventory import load_picker_context
|
||||
|
||||
ctx = load_picker_context()
|
||||
# Calling this is what populates cached_provider_model_ids() ->
|
||||
# provider_models_cache.json for each authed provider. We discard
|
||||
# the result; the side effect (warm disk cache) is the point.
|
||||
list_authenticated_providers(
|
||||
current_provider=ctx.current_provider,
|
||||
current_base_url=ctx.current_base_url,
|
||||
current_model=ctx.current_model,
|
||||
user_providers=ctx.user_providers,
|
||||
custom_providers=ctx.custom_providers,
|
||||
max_models=50,
|
||||
)
|
||||
except Exception:
|
||||
# Best-effort warmup — never surface errors into the session.
|
||||
logger.debug("picker cache prewarm failed", exc_info=True)
|
||||
|
||||
t = _threading.Thread(target=_warm, daemon=True, name="picker-cache-prewarm")
|
||||
t.start()
|
||||
return t
|
||||
|
||||
|
||||
def list_authenticated_providers(
|
||||
current_provider: str = "",
|
||||
current_base_url: str = "",
|
||||
|
||||
+35
-6
@@ -1150,17 +1150,46 @@ _PROVIDER_ALIASES = {
|
||||
}
|
||||
|
||||
|
||||
# Cost-safe overrides for the *silent* auto-default
|
||||
# (``get_default_model_for_provider``). Most providers' curated lists lead with a
|
||||
# sensible default, but Nous Portal is a per-token *metered aggregator* whose
|
||||
# list is ordered best-/most-capable-first — entry [0] is the priciest flagship
|
||||
# (``anthropic/claude-opus-4.8``, $5/$25 per Mtok). Using that as the
|
||||
# non-interactive fallback when a profile sets ``provider: nous`` with no model
|
||||
# silently bills the most expensive model for traffic the user never opted into
|
||||
# (a missing default escalated to Opus and billed 863 requests before the user
|
||||
# noticed). Pin the silent default to a low-cost curated model instead so a
|
||||
# missing model can never escalate to the flagship.
|
||||
#
|
||||
# This is deliberately a fixed, side-effect-free default for the hot resolution
|
||||
# path. The *interactive* default (GUI onboarding / ``hermes model``) uses the
|
||||
# richer free/paid-tier-aware resolver — see ``get_recommended_default_model``
|
||||
# in hermes_cli/web_server.py and ``partition_nous_models_by_tier`` — which can
|
||||
# hit the Portal; this fallback must stay cheap and network-free.
|
||||
_PROVIDER_SILENT_DEFAULT_OVERRIDES: dict[str, str] = {
|
||||
"nous": "deepseek/deepseek-v4-flash",
|
||||
}
|
||||
|
||||
|
||||
def get_default_model_for_provider(provider: str) -> str:
|
||||
"""Return the default model for a provider, or empty string if unknown.
|
||||
"""Return a cost-safe default model for a provider, or "" if unknown.
|
||||
|
||||
Uses the first entry in _PROVIDER_MODELS as the default. This is the
|
||||
model a user would be offered first in the ``hermes model`` picker.
|
||||
Used as a NON-INTERACTIVE fallback when a provider is configured but no
|
||||
model was ever selected (e.g. ``hermes auth add openai-codex`` without
|
||||
``hermes model``, or a profile that sets ``provider`` with no ``model``).
|
||||
|
||||
Used as a fallback when the user has configured a provider but never
|
||||
selected a model (e.g. ``hermes auth add openai-codex`` without
|
||||
``hermes model``).
|
||||
For most providers this is the first entry in ``_PROVIDER_MODELS`` — the
|
||||
same model the ``hermes model`` picker offers first. For metered aggregators
|
||||
whose curated list is ordered most-capable-first, that entry is also the
|
||||
most EXPENSIVE one, so silently defaulting to it is a billing footgun. Such
|
||||
providers carry an explicit low-cost override in
|
||||
``_PROVIDER_SILENT_DEFAULT_OVERRIDES``; a missing model must never
|
||||
auto-escalate to the flagship.
|
||||
"""
|
||||
models = _PROVIDER_MODELS.get(provider, [])
|
||||
override = _PROVIDER_SILENT_DEFAULT_OVERRIDES.get(provider)
|
||||
if override and override in models:
|
||||
return override
|
||||
return models[0] if models else ""
|
||||
|
||||
|
||||
|
||||
@@ -159,6 +159,33 @@ def _has_agent_browser() -> bool:
|
||||
return bool(agent_browser_bin or local_bin.exists())
|
||||
|
||||
|
||||
def _local_browser_runnable() -> bool:
|
||||
"""Return True when the *local* browser backend would actually start.
|
||||
|
||||
The ``agent-browser`` CLI being present is necessary but not sufficient for
|
||||
local mode: agent-browser also needs a Chromium build on disk (without one
|
||||
it hangs on first use until the command timeout fires), unless the
|
||||
Lightpanda engine is selected — text-only navigation needs no Chromium.
|
||||
|
||||
This mirrors the local-mode tail of
|
||||
:func:`tools.browser_tool.check_browser_requirements`, so the setup/status
|
||||
surfaces advertise local browser readiness only when the runtime would
|
||||
actually run it. Cloud providers (Browserbase, Browser Use, Firecrawl) host
|
||||
their own Chromium and therefore gate on :func:`_has_agent_browser` alone.
|
||||
"""
|
||||
if not _has_agent_browser():
|
||||
return False
|
||||
try:
|
||||
from tools.browser_tool import _chromium_installed, _using_lightpanda_engine
|
||||
except Exception:
|
||||
# If the runtime probe can't be imported, fall back to binary presence
|
||||
# (prior behaviour) rather than crashing the setup/status surface.
|
||||
return True
|
||||
if _using_lightpanda_engine():
|
||||
return True
|
||||
return _chromium_installed()
|
||||
|
||||
|
||||
def _browser_label(current_provider: str) -> str:
|
||||
mapping = {
|
||||
"browserbase": "Browserbase",
|
||||
@@ -188,13 +215,23 @@ def _resolve_browser_feature_state(
|
||||
browser_provider: str,
|
||||
browser_provider_explicit: bool,
|
||||
browser_local_available: bool,
|
||||
browser_local_runnable: bool,
|
||||
direct_camofox: bool,
|
||||
direct_browserbase: bool,
|
||||
direct_browser_use: bool,
|
||||
direct_firecrawl: bool,
|
||||
managed_browser_available: bool,
|
||||
) -> tuple[str, bool, bool, bool]:
|
||||
"""Resolve browser availability using the same precedence as runtime."""
|
||||
"""Resolve browser availability using the same precedence as runtime.
|
||||
|
||||
``browser_local_available`` means "the agent-browser CLI is present" — the
|
||||
only local requirement for cloud providers, which host their own Chromium.
|
||||
``browser_local_runnable`` additionally requires a usable local Chromium
|
||||
build (or the Lightpanda engine), mirroring the local-mode tail of
|
||||
:func:`tools.browser_tool.check_browser_requirements`. Local mode must gate
|
||||
on the latter, or setup/status advertise a browser that fails on first use
|
||||
when Chromium is missing.
|
||||
"""
|
||||
if direct_camofox:
|
||||
return "camofox", True, bool(browser_tool_enabled), False
|
||||
|
||||
@@ -223,7 +260,7 @@ def _resolve_browser_feature_state(
|
||||
return current_provider, False, False, False
|
||||
|
||||
current_provider = "local"
|
||||
available = bool(browser_local_available)
|
||||
available = bool(browser_local_runnable)
|
||||
active = bool(browser_tool_enabled and available)
|
||||
return current_provider, available, active, False
|
||||
|
||||
@@ -243,7 +280,7 @@ def _resolve_browser_feature_state(
|
||||
active = bool(browser_tool_enabled and available)
|
||||
return "browserbase", available, active, False
|
||||
|
||||
available = bool(browser_local_available)
|
||||
available = bool(browser_local_runnable)
|
||||
active = bool(browser_tool_enabled and available)
|
||||
return "local", available, active, False
|
||||
|
||||
@@ -445,6 +482,7 @@ def get_nous_subscription_features(
|
||||
tts_active = bool(tts_tool_enabled and tts_available)
|
||||
|
||||
browser_local_available = _has_agent_browser()
|
||||
browser_local_runnable = _local_browser_runnable()
|
||||
(
|
||||
browser_current_provider,
|
||||
browser_available,
|
||||
@@ -455,6 +493,7 @@ def get_nous_subscription_features(
|
||||
browser_provider=browser_provider,
|
||||
browser_provider_explicit=browser_provider_explicit,
|
||||
browser_local_available=browser_local_available,
|
||||
browser_local_runnable=browser_local_runnable,
|
||||
direct_camofox=direct_camofox,
|
||||
direct_browserbase=direct_browserbase,
|
||||
direct_browser_use=direct_browser_use,
|
||||
|
||||
+104
-17
@@ -415,7 +415,9 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||
elif browser_provider == "Camofox":
|
||||
missing_browser_hint = "CAMOFOX_URL"
|
||||
elif browser_provider == "Local browser":
|
||||
missing_browser_hint = "npm install -g agent-browser"
|
||||
missing_browser_hint = (
|
||||
"npm install -g agent-browser && agent-browser install --with-deps"
|
||||
)
|
||||
tool_status.append(
|
||||
("Browser Automation", False, missing_browser_hint)
|
||||
)
|
||||
@@ -1637,6 +1639,52 @@ def setup_agent_settings(config: dict):
|
||||
# =============================================================================
|
||||
|
||||
|
||||
_TELEGRAM_BOT_TOKEN_RE = re.compile(r"^\d+:[A-Za-z0-9_-]{30,}$")
|
||||
|
||||
|
||||
def _is_valid_telegram_bot_token(token: str) -> bool:
|
||||
return bool(_TELEGRAM_BOT_TOKEN_RE.match(token))
|
||||
|
||||
|
||||
def _setup_telegram_auto_result():
|
||||
"""Attempt automatic Telegram bot creation via managed QR onboarding."""
|
||||
try:
|
||||
from hermes_cli.telegram_managed_bot import auto_setup_telegram_bot_result
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
profile_name: str | None = None
|
||||
try:
|
||||
hermes_home = str(get_hermes_home())
|
||||
if "/profiles/" in hermes_home:
|
||||
profile_name = hermes_home.rstrip("/").rsplit("/", 1)[-1]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return auto_setup_telegram_bot_result(profile_name=profile_name)
|
||||
|
||||
|
||||
def _setup_telegram_auto() -> str | None:
|
||||
"""Attempt automatic Telegram bot creation and return only the token."""
|
||||
result = _setup_telegram_auto_result()
|
||||
return result.token if result else None
|
||||
|
||||
|
||||
def _prompt_telegram_bot_token() -> str | None:
|
||||
print_info("Create a bot via @BotFather on Telegram")
|
||||
while True:
|
||||
token = prompt("Telegram bot token", password=True)
|
||||
if not token:
|
||||
return None
|
||||
if not _is_valid_telegram_bot_token(token):
|
||||
print_error(
|
||||
"Invalid token format. Expected: <numeric_id>:<alphanumeric_hash> "
|
||||
"(e.g., 123456789:ABCdefGHI-jklMNOpqrSTUvwxYZ)"
|
||||
)
|
||||
continue
|
||||
return token
|
||||
|
||||
|
||||
def _setup_telegram():
|
||||
"""Configure Telegram bot credentials and allowlist."""
|
||||
print_header("Telegram")
|
||||
@@ -1655,20 +1703,40 @@ def _setup_telegram():
|
||||
print_success("Telegram allowlist configured")
|
||||
return
|
||||
|
||||
print_info("Create a bot via @BotFather on Telegram")
|
||||
import re
|
||||
print_info("How would you like to create your Telegram bot?")
|
||||
print()
|
||||
print_info(" [1] Automatic (recommended)")
|
||||
print_info(" Scan a QR code → confirm in Telegram → done.")
|
||||
print_info(" No token copy-paste needed.")
|
||||
print()
|
||||
print_info(" [2] Manual")
|
||||
print_info(" Create a bot via @BotFather yourself and paste the token.")
|
||||
print()
|
||||
|
||||
while True:
|
||||
token = prompt("Telegram bot token", password=True)
|
||||
choice = prompt("Choice [1/2]", default="1")
|
||||
token = None
|
||||
setup_result = None
|
||||
|
||||
if choice.strip() == "1":
|
||||
setup_result = _setup_telegram_auto_result()
|
||||
if setup_result:
|
||||
token = setup_result.token
|
||||
if not _is_valid_telegram_bot_token(token):
|
||||
print_error("Automatic setup returned an invalid Telegram bot token.")
|
||||
token = None
|
||||
setup_result = None
|
||||
else:
|
||||
token = None
|
||||
if not token:
|
||||
return
|
||||
if not re.match(r"^\d+:[A-Za-z0-9_-]{30,}$", token):
|
||||
print_error(
|
||||
"Invalid token format. Expected: <numeric_id>:<alphanumeric_hash> "
|
||||
"(e.g., 123456789:ABCdefGHI-jklMNOpqrSTUvwxYZ)"
|
||||
)
|
||||
continue
|
||||
break
|
||||
print()
|
||||
print_info("Falling back to manual setup...")
|
||||
print()
|
||||
|
||||
if not token:
|
||||
token = _prompt_telegram_bot_token()
|
||||
if not token:
|
||||
return
|
||||
|
||||
save_env_value("TELEGRAM_BOT_TOKEN", token)
|
||||
print_success("Telegram token saved")
|
||||
|
||||
@@ -1678,11 +1746,30 @@ def _setup_telegram():
|
||||
print_info(" 1. Message @userinfobot on Telegram")
|
||||
print_info(" 2. It will reply with your numeric ID (e.g., 123456789)")
|
||||
print()
|
||||
allowed_users = prompt(
|
||||
"Allowed user IDs (comma-separated, leave empty for open access)"
|
||||
)
|
||||
|
||||
detected_user_id = getattr(setup_result, "owner_user_id", None)
|
||||
if detected_user_id:
|
||||
detected_id = str(detected_user_id)
|
||||
print_success(f"Detected your Telegram user ID: {detected_id}")
|
||||
if prompt_yes_no("Allow this Telegram account to use the bot?", True):
|
||||
extra = prompt("Additional allowed user IDs (comma-separated, optional)")
|
||||
ids = [detected_id]
|
||||
for uid in extra.replace(" ", "").split(","):
|
||||
if uid and uid not in ids:
|
||||
ids.append(uid)
|
||||
allowed_users = ",".join(ids)
|
||||
else:
|
||||
allowed_users = prompt(
|
||||
"Allowed user IDs (comma-separated, leave empty for open access)"
|
||||
)
|
||||
else:
|
||||
allowed_users = prompt(
|
||||
"Allowed user IDs (comma-separated, leave empty for open access)"
|
||||
)
|
||||
|
||||
if allowed_users:
|
||||
save_env_value("TELEGRAM_ALLOWED_USERS", allowed_users.replace(" ", ""))
|
||||
allowed_users = allowed_users.replace(" ", "")
|
||||
save_env_value("TELEGRAM_ALLOWED_USERS", allowed_users)
|
||||
print_success("Telegram allowlist configured - only listed users can use the bot")
|
||||
else:
|
||||
print_info("⚠️ No allowlist set - anyone who finds your bot can use it!")
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
"""Telegram Managed Bot onboarding client.
|
||||
|
||||
Uses Telegram's Managed Bots feature to create a user-owned child bot without
|
||||
manual BotFather token copy-paste. Hermes talks only to the Nous onboarding
|
||||
service; the raw Telegram token is saved locally after one-time retrieval.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
# Default pairing API base URL (Nous-hosted Cloudflare Worker).
|
||||
# Override for PoC/staging with TELEGRAM_ONBOARDING_URL.
|
||||
DEFAULT_API_URL = "https://setup.hermes-agent.nousresearch.com"
|
||||
TELEGRAM_ONBOARDING_URL_ENV = "TELEGRAM_ONBOARDING_URL"
|
||||
|
||||
# The Nous-hosted manager bot username (without @). The backend returns the
|
||||
# actual deep link, so this is only used by local helpers/tests.
|
||||
DEFAULT_MANAGER_BOT = "HermesSetupBot"
|
||||
|
||||
DEFAULT_BOT_NAME = "Hermes Agent"
|
||||
DEFAULT_POLL_TIMEOUT = 180
|
||||
POLL_INTERVAL = 2
|
||||
|
||||
_USERNAME_SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"
|
||||
_TELEGRAM_BOT_TOKEN_RE = re.compile(r"^\d+:[A-Za-z0-9_-]{30,}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TelegramPairing:
|
||||
"""Pairing record returned by the Telegram onboarding service."""
|
||||
|
||||
pairing_id: str
|
||||
poll_token: str
|
||||
suggested_username: str
|
||||
deep_link: str
|
||||
qr_payload: str
|
||||
expires_at: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TelegramBotSetupResult:
|
||||
"""Successful Telegram onboarding result returned by the setup service."""
|
||||
|
||||
token: str
|
||||
bot_username: str | None = None
|
||||
owner_user_id: int | None = None
|
||||
|
||||
|
||||
def _api_url(api_url: str | None = None) -> str:
|
||||
"""Resolve the onboarding API URL, honoring the PoC env override."""
|
||||
return (
|
||||
api_url or os.environ.get(TELEGRAM_ONBOARDING_URL_ENV) or DEFAULT_API_URL
|
||||
).rstrip("/")
|
||||
|
||||
|
||||
def is_valid_telegram_bot_token(token: object) -> bool:
|
||||
"""Return True when *token* has Telegram's bot-token shape."""
|
||||
return isinstance(token, str) and bool(_TELEGRAM_BOT_TOKEN_RE.match(token))
|
||||
|
||||
|
||||
def _parse_owner_user_id(value: object) -> int | None:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return value if value > 0 else None
|
||||
if isinstance(value, str) and value.isdecimal():
|
||||
parsed = int(value)
|
||||
return parsed if parsed > 0 else None
|
||||
return None
|
||||
|
||||
|
||||
def render_qr_terminal(url: str) -> str:
|
||||
"""Render a URL as a QR code string suitable for terminal output."""
|
||||
try:
|
||||
import io
|
||||
|
||||
import qrcode # type: ignore[import-untyped]
|
||||
|
||||
qr = qrcode.QRCode(
|
||||
version=None,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||
box_size=1,
|
||||
border=1,
|
||||
)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
|
||||
buf = io.StringIO()
|
||||
qr.print_ascii(out=buf, invert=True)
|
||||
return buf.getvalue()
|
||||
except ImportError:
|
||||
return ""
|
||||
|
||||
|
||||
def print_qr_code(url: str, *, include_link: bool = True) -> None:
|
||||
"""Print a QR code to stdout, with URL fallback if qrcode is missing."""
|
||||
qr_text = render_qr_terminal(url)
|
||||
if qr_text:
|
||||
print(qr_text)
|
||||
else:
|
||||
print(" (Install 'qrcode' for a scannable QR code: pip install qrcode)")
|
||||
if include_link:
|
||||
print(f" Link: {url}")
|
||||
|
||||
|
||||
def generate_username_slug(length: int = 16) -> str:
|
||||
"""Generate a base32-ish slug for Telegram username correlation.
|
||||
|
||||
Sixteen characters from a 32-symbol alphabet gives 80 bits of entropy while
|
||||
keeping ``hermes_<slug>_bot`` under Telegram's 32-character username limit.
|
||||
"""
|
||||
return "".join(secrets.choice(_USERNAME_SLUG_ALPHABET) for _ in range(length))
|
||||
|
||||
|
||||
def generate_bot_username(profile_name: Optional[str] = None) -> str:
|
||||
"""Generate a secure suggested bot username like ``hermes_<slug>_bot``.
|
||||
|
||||
``profile_name`` is accepted for backward compatibility with the original
|
||||
PoC, but is intentionally not embedded in the username. The username has to
|
||||
carry enough entropy for backend correlation.
|
||||
"""
|
||||
_ = profile_name
|
||||
return f"hermes_{generate_username_slug()}_bot"
|
||||
|
||||
|
||||
def generate_deep_link(
|
||||
manager_bot: str = DEFAULT_MANAGER_BOT,
|
||||
suggested_username: Optional[str] = None,
|
||||
suggested_name: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Build a ``t.me/newbot`` deep link for managed bot creation."""
|
||||
manager = manager_bot.lstrip("@")
|
||||
username = suggested_username or generate_bot_username()
|
||||
base_url = (
|
||||
"https://t.me/newbot/"
|
||||
f"{urllib.parse.quote(manager)}/"
|
||||
f"{urllib.parse.quote(username)}"
|
||||
)
|
||||
|
||||
if suggested_name:
|
||||
params = urllib.parse.urlencode({"name": suggested_name})
|
||||
return f"{base_url}?{params}"
|
||||
return base_url
|
||||
|
||||
|
||||
def generate_pairing_nonce() -> str:
|
||||
"""Generate a legacy-compatible random nonce string.
|
||||
|
||||
The new protocol uses service-created ``pairing_id`` + bearer
|
||||
``poll_token`` instead of a path nonce, but this helper is harmless and
|
||||
still useful for callers/tests that need a generic random id.
|
||||
"""
|
||||
return secrets.token_hex(16)
|
||||
|
||||
|
||||
def create_pairing(
|
||||
api_url: str | None = None,
|
||||
bot_name: str = DEFAULT_BOT_NAME,
|
||||
timeout: float = 10.0,
|
||||
) -> TelegramPairing | None:
|
||||
"""Create a Telegram onboarding pairing.
|
||||
|
||||
``POST /v1/telegram/pairings`` returns the deep link, QR payload, public
|
||||
pairing id, and secret poll token. The token is only used as a bearer
|
||||
credential while polling.
|
||||
"""
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{_api_url(api_url)}/v1/telegram/pairings",
|
||||
json={"bot_name": bot_name},
|
||||
timeout=timeout,
|
||||
)
|
||||
if resp.status_code not in (200, 201):
|
||||
return None
|
||||
data = resp.json()
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return None
|
||||
|
||||
required = ("pairing_id", "poll_token", "suggested_username", "deep_link")
|
||||
if not all(isinstance(data.get(key), str) and data.get(key) for key in required):
|
||||
return None
|
||||
|
||||
qr_payload = data.get("qr_payload") or data["deep_link"]
|
||||
if not isinstance(qr_payload, str):
|
||||
return None
|
||||
|
||||
expires_at = data.get("expires_at")
|
||||
return TelegramPairing(
|
||||
pairing_id=data["pairing_id"],
|
||||
poll_token=data["poll_token"],
|
||||
suggested_username=data["suggested_username"],
|
||||
deep_link=data["deep_link"],
|
||||
qr_payload=qr_payload,
|
||||
expires_at=expires_at if isinstance(expires_at, str) else None,
|
||||
)
|
||||
|
||||
|
||||
def poll_pairing_result_once(
|
||||
api_url: str | None,
|
||||
pairing: TelegramPairing,
|
||||
timeout: float = 10.0,
|
||||
) -> TelegramBotSetupResult | None:
|
||||
"""Poll the onboarding service once. Returns setup metadata when ready."""
|
||||
resp = httpx.get(
|
||||
f"{_api_url(api_url)}/v1/telegram/pairings/{pairing.pairing_id}",
|
||||
headers={"Authorization": f"Bearer {pairing.poll_token}"},
|
||||
timeout=timeout,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
|
||||
data = resp.json()
|
||||
if data.get("status") != "ready":
|
||||
return None
|
||||
token = data.get("token")
|
||||
if not is_valid_telegram_bot_token(token):
|
||||
return None
|
||||
|
||||
bot_username = data.get("bot_username")
|
||||
return TelegramBotSetupResult(
|
||||
token=token,
|
||||
bot_username=bot_username
|
||||
if isinstance(bot_username, str) and bot_username
|
||||
else None,
|
||||
owner_user_id=_parse_owner_user_id(data.get("owner_user_id")),
|
||||
)
|
||||
|
||||
|
||||
def poll_pairing_once(
|
||||
api_url: str | None,
|
||||
pairing: TelegramPairing,
|
||||
timeout: float = 10.0,
|
||||
) -> str | None:
|
||||
"""Poll the onboarding service once. Returns the token when ready."""
|
||||
result = poll_pairing_result_once(api_url, pairing, timeout=timeout)
|
||||
return result.token if result else None
|
||||
|
||||
|
||||
def poll_for_setup_result(
|
||||
api_url: str | None,
|
||||
pairing: TelegramPairing,
|
||||
timeout: float = DEFAULT_POLL_TIMEOUT,
|
||||
interval: float = POLL_INTERVAL,
|
||||
) -> Optional[TelegramBotSetupResult]:
|
||||
"""Poll the pairing API until setup metadata is available or timeout."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
result = poll_pairing_result_once(api_url, pairing)
|
||||
if result:
|
||||
return result
|
||||
except (httpx.HTTPError, ValueError):
|
||||
pass
|
||||
time.sleep(interval)
|
||||
return None
|
||||
|
||||
|
||||
def poll_for_token(
|
||||
api_url: str | None,
|
||||
pairing: TelegramPairing,
|
||||
timeout: float = DEFAULT_POLL_TIMEOUT,
|
||||
interval: float = POLL_INTERVAL,
|
||||
) -> Optional[str]:
|
||||
"""Poll the pairing API until the bot token is available or timeout."""
|
||||
result = poll_for_setup_result(api_url, pairing, timeout=timeout, interval=interval)
|
||||
return result.token if result else None
|
||||
|
||||
|
||||
def auto_setup_telegram_bot_result(
|
||||
api_url: str | None = None,
|
||||
manager_bot: str = DEFAULT_MANAGER_BOT,
|
||||
profile_name: Optional[str] = None,
|
||||
poll_timeout: float = DEFAULT_POLL_TIMEOUT,
|
||||
) -> Optional[TelegramBotSetupResult]:
|
||||
"""Run the full automatic Telegram bot creation flow."""
|
||||
_ = manager_bot, profile_name
|
||||
resolved_api_url = _api_url(api_url)
|
||||
print()
|
||||
print(f" Contacting Hermes Telegram onboarding service: {resolved_api_url}")
|
||||
sys.stdout.flush()
|
||||
pairing = create_pairing(resolved_api_url)
|
||||
if not pairing:
|
||||
print(" ✗ Could not reach the Hermes Telegram onboarding service.")
|
||||
print(" Try the manual setup instead, or check your network.")
|
||||
return None
|
||||
|
||||
print(" ✓ Pairing created")
|
||||
print(" Rendering QR code...")
|
||||
sys.stdout.flush()
|
||||
print()
|
||||
print(" Scan this QR code with your phone, or open the link below:")
|
||||
print()
|
||||
print_qr_code(pairing.qr_payload, include_link=False)
|
||||
print()
|
||||
print(f" Link: {pairing.deep_link}")
|
||||
print()
|
||||
print(" When Telegram opens, tap 'Create Bot' to confirm.")
|
||||
print(" (You can edit the bot display name before confirming)")
|
||||
print()
|
||||
|
||||
spinner_chars = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
||||
start = time.monotonic()
|
||||
deadline = start + poll_timeout
|
||||
idx = 0
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
char = spinner_chars[idx % len(spinner_chars)]
|
||||
elapsed = int(time.monotonic() - start)
|
||||
remaining = max(0, int(poll_timeout - elapsed))
|
||||
sys.stdout.write(
|
||||
f"\r {char} Waiting for bot creation... ({remaining}s remaining) "
|
||||
)
|
||||
sys.stdout.flush()
|
||||
idx += 1
|
||||
|
||||
try:
|
||||
result = poll_pairing_result_once(resolved_api_url, pairing)
|
||||
if result:
|
||||
sys.stdout.write(
|
||||
"\r ✓ Bot created successfully! \n"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
return result
|
||||
except (httpx.HTTPError, ValueError):
|
||||
pass
|
||||
time.sleep(POLL_INTERVAL)
|
||||
|
||||
sys.stdout.write("\r ✗ Timed out waiting for bot creation. \n")
|
||||
sys.stdout.flush()
|
||||
print(" The bot may still be created — check Telegram.")
|
||||
print(" You can paste the token manually below, or re-run setup.")
|
||||
return None
|
||||
|
||||
|
||||
def auto_setup_telegram_bot(
|
||||
api_url: str | None = None,
|
||||
manager_bot: str = DEFAULT_MANAGER_BOT,
|
||||
profile_name: Optional[str] = None,
|
||||
poll_timeout: float = DEFAULT_POLL_TIMEOUT,
|
||||
) -> Optional[str]:
|
||||
"""Run automatic Telegram bot creation and return only the bot token."""
|
||||
result = auto_setup_telegram_bot_result(
|
||||
api_url=api_url,
|
||||
manager_bot=manager_bot,
|
||||
profile_name=profile_name,
|
||||
poll_timeout=poll_timeout,
|
||||
)
|
||||
return result.token if result else None
|
||||
@@ -439,6 +439,16 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
|
||||
"description": "Reasoning effort for delegated subagents",
|
||||
"options": ["", "low", "medium", "high"],
|
||||
},
|
||||
"updates.non_interactive_local_changes": {
|
||||
"type": "select",
|
||||
"description": (
|
||||
"When the chat app / gateway updates Hermes (no terminal prompt), "
|
||||
"what to do with uncommitted local source edits. 'stash' keeps them "
|
||||
"and re-applies them after the update; 'discard' throws them away. "
|
||||
"Terminal updates always ask, regardless of this setting."
|
||||
),
|
||||
"options": ["stash", "discard"],
|
||||
},
|
||||
}
|
||||
|
||||
# Categories with fewer fields get merged into "general" to avoid tab sprawl.
|
||||
@@ -455,6 +465,7 @@ _CATEGORY_MERGE: Dict[str, str] = {
|
||||
"code_execution": "agent",
|
||||
"prompt_caching": "agent",
|
||||
"goals": "agent",
|
||||
"updates": "general",
|
||||
# Only `telegram.reactions` currently lives under telegram — fold it in
|
||||
# with the other messaging-platform config (discord) so it isn't an
|
||||
# orphan tab of one field.
|
||||
|
||||
@@ -343,6 +343,8 @@ gateway:
|
||||
label_off: "Af (slegs teks)"
|
||||
label_voice_only: "Aan (stemantwoord op stemboodskappe)"
|
||||
label_all: "TTS (stemantwoord op alle boodskappe)"
|
||||
help: "{toggle}\n\n**Hoe /voice werk**\n• `/voice on` — stemantwoord wanneer jy 'n stemboodskap stuur\n• `/voice tts` — stemantwoord op *elke* boodskap\n• `/voice off` — terug na slegs-teks antwoorde\n• `/voice status` — wys die huidige modus\n• `/voice` (geen argument) — wissel vinnig tussen aan en af{channels}"
|
||||
help_channels: "\n\n**Lewendige stemkanale (Discord)**\n• Sluit eers by 'n stemkanaal aan, dan `/voice channel` — ek sluit aan, luister en praat my antwoorde\n• `/voice leave` — ontkoppel van die stemkanaal"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ YOLO-modus **AF** vir hierdie sessie — gevaarlike opdragte sal goedkeuring vereis."
|
||||
|
||||
@@ -343,6 +343,8 @@ gateway:
|
||||
label_off: "Aus (nur Text)"
|
||||
label_voice_only: "An (Sprachantwort auf Sprachnachrichten)"
|
||||
label_all: "TTS (Sprachantwort auf alle Nachrichten)"
|
||||
help: "{toggle}\n\n**So funktioniert /voice**\n• `/voice on` — Sprachantwort, wenn du eine Sprachnachricht sendest\n• `/voice tts` — Sprachantwort auf *jede* Nachricht\n• `/voice off` — zurück zu reinen Textantworten\n• `/voice status` — aktuellen Modus anzeigen\n• `/voice` (ohne Argument) — schnelles Umschalten zwischen an und aus{channels}"
|
||||
help_channels: "\n\n**Live-Sprachkanäle (Discord)**\n• Tritt zuerst einem Sprachkanal bei, dann `/voice channel` — ich trete bei, höre zu und spreche meine Antworten\n• `/voice leave` — vom Sprachkanal trennen"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ YOLO-Modus für diese Sitzung **AUS** — gefährliche Befehle benötigen eine Genehmigung."
|
||||
|
||||
@@ -358,6 +358,8 @@ gateway:
|
||||
label_off: "Off (text only)"
|
||||
label_voice_only: "On (voice reply to voice messages)"
|
||||
label_all: "TTS (voice reply to all messages)"
|
||||
help: "{toggle}\n\n**How /voice works**\n• `/voice on` — voice reply when you send a voice message\n• `/voice tts` — voice reply to *every* message\n• `/voice off` — back to text-only replies\n• `/voice status` — show the current mode\n• `/voice` (no argument) — quick toggle between on and off{channels}"
|
||||
help_channels: "\n\n**Live voice channels (Discord)**\n• Join a voice channel first, then `/voice channel` — I'll join, listen, and speak my replies\n• `/voice leave` — disconnect from the voice channel"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ YOLO mode **OFF** for this session — dangerous commands will require approval."
|
||||
|
||||
@@ -343,6 +343,8 @@ gateway:
|
||||
label_off: "Desactivado (solo texto)"
|
||||
label_voice_only: "Activado (responder con voz a mensajes de voz)"
|
||||
label_all: "TTS (responder con voz a todos los mensajes)"
|
||||
help: "{toggle}\n\n**Cómo funciona /voice**\n• `/voice on` — respuesta de voz cuando envías un mensaje de voz\n• `/voice tts` — respuesta de voz a *cada* mensaje\n• `/voice off` — volver a respuestas solo de texto\n• `/voice status` — mostrar el modo actual\n• `/voice` (sin argumento) — alternar rápido entre activado y desactivado{channels}"
|
||||
help_channels: "\n\n**Canales de voz en vivo (Discord)**\n• Únete primero a un canal de voz, luego `/voice channel` — me uno, escucho y digo mis respuestas\n• `/voice leave` — desconectar del canal de voz"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ Modo YOLO **DESACTIVADO** en esta sesión — los comandos peligrosos requerirán aprobación."
|
||||
|
||||
@@ -343,6 +343,8 @@ gateway:
|
||||
label_off: "Désactivé (texte seulement)"
|
||||
label_voice_only: "Activé (réponse vocale aux messages vocaux)"
|
||||
label_all: "TTS (réponse vocale à tous les messages)"
|
||||
help: "{toggle}\n\n**Comment fonctionne /voice**\n• `/voice on` — réponse vocale quand vous envoyez un message vocal\n• `/voice tts` — réponse vocale à *chaque* message\n• `/voice off` — retour aux réponses texte uniquement\n• `/voice status` — afficher le mode actuel\n• `/voice` (sans argument) — bascule rapide entre activé et désactivé{channels}"
|
||||
help_channels: "\n\n**Salons vocaux en direct (Discord)**\n• Rejoignez d'abord un salon vocal, puis `/voice channel` — je rejoins, j'écoute et je parle mes réponses\n• `/voice leave` — se déconnecter du salon vocal"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ Mode YOLO **DÉSACTIVÉ** pour cette session — les commandes dangereuses nécessiteront une approbation."
|
||||
|
||||
@@ -347,6 +347,8 @@ gateway:
|
||||
label_off: "As (téacs amháin)"
|
||||
label_voice_only: "Ar (freagra gutha do theachtaireachtaí gutha)"
|
||||
label_all: "TTS (freagra gutha do gach teachtaireacht)"
|
||||
help: "{toggle}\n\n**Conas a oibríonn /voice**\n• `/voice on` — freagra gutha nuair a sheolann tú teachtaireacht gutha\n• `/voice tts` — freagra gutha do *gach* teachtaireacht\n• `/voice off` — ar ais go freagraí téacs amháin\n• `/voice status` — taispeáin an mód reatha\n• `/voice` (gan argóint) — scoránaigh go tapa idir air agus as{channels}"
|
||||
help_channels: "\n\n**Cainéil gutha bheo (Discord)**\n• Téigh isteach i gcainéal gutha ar dtús, ansin `/voice channel` — téim isteach, éistim agus labhraím mo fhreagraí\n• `/voice leave` — dícheangail ón gcainéal gutha"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ Mód YOLO **AS** don seisiún seo — beidh cead de dhíth d'orduithe contúirteacha."
|
||||
|
||||
@@ -343,6 +343,8 @@ gateway:
|
||||
label_off: "Ki (csak szöveg)"
|
||||
label_voice_only: "Be (hangválasz hangüzenetekre)"
|
||||
label_all: "TTS (hangválasz minden üzenetre)"
|
||||
help: "{toggle}\n\n**Hogyan működik a /voice**\n• `/voice on` — hangválasz, amikor hangüzenetet küldesz\n• `/voice tts` — hangválasz *minden* üzenetre\n• `/voice off` — vissza a csak szöveges válaszokhoz\n• `/voice status` — az aktuális mód megjelenítése\n• `/voice` (argumentum nélkül) — gyors váltás be és ki között{channels}"
|
||||
help_channels: "\n\n**Élő hangcsatornák (Discord)**\n• Először lépj be egy hangcsatornába, majd `/voice channel` — csatlakozom, hallgatok és hangosan válaszolok\n• `/voice leave` — lecsatlakozás a hangcsatornáról"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ YOLO mód **KI** ebben a munkamenetben — a veszélyes parancsok jóváhagyást igényelnek."
|
||||
|
||||
@@ -343,6 +343,8 @@ gateway:
|
||||
label_off: "Off (solo testo)"
|
||||
label_voice_only: "On (risposta vocale ai messaggi vocali)"
|
||||
label_all: "TTS (risposta vocale a tutti i messaggi)"
|
||||
help: "{toggle}\n\n**Come funziona /voice**\n• `/voice on` — risposta vocale quando invii un messaggio vocale\n• `/voice tts` — risposta vocale a *ogni* messaggio\n• `/voice off` — torna alle risposte solo testo\n• `/voice status` — mostra la modalità attuale\n• `/voice` (senza argomento) — alterna rapidamente tra attivo e disattivo{channels}"
|
||||
help_channels: "\n\n**Canali vocali dal vivo (Discord)**\n• Entra prima in un canale vocale, poi `/voice channel` — mi unisco, ascolto e parlo le mie risposte\n• `/voice leave` — disconnetti dal canale vocale"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ Modalità YOLO **OFF** per questa sessione — i comandi pericolosi richiederanno approvazione."
|
||||
|
||||
@@ -343,6 +343,8 @@ gateway:
|
||||
label_off: "オフ (テキストのみ)"
|
||||
label_voice_only: "オン (音声メッセージにのみ音声で返信)"
|
||||
label_all: "TTS (すべてのメッセージに音声で返信)"
|
||||
help: "{toggle}\n\n**/voice の使い方**\n• `/voice on` — 音声メッセージを送ると音声で返信\n• `/voice tts` — *すべての*メッセージに音声で返信\n• `/voice off` — テキストのみの返信に戻す\n• `/voice status` — 現在のモードを表示\n• `/voice`(引数なし)— オンとオフをすばやく切り替え{channels}"
|
||||
help_channels: "\n\n**ライブ音声チャンネル (Discord)**\n• 先に音声チャンネルに参加してから `/voice channel` — 参加して聞き取り、音声で返信します\n• `/voice leave` — 音声チャンネルから切断"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ このセッションの YOLO モードは **OFF** — 危険なコマンドには承認が必要です。"
|
||||
|
||||
@@ -343,6 +343,8 @@ gateway:
|
||||
label_off: "꺼짐 (텍스트 전용)"
|
||||
label_voice_only: "켜짐 (음성 메시지에 음성으로 응답)"
|
||||
label_all: "TTS (모든 메시지에 음성으로 응답)"
|
||||
help: "{toggle}\n\n**/voice 사용법**\n• `/voice on` — 음성 메시지를 보내면 음성으로 답변\n• `/voice tts` — *모든* 메시지에 음성으로 답변\n• `/voice off` — 텍스트 전용 답변으로 복귀\n• `/voice status` — 현재 모드 표시\n• `/voice` (인자 없음) — 켜기와 끄기를 빠르게 전환{channels}"
|
||||
help_channels: "\n\n**라이브 음성 채널 (Discord)**\n• 먼저 음성 채널에 들어간 다음 `/voice channel` — 제가 참여해 듣고 음성으로 답변합니다\n• `/voice leave` — 음성 채널에서 연결 해제"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ 이 세션에서 YOLO 모드 **꺼짐** — 위험한 명령은 승인이 필요합니다."
|
||||
|
||||
@@ -343,6 +343,8 @@ gateway:
|
||||
label_off: "Desativado (apenas texto)"
|
||||
label_voice_only: "Ativado (resposta de voz a mensagens de voz)"
|
||||
label_all: "TTS (resposta de voz a todas as mensagens)"
|
||||
help: "{toggle}\n\n**Como funciona o /voice**\n• `/voice on` — resposta por voz quando envias uma mensagem de voz\n• `/voice tts` — resposta por voz a *todas* as mensagens\n• `/voice off` — voltar às respostas apenas em texto\n• `/voice status` — mostrar o modo atual\n• `/voice` (sem argumento) — alternar rapidamente entre ligado e desligado{channels}"
|
||||
help_channels: "\n\n**Canais de voz ao vivo (Discord)**\n• Entra primeiro num canal de voz, depois `/voice channel` — eu entro, ouço e falo as minhas respostas\n• `/voice leave` — desligar do canal de voz"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ Modo YOLO **DESATIVADO** nesta sessão — comandos perigosos exigirão aprovação."
|
||||
|
||||
@@ -343,6 +343,8 @@ gateway:
|
||||
label_off: "Выкл. (только текст)"
|
||||
label_voice_only: "Вкл. (голосовой ответ на голосовые сообщения)"
|
||||
label_all: "TTS (голосовой ответ на все сообщения)"
|
||||
help: "{toggle}\n\n**Как работает /voice**\n• `/voice on` — голосовой ответ, когда вы отправляете голосовое сообщение\n• `/voice tts` — голосовой ответ на *каждое* сообщение\n• `/voice off` — вернуться к ответам только текстом\n• `/voice status` — показать текущий режим\n• `/voice` (без аргумента) — быстрое переключение между вкл и выкл{channels}"
|
||||
help_channels: "\n\n**Живые голосовые каналы (Discord)**\n• Сначала зайдите в голосовой канал, затем `/voice channel` — я подключусь, буду слушать и отвечать голосом\n• `/voice leave` — отключиться от голосового канала"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ Режим YOLO для этого сеанса **ОТКЛЮЧЁН** — опасные команды потребуют одобрения."
|
||||
|
||||
@@ -343,6 +343,8 @@ gateway:
|
||||
label_off: "Kapalı (yalnızca metin)"
|
||||
label_voice_only: "Açık (sesli mesajlara sesli yanıt)"
|
||||
label_all: "TTS (tüm mesajlara sesli yanıt)"
|
||||
help: "{toggle}\n\n**/voice nasıl çalışır**\n• `/voice on` — sesli mesaj gönderdiğinde sesli yanıt\n• `/voice tts` — *her* mesaja sesli yanıt\n• `/voice off` — yalnızca metin yanıtlarına dön\n• `/voice status` — geçerli modu göster\n• `/voice` (argümansız) — açık ve kapalı arasında hızlı geçiş{channels}"
|
||||
help_channels: "\n\n**Canlı ses kanalları (Discord)**\n• Önce bir ses kanalına katıl, sonra `/voice channel` — katılırım, dinlerim ve yanıtlarımı sesli söylerim\n• `/voice leave` — ses kanalından ayrıl"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ Bu oturumda YOLO modu **KAPALI** — tehlikeli komutlar onay gerektirecek."
|
||||
|
||||
@@ -343,6 +343,8 @@ gateway:
|
||||
label_off: "Вимкнено (лише текст)"
|
||||
label_voice_only: "Увімкнено (голосова відповідь на голосові повідомлення)"
|
||||
label_all: "TTS (голосова відповідь на всі повідомлення)"
|
||||
help: "{toggle}\n\n**Як працює /voice**\n• `/voice on` — голосова відповідь, коли ви надсилаєте голосове повідомлення\n• `/voice tts` — голосова відповідь на *кожне* повідомлення\n• `/voice off` — повернутися до відповідей лише текстом\n• `/voice status` — показати поточний режим\n• `/voice` (без аргументу) — швидке перемикання між увімк і вимк{channels}"
|
||||
help_channels: "\n\n**Живі голосові канали (Discord)**\n• Спершу зайдіть у голосовий канал, потім `/voice channel` — я підключуся, слухатиму й відповідатиму голосом\n• `/voice leave` — від'єднатися від голосового каналу"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ Режим YOLO для цього сеансу **ВИМКНЕНО** — небезпечні команди потребуватимуть схвалення."
|
||||
|
||||
@@ -343,6 +343,8 @@ gateway:
|
||||
label_off: "關閉(僅文字)"
|
||||
label_voice_only: "開啟(僅對語音訊息進行語音回覆)"
|
||||
label_all: "TTS(對所有訊息進行語音回覆)"
|
||||
help: "{toggle}\n\n**/voice 用法**\n• `/voice on` — 當你傳送語音訊息時以語音回覆\n• `/voice tts` — 對*每則*訊息都以語音回覆\n• `/voice off` — 恢復為純文字回覆\n• `/voice status` — 顯示目前模式\n• `/voice`(無參數)— 在開啟和關閉之間快速切換{channels}"
|
||||
help_channels: "\n\n**即時語音頻道 (Discord)**\n• 先加入一個語音頻道,然後 `/voice channel` — 我會加入、聆聽並以語音回覆\n• `/voice leave` — 中斷語音頻道"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ 本工作階段 YOLO 模式 **已關閉** — 危險指令將需要批准。"
|
||||
|
||||
@@ -343,6 +343,8 @@ gateway:
|
||||
label_off: "关闭(仅文本)"
|
||||
label_voice_only: "开启(仅对语音消息进行语音回复)"
|
||||
label_all: "TTS(对所有消息进行语音回复)"
|
||||
help: "{toggle}\n\n**/voice 用法**\n• `/voice on` — 当你发送语音消息时用语音回复\n• `/voice tts` — 对*每条*消息都用语音回复\n• `/voice off` — 恢复为纯文本回复\n• `/voice status` — 显示当前模式\n• `/voice`(无参数)— 在开启和关闭之间快速切换{channels}"
|
||||
help_channels: "\n\n**实时语音频道 (Discord)**\n• 先加入一个语音频道,然后 `/voice channel` — 我会加入、聆听并用语音回复\n• `/voice leave` — 断开语音频道"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ 本会话 YOLO 模式 **已关闭** — 危险命令将需要批准。"
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ let
|
||||
|
||||
# Single npm deps fetch from the workspace root lockfile.
|
||||
# All workspace packages share this derivation.
|
||||
npmDepsHash = "sha256-T9UtpXgBCl/GywDZyrvG4a69RkV8oD6p1UOT7GPgAS0=";
|
||||
npmDepsHash = "sha256-cY+gM1FnTBjmld/uqt7RsqRtW9uQGs8LGokCcxu7bjQ=";
|
||||
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
|
||||
Generated
+22
-48
@@ -100,7 +100,7 @@
|
||||
"react": "^19.2.5",
|
||||
"react-arborist": "^3.5.0",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"react-shiki": "^0.9.3",
|
||||
"remark-math": "^6.0.0",
|
||||
"shiki": "^4.0.2",
|
||||
@@ -10188,6 +10188,19 @@
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
|
||||
@@ -17785,9 +17798,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.14.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
|
||||
"integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==",
|
||||
"version": "7.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz",
|
||||
"integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
@@ -17807,12 +17820,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.14.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz",
|
||||
"integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==",
|
||||
"version": "7.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz",
|
||||
"integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.14.2"
|
||||
"react-router": "7.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -17822,19 +17835,6 @@
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router/node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/react-shiki": {
|
||||
"version": "0.9.3",
|
||||
"resolved": "https://registry.npmjs.org/react-shiki/-/react-shiki-0.9.3.tgz",
|
||||
@@ -19701,7 +19701,6 @@
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19718,7 +19717,6 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19735,7 +19733,6 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19752,7 +19749,6 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19769,7 +19765,6 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19786,7 +19781,6 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19803,7 +19797,6 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19820,7 +19813,6 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19837,7 +19829,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19854,7 +19845,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19871,7 +19861,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19888,7 +19877,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19905,7 +19893,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19922,7 +19909,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19939,7 +19925,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19956,7 +19941,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19973,7 +19957,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19990,7 +19973,6 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20007,7 +19989,6 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20024,7 +20005,6 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20041,7 +20021,6 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20058,7 +20037,6 @@
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20075,7 +20053,6 @@
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20092,7 +20069,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20109,7 +20085,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20126,7 +20101,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22172,7 +22146,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"unicode-animations": "^1.0.3"
|
||||
|
||||
@@ -348,9 +348,10 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
|
||||
|
||||
|
||||
def verify_session(self, *, access_token: str) -> Optional[Session]:
|
||||
# Contract: returns None on expiry/invalidity (middleware then
|
||||
# triggers redirect-to-login since refresh_session can never succeed
|
||||
# under V1); raises ProviderError if the IDP is unreachable.
|
||||
# Contract: returns None on expiry/invalidity (the middleware then
|
||||
# tries refresh_session with the RT cookie, falling back to
|
||||
# redirect-to-login if that also fails); raises ProviderError if the
|
||||
# IDP is unreachable.
|
||||
try:
|
||||
claims = self._verify_jwt(access_token)
|
||||
except InvalidCodeError:
|
||||
@@ -359,8 +360,9 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
|
||||
except ProviderError:
|
||||
# JWKS unreachable, etc. Bubble up so middleware emits 503.
|
||||
raise
|
||||
# verify_session has no access to the original refresh_token; pass
|
||||
# "" because in contract V1 there is none anyway.
|
||||
# verify_session validates the AT in isolation and has no access to the
|
||||
# refresh token (it lives in a separate cookie the middleware reads);
|
||||
# pass "" here — the RT-driven rotation path is middleware's job.
|
||||
return self._session_from_claims(access_token, "", claims)
|
||||
|
||||
def revoke_session(self, *, refresh_token: str) -> None:
|
||||
|
||||
@@ -600,6 +600,12 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
self._voice_listen_tasks: Dict[int, asyncio.Task] = {} # guild_id -> listen loop
|
||||
self._voice_input_callback: Optional[Callable] = None # set by run.py
|
||||
self._on_voice_disconnect: Optional[Callable] = None # set by run.py
|
||||
# Phase 3: continuous voice mixer (ambient idle bed + ducked speech).
|
||||
# Installed once per guild on join; lets acks / TTS / the "thinking"
|
||||
# loop overlap in one outgoing stream instead of stop-and-swap.
|
||||
self._voice_mixers: Dict[int, Any] = {} # guild_id -> VoiceMixer
|
||||
self._ambient_pcm_cache: Optional[bytes] = None # decoded ambient bed
|
||||
self._voice_fx_cfg: Dict[str, Any] = self._load_voice_fx_config()
|
||||
# Track threads where the bot has participated so follow-up messages
|
||||
# in those threads don't require @mention. Persisted to disk so the
|
||||
# set survives gateway restarts.
|
||||
@@ -1925,6 +1931,160 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
# Voice channel methods (join / leave / play)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_voice_fx_config(self) -> Dict[str, Any]:
|
||||
"""Read voice mixer / ambient / ack settings from config.yaml.
|
||||
|
||||
All settings live under ``discord.voice_fx`` in config.yaml (NOT the
|
||||
.env file — these are behavioral, not secrets). The feature is OFF by
|
||||
default; users opt in with ``discord.voice_fx.enabled: true``.
|
||||
|
||||
Returns a dict with safe defaults so callers never KeyError.
|
||||
"""
|
||||
defaults: Dict[str, Any] = {
|
||||
"enabled": False, # master switch for the mixer subsystem
|
||||
"ambient_enabled": True, # idle "thinking" bed while tools run
|
||||
"ambient_path": "", # optional custom loop file; "" = synthesised
|
||||
"ambient_gain": 0.18, # idle bed loudness (0..1)
|
||||
"duck_gain": 0.06, # ambient loudness while speech plays
|
||||
"speech_gain": 1.0, # TTS / ack loudness
|
||||
"ack_enabled": True, # speak a short phrase before tool calls
|
||||
"ack_phrases": [
|
||||
"Let me look into that.",
|
||||
"One moment.",
|
||||
"Checking on that now.",
|
||||
"Give me a sec.",
|
||||
"On it.",
|
||||
],
|
||||
}
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
cfg = read_raw_config() or {}
|
||||
fx = ((cfg.get("discord") or {}).get("voice_fx") or {})
|
||||
if isinstance(fx, dict):
|
||||
for k, v in fx.items():
|
||||
if k in defaults and v is not None:
|
||||
defaults[k] = v
|
||||
except Exception as e:
|
||||
logger.debug("Could not load discord.voice_fx config: %s", e)
|
||||
return defaults
|
||||
|
||||
def _get_ambient_pcm(self) -> Optional[bytes]:
|
||||
"""Return decoded 48k/stereo/s16le PCM for the ambient idle bed.
|
||||
|
||||
Uses a custom file when ``ambient_path`` is set and decodable, else a
|
||||
synthesised pad. Cached after first build.
|
||||
"""
|
||||
if self._ambient_pcm_cache is not None:
|
||||
return self._ambient_pcm_cache
|
||||
if not self._voice_fx_cfg.get("ambient_enabled"):
|
||||
return None
|
||||
try:
|
||||
from voice_mixer import decode_to_pcm, synth_ambient_pcm
|
||||
except ImportError:
|
||||
from .voice_mixer import decode_to_pcm, synth_ambient_pcm
|
||||
|
||||
pcm: Optional[bytes] = None
|
||||
path = (self._voice_fx_cfg.get("ambient_path") or "").strip()
|
||||
if path and os.path.isfile(path):
|
||||
pcm = decode_to_pcm(path)
|
||||
if not pcm:
|
||||
logger.warning("Ambient file %s failed to decode; using synth bed", path)
|
||||
if not pcm:
|
||||
pcm = synth_ambient_pcm()
|
||||
self._ambient_pcm_cache = pcm
|
||||
return pcm
|
||||
|
||||
async def _install_voice_mixer(self, guild_id: int, vc) -> None:
|
||||
"""Create a VoiceMixer, start the ambient bed, and play it on the VC.
|
||||
|
||||
The mixer runs continuously for the life of the connection: one
|
||||
``vc.play(mixer)`` call, never stopped until leave.
|
||||
"""
|
||||
try:
|
||||
from voice_mixer import VoiceMixer
|
||||
except ImportError:
|
||||
from .voice_mixer import VoiceMixer
|
||||
|
||||
mixer = VoiceMixer(
|
||||
ambient_gain=float(self._voice_fx_cfg.get("ambient_gain", 0.18)),
|
||||
duck_gain=float(self._voice_fx_cfg.get("duck_gain", 0.06)),
|
||||
speech_gain=float(self._voice_fx_cfg.get("speech_gain", 1.0)),
|
||||
)
|
||||
ambient = await asyncio.to_thread(self._get_ambient_pcm)
|
||||
if ambient:
|
||||
mixer.set_ambient(ambient)
|
||||
|
||||
def _after(error):
|
||||
if error:
|
||||
logger.error("Voice mixer stream error (guild=%d): %s", guild_id, error)
|
||||
|
||||
if vc.is_playing():
|
||||
vc.stop()
|
||||
vc.play(mixer, after=_after)
|
||||
self._voice_mixers[guild_id] = mixer
|
||||
logger.info("Voice mixer installed (guild=%d, ambient=%s)", guild_id, bool(ambient))
|
||||
|
||||
async def play_ack_in_voice(self, guild_id: int, phrase: Optional[str] = None) -> bool:
|
||||
"""Speak a short acknowledgement over the ambient bed.
|
||||
|
||||
Called from the gateway's tool-progress hook on the first tool call of
|
||||
a turn, so the user hears "let me look into that" before the bot goes
|
||||
quiet to work. No-op unless the mixer is installed and acks enabled.
|
||||
"""
|
||||
if not self._voice_fx_cfg.get("ack_enabled"):
|
||||
return False
|
||||
mixer = self._voice_mixers.get(guild_id)
|
||||
if mixer is None:
|
||||
return False
|
||||
if phrase is None:
|
||||
import random
|
||||
phrases = self._voice_fx_cfg.get("ack_phrases") or ["One moment."]
|
||||
phrase = random.choice(phrases)
|
||||
|
||||
# Synthesise the ack via the configured TTS provider, then layer it.
|
||||
import uuid as _uuid
|
||||
audio_path = os.path.join(
|
||||
tempfile.gettempdir(), "hermes_voice",
|
||||
f"ack_{_uuid.uuid4().hex[:12]}.mp3",
|
||||
)
|
||||
os.makedirs(os.path.dirname(audio_path), exist_ok=True)
|
||||
try:
|
||||
from tools.tts_tool import text_to_speech_tool
|
||||
result_json = await asyncio.to_thread(
|
||||
text_to_speech_tool, text=phrase, output_path=audio_path
|
||||
)
|
||||
result = json.loads(result_json)
|
||||
actual = result.get("file_path", audio_path)
|
||||
if not result.get("success") or not os.path.isfile(actual):
|
||||
return False
|
||||
try:
|
||||
from voice_mixer import decode_to_pcm
|
||||
except ImportError:
|
||||
from .voice_mixer import decode_to_pcm
|
||||
pcm = await asyncio.to_thread(decode_to_pcm, actual)
|
||||
if not pcm:
|
||||
return False
|
||||
mixer.play_speech(
|
||||
pcm, gain=float(self._voice_fx_cfg.get("speech_gain", 1.0))
|
||||
)
|
||||
self._reset_voice_timeout(guild_id)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("play_ack_in_voice failed: %s", e)
|
||||
return False
|
||||
finally:
|
||||
for p in {audio_path, locals().get("actual")}:
|
||||
if p and os.path.isfile(p):
|
||||
try:
|
||||
os.unlink(p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def voice_mixer_active(self, guild_id: int) -> bool:
|
||||
"""True when a continuous mixer is installed for this guild."""
|
||||
mixers = getattr(self, "_voice_mixers", None)
|
||||
return bool(mixers) and mixers.get(guild_id) is not None
|
||||
|
||||
async def join_voice_channel(self, channel) -> bool:
|
||||
"""Join a Discord voice channel. Returns True on success."""
|
||||
if not self._client or not DISCORD_AVAILABLE:
|
||||
@@ -1957,6 +2117,15 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
except Exception as e:
|
||||
logger.warning("Voice receiver failed to start: %s", e)
|
||||
|
||||
# Phase 3: install the continuous mixer (ambient bed + ducked
|
||||
# speech). Best-effort — if it fails we fall back to the legacy
|
||||
# one-shot FFmpegPCMAudio playback path in play_in_voice_channel.
|
||||
if getattr(self, "_voice_fx_cfg", {}).get("enabled"):
|
||||
try:
|
||||
await self._install_voice_mixer(guild_id, vc)
|
||||
except Exception as e:
|
||||
logger.warning("Voice mixer failed to start: %s", e)
|
||||
|
||||
return True
|
||||
|
||||
async def leave_voice_channel(self, guild_id: int) -> None:
|
||||
@@ -1970,8 +2139,17 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
if listen_task:
|
||||
listen_task.cancel()
|
||||
|
||||
# Tear down the mixer (stops the continuous outgoing stream).
|
||||
if getattr(self, "_voice_mixers", None) is not None:
|
||||
self._voice_mixers.pop(guild_id, None)
|
||||
|
||||
vc = self._voice_clients.pop(guild_id, None)
|
||||
if vc and vc.is_connected():
|
||||
try:
|
||||
if vc.is_playing():
|
||||
vc.stop()
|
||||
except Exception:
|
||||
pass
|
||||
await vc.disconnect()
|
||||
task = self._voice_timeout_tasks.pop(guild_id, None)
|
||||
if task:
|
||||
@@ -1983,11 +2161,43 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
PLAYBACK_TIMEOUT = 120
|
||||
|
||||
async def play_in_voice_channel(self, guild_id: int, audio_path: str) -> bool:
|
||||
"""Play an audio file in the connected voice channel."""
|
||||
"""Play an audio file in the connected voice channel.
|
||||
|
||||
When the continuous mixer is installed for this guild, the clip is
|
||||
decoded to PCM and layered over the ambient bed (ducking it) so the
|
||||
reply can overlap the idle "thinking" loop seamlessly. Otherwise we
|
||||
fall back to the legacy one-shot FFmpegPCMAudio path.
|
||||
"""
|
||||
vc = self._voice_clients.get(guild_id)
|
||||
if not vc or not vc.is_connected():
|
||||
return False
|
||||
|
||||
# ── Mixer path (overlap + ducking) ──────────────────────────────
|
||||
mixer = getattr(self, "_voice_mixers", {}).get(guild_id) if getattr(self, "_voice_mixers", None) else None
|
||||
if mixer is not None:
|
||||
try:
|
||||
from voice_mixer import decode_to_pcm
|
||||
except ImportError:
|
||||
from .voice_mixer import decode_to_pcm
|
||||
pcm = await asyncio.to_thread(decode_to_pcm, audio_path)
|
||||
if pcm:
|
||||
speech_gain = float(self._voice_fx_cfg.get("speech_gain", 1.0))
|
||||
mixer.play_speech(pcm, gain=speech_gain)
|
||||
# Block until the speech child drains so callers serialise
|
||||
# replies (mirrors legacy semantics) but the ambient keeps
|
||||
# playing underneath the whole time.
|
||||
wait_start = time.monotonic()
|
||||
while mixer.speech_active:
|
||||
if time.monotonic() - wait_start > self.PLAYBACK_TIMEOUT:
|
||||
logger.warning("Mixer speech playback timed out after %ds", self.PLAYBACK_TIMEOUT)
|
||||
mixer.stop_speech()
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
self._reset_voice_timeout(guild_id)
|
||||
return True
|
||||
logger.warning("Mixer decode failed for %s; falling back to legacy playback", audio_path)
|
||||
|
||||
# ── Legacy one-shot path (no mixer) ─────────────────────────────
|
||||
# Pause voice receiver while playing (echo prevention)
|
||||
receiver = self._voice_receivers.get(guild_id)
|
||||
if receiver:
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""
|
||||
Continuous PCM audio mixer for Discord voice channels.
|
||||
|
||||
discord.py (Rapptz) ships no audio mixer: ``VoiceClient.play()`` accepts a
|
||||
single :class:`discord.AudioSource` and raises ``ClientException`` if called
|
||||
while already playing. One opus stream per connection, one source feeding it.
|
||||
|
||||
This module adds software mixing *upstream* of that single stream. A
|
||||
:class:`VoiceMixer` is itself a ``discord.AudioSource`` that discord.py polls
|
||||
every 20 ms via :meth:`read`. Internally it sums the 20 ms PCM frames of any
|
||||
number of child sources, clamps to int16, and returns one blended frame.
|
||||
discord.py never knows several streams were combined underneath — it just
|
||||
encodes and sends the single mixed frame.
|
||||
|
||||
This gives us, for one voice connection at once:
|
||||
|
||||
* an always-on low-volume **ambient/idle loop** (the "thinking" sound),
|
||||
* a **speech** channel (TTS replies, verbal acknowledgements) that plays
|
||||
*over* the ambient bed, automatically **ducking** the ambient gain down
|
||||
while speech is active and restoring it when speech ends — the smooth
|
||||
Grok-voice-mode feel, instead of stop-and-swap.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
* The mixer is installed **once** per guild on join (``vc.play(mixer)``) and
|
||||
runs continuously until the bot leaves. Children come and go; the mixer
|
||||
itself never stops, so there is no ``is_playing()`` race between an
|
||||
acknowledgement and the final reply.
|
||||
* Frame format is Discord-native: 48 kHz, 2 channels, signed 16-bit LE,
|
||||
20 ms per frame == ``discord.opus.Encoder.FRAME_SIZE`` bytes
|
||||
(3840 = 960 samples * 2 channels * 2 bytes).
|
||||
* Mixing is a single vectorised int32 add + clip per 20 ms frame (numpy,
|
||||
already a core dependency). CPU cost is negligible.
|
||||
* :meth:`read` is called from discord.py's audio sender **thread**, while
|
||||
children are added/removed from the asyncio event loop thread, so all
|
||||
shared state is guarded by a plain ``threading.Lock``.
|
||||
|
||||
The mixer NEVER touches the inbound receive path: it only produces the bot's
|
||||
*outgoing* stream. The :class:`VoiceReceiver` decodes incoming SSRCs only, so
|
||||
the mixer's output cannot echo back into transcription.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
if TYPE_CHECKING: # numpy is an optional ("voice" extra) dep — never import at runtime top-level
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _require_numpy():
|
||||
"""Import numpy lazily.
|
||||
|
||||
numpy ships in the optional ``voice`` extra, not the base install, so this
|
||||
module must import cleanly without it (the Discord adapter imports this
|
||||
file unconditionally). Callers that actually mix audio call this; if the
|
||||
voice extra isn't installed they get a clear error instead of a top-level
|
||||
ImportError that would break the whole adapter import.
|
||||
"""
|
||||
import numpy as np # noqa: PLC0415 — intentional lazy import
|
||||
return np
|
||||
|
||||
# Discord-native frame geometry (matches discord.opus.Encoder).
|
||||
SAMPLE_RATE = 48000
|
||||
CHANNELS = 2
|
||||
SAMPLE_WIDTH = 2 # bytes per sample (s16)
|
||||
FRAME_LENGTH_MS = 20
|
||||
SAMPLES_PER_FRAME = SAMPLE_RATE * FRAME_LENGTH_MS // 1000 # 960
|
||||
FRAME_SIZE = SAMPLES_PER_FRAME * CHANNELS * SAMPLE_WIDTH # 3840 bytes
|
||||
SILENCE_FRAME = b"\x00" * FRAME_SIZE
|
||||
|
||||
|
||||
class MixerChild:
|
||||
"""A single audio stream feeding into :class:`VoiceMixer`.
|
||||
|
||||
Wraps raw 48 kHz / stereo / s16le PCM bytes. ``read_frame`` hands back one
|
||||
20 ms frame at a time, optionally looping, with a per-child gain applied.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"name", "_pcm", "_pos", "loop", "gain",
|
||||
"is_speech", "fade_frames", "_fade_done", "_finished",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
pcm: bytes,
|
||||
*,
|
||||
loop: bool = False,
|
||||
gain: float = 1.0,
|
||||
is_speech: bool = False,
|
||||
fade_in_ms: int = 0,
|
||||
):
|
||||
# Pad to a whole number of frames so looping is seamless and the final
|
||||
# partial frame doesn't click.
|
||||
remainder = len(pcm) % FRAME_SIZE
|
||||
if remainder:
|
||||
pcm = pcm + b"\x00" * (FRAME_SIZE - remainder)
|
||||
self.name = name
|
||||
self._pcm = pcm
|
||||
self._pos = 0
|
||||
self.loop = loop
|
||||
self.gain = float(gain)
|
||||
self.is_speech = is_speech
|
||||
# Linear fade-in over N frames avoids a click when a loud child starts.
|
||||
self.fade_frames = max(0, fade_in_ms // FRAME_LENGTH_MS)
|
||||
self._fade_done = 0
|
||||
self._finished = False
|
||||
|
||||
@property
|
||||
def finished(self) -> bool:
|
||||
return self._finished
|
||||
|
||||
def read_frame(self) -> "Optional[np.ndarray]":
|
||||
"""Return the next 20 ms frame as an int16 ndarray, or None if done."""
|
||||
if self._finished:
|
||||
return None
|
||||
if self._pos >= len(self._pcm):
|
||||
if self.loop and self._pcm:
|
||||
self._pos = 0
|
||||
else:
|
||||
self._finished = True
|
||||
return None
|
||||
|
||||
np = _require_numpy()
|
||||
chunk = self._pcm[self._pos:self._pos + FRAME_SIZE]
|
||||
self._pos += FRAME_SIZE
|
||||
if len(chunk) < FRAME_SIZE:
|
||||
chunk = chunk + b"\x00" * (FRAME_SIZE - len(chunk))
|
||||
|
||||
samples = np.frombuffer(chunk, dtype=np.int16).astype(np.float32)
|
||||
|
||||
gain = self.gain
|
||||
if self.fade_frames and self._fade_done < self.fade_frames:
|
||||
self._fade_done += 1
|
||||
gain *= self._fade_done / self.fade_frames
|
||||
|
||||
if gain != 1.0:
|
||||
samples = samples * gain
|
||||
return samples
|
||||
|
||||
|
||||
class VoiceMixer:
|
||||
"""A continuous ``discord.AudioSource`` that mixes N child streams.
|
||||
|
||||
Use :meth:`set_ambient` to install/replace the looping idle bed and
|
||||
:meth:`play_speech` to layer a one-shot clip over it (ducking the ambient
|
||||
while it plays). Both are safe to call from the asyncio loop thread while
|
||||
discord.py drains :meth:`read` from its sender thread.
|
||||
"""
|
||||
|
||||
# discord.AudioSource subclasses set is_opus()==False to receive PCM.
|
||||
def is_opus(self) -> bool: # pragma: no cover - trivial
|
||||
return False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ambient_gain: float = 0.18,
|
||||
duck_gain: float = 0.06,
|
||||
speech_gain: float = 1.0,
|
||||
duck_release_ms: int = 400,
|
||||
):
|
||||
self._lock = threading.Lock()
|
||||
self._ambient: Optional[MixerChild] = None
|
||||
self._speech: List[MixerChild] = []
|
||||
self._ambient_gain = float(ambient_gain)
|
||||
self._duck_gain = float(duck_gain)
|
||||
self._speech_gain = float(speech_gain)
|
||||
# When speech ends, ramp the ambient back up over this many frames
|
||||
# instead of jumping, so the bed swells back smoothly.
|
||||
self._duck_release_frames = max(1, duck_release_ms // FRAME_LENGTH_MS)
|
||||
self._duck_release_left = 0
|
||||
self._closed = False
|
||||
# Tracks whether speech is currently active, for external callers that
|
||||
# want to avoid double-ducking or know when a reply is mid-flight.
|
||||
self._speech_active = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Ambient (idle / "thinking") bed
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_ambient(self, pcm: Optional[bytes], *, gain: Optional[float] = None) -> None:
|
||||
"""Install (or clear, with ``pcm=None``) the looping ambient bed."""
|
||||
with self._lock:
|
||||
if gain is not None:
|
||||
self._ambient_gain = float(gain)
|
||||
if not pcm:
|
||||
self._ambient = None
|
||||
return
|
||||
self._ambient = MixerChild(
|
||||
"ambient", pcm, loop=True,
|
||||
gain=self._effective_ambient_gain(), fade_in_ms=200,
|
||||
)
|
||||
|
||||
def _effective_ambient_gain(self) -> float:
|
||||
return self._duck_gain if self._speech_active else self._ambient_gain
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Speech (TTS replies, verbal acks) layered over the ambient bed
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def play_speech(self, pcm: bytes, *, gain: Optional[float] = None,
|
||||
fade_in_ms: int = 40) -> None:
|
||||
"""Layer a one-shot speech clip over the ambient bed (ducks ambient)."""
|
||||
if not pcm:
|
||||
return
|
||||
with self._lock:
|
||||
child = MixerChild(
|
||||
"speech", pcm, loop=False,
|
||||
gain=self._speech_gain if gain is None else float(gain),
|
||||
is_speech=True, fade_in_ms=fade_in_ms,
|
||||
)
|
||||
self._speech.append(child)
|
||||
self._speech_active = True
|
||||
self._duck_release_left = 0
|
||||
if self._ambient is not None:
|
||||
self._ambient.gain = self._duck_gain
|
||||
|
||||
@property
|
||||
def speech_active(self) -> bool:
|
||||
with self._lock:
|
||||
return self._speech_active
|
||||
|
||||
def stop_speech(self) -> None:
|
||||
"""Drop any in-flight speech immediately and release the duck."""
|
||||
with self._lock:
|
||||
self._speech.clear()
|
||||
self._begin_duck_release_locked()
|
||||
|
||||
def _begin_duck_release_locked(self) -> None:
|
||||
self._speech_active = False
|
||||
self._duck_release_left = self._duck_release_frames
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# AudioSource interface — called from discord.py's sender thread
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def read(self) -> bytes:
|
||||
"""Return one 20 ms mixed PCM frame (always FRAME_SIZE bytes).
|
||||
|
||||
Returning a non-empty frame keeps discord.py's player alive; we never
|
||||
return b"" because that would stop the single underlying stream and we
|
||||
want the mixer to run continuously for the lifetime of the connection.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
return SILENCE_FRAME
|
||||
|
||||
np = _require_numpy()
|
||||
acc: "Optional[np.ndarray]" = None
|
||||
|
||||
# Speech children (drop exhausted ones; release duck when last ends)
|
||||
if self._speech:
|
||||
still_live: List[MixerChild] = []
|
||||
for child in self._speech:
|
||||
frame = child.read_frame()
|
||||
if frame is None:
|
||||
continue
|
||||
acc = frame if acc is None else acc + frame
|
||||
still_live.append(child)
|
||||
self._speech = still_live
|
||||
if not self._speech and self._speech_active:
|
||||
self._begin_duck_release_locked()
|
||||
|
||||
# Ambient bed — ramp gain back up during duck-release.
|
||||
if self._ambient is not None:
|
||||
if self._duck_release_left > 0 and not self._speech_active:
|
||||
self._duck_release_left -= 1
|
||||
frac = 1.0 - (self._duck_release_left / self._duck_release_frames)
|
||||
self._ambient.gain = (
|
||||
self._duck_gain
|
||||
+ (self._ambient_gain - self._duck_gain) * frac
|
||||
)
|
||||
elif not self._speech_active and self._duck_release_left == 0:
|
||||
self._ambient.gain = self._ambient_gain
|
||||
amb = self._ambient.read_frame()
|
||||
if amb is not None:
|
||||
acc = amb if acc is None else acc + amb
|
||||
|
||||
if acc is None:
|
||||
return SILENCE_FRAME
|
||||
|
||||
np.clip(acc, -32768, 32767, out=acc)
|
||||
return acc.astype(np.int16).tobytes()
|
||||
|
||||
def cleanup(self) -> None: # called by discord.py when playback stops
|
||||
with self._lock:
|
||||
self._closed = True
|
||||
self._ambient = None
|
||||
self._speech.clear()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# PCM helpers
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def decode_to_pcm(path: str, *, timeout: float = 30.0) -> Optional[bytes]:
|
||||
"""Decode any audio file to 48 kHz / stereo / s16le PCM via ffmpeg.
|
||||
|
||||
Returns the raw PCM bytes, or None on failure. ffmpeg is already a hard
|
||||
requirement of the voice path (see ``VoiceReceiver.pcm_to_wav``).
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-y", "-loglevel", "error",
|
||||
"-i", path,
|
||||
"-f", "s16le",
|
||||
"-ar", str(SAMPLE_RATE),
|
||||
"-ac", str(CHANNELS),
|
||||
"pipe:1",
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
||||
logger.warning("decode_to_pcm failed for %s: %s", path, e)
|
||||
return None
|
||||
if proc.returncode != 0:
|
||||
logger.warning(
|
||||
"ffmpeg decode failed for %s (rc=%d): %s",
|
||||
path, proc.returncode, (proc.stderr or b"").decode("utf-8", "replace")[:200],
|
||||
)
|
||||
return None
|
||||
return proc.stdout or None
|
||||
|
||||
|
||||
def synth_ambient_pcm(seconds: float = 4.0) -> bytes:
|
||||
"""Synthesise a subtle looping ambient bed (no asset file required).
|
||||
|
||||
A soft, slowly-pulsing low pad: two detuned sine partials with a gentle
|
||||
tremolo, plus a touch of filtered noise. Designed to loop seamlessly
|
||||
(whole number of cycles, zero-crossing endpoints) and sit quietly under
|
||||
speech. Mono content duplicated to stereo.
|
||||
"""
|
||||
np = _require_numpy()
|
||||
n = int(SAMPLE_RATE * seconds)
|
||||
t = np.arange(n, dtype=np.float64) / SAMPLE_RATE
|
||||
|
||||
# Choose base frequencies that complete whole cycles over the loop so the
|
||||
# wrap point is click-free.
|
||||
def _whole_cycle_freq(target: float) -> float:
|
||||
cycles = max(1, round(target * seconds))
|
||||
return cycles / seconds
|
||||
|
||||
f1 = _whole_cycle_freq(110.0)
|
||||
f2 = _whole_cycle_freq(110.5)
|
||||
trem = _whole_cycle_freq(0.5) # ~0.5 Hz tremolo
|
||||
|
||||
pad = (
|
||||
0.55 * np.sin(2 * np.pi * f1 * t)
|
||||
+ 0.45 * np.sin(2 * np.pi * f2 * t)
|
||||
)
|
||||
tremolo = 0.6 + 0.4 * (0.5 * (1 + np.sin(2 * np.pi * trem * t)))
|
||||
signal = pad * tremolo
|
||||
|
||||
# Smooth filtered noise for air, kept very low.
|
||||
rng = np.random.default_rng(7)
|
||||
noise = rng.standard_normal(n)
|
||||
kernel = np.ones(64) / 64.0
|
||||
noise = np.convolve(noise, kernel, mode="same")
|
||||
signal = signal + 0.08 * noise
|
||||
|
||||
# Normalise to a modest peak (mixer applies the real ambient gain on top).
|
||||
peak = float(np.max(np.abs(signal))) or 1.0
|
||||
signal = (signal / peak) * 0.5
|
||||
|
||||
mono16 = (signal * 32767.0).astype(np.int16)
|
||||
stereo16 = np.repeat(mono16[:, None], CHANNELS, axis=1).reshape(-1)
|
||||
return stereo16.tobytes()
|
||||
@@ -133,6 +133,21 @@ MEDIA_TOKEN_TTL_SECONDS = 1800 # 30 minutes; LINE caches the URL aggressively
|
||||
LINE_IMAGE_MAX_BYTES = 10 * 1024 * 1024 # 10 MB per LINE docs
|
||||
LINE_AV_MAX_BYTES = 200 * 1024 * 1024 # 200 MB for voice/video
|
||||
|
||||
# Map LINE webhook message types to the normalized MessageType the gateway
|
||||
# routes on. LINE has no separate "voice" type — audio messages are recorded
|
||||
# voice clips, so they map to VOICE (which the gateway sends through STT),
|
||||
# mirroring how Telegram/WhatsApp classify voice notes. Anything unknown
|
||||
# falls back to TEXT.
|
||||
_LINE_MESSAGE_TYPES = {
|
||||
"text": MessageType.TEXT,
|
||||
"image": MessageType.PHOTO,
|
||||
"video": MessageType.VIDEO,
|
||||
"audio": MessageType.VOICE,
|
||||
"file": MessageType.DOCUMENT,
|
||||
"location": MessageType.LOCATION,
|
||||
"sticker": MessageType.STICKER,
|
||||
}
|
||||
|
||||
# A 1×1 transparent PNG used as fallback video preview thumbnail when no
|
||||
# explicit preview is supplied — LINE requires ``previewImageUrl`` for
|
||||
# video messages. Sourced from the Python stdlib (no Pillow dependency).
|
||||
@@ -968,7 +983,7 @@ class LineAdapter(BasePlatformAdapter):
|
||||
|
||||
event_obj = MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT if msg_type == "text" else MessageType.IMAGE,
|
||||
message_type=_LINE_MESSAGE_TYPES.get(msg_type, MessageType.TEXT),
|
||||
source=source_obj,
|
||||
raw_message=event,
|
||||
message_id=message_id,
|
||||
|
||||
+70
-6
@@ -1063,6 +1063,7 @@ function Install-Repository {
|
||||
# EAP=Stop. We rely on $LASTEXITCODE for actual failures.
|
||||
$prevEAP = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
$autostashRef = ""
|
||||
try {
|
||||
# This is a MANAGED checkout, not a repo the user edits. Git for
|
||||
# Windows defaults to core.autocrlf=true, which renormalizes the
|
||||
@@ -1071,12 +1072,23 @@ function Install-Repository {
|
||||
# show as locally modified even though nobody touched them. A
|
||||
# bare `git checkout` then aborts with "Your local changes would
|
||||
# be overwritten by checkout", which is exactly the failure GUI
|
||||
# users hit on update. Two-part fix: (1) stop creating the dirt
|
||||
# by pinning autocrlf=false on this clone, (2) discard any
|
||||
# pre-existing dirt with a hard reset before the checkout. Safe
|
||||
# because nothing here is user-authored.
|
||||
# users hit on update. Pin autocrlf=false so the dirt is never
|
||||
# created in the first place.
|
||||
git -c windows.appendAtomically=false config core.autocrlf false 2>$null
|
||||
git -c windows.appendAtomically=false reset --hard HEAD 2>$null
|
||||
# Preserve any real local changes before the checkout instead of
|
||||
# discarding them with `reset --hard HEAD`. The old hard reset
|
||||
# silently destroyed agent-edited source on managed clones (the
|
||||
# #38542 data-loss class). Stash + restore mirrors install.sh:
|
||||
# nothing is lost, and a failed restore leaves the work in a
|
||||
# git stash for manual recovery. Untracked files are included so
|
||||
# agent-created dirs (e.g. tinker-atropos/) survive too.
|
||||
$statusOut = git -c windows.appendAtomically=false status --porcelain 2>$null
|
||||
if (-not [string]::IsNullOrWhiteSpace(($statusOut -join "`n"))) {
|
||||
$stashName = "hermes-install-autostash-" + (Get-Date -Format "yyyyMMdd-HHmmss")
|
||||
Write-Info "Local changes detected, stashing before update..."
|
||||
git -c windows.appendAtomically=false stash push --include-untracked -m "$stashName"
|
||||
if ($LASTEXITCODE -eq 0) { $autostashRef = "stash@{0}" }
|
||||
}
|
||||
git -c windows.appendAtomically=false fetch origin
|
||||
if ($LASTEXITCODE -ne 0) { throw "git fetch failed (exit $LASTEXITCODE)" }
|
||||
# Precedence: Commit > Tag > Branch. Commit and Tag check
|
||||
@@ -1095,10 +1107,62 @@ function Install-Repository {
|
||||
} else {
|
||||
git -c windows.appendAtomically=false checkout $Branch
|
||||
if ($LASTEXITCODE -ne 0) { throw "git checkout $Branch failed (exit $LASTEXITCODE)" }
|
||||
git -c windows.appendAtomically=false pull origin $Branch
|
||||
git -c windows.appendAtomically=false pull --ff-only origin $Branch
|
||||
if ($LASTEXITCODE -ne 0) { throw "git pull failed (exit $LASTEXITCODE)" }
|
||||
}
|
||||
|
||||
if ($autostashRef) {
|
||||
# Default to restoring so work is never silently dropped.
|
||||
# Only prompt when we're certain a human can answer: an
|
||||
# interactive session AND a real, non-redirected console on
|
||||
# both stdin and stdout. The desktop "Update" button and
|
||||
# bootstrap run the installer without a usable console -- in
|
||||
# those cases Read-Host would hang or return empty, so we
|
||||
# skip the prompt and just restore (the safe default).
|
||||
$restoreNow = $true
|
||||
$hasConsole = $false
|
||||
try {
|
||||
$hasConsole = (
|
||||
[Environment]::UserInteractive `
|
||||
-and (-not [Console]::IsInputRedirected) `
|
||||
-and (-not [Console]::IsOutputRedirected) `
|
||||
-and ($Host.Name -eq "ConsoleHost")
|
||||
)
|
||||
} catch { $hasConsole = $false }
|
||||
if ($hasConsole) {
|
||||
Write-Warn "Local changes were stashed before updating."
|
||||
Write-Warn "Restoring them may reapply local customizations onto the updated codebase."
|
||||
$restoreAnswer = Read-Host "Restore local changes now? [Y/n]"
|
||||
if ($restoreAnswer -match '^(n|no)$') { $restoreNow = $false }
|
||||
}
|
||||
|
||||
if ($restoreNow) {
|
||||
Write-Info "Restoring local changes..."
|
||||
git -c windows.appendAtomically=false stash apply $autostashRef
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
git -c windows.appendAtomically=false stash drop $autostashRef 2>$null
|
||||
Write-Warn "Local changes were restored on top of the updated codebase."
|
||||
Write-Warn "Review git diff / git status if Hermes behaves unexpectedly."
|
||||
} else {
|
||||
Write-Err "Update succeeded, but restoring local changes failed. Your changes are still preserved in git stash."
|
||||
Write-Info "Resolve manually with: git stash apply $autostashRef"
|
||||
throw "git stash apply failed after update"
|
||||
}
|
||||
} else {
|
||||
Write-Info "Skipped restoring local changes."
|
||||
Write-Info "Your changes are still preserved in git stash."
|
||||
Write-Info "Restore manually with: git stash apply $autostashRef"
|
||||
}
|
||||
$autostashRef = ""
|
||||
}
|
||||
} finally {
|
||||
if ($autostashRef) {
|
||||
# We stashed but never reached the restore block (a fetch/
|
||||
# checkout/pull failure threw). Leave the stash in place and
|
||||
# tell the user how to recover it -- never silently drop it.
|
||||
Write-Warn "Update did not complete. Your local changes are preserved in git stash."
|
||||
Write-Info "Restore manually with: git stash apply $autostashRef"
|
||||
}
|
||||
$ErrorActionPreference = $prevEAP
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
+41
-15
@@ -1097,24 +1097,50 @@ clone_repo() {
|
||||
log_info "Existing installation found, updating..."
|
||||
cd "$INSTALL_DIR"
|
||||
|
||||
# This is a managed clone the user never edits, so any working-tree
|
||||
# dirt is git artifact (CRLF renormalization, npm lockfile churn,
|
||||
# files left behind when a directory was deleted upstream such as
|
||||
# apps/bootstrap-installer/). The old path stashed that dirt and
|
||||
# re-applied it after the pull, but the stash/restore cycle has
|
||||
# clobbered freshly-pulled source files (apps/desktop/ →
|
||||
# "[UNRESOLVED_ENTRY] Cannot resolve entry module index.html").
|
||||
# Discard the dirt with a hard reset instead — mirrors install.ps1's
|
||||
# update path. Fork users customize via `hermes update`, which keeps
|
||||
# the stash machinery; the installer is a managed-only entry point.
|
||||
git fetch origin
|
||||
local autostash_ref=""
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
log_info "Discarding working-tree changes on managed clone before update..."
|
||||
git reset --hard HEAD >/dev/null 2>&1 || true
|
||||
git clean -fd >/dev/null 2>&1 || true
|
||||
local stash_name
|
||||
stash_name="hermes-install-autostash-$(date -u +%Y%m%d-%H%M%S)"
|
||||
log_info "Local changes detected, stashing before update..."
|
||||
git stash push --include-untracked -m "$stash_name"
|
||||
autostash_ref="stash@{0}"
|
||||
fi
|
||||
|
||||
git fetch origin
|
||||
git checkout "$BRANCH"
|
||||
git reset --hard "origin/$BRANCH"
|
||||
git pull --ff-only origin "$BRANCH"
|
||||
|
||||
if [ -n "$autostash_ref" ]; then
|
||||
local restore_now="yes"
|
||||
if [ -t 0 ] && [ -t 1 ]; then
|
||||
echo
|
||||
log_warn "Local changes were stashed before updating."
|
||||
log_warn "Restoring them may reapply local customizations onto the updated codebase."
|
||||
printf "Restore local changes now? [Y/n] "
|
||||
read -r restore_answer
|
||||
case "$restore_answer" in
|
||||
""|y|Y|yes|YES|Yes) restore_now="yes" ;;
|
||||
*) restore_now="no" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ "$restore_now" = "yes" ]; then
|
||||
log_info "Restoring local changes..."
|
||||
if git stash apply "$autostash_ref"; then
|
||||
git stash drop "$autostash_ref" >/dev/null
|
||||
log_warn "Local changes were restored on top of the updated codebase."
|
||||
log_warn "Review git diff / git status if Hermes behaves unexpectedly."
|
||||
else
|
||||
log_error "Update succeeded, but restoring local changes failed. Your changes are still preserved in git stash."
|
||||
log_info "Resolve manually with: git stash apply $autostash_ref"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
log_info "Skipped restoring local changes."
|
||||
log_info "Your changes are still preserved in git stash."
|
||||
log_info "Restore manually with: git stash apply $autostash_ref"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
log_error "Directory exists but is not a git repository: $INSTALL_DIR"
|
||||
log_info "Remove it or choose a different directory with --dir"
|
||||
|
||||
@@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
||||
|
||||
# Auto-extracted from noreply emails + manual overrides
|
||||
AUTHOR_MAP = {
|
||||
"harjoth.khara@gmail.com": "harjothkhara",
|
||||
"129007007+HeLLGURD@users.noreply.github.com": "HeLLGURD",
|
||||
"dirtyren@users.noreply.github.com": "dirtyren",
|
||||
"zhaolei.vc@bytedance.com": "zhaoleibd",
|
||||
|
||||
@@ -326,3 +326,27 @@ def test_stream_event_translation_keeps_identical_calls_in_distinct_parts():
|
||||
assert tool_chunks[0].choices[0].delta.tool_calls[0].index == 0
|
||||
assert tool_chunks[1].choices[0].delta.tool_calls[0].index == 1
|
||||
assert tool_chunks[0].choices[0].delta.tool_calls[0].id != tool_chunks[1].choices[0].delta.tool_calls[0].id
|
||||
|
||||
|
||||
def test_max_tokens_none_defaults_to_gemini_output_ceiling():
|
||||
"""max_tokens=None must send the model's full output ceiling, not omit it.
|
||||
|
||||
Gemini's native generateContent applies a low internal default when
|
||||
maxOutputTokens is absent, truncating tool calls mid-stream. Hermes passes
|
||||
None to mean "unlimited", so the adapter must translate that to the
|
||||
published 65,535 ceiling rather than leaving the field unset.
|
||||
"""
|
||||
from agent.gemini_native_adapter import (
|
||||
build_gemini_request,
|
||||
GEMINI_DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
)
|
||||
|
||||
req = build_gemini_request(messages=[{"role": "user", "content": "hi"}], max_tokens=None)
|
||||
assert req["generationConfig"]["maxOutputTokens"] == GEMINI_DEFAULT_MAX_OUTPUT_TOKENS == 65535
|
||||
|
||||
|
||||
def test_explicit_max_tokens_is_respected():
|
||||
from agent.gemini_native_adapter import build_gemini_request
|
||||
|
||||
req = build_gemini_request(messages=[{"role": "user", "content": "hi"}], max_tokens=4096)
|
||||
assert req["generationConfig"]["maxOutputTokens"] == 4096
|
||||
|
||||
@@ -859,3 +859,53 @@ class TestChatCompletionsCacheStats:
|
||||
r = SimpleNamespace(usage=SimpleNamespace(prompt_tokens_details=details))
|
||||
result = transport.extract_cache_stats(r)
|
||||
assert result == {"cached_tokens": 500, "creation_tokens": 100}
|
||||
|
||||
|
||||
class TestChatCompletionsGeminiNativeExtraBodyStrip:
|
||||
"""Profile extra_body (e.g. Nous portal tags) must not reach a native
|
||||
Gemini endpoint — Google's REST API rejects unknown fields with HTTP 400.
|
||||
"""
|
||||
|
||||
def _nous_profile(self):
|
||||
from providers import get_provider_profile
|
||||
return get_provider_profile("nous")
|
||||
|
||||
def test_tags_stripped_when_endpoint_is_native_gemini(self, transport):
|
||||
kw = transport.build_kwargs(
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
[{"role": "user", "content": "hi"}],
|
||||
None,
|
||||
provider_profile=self._nous_profile(),
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta",
|
||||
session_id="s1",
|
||||
max_tokens=None,
|
||||
)
|
||||
eb = kw.get("extra_body")
|
||||
assert not eb or "tags" not in eb
|
||||
|
||||
def test_tags_preserved_on_nous_endpoint(self, transport):
|
||||
kw = transport.build_kwargs(
|
||||
"hermes-3-405b",
|
||||
[{"role": "user", "content": "hi"}],
|
||||
None,
|
||||
provider_profile=self._nous_profile(),
|
||||
base_url="https://inference.nousresearch.com/v1",
|
||||
session_id="s1",
|
||||
max_tokens=None,
|
||||
)
|
||||
eb = kw.get("extra_body")
|
||||
assert eb and "tags" in eb
|
||||
|
||||
def test_tags_pass_through_on_gemini_openai_compat(self, transport):
|
||||
# /openai compat endpoint is not "native" — unchanged behavior.
|
||||
kw = transport.build_kwargs(
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
[{"role": "user", "content": "hi"}],
|
||||
None,
|
||||
provider_profile=self._nous_profile(),
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
session_id="s1",
|
||||
max_tokens=None,
|
||||
)
|
||||
eb = kw.get("extra_body")
|
||||
assert eb and "tags" in eb
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Tests for the Discord continuous voice mixer (ambient + ducked speech)
|
||||
and the verbal-ack-before-tool-calls hook.
|
||||
|
||||
The mixer (plugins/platforms/discord/voice_mixer.py) is pure-PCM and has no
|
||||
discord.py dependency, so its core is tested directly. The adapter
|
||||
integration (install on join, play routing, ack) is tested with the standard
|
||||
``object.__new__(DiscordAdapter)`` helper used elsewhere in the voice suite.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# numpy ships only in the optional "voice" extra (not [all,dev]); the mixer
|
||||
# math needs it, so skip this whole module when it isn't installed.
|
||||
np = pytest.importorskip("numpy")
|
||||
|
||||
# voice_mixer lives inside the discord plugin package dir; import by path the
|
||||
# same way the adapter does.
|
||||
_DISCORD_DIR = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"plugins", "platforms", "discord",
|
||||
)
|
||||
if _DISCORD_DIR not in sys.path:
|
||||
sys.path.insert(0, _DISCORD_DIR)
|
||||
|
||||
import voice_mixer as vm # noqa: E402
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Pure mixer unit tests
|
||||
# =====================================================================
|
||||
|
||||
class TestVoiceMixerCore:
|
||||
def test_frame_geometry_matches_discord(self):
|
||||
# 20ms @ 48kHz stereo s16 == 3840 bytes (discord.opus.Encoder.FRAME_SIZE)
|
||||
assert vm.FRAME_SIZE == 3840
|
||||
assert vm.SAMPLES_PER_FRAME == 960
|
||||
assert len(vm.SILENCE_FRAME) == vm.FRAME_SIZE
|
||||
|
||||
def test_empty_mixer_returns_silence_frames(self):
|
||||
mx = vm.VoiceMixer()
|
||||
for _ in range(5):
|
||||
frame = mx.read()
|
||||
assert len(frame) == vm.FRAME_SIZE
|
||||
assert frame == vm.SILENCE_FRAME
|
||||
|
||||
def test_is_opus_false(self):
|
||||
# discord.py sends raw PCM when is_opus() is False.
|
||||
assert vm.VoiceMixer().is_opus() is False
|
||||
|
||||
def test_ambient_loops_and_is_quiet(self):
|
||||
mx = vm.VoiceMixer(ambient_gain=0.2)
|
||||
amb = vm.synth_ambient_pcm(seconds=0.5)
|
||||
assert len(amb) % vm.FRAME_SIZE == 0 # frame-aligned for seamless loop
|
||||
mx.set_ambient(amb)
|
||||
peaks = [int(np.max(np.abs(np.frombuffer(mx.read(), dtype=np.int16))))
|
||||
for _ in range(100)] # 2s >> 0.5s loop
|
||||
# Produces audio after the fade-in and stays under the configured gain.
|
||||
assert any(p > 0 for p in peaks[10:])
|
||||
assert max(peaks) < int(32767 * 0.5)
|
||||
|
||||
def test_speech_audible_over_ambient_then_releases(self):
|
||||
mx = vm.VoiceMixer(ambient_gain=0.2, duck_gain=0.05, duck_release_ms=200)
|
||||
mx.set_ambient(vm.synth_ambient_pcm(seconds=0.5))
|
||||
base = max(int(np.max(np.abs(np.frombuffer(mx.read(), dtype=np.int16))))
|
||||
for _ in range(10))
|
||||
tone = (np.sin(2 * np.pi * 440 * np.arange(int(48000 * 0.4)) / 48000)
|
||||
* 20000).astype(np.int16)
|
||||
stereo = np.repeat(tone[:, None], 2, axis=1).reshape(-1).tobytes()
|
||||
mx.play_speech(stereo, fade_in_ms=0)
|
||||
assert mx.speech_active
|
||||
speech_peak = max(int(np.max(np.abs(np.frombuffer(mx.read(), dtype=np.int16))))
|
||||
for _ in range(15))
|
||||
assert speech_peak > base
|
||||
# Drain past speech + release ramp; speech_active clears.
|
||||
for _ in range(40):
|
||||
mx.read()
|
||||
assert not mx.speech_active
|
||||
|
||||
def test_clipping_prevents_int16_wraparound(self):
|
||||
mx = vm.VoiceMixer()
|
||||
loud = (np.ones(vm.SAMPLES_PER_FRAME * 2) * 30000).astype(np.int16).tobytes()
|
||||
mx.play_speech(loud, fade_in_ms=0)
|
||||
mx.play_speech(loud, fade_in_ms=0)
|
||||
out = np.frombuffer(mx.read(), dtype=np.int16)
|
||||
assert int(out.max()) == 32767 # clamped, not wrapped to negative
|
||||
assert int(out.min()) >= -32768
|
||||
|
||||
def test_stop_speech_clears_in_flight(self):
|
||||
mx = vm.VoiceMixer()
|
||||
tone = (np.ones(48000) * 10000).astype(np.int16)
|
||||
stereo = np.repeat(tone[:, None], 2, axis=1).reshape(-1).tobytes()
|
||||
mx.play_speech(stereo)
|
||||
assert mx.speech_active
|
||||
mx.stop_speech()
|
||||
mx.read()
|
||||
assert not mx.speech_active
|
||||
|
||||
def test_set_ambient_none_clears(self):
|
||||
mx = vm.VoiceMixer()
|
||||
mx.set_ambient(vm.synth_ambient_pcm(seconds=0.5))
|
||||
mx.set_ambient(None)
|
||||
# No ambient, no speech -> silence.
|
||||
assert mx.read() == vm.SILENCE_FRAME
|
||||
|
||||
def test_cleanup_silences(self):
|
||||
mx = vm.VoiceMixer()
|
||||
mx.set_ambient(vm.synth_ambient_pcm(seconds=0.5))
|
||||
mx.cleanup()
|
||||
assert mx.read() == vm.SILENCE_FRAME
|
||||
|
||||
def test_pcm_not_frame_aligned_is_padded(self):
|
||||
# Odd-length PCM must be padded to whole frames (no IndexError, no click).
|
||||
mx = vm.VoiceMixer()
|
||||
mx.play_speech(b"\x01\x02\x03", fade_in_ms=0) # 3 bytes << one frame
|
||||
out = mx.read()
|
||||
assert len(out) == vm.FRAME_SIZE
|
||||
|
||||
def test_synth_ambient_is_stereo_and_frame_aligned(self):
|
||||
pcm = vm.synth_ambient_pcm(seconds=1.0)
|
||||
assert len(pcm) % (vm.CHANNELS * vm.SAMPLE_WIDTH) == 0
|
||||
assert len(pcm) % vm.FRAME_SIZE == 0
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Adapter integration
|
||||
# =====================================================================
|
||||
|
||||
def _make_adapter(fx_cfg=None):
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
config = PlatformConfig(enabled=True, extra={})
|
||||
config.token = "fake-token"
|
||||
adapter = object.__new__(DiscordAdapter)
|
||||
adapter.platform = Platform.DISCORD
|
||||
adapter.config = config
|
||||
adapter._client = MagicMock()
|
||||
adapter._voice_clients = {}
|
||||
adapter._voice_locks = {}
|
||||
adapter._voice_text_channels = {}
|
||||
adapter._voice_sources = {}
|
||||
adapter._voice_timeout_tasks = {}
|
||||
adapter._voice_receivers = {}
|
||||
adapter._voice_listen_tasks = {}
|
||||
adapter._voice_mixers = {}
|
||||
adapter._ambient_pcm_cache = None
|
||||
adapter._voice_fx_cfg = fx_cfg if fx_cfg is not None else {
|
||||
"enabled": True, "ambient_enabled": True, "ambient_path": "",
|
||||
"ambient_gain": 0.18, "duck_gain": 0.06, "speech_gain": 1.0,
|
||||
"ack_enabled": True, "ack_phrases": ["One moment."],
|
||||
}
|
||||
return adapter
|
||||
|
||||
|
||||
class TestVoiceMixerActive:
|
||||
def test_false_when_no_mixer(self):
|
||||
adapter = _make_adapter()
|
||||
assert adapter.voice_mixer_active(111) is False
|
||||
|
||||
def test_true_when_mixer_present(self):
|
||||
adapter = _make_adapter()
|
||||
adapter._voice_mixers[111] = object()
|
||||
assert adapter.voice_mixer_active(111) is True
|
||||
|
||||
def test_false_when_attr_missing(self):
|
||||
# Defensive getattr path (object.__new__ helper that forgot the attr).
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
from gateway.config import Platform
|
||||
bare = object.__new__(DiscordAdapter)
|
||||
bare.platform = Platform.DISCORD
|
||||
assert bare.voice_mixer_active(111) is False
|
||||
|
||||
|
||||
class TestPlayInVoiceChannelMixerPath:
|
||||
@pytest.mark.asyncio
|
||||
async def test_routes_through_mixer_when_present(self):
|
||||
adapter = _make_adapter()
|
||||
vc = MagicMock()
|
||||
vc.is_connected.return_value = True
|
||||
adapter._voice_clients[111] = vc
|
||||
|
||||
# speech_active returns True once (so play_speech is observed) then
|
||||
# False so the wait loop exits promptly.
|
||||
class _Mixer:
|
||||
def __init__(self):
|
||||
self._polls = 0
|
||||
self.play_speech = MagicMock()
|
||||
|
||||
@property
|
||||
def speech_active(self):
|
||||
self._polls += 1
|
||||
return self._polls <= 1
|
||||
|
||||
mixer = _Mixer()
|
||||
adapter._voice_mixers[111] = mixer
|
||||
adapter._reset_voice_timeout = MagicMock()
|
||||
|
||||
fake_pcm = b"\x00" * vm.FRAME_SIZE
|
||||
with patch.object(vm, "decode_to_pcm", return_value=fake_pcm):
|
||||
ok = await adapter.play_in_voice_channel(111, "/tmp/x.mp3")
|
||||
assert ok is True
|
||||
mixer.play_speech.assert_called_once()
|
||||
# Legacy path must NOT have been used.
|
||||
vc.play.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_when_decode_fails(self):
|
||||
adapter = _make_adapter()
|
||||
vc = MagicMock()
|
||||
vc.is_connected.return_value = True
|
||||
vc.is_playing.return_value = False
|
||||
adapter._voice_clients[111] = vc
|
||||
adapter._voice_mixers[111] = MagicMock()
|
||||
adapter._reset_voice_timeout = MagicMock()
|
||||
adapter._voice_receivers[111] = MagicMock()
|
||||
|
||||
with patch.object(vm, "decode_to_pcm", return_value=None), \
|
||||
patch("plugins.platforms.discord.adapter.discord") as mock_discord:
|
||||
mock_discord.FFmpegPCMAudio.return_value = MagicMock()
|
||||
mock_discord.PCMVolumeTransformer.return_value = MagicMock()
|
||||
|
||||
# Make the legacy wait loop resolve immediately without leaving the
|
||||
# real Event.wait() coroutine unawaited.
|
||||
async def _fast(coro, *a, **k):
|
||||
if hasattr(coro, "close"):
|
||||
coro.close()
|
||||
return None
|
||||
with patch("asyncio.wait_for", _fast):
|
||||
ok = await adapter.play_in_voice_channel(111, "/tmp/x.mp3")
|
||||
# Fell through to legacy path -> vc.play called.
|
||||
assert vc.play.called
|
||||
|
||||
|
||||
class TestPlayAckInVoice:
|
||||
@pytest.mark.asyncio
|
||||
async def test_noop_when_ack_disabled(self):
|
||||
adapter = _make_adapter({"ack_enabled": False})
|
||||
adapter._voice_mixers[111] = MagicMock()
|
||||
assert await adapter.play_ack_in_voice(111) is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_noop_when_no_mixer(self):
|
||||
adapter = _make_adapter()
|
||||
assert await adapter.play_ack_in_voice(111) is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plays_speech_when_armed(self, tmp_path):
|
||||
adapter = _make_adapter()
|
||||
mixer = MagicMock()
|
||||
adapter._voice_mixers[111] = mixer
|
||||
adapter._reset_voice_timeout = MagicMock()
|
||||
|
||||
ack_file = tmp_path / "ack.mp3"
|
||||
ack_file.write_bytes(b"id3")
|
||||
import json as _json
|
||||
with patch("tools.tts_tool.text_to_speech_tool",
|
||||
return_value=_json.dumps({"success": True, "file_path": str(ack_file)})), \
|
||||
patch.object(vm, "decode_to_pcm", return_value=b"\x00" * vm.FRAME_SIZE):
|
||||
ok = await adapter.play_ack_in_voice(111, phrase="Testing one two.")
|
||||
assert ok is True
|
||||
mixer.play_speech.assert_called_once()
|
||||
@@ -641,3 +641,36 @@ class TestAdapterInit:
|
||||
assert asyncio.run(ad.get_chat_info("U123"))["type"] == "dm"
|
||||
assert asyncio.run(ad.get_chat_info("C123"))["type"] == "group"
|
||||
assert asyncio.run(ad.get_chat_info("R123"))["type"] == "channel"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. Inbound message-type classification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMessageTypeMapping:
|
||||
"""LINE webhook message types must map to the right normalized
|
||||
MessageType so the gateway routes media correctly (e.g. voice → STT,
|
||||
files → document handling). Regression guard for the old code that
|
||||
referenced the non-existent ``MessageType.IMAGE`` and collapsed every
|
||||
non-text message onto a single type."""
|
||||
|
||||
def test_image_event_not_attributeerror_regression(self):
|
||||
# The bug: MessageType.IMAGE doesn't exist on the enum.
|
||||
MessageType = _line.MessageType
|
||||
assert not hasattr(MessageType, "IMAGE")
|
||||
|
||||
def test_every_line_type_maps_to_correct_enum(self):
|
||||
MessageType = _line.MessageType
|
||||
mapping = _line._LINE_MESSAGE_TYPES
|
||||
assert mapping["text"] == MessageType.TEXT
|
||||
assert mapping["image"] == MessageType.PHOTO
|
||||
assert mapping["video"] == MessageType.VIDEO
|
||||
# LINE has no separate voice type — audio clips are voice notes.
|
||||
assert mapping["audio"] == MessageType.VOICE
|
||||
assert mapping["file"] == MessageType.DOCUMENT
|
||||
assert mapping["location"] == MessageType.LOCATION
|
||||
assert mapping["sticker"] == MessageType.STICKER
|
||||
|
||||
def test_unknown_type_falls_back_to_text(self):
|
||||
MessageType = _line.MessageType
|
||||
assert _line._LINE_MESSAGE_TYPES.get("flex", MessageType.TEXT) == MessageType.TEXT
|
||||
|
||||
@@ -1036,3 +1036,28 @@ class TestReadProcessCmdlinePsFallback:
|
||||
)
|
||||
result = status._read_process_cmdline(12345)
|
||||
assert "hermes_cli/main.py" in result
|
||||
|
||||
|
||||
class TestCorruptStatusFiles:
|
||||
"""A status / pid file holding non-UTF-8 (binary) bytes must read as
|
||||
None, not crash the gateway status path with UnicodeDecodeError."""
|
||||
|
||||
def test_read_json_file_returns_none_on_binary_garbage(self, tmp_path):
|
||||
p = tmp_path / "runtime.json"
|
||||
p.write_bytes(b"\xff\xfe\x00\x80not utf-8\x81")
|
||||
assert status._read_json_file(p) is None
|
||||
|
||||
def test_read_json_file_still_parses_valid_json(self, tmp_path):
|
||||
p = tmp_path / "runtime.json"
|
||||
p.write_text(json.dumps({"pid": 7}), encoding="utf-8")
|
||||
assert status._read_json_file(p) == {"pid": 7}
|
||||
|
||||
def test_read_pid_record_returns_none_on_binary_garbage(self, tmp_path):
|
||||
p = tmp_path / "gateway.pid"
|
||||
p.write_bytes(b"\xff\xfe\x00\x80\x81")
|
||||
assert status._read_pid_record(p) is None
|
||||
|
||||
def test_read_pid_record_still_parses_bare_pid(self, tmp_path):
|
||||
p = tmp_path / "gateway.pid"
|
||||
p.write_text("4242", encoding="utf-8")
|
||||
assert status._read_pid_record(p) == {"pid": 4242}
|
||||
|
||||
@@ -111,3 +111,19 @@ class TestCronCommandLifecycle:
|
||||
assert jobs[0]["skills"] == ["blogwatcher", "maps"]
|
||||
assert jobs[0]["name"] == "Skill combo"
|
||||
assert jobs[0]["profile"] == "default"
|
||||
|
||||
def test_list_does_not_crash_when_repeat_is_null(self, tmp_cron_dir, capsys):
|
||||
"""A one-shot job can be persisted with ``"repeat": null``. `cron
|
||||
list` must render it as ∞ rather than crashing on .get(...)\\.get."""
|
||||
from cron.jobs import load_jobs, save_jobs
|
||||
|
||||
create_job(prompt="One shot", schedule="every 1h")
|
||||
# Force the present-but-null shape that .get("repeat", {}) mishandles.
|
||||
jobs = load_jobs()
|
||||
jobs[0]["repeat"] = None
|
||||
save_jobs(jobs)
|
||||
|
||||
cron_command(Namespace(cron_command="list", all=True))
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Repeat: ∞" in out
|
||||
|
||||
@@ -92,12 +92,108 @@ class TestEnsureUv:
|
||||
assert path == str(tmp_path / "bin" / "uv")
|
||||
mock_install.assert_called_once()
|
||||
|
||||
def test_install_failure_returns_none(self, tmp_path):
|
||||
def test_install_failure_returns_falsy(self, tmp_path):
|
||||
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
||||
patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")):
|
||||
from hermes_cli.managed_uv import ensure_uv
|
||||
path = ensure_uv()
|
||||
assert path is None
|
||||
# Failure is a falsy sentinel (not None) so legacy 2-target call
|
||||
# sites can still unpack it without raising — see
|
||||
# TestEnsureUvUpdateBoundary for why.
|
||||
assert not path
|
||||
|
||||
|
||||
class TestEnsureUvUpdateBoundary:
|
||||
"""``ensure_uv()`` must answer to both the single-value and the legacy
|
||||
``(path, fresh_bootstrap)`` call conventions — **on POSIX**.
|
||||
|
||||
``hermes update`` runs the call site from the old, already-imported
|
||||
``hermes_cli.main`` against the freshly pulled ``managed_uv``. A release
|
||||
parked on a ``(path, fresh)`` tuple runs ``uv_bin, fresh = ensure_uv()``
|
||||
against the single-value module; the path is an iterable ``str`` so the
|
||||
2-target unpack walked its characters and raised
|
||||
``ValueError: too many values to unpack (expected 2)`` (root cause behind
|
||||
PR #39763), or ``TypeError`` on the ``None`` failure path. On POSIX the
|
||||
result must therefore be usable as a bare path *and* unpackable as a
|
||||
2-tuple, in both the success and failure cases.
|
||||
|
||||
The dual contract is intentionally **not** offered on Windows — see
|
||||
``TestEnsureUvWindowsSafe`` for why — so these tests pin ``platform.system``
|
||||
to a POSIX value.
|
||||
"""
|
||||
|
||||
def test_success_usable_as_single_value(self, tmp_path):
|
||||
_make_executable(tmp_path / "bin" / "uv")
|
||||
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
||||
patch("hermes_cli.managed_uv.platform.system", return_value="Linux"):
|
||||
from hermes_cli.managed_uv import ensure_uv
|
||||
uv_bin = ensure_uv()
|
||||
assert uv_bin == str(tmp_path / "bin" / "uv")
|
||||
assert bool(uv_bin) is True
|
||||
|
||||
def test_success_unpacks_as_legacy_two_tuple(self, tmp_path):
|
||||
_make_executable(tmp_path / "bin" / "uv")
|
||||
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
||||
patch("hermes_cli.managed_uv.platform.system", return_value="Linux"):
|
||||
from hermes_cli.managed_uv import ensure_uv
|
||||
uv_bin, fresh = ensure_uv() # old: uv_bin, fresh_bootstrap = ensure_uv()
|
||||
assert uv_bin == str(tmp_path / "bin" / "uv")
|
||||
assert fresh is False
|
||||
|
||||
def test_failure_unpacks_without_raising(self, tmp_path):
|
||||
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
||||
patch("hermes_cli.managed_uv.platform.system", return_value="Linux"), \
|
||||
patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")):
|
||||
from hermes_cli.managed_uv import ensure_uv
|
||||
uv_bin, fresh = ensure_uv()
|
||||
assert uv_bin is None
|
||||
assert fresh is False
|
||||
|
||||
|
||||
class TestEnsureUvWindowsSafe:
|
||||
"""On Windows ``ensure_uv()`` must return a plain ``str``/``None``.
|
||||
|
||||
``subprocess`` on Windows serializes argv through
|
||||
``subprocess.list2cmdline``, which iterates every entry *as a string*
|
||||
(``for c in arg``). The dependency installer feeds uv straight into the
|
||||
command list (``[uv_bin, "pip", "install", ...]``). A ``str`` subclass
|
||||
whose ``__iter__`` yields ``(path, fresh_bootstrap)`` instead of characters
|
||||
therefore injects the bool into the command line and crashes the install
|
||||
with ``TypeError: sequence item 1: expected str instance, bool found``
|
||||
(a real field report on a 10-commits-behind Windows install). A single
|
||||
return value cannot serve both the legacy 2-tuple unpack and Windows
|
||||
char-iteration — both use the iterator protocol — so Windows opts out of
|
||||
the wrapper entirely.
|
||||
"""
|
||||
|
||||
def test_uvresult_would_break_windows_list2cmdline(self):
|
||||
# Canary: this is *why* the wrapper is gated off Windows. If a future
|
||||
# change makes _UvResult char-iterable (and thus list2cmdline-safe),
|
||||
# the gate may be revisited.
|
||||
import subprocess
|
||||
from hermes_cli.managed_uv import _UvResult
|
||||
with pytest.raises(TypeError):
|
||||
subprocess.list2cmdline([_UvResult("C:\\hermes\\uv.exe"), "pip"])
|
||||
|
||||
def test_windows_returns_plain_str_safe_for_subprocess(self, tmp_path):
|
||||
import subprocess
|
||||
# On (mocked) Windows the managed binary is uv.exe.
|
||||
_make_executable(tmp_path / "bin" / "uv.exe")
|
||||
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
||||
patch("hermes_cli.managed_uv.platform.system", return_value="Windows"):
|
||||
from hermes_cli.managed_uv import _UvResult, ensure_uv
|
||||
uv_bin = ensure_uv()
|
||||
assert type(uv_bin) is str and not isinstance(uv_bin, _UvResult)
|
||||
# The exact operation that crashed in the field must now succeed.
|
||||
cmdline = subprocess.list2cmdline([uv_bin, "pip", "install", "-e", "."])
|
||||
assert "pip" in cmdline and "install" in cmdline
|
||||
|
||||
def test_windows_failure_returns_none(self, tmp_path):
|
||||
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
||||
patch("hermes_cli.managed_uv.platform.system", return_value="Windows"), \
|
||||
patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")):
|
||||
from hermes_cli.managed_uv import ensure_uv
|
||||
assert ensure_uv() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -257,6 +257,114 @@ def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch):
|
||||
assert set(unconfigured) == {"image_gen", "video_gen", "tts", "browser"}
|
||||
|
||||
|
||||
def _stub_browser_probes(monkeypatch, *, has_agent_browser, chromium, lightpanda=False):
|
||||
"""Common monkeypatches for local-browser readiness scenarios.
|
||||
|
||||
``chromium`` / ``lightpanda`` drive the runtime probes that
|
||||
``_local_browser_runnable`` reuses from ``tools.browser_tool`` (lazy import,
|
||||
so patching the module attributes is enough).
|
||||
"""
|
||||
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
|
||||
monkeypatch.setattr(
|
||||
ns, "get_nous_portal_account_info", lambda: _account(logged_in=False)
|
||||
)
|
||||
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
|
||||
monkeypatch.setattr(ns, "_has_agent_browser", lambda: has_agent_browser)
|
||||
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
||||
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
||||
monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda vendor: False)
|
||||
monkeypatch.setattr("tools.browser_tool._chromium_installed", lambda: chromium)
|
||||
monkeypatch.setattr(
|
||||
"tools.browser_tool._using_lightpanda_engine", lambda: lightpanda
|
||||
)
|
||||
|
||||
|
||||
def test_local_browser_unavailable_without_chromium(monkeypatch):
|
||||
"""agent-browser present but Chromium absent must NOT advertise local browser.
|
||||
|
||||
The runtime (``check_browser_requirements``) refuses local mode without a
|
||||
Chromium build, so the setup/status surface must report unavailable too —
|
||||
otherwise the user sees "Browser Automation available" and the first real
|
||||
call fails. Regression for the false-positive setup bug.
|
||||
"""
|
||||
_stub_browser_probes(monkeypatch, has_agent_browser=True, chromium=False)
|
||||
|
||||
features = ns.get_nous_subscription_features(
|
||||
{"browser": {"cloud_provider": "local"}}
|
||||
)
|
||||
|
||||
assert features.browser.available is False
|
||||
assert features.browser.active is False
|
||||
assert features.browser.managed_by_nous is False
|
||||
assert features.browser.current_provider == "Local browser"
|
||||
|
||||
|
||||
def test_local_browser_available_with_chromium(monkeypatch):
|
||||
_stub_browser_probes(monkeypatch, has_agent_browser=True, chromium=True)
|
||||
|
||||
features = ns.get_nous_subscription_features(
|
||||
{"browser": {"cloud_provider": "local"}}
|
||||
)
|
||||
|
||||
assert features.browser.available is True
|
||||
assert features.browser.active is True
|
||||
assert features.browser.current_provider == "Local browser"
|
||||
|
||||
|
||||
def test_local_browser_available_with_lightpanda_without_chromium(monkeypatch):
|
||||
"""Lightpanda is text-only and needs no Chromium, so it stays available.
|
||||
|
||||
Guards against the fix over-correcting into a false-negative for the
|
||||
legitimate Lightpanda-without-Chromium configuration.
|
||||
"""
|
||||
_stub_browser_probes(
|
||||
monkeypatch, has_agent_browser=True, chromium=False, lightpanda=True
|
||||
)
|
||||
|
||||
features = ns.get_nous_subscription_features(
|
||||
{"browser": {"cloud_provider": "local"}}
|
||||
)
|
||||
|
||||
assert features.browser.available is True
|
||||
assert features.browser.active is True
|
||||
|
||||
|
||||
def test_default_local_browser_unavailable_without_chromium(monkeypatch):
|
||||
"""The implicit (no cloud_provider) local fallthrough is gated on Chromium too."""
|
||||
_stub_browser_probes(monkeypatch, has_agent_browser=True, chromium=False)
|
||||
|
||||
features = ns.get_nous_subscription_features({})
|
||||
|
||||
assert features.browser.available is False
|
||||
assert features.browser.current_provider == "Local browser"
|
||||
|
||||
|
||||
def test_cloud_browserbase_available_without_local_chromium(monkeypatch):
|
||||
"""Cloud providers host their own Chromium, so the new local gate must not
|
||||
regress them: agent-browser binary present + Browserbase creds is enough."""
|
||||
env = {"BROWSERBASE_API_KEY": "bb-key", "BROWSERBASE_PROJECT_ID": "bb-project"}
|
||||
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
|
||||
monkeypatch.setattr(
|
||||
ns, "get_nous_portal_account_info", lambda: _account(logged_in=False)
|
||||
)
|
||||
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
|
||||
monkeypatch.setattr(ns, "_has_agent_browser", lambda: True)
|
||||
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
||||
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
||||
monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda vendor: False)
|
||||
# Chromium absent locally — must not matter for a cloud provider.
|
||||
monkeypatch.setattr("tools.browser_tool._chromium_installed", lambda: False)
|
||||
monkeypatch.setattr("tools.browser_tool._using_lightpanda_engine", lambda: False)
|
||||
|
||||
features = ns.get_nous_subscription_features(
|
||||
{"browser": {"cloud_provider": "browserbase"}}
|
||||
)
|
||||
|
||||
assert features.browser.available is True
|
||||
assert features.browser.active is True
|
||||
assert features.browser.current_provider == "Browserbase"
|
||||
|
||||
|
||||
def test_get_gateway_eligible_tools_pool_excludes_video(monkeypatch):
|
||||
"""A free-tool-pool user is offered the covered tools but NOT video gen."""
|
||||
monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _pool_account())
|
||||
|
||||
@@ -178,72 +178,6 @@ class TestModelSwitchPersistence:
|
||||
assert result.base_url == "https://api.anthropic.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /model tab completion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestModelTabCompletion:
|
||||
"""SlashCommandCompleter provides model alias completions for /model."""
|
||||
|
||||
def test_model_completions_yields_direct_aliases(self, monkeypatch):
|
||||
"""_model_completions yields direct aliases with model and provider info."""
|
||||
from hermes_cli.commands import SlashCommandCompleter
|
||||
from hermes_cli.model_switch import DirectAlias
|
||||
import hermes_cli.model_switch as ms
|
||||
|
||||
test_aliases = {
|
||||
"opus": DirectAlias("claude-opus-4-6", "anthropic", ""),
|
||||
"qwen": DirectAlias("qwen3.5:397b", "custom", "https://ollama.com/v1"),
|
||||
}
|
||||
monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases)
|
||||
|
||||
completer = SlashCommandCompleter()
|
||||
completions = list(completer._model_completions("", ""))
|
||||
|
||||
names = [c.text for c in completions]
|
||||
assert "opus" in names
|
||||
assert "qwen" in names
|
||||
|
||||
def test_model_completions_filters_by_prefix(self, monkeypatch):
|
||||
"""Completions filter by typed prefix."""
|
||||
from hermes_cli.commands import SlashCommandCompleter
|
||||
from hermes_cli.model_switch import DirectAlias
|
||||
import hermes_cli.model_switch as ms
|
||||
|
||||
test_aliases = {
|
||||
"opus": DirectAlias("claude-opus-4-6", "anthropic", ""),
|
||||
"qwen": DirectAlias("qwen3.5:397b", "custom", "https://ollama.com/v1"),
|
||||
}
|
||||
monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases)
|
||||
|
||||
completer = SlashCommandCompleter()
|
||||
completions = list(completer._model_completions("o", "o"))
|
||||
|
||||
names = [c.text for c in completions]
|
||||
assert "opus" in names
|
||||
assert "qwen" not in names
|
||||
|
||||
def test_model_completions_shows_metadata(self, monkeypatch):
|
||||
"""Completions include model name and provider in display_meta."""
|
||||
from hermes_cli.commands import SlashCommandCompleter
|
||||
from hermes_cli.model_switch import DirectAlias
|
||||
import hermes_cli.model_switch as ms
|
||||
|
||||
test_aliases = {
|
||||
"glm": DirectAlias("glm-4.7", "custom", "https://ollama.com/v1"),
|
||||
}
|
||||
monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases)
|
||||
|
||||
completer = SlashCommandCompleter()
|
||||
completions = list(completer._model_completions("g", "g"))
|
||||
|
||||
assert len(completions) >= 1
|
||||
glm_comp = [c for c in completions if c.text == "glm"][0]
|
||||
meta_str = str(glm_comp.display_meta)
|
||||
assert "glm-4.7" in meta_str
|
||||
assert "custom" in meta_str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fallback base_url passthrough
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Tests for the /model picker background cache prewarm.
|
||||
|
||||
``prewarm_picker_cache_async()`` warms the provider-models disk cache off the
|
||||
user's critical path so the first ``/model`` open in a session is fast instead
|
||||
of blocking ~1-2s on serial /v1/models fetches. These pin the two contracts
|
||||
that matter: it runs the warm path exactly once per process (no thread leak),
|
||||
and it delegates to ``list_authenticated_providers`` to do the warming.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import hermes_cli.model_switch as ms
|
||||
|
||||
|
||||
def _reset_guard():
|
||||
ms._picker_prewarm_done.clear()
|
||||
|
||||
|
||||
def test_prewarm_runs_list_authenticated_providers_once():
|
||||
"""First call spawns a thread that calls list_authenticated_providers;
|
||||
the warm side effect is delegated there (which disk-caches per provider)."""
|
||||
_reset_guard()
|
||||
with patch.object(ms, "list_authenticated_providers", return_value=[]) as mock_list:
|
||||
t = ms.prewarm_picker_cache_async()
|
||||
assert t is not None, "first call must spawn a prewarm thread"
|
||||
t.join(timeout=10)
|
||||
assert not t.is_alive(), "prewarm thread should finish promptly"
|
||||
mock_list.assert_called_once()
|
||||
_reset_guard()
|
||||
|
||||
|
||||
def test_prewarm_guard_is_once_per_process():
|
||||
"""The process-level Event guard must make repeat calls no-ops so a
|
||||
long-lived process never leaks one OS thread per call."""
|
||||
_reset_guard()
|
||||
with patch.object(ms, "list_authenticated_providers", return_value=[]):
|
||||
t1 = ms.prewarm_picker_cache_async()
|
||||
assert t1 is not None
|
||||
t1.join(timeout=10)
|
||||
# Subsequent calls return None (guard set) — no new thread.
|
||||
assert ms.prewarm_picker_cache_async() is None
|
||||
assert ms.prewarm_picker_cache_async() is None
|
||||
_reset_guard()
|
||||
|
||||
|
||||
def test_prewarm_never_raises_on_failure():
|
||||
"""A failing/offline provider path must be fully swallowed — the prewarm
|
||||
is best-effort and must never surface errors into the session."""
|
||||
_reset_guard()
|
||||
with patch.object(
|
||||
ms, "list_authenticated_providers", side_effect=RuntimeError("boom")
|
||||
):
|
||||
t = ms.prewarm_picker_cache_async()
|
||||
assert t is not None
|
||||
# join must not raise; the worker swallows the exception internally.
|
||||
t.join(timeout=10)
|
||||
assert not t.is_alive()
|
||||
_reset_guard()
|
||||
@@ -344,3 +344,43 @@ def test_setup_summary_does_not_mark_incomplete_browserbase_as_available(tmp_pat
|
||||
assert "Browser Automation (Browserbase)" not in output
|
||||
assert "Browser Automation" in output
|
||||
assert "BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID" in output
|
||||
|
||||
|
||||
def test_setup_summary_local_browser_unavailable_without_chromium(
|
||||
tmp_path, monkeypatch, capsys
|
||||
):
|
||||
"""End-to-end: agent-browser present but no Chromium in local mode must
|
||||
render as unavailable with an install hint — not a false 'available'.
|
||||
|
||||
Unlike the mocked-feature tests above, this drives the real
|
||||
``get_nous_subscription_features`` so the surface stays aligned with the
|
||||
runtime gate in ``tools.browser_tool.check_browser_requirements``.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_clear_provider_env(monkeypatch)
|
||||
|
||||
cfg = load_config()
|
||||
browser_cfg = cfg.get("browser")
|
||||
if not isinstance(browser_cfg, dict):
|
||||
browser_cfg = {}
|
||||
cfg["browser"] = browser_cfg
|
||||
browser_cfg["cloud_provider"] = "local"
|
||||
save_config(cfg)
|
||||
|
||||
# Only stub the readiness probes; the feature resolver itself is real.
|
||||
monkeypatch.setattr("hermes_cli.nous_subscription._has_agent_browser", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.nous_subscription.get_nous_portal_account_info",
|
||||
lambda *a, **k: None,
|
||||
)
|
||||
monkeypatch.setattr("tools.browser_tool._chromium_installed", lambda: False)
|
||||
monkeypatch.setattr("tools.browser_tool._using_lightpanda_engine", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
"agent.auxiliary_client.get_available_vision_backends", lambda: []
|
||||
)
|
||||
|
||||
_print_setup_summary(load_config(), tmp_path)
|
||||
output = capsys.readouterr().out
|
||||
|
||||
assert "Browser Automation (Local browser)" not in output
|
||||
assert "agent-browser install --with-deps" in output
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
"""Tests for hermes_cli.telegram_managed_bot — QR codes, deep links, pairing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from hermes_cli.telegram_managed_bot import (
|
||||
DEFAULT_MANAGER_BOT,
|
||||
TELEGRAM_ONBOARDING_URL_ENV,
|
||||
TelegramBotSetupResult,
|
||||
TelegramPairing,
|
||||
create_pairing,
|
||||
generate_bot_username,
|
||||
generate_deep_link,
|
||||
generate_pairing_nonce,
|
||||
poll_for_setup_result,
|
||||
poll_for_token,
|
||||
print_qr_code,
|
||||
render_qr_terminal,
|
||||
)
|
||||
|
||||
|
||||
VALID_TOKEN = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef"
|
||||
SECOND_VALID_TOKEN = "987654321:abcdefghijklmnopqrstuvwxyzABCDEF"
|
||||
|
||||
|
||||
class TestGenerateBotUsername:
|
||||
def test_secure_default_format(self):
|
||||
name = generate_bot_username()
|
||||
assert name.startswith("hermes_")
|
||||
assert name.endswith("_bot")
|
||||
assert len(name) == len("hermes_") + 16 + len("_bot")
|
||||
assert len(name) <= 32
|
||||
|
||||
def test_profile_name_not_embedded(self):
|
||||
name = generate_bot_username("work")
|
||||
assert "work" not in name
|
||||
assert name.startswith("hermes_")
|
||||
assert name.endswith("_bot")
|
||||
|
||||
def test_slug_uses_telegram_safe_base32_chars(self):
|
||||
name = generate_bot_username()
|
||||
slug = name.removeprefix("hermes_").removesuffix("_bot")
|
||||
assert len(slug) == 16
|
||||
assert set(slug) <= set("abcdefghijklmnopqrstuvwxyz234567")
|
||||
|
||||
def test_uniqueness(self):
|
||||
names = {generate_bot_username() for _ in range(20)}
|
||||
assert len(names) == 20
|
||||
|
||||
|
||||
class TestGenerateDeepLink:
|
||||
def test_basic_format(self):
|
||||
link = generate_deep_link(
|
||||
manager_bot="TestBot",
|
||||
suggested_username="my_bot",
|
||||
)
|
||||
assert link == "https://t.me/newbot/TestBot/my_bot"
|
||||
|
||||
def test_with_name(self):
|
||||
link = generate_deep_link(
|
||||
manager_bot="@TestBot",
|
||||
suggested_username="my_bot",
|
||||
suggested_name="My Agent",
|
||||
)
|
||||
assert "https://t.me/newbot/TestBot/my_bot?" in link
|
||||
assert "name=My+Agent" in link
|
||||
|
||||
def test_defaults(self):
|
||||
link = generate_deep_link()
|
||||
assert f"https://t.me/newbot/{DEFAULT_MANAGER_BOT}/" in link
|
||||
assert "hermes_" in link
|
||||
|
||||
def test_name_url_encoded(self):
|
||||
link = generate_deep_link(
|
||||
manager_bot="Bot",
|
||||
suggested_username="test_bot",
|
||||
suggested_name="Hermes & Friends",
|
||||
)
|
||||
assert "Hermes+%26+Friends" in link
|
||||
|
||||
|
||||
class TestPairingNonce:
|
||||
def test_length(self):
|
||||
nonce = generate_pairing_nonce()
|
||||
assert len(nonce) == 32
|
||||
|
||||
def test_hex_chars(self):
|
||||
nonce = generate_pairing_nonce()
|
||||
assert all(c in "0123456789abcdef" for c in nonce)
|
||||
|
||||
def test_uniqueness(self):
|
||||
nonces = {generate_pairing_nonce() for _ in range(100)}
|
||||
assert len(nonces) == 100
|
||||
|
||||
|
||||
class TestQRCode:
|
||||
def test_render_returns_string(self):
|
||||
result = render_qr_terminal("https://example.com")
|
||||
if result:
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 10
|
||||
|
||||
def test_render_graceful_without_qrcode(self):
|
||||
with patch.dict("sys.modules", {"qrcode": None}):
|
||||
render_qr_terminal("https://example.com")
|
||||
|
||||
def test_print_qr_code_with_url(self, capsys):
|
||||
print_qr_code("https://t.me/newbot/Bot/test_bot")
|
||||
captured = capsys.readouterr()
|
||||
assert "https://t.me/newbot/Bot/test_bot" in captured.out
|
||||
|
||||
|
||||
class TestCreatePairing:
|
||||
def test_success(self):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 201
|
||||
mock_resp.json.return_value = {
|
||||
"pairing_id": "abcdefghijklmnop",
|
||||
"poll_token": "secret-token",
|
||||
"suggested_username": "hermes_abcdefghijklmnop_bot",
|
||||
"deep_link": "https://t.me/newbot/HermesSetupBot/hermes_abcdefghijklmnop_bot?name=Hermes+Agent",
|
||||
"qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_abcdefghijklmnop_bot?name=Hermes+Agent",
|
||||
"expires_at": "2026-05-18T00:00:00.000Z",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"hermes_cli.telegram_managed_bot.httpx.post", return_value=mock_resp
|
||||
) as post:
|
||||
pairing = create_pairing("https://api.example.com", bot_name="Hermes Agent")
|
||||
|
||||
assert pairing == TelegramPairing(
|
||||
pairing_id="abcdefghijklmnop",
|
||||
poll_token="secret-token",
|
||||
suggested_username="hermes_abcdefghijklmnop_bot",
|
||||
deep_link="https://t.me/newbot/HermesSetupBot/hermes_abcdefghijklmnop_bot?name=Hermes+Agent",
|
||||
qr_payload="https://t.me/newbot/HermesSetupBot/hermes_abcdefghijklmnop_bot?name=Hermes+Agent",
|
||||
expires_at="2026-05-18T00:00:00.000Z",
|
||||
)
|
||||
post.assert_called_once_with(
|
||||
"https://api.example.com/v1/telegram/pairings",
|
||||
json={"bot_name": "Hermes Agent"},
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
def test_failure_status(self):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 500
|
||||
with patch(
|
||||
"hermes_cli.telegram_managed_bot.httpx.post", return_value=mock_resp
|
||||
):
|
||||
assert create_pairing("https://api.example.com") is None
|
||||
|
||||
def test_invalid_payload(self):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 201
|
||||
mock_resp.json.return_value = {"pairing_id": "missing-poll-token"}
|
||||
with patch(
|
||||
"hermes_cli.telegram_managed_bot.httpx.post", return_value=mock_resp
|
||||
):
|
||||
assert create_pairing("https://api.example.com") is None
|
||||
|
||||
def test_uses_env_override(self, monkeypatch):
|
||||
monkeypatch.setenv(TELEGRAM_ONBOARDING_URL_ENV, "https://worker.example")
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 500
|
||||
with patch(
|
||||
"hermes_cli.telegram_managed_bot.httpx.post", return_value=mock_resp
|
||||
) as post:
|
||||
create_pairing()
|
||||
assert post.call_args.args[0] == "https://worker.example/v1/telegram/pairings"
|
||||
|
||||
|
||||
class TestPollForToken:
|
||||
def pairing(self):
|
||||
return TelegramPairing(
|
||||
pairing_id="abcdefghijklmnop",
|
||||
poll_token="secret-token",
|
||||
suggested_username="hermes_abcdefghijklmnop_bot",
|
||||
deep_link="https://t.me/newbot/HermesSetupBot/hermes_abcdefghijklmnop_bot",
|
||||
qr_payload="https://t.me/newbot/HermesSetupBot/hermes_abcdefghijklmnop_bot",
|
||||
)
|
||||
|
||||
def test_immediate_success(self):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {
|
||||
"bot_username": "hermes_abcdefghijklmnop_bot",
|
||||
"owner_user_id": 42,
|
||||
"status": "ready",
|
||||
"token": VALID_TOKEN,
|
||||
}
|
||||
|
||||
with patch(
|
||||
"hermes_cli.telegram_managed_bot.httpx.get", return_value=mock_resp
|
||||
) as get:
|
||||
with patch("hermes_cli.telegram_managed_bot.time.sleep"):
|
||||
token = poll_for_token(
|
||||
"https://api.example.com", self.pairing(), timeout=5
|
||||
)
|
||||
|
||||
assert token == VALID_TOKEN
|
||||
assert (
|
||||
get.call_args.args[0]
|
||||
== "https://api.example.com/v1/telegram/pairings/abcdefghijklmnop"
|
||||
)
|
||||
assert get.call_args.kwargs["headers"] == {
|
||||
"Authorization": "Bearer secret-token"
|
||||
}
|
||||
|
||||
def test_setup_result_includes_owner_user_id(self):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {
|
||||
"bot_username": "hermes_abcdefghijklmnop_bot",
|
||||
"owner_user_id": 42,
|
||||
"status": "ready",
|
||||
"token": VALID_TOKEN,
|
||||
}
|
||||
|
||||
with patch("hermes_cli.telegram_managed_bot.httpx.get", return_value=mock_resp):
|
||||
with patch("hermes_cli.telegram_managed_bot.time.sleep"):
|
||||
result = poll_for_setup_result(
|
||||
"https://api.example.com", self.pairing(), timeout=5
|
||||
)
|
||||
|
||||
assert result == TelegramBotSetupResult(
|
||||
token=VALID_TOKEN,
|
||||
bot_username="hermes_abcdefghijklmnop_bot",
|
||||
owner_user_id=42,
|
||||
)
|
||||
|
||||
def test_setup_result_accepts_string_owner_user_id(self):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {
|
||||
"bot_username": "hermes_abcdefghijklmnop_bot",
|
||||
"owner_user_id": "42",
|
||||
"status": "ready",
|
||||
"token": VALID_TOKEN,
|
||||
}
|
||||
|
||||
with patch("hermes_cli.telegram_managed_bot.httpx.get", return_value=mock_resp):
|
||||
result = poll_for_setup_result(
|
||||
"https://api.example.com", self.pairing(), timeout=5
|
||||
)
|
||||
|
||||
assert result == TelegramBotSetupResult(
|
||||
token=VALID_TOKEN,
|
||||
bot_username="hermes_abcdefghijklmnop_bot",
|
||||
owner_user_id=42,
|
||||
)
|
||||
|
||||
def test_invalid_ready_token_returns_none(self):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {
|
||||
"bot_username": "hermes_abcdefghijklmnop_bot",
|
||||
"owner_user_id": 42,
|
||||
"status": "ready",
|
||||
"token": "not-a-real-token",
|
||||
}
|
||||
|
||||
with patch("hermes_cli.telegram_managed_bot.httpx.get", return_value=mock_resp):
|
||||
with patch("hermes_cli.telegram_managed_bot.time.sleep"):
|
||||
with patch(
|
||||
"hermes_cli.telegram_managed_bot.time.monotonic"
|
||||
) as mock_time:
|
||||
mock_time.side_effect = [0, 0, 999]
|
||||
assert (
|
||||
poll_for_token(
|
||||
"https://api.example.com", self.pairing(), timeout=1
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_timeout_returns_none(self):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"status": "waiting"}
|
||||
|
||||
with patch("hermes_cli.telegram_managed_bot.httpx.get", return_value=mock_resp):
|
||||
with patch("hermes_cli.telegram_managed_bot.time.sleep"):
|
||||
with patch(
|
||||
"hermes_cli.telegram_managed_bot.time.monotonic"
|
||||
) as mock_time:
|
||||
mock_time.side_effect = [0, 0, 999]
|
||||
token = poll_for_token(
|
||||
"https://api.example.com", self.pairing(), timeout=1
|
||||
)
|
||||
assert token is None
|
||||
|
||||
def test_eventual_success(self):
|
||||
not_ready = MagicMock()
|
||||
not_ready.status_code = 200
|
||||
not_ready.json.return_value = {"status": "waiting"}
|
||||
|
||||
ready = MagicMock()
|
||||
ready.status_code = 200
|
||||
ready.json.return_value = {"status": "ready", "token": SECOND_VALID_TOKEN}
|
||||
|
||||
call_count = 0
|
||||
|
||||
def fake_get(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3:
|
||||
return not_ready
|
||||
return ready
|
||||
|
||||
with patch("hermes_cli.telegram_managed_bot.httpx.get", side_effect=fake_get):
|
||||
with patch("hermes_cli.telegram_managed_bot.time.sleep"):
|
||||
token = poll_for_token(
|
||||
"https://api.example.com", self.pairing(), timeout=30
|
||||
)
|
||||
assert token == SECOND_VALID_TOKEN
|
||||
|
||||
|
||||
class TestSetupTelegramAuto:
|
||||
def test_setup_helper_exists(self):
|
||||
from hermes_cli.setup import _setup_telegram_auto
|
||||
|
||||
assert callable(_setup_telegram_auto)
|
||||
@@ -172,6 +172,97 @@ def test_make_tui_argv_skips_build_only_on_termux_when_fresh(
|
||||
assert cwd == tmp_path
|
||||
|
||||
|
||||
def test_make_tui_argv_skips_install_on_termux_when_bundle_fresh(
|
||||
tmp_path: Path, main_mod, monkeypatch
|
||||
) -> None:
|
||||
_touch_tui_entry(tmp_path)
|
||||
monkeypatch.setenv("TERMUX_VERSION", "1")
|
||||
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
|
||||
monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False)
|
||||
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
|
||||
|
||||
def fail_run(*_args, **_kwargs):
|
||||
raise AssertionError("fresh Termux TUI launch must not run npm")
|
||||
|
||||
monkeypatch.setattr(main_mod.subprocess, "run", fail_run)
|
||||
|
||||
argv, cwd = main_mod._make_tui_argv(tmp_path, tui_dev=False)
|
||||
|
||||
assert argv == ["/bin/node", "--expose-gc", str(tmp_path / "dist" / "entry.js")]
|
||||
assert cwd == tmp_path
|
||||
|
||||
|
||||
def test_make_tui_argv_scopes_npm_install_on_termux_workspace(
|
||||
tmp_path: Path, main_mod, monkeypatch
|
||||
) -> None:
|
||||
tui_dir = tmp_path / "ui-tui"
|
||||
tui_dir.mkdir()
|
||||
(tui_dir / "package.json").write_text("{}")
|
||||
ink_dir = tui_dir / "packages" / "hermes-ink"
|
||||
ink_dir.mkdir(parents=True)
|
||||
(ink_dir / "package.json").write_text("{}")
|
||||
(tmp_path / "package-lock.json").write_text("{}")
|
||||
|
||||
monkeypatch.setenv("TERMUX_VERSION", "1")
|
||||
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
|
||||
monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: True)
|
||||
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
|
||||
calls = []
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
|
||||
|
||||
main_mod._make_tui_argv(tui_dir, tui_dev=False)
|
||||
|
||||
install_cmd = calls[0][0][0]
|
||||
assert install_cmd[:7] == [
|
||||
"/bin/npm",
|
||||
"install",
|
||||
"--workspace",
|
||||
"ui-tui",
|
||||
"--workspace",
|
||||
"ui-tui/packages/hermes-ink",
|
||||
"--include-workspace-root=false",
|
||||
]
|
||||
assert calls[0][1]["cwd"] == str(tmp_path)
|
||||
|
||||
|
||||
def test_make_tui_argv_keeps_desktop_workspace_install_behaviour(
|
||||
tmp_path: Path, main_mod, monkeypatch
|
||||
) -> None:
|
||||
tui_dir = tmp_path / "ui-tui"
|
||||
tui_dir.mkdir()
|
||||
(tui_dir / "package.json").write_text("{}")
|
||||
(tmp_path / "package-lock.json").write_text("{}")
|
||||
|
||||
monkeypatch.delenv("TERMUX_VERSION", raising=False)
|
||||
monkeypatch.setenv("PREFIX", "/usr")
|
||||
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
|
||||
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
|
||||
calls = []
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
|
||||
|
||||
main_mod._make_tui_argv(tui_dir, tui_dev=False)
|
||||
|
||||
assert calls[0][0][0] == [
|
||||
"/bin/npm",
|
||||
"install",
|
||||
"--silent",
|
||||
"--no-fund",
|
||||
"--no-audit",
|
||||
"--progress=false",
|
||||
]
|
||||
assert calls[0][1]["cwd"] == str(tmp_path)
|
||||
|
||||
|
||||
def test_make_tui_argv_keeps_desktop_always_build_behaviour(
|
||||
tmp_path: Path, main_mod, monkeypatch
|
||||
) -> None:
|
||||
|
||||
@@ -592,10 +592,6 @@ def test_cmd_update_restores_stash_and_branch_when_already_up_to_date(monkeypatc
|
||||
hermes_main, "_stash_local_changes_if_needed",
|
||||
lambda *a, **kw: "abc123deadbeef",
|
||||
)
|
||||
# Force the stash path (not the managed-clone clean path) so this test
|
||||
# exercises stash restore. A real fork, or a clone where the managed
|
||||
# clean fails, falls through to stash.
|
||||
monkeypatch.setattr(hermes_main, "_clean_managed_worktree", lambda *a, **kw: False)
|
||||
restore_calls = []
|
||||
monkeypatch.setattr(
|
||||
hermes_main, "_restore_stashed_changes",
|
||||
@@ -634,81 +630,6 @@ def test_cmd_update_no_checkout_when_already_on_main(monkeypatch, tmp_path):
|
||||
assert len(checkout_calls) == 0
|
||||
|
||||
|
||||
def test_cmd_update_managed_clone_cleans_instead_of_stashing(monkeypatch, tmp_path):
|
||||
"""On a non-fork (managed) clone, working-tree dirt is discarded via
|
||||
_clean_managed_worktree, NOT preserved via stash/restore.
|
||||
|
||||
The stash/restore cycle has clobbered freshly-pulled source files
|
||||
(apps/desktop/ deletion → [UNRESOLVED_ENTRY] index.html). A managed clone
|
||||
has nothing the user authored, so the correct move is to throw the
|
||||
git-artifact dirt away and pull cleanly.
|
||||
"""
|
||||
_setup_update_mocks(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
|
||||
# Official origin → not a fork.
|
||||
monkeypatch.setattr(
|
||||
hermes_main, "_get_origin_url",
|
||||
lambda *a, **kw: "https://github.com/NousResearch/hermes-agent.git",
|
||||
)
|
||||
clean_calls = []
|
||||
monkeypatch.setattr(
|
||||
hermes_main, "_clean_managed_worktree",
|
||||
lambda *a, **kw: clean_calls.append(1) or True,
|
||||
)
|
||||
stash_calls = []
|
||||
monkeypatch.setattr(
|
||||
hermes_main, "_stash_local_changes_if_needed",
|
||||
lambda *a, **kw: stash_calls.append(1) or "shouldnotbeused",
|
||||
)
|
||||
restore_calls = []
|
||||
monkeypatch.setattr(
|
||||
hermes_main, "_restore_stashed_changes",
|
||||
lambda *a, **kw: restore_calls.append(1) or True,
|
||||
)
|
||||
|
||||
side_effect, _ = _make_update_side_effect(commit_count="0")
|
||||
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
|
||||
|
||||
hermes_main.cmd_update(SimpleNamespace())
|
||||
|
||||
# Managed clean path used; stash path never touched.
|
||||
assert len(clean_calls) == 1
|
||||
assert len(stash_calls) == 0
|
||||
assert len(restore_calls) == 0
|
||||
|
||||
|
||||
def test_cmd_update_fork_still_uses_stash(monkeypatch, tmp_path):
|
||||
"""A fork (non-official origin) keeps the stash machinery so the user's
|
||||
intentional local edits survive the update."""
|
||||
_setup_update_mocks(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
|
||||
monkeypatch.setattr(
|
||||
hermes_main, "_get_origin_url",
|
||||
lambda *a, **kw: "https://github.com/someuser/hermes-agent.git",
|
||||
)
|
||||
clean_calls = []
|
||||
monkeypatch.setattr(
|
||||
hermes_main, "_clean_managed_worktree",
|
||||
lambda *a, **kw: clean_calls.append(1) or True,
|
||||
)
|
||||
stash_calls = []
|
||||
monkeypatch.setattr(
|
||||
hermes_main, "_stash_local_changes_if_needed",
|
||||
lambda *a, **kw: stash_calls.append(1) or "abc123",
|
||||
)
|
||||
monkeypatch.setattr(hermes_main, "_restore_stashed_changes", lambda *a, **kw: True)
|
||||
monkeypatch.setattr(hermes_main, "_sync_with_upstream_if_needed", lambda *a, **kw: None)
|
||||
|
||||
side_effect, _ = _make_update_side_effect(commit_count="0")
|
||||
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
|
||||
|
||||
hermes_main.cmd_update(SimpleNamespace())
|
||||
|
||||
# Fork: stash path used, managed clean NOT used.
|
||||
assert len(stash_calls) == 1
|
||||
assert len(clean_calls) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fetch failure — friendly error messages
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -759,9 +680,6 @@ def test_cmd_update_skips_stash_restore_when_reset_fails(monkeypatch, tmp_path,
|
||||
hermes_main, "_stash_local_changes_if_needed",
|
||||
lambda *a, **kw: "abc123deadbeef",
|
||||
)
|
||||
# Force the stash path so this test exercises the reset-failure handling
|
||||
# of the stash branch (not the managed-clone clean path).
|
||||
monkeypatch.setattr(hermes_main, "_clean_managed_worktree", lambda *a, **kw: False)
|
||||
restore_calls = []
|
||||
monkeypatch.setattr(
|
||||
hermes_main, "_restore_stashed_changes",
|
||||
@@ -779,3 +697,133 @@ def test_cmd_update_skips_stash_restore_when_reset_fails(monkeypatch, tmp_path,
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "preserved in stash" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-interactive update.non_interactive_local_changes setting
|
||||
# (chat app / gateway): "discard" throws stashed changes away, "stash"
|
||||
# (default) restores them. Interactive terminal updates ignore the setting
|
||||
# and always go through the restore path.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _setup_setting_test(monkeypatch, tmp_path, mode):
|
||||
"""Common wiring: real stash returns a ref, restore + discard are
|
||||
recorded, and load_config reports the given non_interactive_local_changes
|
||||
mode."""
|
||||
_setup_update_mocks(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
|
||||
monkeypatch.setattr(
|
||||
hermes_main, "_stash_local_changes_if_needed",
|
||||
lambda *a, **kw: "abc123deadbeef",
|
||||
)
|
||||
restore_calls = []
|
||||
discard_calls = []
|
||||
monkeypatch.setattr(
|
||||
hermes_main, "_restore_stashed_changes",
|
||||
lambda *a, **kw: restore_calls.append(1) or True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
hermes_main, "_discard_stashed_changes",
|
||||
lambda *a, **kw: discard_calls.append(1) or True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
hermes_config, "load_config",
|
||||
lambda *a, **kw: {"updates": {"non_interactive_local_changes": mode}},
|
||||
)
|
||||
side_effect, recorded = _make_update_side_effect()
|
||||
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
|
||||
return restore_calls, discard_calls, recorded
|
||||
|
||||
|
||||
def test_non_interactive_discard_throws_changes_away(monkeypatch, tmp_path):
|
||||
"""Gateway/chat-app update with discard mode drops the stash, never restores."""
|
||||
restore_calls, discard_calls, _ = _setup_setting_test(monkeypatch, tmp_path, "discard")
|
||||
|
||||
hermes_main.cmd_update(SimpleNamespace(gateway=True))
|
||||
|
||||
assert len(discard_calls) == 1
|
||||
assert len(restore_calls) == 0
|
||||
|
||||
|
||||
def test_non_interactive_stash_restores_changes(monkeypatch, tmp_path):
|
||||
"""Gateway/chat-app update with the default stash mode restores, never discards."""
|
||||
restore_calls, discard_calls, _ = _setup_setting_test(monkeypatch, tmp_path, "stash")
|
||||
|
||||
hermes_main.cmd_update(SimpleNamespace(gateway=True))
|
||||
|
||||
assert len(restore_calls) == 1
|
||||
assert len(discard_calls) == 0
|
||||
|
||||
|
||||
def test_interactive_update_ignores_discard_setting(monkeypatch, tmp_path):
|
||||
"""An interactive (TTY) terminal update always restores — the discard
|
||||
setting only governs non-interactive updates."""
|
||||
restore_calls, discard_calls, _ = _setup_setting_test(monkeypatch, tmp_path, "discard")
|
||||
# Force an interactive TTY so _non_interactive_update is False even though
|
||||
# the config says discard.
|
||||
monkeypatch.setattr(hermes_main.sys.stdin, "isatty", lambda: True)
|
||||
monkeypatch.setattr(hermes_main.sys.stdout, "isatty", lambda: True)
|
||||
|
||||
hermes_main.cmd_update(SimpleNamespace()) # no gateway, no --yes
|
||||
|
||||
assert len(restore_calls) == 1
|
||||
assert len(discard_calls) == 0
|
||||
|
||||
|
||||
def test_non_interactive_defaults_to_stash_when_setting_absent(monkeypatch, tmp_path):
|
||||
"""A config with no update section falls back to stash (safe default)."""
|
||||
restore_calls, discard_calls, _ = _setup_setting_test(monkeypatch, tmp_path, "stash")
|
||||
# Override load_config to return a config with NO update section at all.
|
||||
monkeypatch.setattr(hermes_config, "load_config", lambda *a, **kw: {"model": {}})
|
||||
|
||||
hermes_main.cmd_update(SimpleNamespace(gateway=True))
|
||||
|
||||
assert len(restore_calls) == 1
|
||||
assert len(discard_calls) == 0
|
||||
|
||||
|
||||
def test_bootstrap_marker_not_autostashed_by_update(tmp_path):
|
||||
"""#38529: the Desktop bootstrap marker must be git-ignored so that
|
||||
``hermes update``'s ``git stash push --include-untracked`` does not sweep it
|
||||
into an autostash on every run.
|
||||
|
||||
Behavioral + hermetic: build a throwaway repo that adopts the project's real
|
||||
``.gitignore`` (the contract under test), drop the marker, and confirm the
|
||||
same stash invocation the updater uses leaves it untouched.
|
||||
"""
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
if shutil.which("git") is None:
|
||||
pytest.skip("git not available")
|
||||
|
||||
repo_gitignore = Path(hermes_main.__file__).resolve().parents[1] / ".gitignore"
|
||||
|
||||
def git(*args):
|
||||
return subprocess.run(
|
||||
["git", *args], cwd=tmp_path, capture_output=True, text=True, check=True
|
||||
)
|
||||
|
||||
git("init", "-q")
|
||||
git("config", "user.email", "t@example.com")
|
||||
git("config", "user.name", "t")
|
||||
(tmp_path / ".gitignore").write_text(repo_gitignore.read_text())
|
||||
(tmp_path / "tracked.txt").write_text("x\n")
|
||||
git("add", "-A")
|
||||
git("commit", "-qm", "init")
|
||||
|
||||
marker = tmp_path / ".hermes-bootstrap-complete"
|
||||
marker.write_text("")
|
||||
|
||||
# Exact flags used by hermes update (hermes_cli/main.py).
|
||||
git("stash", "push", "--include-untracked", "-m", "hermes-update-autostash")
|
||||
|
||||
assert marker.exists(), (
|
||||
".hermes-bootstrap-complete was swept into the update autostash — it must "
|
||||
"be listed in .gitignore so `git stash -u` skips it (#38529)."
|
||||
)
|
||||
# It must not even register as a dirty/untracked change.
|
||||
status = subprocess.run(
|
||||
["git", "status", "--porcelain"], cwd=tmp_path, capture_output=True, text=True
|
||||
).stdout
|
||||
assert ".hermes-bootstrap-complete" not in status
|
||||
|
||||
@@ -131,6 +131,62 @@ def test_check_for_updates_fallback_to_project_root(tmp_path, monkeypatch):
|
||||
assert mock_run.call_count >= 1
|
||||
|
||||
|
||||
def test_check_for_updates_docker_returns_none(tmp_path, monkeypatch):
|
||||
"""Inside the Docker image, check_for_updates() must short-circuit to None.
|
||||
|
||||
Regression: the published image excludes .git (.dockerignore) and sets no
|
||||
HERMES_REVISION (nix-only), so without a docker guard check_for_updates()
|
||||
falls through to check_via_pypi(), whose version-mismatch flag (1) gets
|
||||
rendered by both the Rich banner and the Ink TUI badge as a phantom
|
||||
"1 commit behind" — despite there being no git repo or commit math in the
|
||||
container, and `hermes update` correctly refusing to run there. The guard
|
||||
must return None (so the > 0 render guards stay false) AND not reach the
|
||||
git/pypi probes or write a cache entry.
|
||||
"""
|
||||
import hermes_cli.banner as banner
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
cache_file = tmp_path / ".update_check"
|
||||
|
||||
with patch("hermes_cli.config.detect_install_method", return_value="docker"), \
|
||||
patch("hermes_cli.banner.subprocess.run") as mock_run, \
|
||||
patch("hermes_cli.banner.check_via_pypi") as mock_pypi:
|
||||
result = banner.check_for_updates()
|
||||
|
||||
assert result is None
|
||||
# Neither the git probe nor the PyPI probe should have run.
|
||||
mock_run.assert_not_called()
|
||||
mock_pypi.assert_not_called()
|
||||
# And no phantom "behind" count should be cached for the next 6h.
|
||||
assert not cache_file.exists()
|
||||
|
||||
|
||||
def test_check_for_updates_non_docker_still_checks(tmp_path, monkeypatch):
|
||||
"""The docker guard must NOT over-broaden: a pip install still version-checks.
|
||||
|
||||
Invariant guarding against the guard firing for non-docker methods — pip
|
||||
installs legitimately reach check_via_pypi() and surface a real update.
|
||||
"""
|
||||
import hermes_cli.banner as banner
|
||||
|
||||
# No local git checkout -> the PyPI (pip-install) path is exercised.
|
||||
fake_banner = tmp_path / "hermes_cli" / "banner.py"
|
||||
fake_banner.parent.mkdir(parents=True, exist_ok=True)
|
||||
fake_banner.touch()
|
||||
monkeypatch.setattr(banner, "__file__", str(fake_banner))
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("HERMES_REVISION", raising=False)
|
||||
|
||||
with patch("hermes_cli.config.detect_install_method", return_value="pip"), \
|
||||
patch("hermes_cli.banner.subprocess.run") as mock_run, \
|
||||
patch("hermes_cli.banner.check_via_pypi", return_value=1) as mock_pypi:
|
||||
result = banner.check_for_updates()
|
||||
|
||||
assert result == 1
|
||||
mock_pypi.assert_called_once()
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
def test_prefetch_non_blocking():
|
||||
"""prefetch_update_check() should return immediately without blocking."""
|
||||
import hermes_cli.banner as banner
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
"""Tests for the post-pull subprocess hand-off in ``hermes update``.
|
||||
|
||||
``hermes update`` runs from the *old* install. Before this hand-off, the
|
||||
post-pull steps (dep install, config migration, gateway restart) executed
|
||||
stale in-memory code even though the new source was already on disk, so a bug
|
||||
fixed in the pulled version still crashed the first run — users had to run
|
||||
``hermes update`` a second time. After a successful pull + dep install we now
|
||||
finish in a fresh subprocess running the refreshed code and forward its exit
|
||||
code. Unlike an ``os.exec*`` replacement, a child subprocess keeps the parent
|
||||
PID intact, so this works on Windows too.
|
||||
|
||||
These tests cover the gate (when we hand off vs. finish in-process), the
|
||||
subprocess mechanics, and the finalize pass the child process takes.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import config as hermes_config
|
||||
from hermes_cli import main as hermes_main
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Managed-uv compatibility: make managed_uv helpers follow shutil.which mocking
|
||||
# (mirrors the autouse fixture in test_update_autostash.py).
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_managed_uv():
|
||||
import shutil
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch("hermes_cli.managed_uv.resolve_uv", side_effect=lambda: shutil.which("uv")), \
|
||||
patch("hermes_cli.managed_uv.ensure_uv", side_effect=lambda: shutil.which("uv")), \
|
||||
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=lambda: None):
|
||||
yield
|
||||
|
||||
|
||||
def _clear_handoff_env(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_UPDATE_FINALIZE", raising=False)
|
||||
monkeypatch.delenv("HERMES_UPDATE_NO_HANDOFF", raising=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _should_handoff_after_pull — the gate
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_should_handoff_false_in_finalize_mode(monkeypatch):
|
||||
_clear_handoff_env(monkeypatch)
|
||||
assert hermes_main._should_handoff_after_pull(finalize_only=True) is False
|
||||
|
||||
|
||||
def test_should_handoff_false_when_finalize_env_set(monkeypatch):
|
||||
_clear_handoff_env(monkeypatch)
|
||||
monkeypatch.setenv("HERMES_UPDATE_FINALIZE", "1")
|
||||
assert hermes_main._should_handoff_after_pull(finalize_only=False) is False
|
||||
|
||||
|
||||
def test_should_handoff_false_with_opt_out_env(monkeypatch):
|
||||
_clear_handoff_env(monkeypatch)
|
||||
monkeypatch.delitem(sys.modules, "pytest", raising=False)
|
||||
monkeypatch.setenv("HERMES_UPDATE_NO_HANDOFF", "1")
|
||||
assert hermes_main._should_handoff_after_pull(finalize_only=False) is False
|
||||
|
||||
|
||||
def test_should_handoff_false_under_pytest(monkeypatch):
|
||||
# Safety invariant: never spawn a real recursive update while the test
|
||||
# suite is running. ``pytest`` is in sys.modules during the suite, so the
|
||||
# gate must stay closed even with a clean env.
|
||||
_clear_handoff_env(monkeypatch)
|
||||
assert "pytest" in sys.modules
|
||||
assert hermes_main._should_handoff_after_pull(finalize_only=False) is False
|
||||
|
||||
|
||||
def test_should_handoff_true_when_allowed(monkeypatch):
|
||||
_clear_handoff_env(monkeypatch)
|
||||
monkeypatch.delitem(sys.modules, "pytest", raising=False)
|
||||
assert hermes_main._should_handoff_after_pull(finalize_only=False) is True
|
||||
|
||||
|
||||
def test_should_handoff_stays_on_for_windows(monkeypatch):
|
||||
# The whole reason we use a subprocess instead of os.exec*: it keeps the
|
||||
# parent PID intact, so the hand-off works on Windows too (the previous
|
||||
# exec-based attempt had to disable itself there).
|
||||
_clear_handoff_env(monkeypatch)
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
monkeypatch.delitem(sys.modules, "pytest", raising=False)
|
||||
assert hermes_main._should_handoff_after_pull(finalize_only=False) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _handoff_update_to_refreshed_code — the subprocess mechanics
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_handoff_runs_subprocess_with_finalize_env(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
captured["cmd"] = list(cmd)
|
||||
captured["env"] = dict(kwargs.get("env") or {})
|
||||
return SimpleNamespace(returncode=0)
|
||||
|
||||
monkeypatch.setattr(hermes_main.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(hermes_main.sys, "argv", ["hermes", "update", "--yes", "--gateway"])
|
||||
|
||||
rc = hermes_main._handoff_update_to_refreshed_code()
|
||||
|
||||
assert rc == 0
|
||||
assert captured["cmd"][0] == sys.executable
|
||||
assert captured["cmd"][1:3] == ["-m", "hermes_cli.main"]
|
||||
# The original CLI args (minus argv[0]) are carried through verbatim.
|
||||
assert captured["cmd"][3:] == ["update", "--yes", "--gateway"]
|
||||
# The loop-breaker guard the child reads.
|
||||
assert captured["env"]["HERMES_UPDATE_FINALIZE"] == "1"
|
||||
|
||||
|
||||
def test_handoff_forwards_nonzero_exit_code(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
hermes_main.subprocess, "run", lambda *a, **k: SimpleNamespace(returncode=42)
|
||||
)
|
||||
monkeypatch.setattr(hermes_main.sys, "argv", ["hermes", "update"])
|
||||
assert hermes_main._handoff_update_to_refreshed_code() == 42
|
||||
|
||||
|
||||
def test_handoff_returns_none_when_spawn_fails(monkeypatch):
|
||||
def boom(*_a, **_kw):
|
||||
raise OSError("could not spawn")
|
||||
|
||||
monkeypatch.setattr(hermes_main.subprocess, "run", boom)
|
||||
monkeypatch.setattr(hermes_main.sys, "argv", ["hermes", "update"])
|
||||
# None signals the caller to finish in-process instead of bailing out.
|
||||
assert hermes_main._handoff_update_to_refreshed_code() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cmd_update integration — original pass hands off, finalize pass finishes
|
||||
# ---------------------------------------------------------------------------
|
||||
def _setup_update_mocks(monkeypatch, tmp_path):
|
||||
(tmp_path / ".git").mkdir()
|
||||
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", tmp_path)
|
||||
monkeypatch.setattr(hermes_main, "_stash_local_changes_if_needed", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(hermes_main, "_restore_stashed_changes", lambda *a, **kw: True)
|
||||
monkeypatch.setattr(hermes_config, "get_missing_env_vars", lambda required_only=True: [])
|
||||
monkeypatch.setattr(hermes_config, "get_missing_config_fields", lambda: [])
|
||||
monkeypatch.setattr(hermes_config, "check_config_version", lambda: (5, 5))
|
||||
monkeypatch.setattr(hermes_config, "migrate_config", lambda **kw: {"env_added": [], "config_added": []})
|
||||
monkeypatch.setattr(hermes_main, "_refresh_active_lazy_features", lambda: None)
|
||||
|
||||
|
||||
def _fake_git_run(commit_count):
|
||||
recorded = []
|
||||
|
||||
def side_effect(cmd, **kwargs):
|
||||
recorded.append(cmd)
|
||||
joined = " ".join(str(c) for c in cmd)
|
||||
if "fetch" in joined and "origin" in joined:
|
||||
return SimpleNamespace(stdout="", stderr="", returncode=0)
|
||||
if "rev-parse" in joined and "--abbrev-ref" in joined:
|
||||
return SimpleNamespace(stdout="main\n", stderr="", returncode=0)
|
||||
if "rev-list" in joined:
|
||||
return SimpleNamespace(stdout=f"{commit_count}\n", stderr="", returncode=0)
|
||||
if "--ff-only" in joined:
|
||||
return SimpleNamespace(stdout="Already up to date.\n", stderr="", returncode=0)
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
return side_effect, recorded
|
||||
|
||||
|
||||
def test_original_pass_hands_off_and_forwards_exit_code(monkeypatch, tmp_path):
|
||||
_clear_handoff_env(monkeypatch)
|
||||
_setup_update_mocks(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
|
||||
monkeypatch.setattr(hermes_main, "_is_termux_env", lambda env=None: False)
|
||||
|
||||
# Force the gate open (pytest normally closes it) and stub the child run.
|
||||
monkeypatch.setattr(hermes_main, "_should_handoff_after_pull", lambda finalize_only: True)
|
||||
monkeypatch.setattr(hermes_main, "_handoff_update_to_refreshed_code", lambda: 0)
|
||||
|
||||
node_called = []
|
||||
monkeypatch.setattr(
|
||||
hermes_main, "_update_node_dependencies", lambda *a, **k: node_called.append(True)
|
||||
)
|
||||
|
||||
side_effect, _recorded = _fake_git_run(commit_count="3")
|
||||
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
hermes_main.cmd_update(SimpleNamespace())
|
||||
|
||||
assert exc.value.code == 0
|
||||
# After a successful hand-off the parent must NOT run the remaining
|
||||
# post-pull steps — the child already did them on new code.
|
||||
assert node_called == [], "parent ran post-handoff steps after a successful hand-off"
|
||||
|
||||
|
||||
def test_handoff_failure_falls_back_in_process(monkeypatch, tmp_path):
|
||||
_clear_handoff_env(monkeypatch)
|
||||
_setup_update_mocks(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
|
||||
monkeypatch.setattr(hermes_main, "_is_termux_env", lambda env=None: False)
|
||||
|
||||
monkeypatch.setattr(hermes_main, "_should_handoff_after_pull", lambda finalize_only: True)
|
||||
# Child couldn't be spawned -> None -> parent finishes in-process.
|
||||
monkeypatch.setattr(hermes_main, "_handoff_update_to_refreshed_code", lambda: None)
|
||||
|
||||
node_called = []
|
||||
monkeypatch.setattr(
|
||||
hermes_main, "_update_node_dependencies", lambda *a, **k: node_called.append(True)
|
||||
)
|
||||
monkeypatch.setattr(hermes_main, "_build_web_ui", lambda *a, **k: None)
|
||||
|
||||
side_effect, _recorded = _fake_git_run(commit_count="3")
|
||||
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
|
||||
|
||||
hermes_main.cmd_update(SimpleNamespace())
|
||||
|
||||
assert node_called == [True], "fallback should finish the post-pull steps in-process"
|
||||
|
||||
|
||||
def test_finalize_pass_does_not_hand_off(monkeypatch, tmp_path):
|
||||
# In finalize mode the gate is closed, so we must never spawn another
|
||||
# update. Make the hand-off explode so the test fails loudly if reached.
|
||||
_clear_handoff_env(monkeypatch)
|
||||
monkeypatch.setenv("HERMES_UPDATE_FINALIZE", "1")
|
||||
_setup_update_mocks(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
|
||||
monkeypatch.setattr(hermes_main, "_is_termux_env", lambda env=None: False)
|
||||
|
||||
def no_handoff(): # pragma: no cover - asserts it's not called
|
||||
raise AssertionError("finalize pass must not hand off")
|
||||
|
||||
monkeypatch.setattr(hermes_main, "_handoff_update_to_refreshed_code", no_handoff)
|
||||
|
||||
side_effect, recorded = _fake_git_run(commit_count="0")
|
||||
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
|
||||
|
||||
hermes_main.cmd_update(SimpleNamespace())
|
||||
|
||||
# The whole point of the finalize pass: even with zero new commits (the
|
||||
# pull already happened in the original pass) it does NOT take the "Already
|
||||
# up to date" early return — it runs the post-pull dependency install.
|
||||
install_cmds = [c for c in recorded if "pip" in c and "install" in c]
|
||||
assert install_cmds, "finalize pass should run the dependency install, not early-return"
|
||||
|
||||
|
||||
def test_finalize_pass_skips_pre_update_backup(monkeypatch, tmp_path):
|
||||
_clear_handoff_env(monkeypatch)
|
||||
monkeypatch.setenv("HERMES_UPDATE_FINALIZE", "1")
|
||||
_setup_update_mocks(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
|
||||
monkeypatch.setattr(hermes_main, "_is_termux_env", lambda env=None: False)
|
||||
|
||||
backup_calls = []
|
||||
monkeypatch.setattr(hermes_main, "_run_pre_update_backup", lambda args: backup_calls.append(args))
|
||||
|
||||
side_effect, _recorded = _fake_git_run(commit_count="0")
|
||||
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
|
||||
|
||||
hermes_main.cmd_update(SimpleNamespace())
|
||||
|
||||
assert backup_calls == [], "finalize pass must not retake the pre-update backup"
|
||||
|
||||
|
||||
def test_original_pass_still_runs_pre_update_backup(monkeypatch, tmp_path):
|
||||
# Sanity counter-check: a normal (non-finalize) run still takes the backup.
|
||||
# Under pytest the gate is closed, so the run finishes in-process exactly
|
||||
# as it always did.
|
||||
_clear_handoff_env(monkeypatch)
|
||||
_setup_update_mocks(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
|
||||
monkeypatch.setattr(hermes_main, "_is_termux_env", lambda env=None: False)
|
||||
|
||||
backup_calls = []
|
||||
monkeypatch.setattr(hermes_main, "_run_pre_update_backup", lambda args: backup_calls.append(args))
|
||||
|
||||
side_effect, _recorded = _fake_git_run(commit_count="3")
|
||||
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
|
||||
|
||||
hermes_main.cmd_update(SimpleNamespace())
|
||||
|
||||
assert len(backup_calls) == 1
|
||||
@@ -164,6 +164,50 @@ class TestBuildWebUISkipsWhenFresh:
|
||||
assert args[0] == ["/usr/bin/npm", "run", "build"]
|
||||
assert kwargs["cwd"] == web_dir
|
||||
|
||||
def test_termux_web_install_is_workspace_scoped(self, tmp_path, monkeypatch):
|
||||
web_dir, _ = _make_web_dir(tmp_path)
|
||||
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setenv("TERMUX_VERSION", "1")
|
||||
|
||||
install_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
|
||||
build_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main.subprocess.run", return_value=install_cp) as mock_run, \
|
||||
patch("hermes_cli.main._run_with_idle_timeout", return_value=build_cp):
|
||||
result = _build_web_ui(web_dir)
|
||||
|
||||
assert result is True
|
||||
args, kwargs = mock_run.call_args
|
||||
assert args[0] == [
|
||||
"/usr/bin/npm",
|
||||
"ci",
|
||||
"--workspace",
|
||||
"web",
|
||||
"--include-workspace-root=false",
|
||||
"--silent",
|
||||
]
|
||||
assert kwargs["cwd"] == tmp_path
|
||||
|
||||
def test_desktop_web_install_uses_existing_workspace_root(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
web_dir, _ = _make_web_dir(tmp_path)
|
||||
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.delenv("TERMUX_VERSION", raising=False)
|
||||
monkeypatch.setenv("PREFIX", "/usr")
|
||||
|
||||
install_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
|
||||
build_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main.subprocess.run", return_value=install_cp) as mock_run, \
|
||||
patch("hermes_cli.main._run_with_idle_timeout", return_value=build_cp):
|
||||
result = _build_web_ui(web_dir)
|
||||
|
||||
assert result is True
|
||||
args, kwargs = mock_run.call_args
|
||||
assert args[0] == ["/usr/bin/npm", "ci", "--silent"]
|
||||
assert kwargs["cwd"] == tmp_path
|
||||
|
||||
|
||||
class TestBuildWebUIRetryAndStaleFallback:
|
||||
"""Coverage for the retry + stale-dist fallback added in #23824 / issue #23817."""
|
||||
|
||||
@@ -94,7 +94,11 @@ def agent():
|
||||
a._cached_system_prompt = "You are helpful."
|
||||
a._use_prompt_caching = False
|
||||
a.tool_delay = 0
|
||||
a.compression_enabled = False
|
||||
# Default matches production (`compression.enabled` defaults to True).
|
||||
# Overflow-recovery tests below verify that 413 / context-overflow
|
||||
# errors DO trigger compression; the disabled-path behavior is
|
||||
# covered explicitly by TestOverflowWithCompactionDisabled.
|
||||
a.compression_enabled = True
|
||||
a.save_trajectories = False
|
||||
return a
|
||||
|
||||
@@ -415,6 +419,13 @@ class TestPreflightCompression:
|
||||
|
||||
def test_compress_context_emits_lifecycle_status_before_work(self, agent):
|
||||
"""Direct context compression should tell gateway users why the turn paused."""
|
||||
# This test calls _compress_context directly and asserts the FIRST
|
||||
# status event is the lifecycle "Compacting context" message. With
|
||||
# compaction enabled the lazy feasibility probe would emit an
|
||||
# aux-provider warning first (no aux key in the hermetic test env),
|
||||
# displacing events[0]. The flag value is irrelevant to what this
|
||||
# test asserts, so disable it to suppress the probe.
|
||||
agent.compression_enabled = False
|
||||
events = []
|
||||
agent.status_callback = lambda ev, msg: events.append((ev, msg))
|
||||
|
||||
@@ -802,3 +813,95 @@ class TestToolResultPreflightCompression:
|
||||
|
||||
mock_compress.assert_called_once()
|
||||
assert result["completed"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Disabled auto-compaction on overflow (port of anomalyco/opencode#30749)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestOverflowWithCompactionDisabled:
|
||||
"""When ``compression.enabled`` is False, NO automatic compaction may
|
||||
fire — including the provider/request-size overflow recovery paths.
|
||||
|
||||
Ported from anomalyco/opencode#30749: the proactive token-threshold
|
||||
path already honoured the setting, but provider overflow errors
|
||||
(413 payload-too-large, context-overflow, long-context-tier 429) still
|
||||
silently compressed + rotated the session. The fix surfaces a terminal
|
||||
error so the user can compact manually, start fresh, or switch models.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _prefill():
|
||||
return [
|
||||
{"role": "user", "content": "previous question"},
|
||||
{"role": "assistant", "content": "previous answer"},
|
||||
]
|
||||
|
||||
def test_413_does_not_compress_when_disabled(self, agent):
|
||||
"""413 must NOT call _compress_context when compaction is disabled."""
|
||||
agent.compression_enabled = False
|
||||
err_413 = _make_413_error()
|
||||
# If the guard fails, a second (success) response would be consumed.
|
||||
agent.client.chat.completions.create.side_effect = [err_413, _mock_response()]
|
||||
|
||||
with (
|
||||
patch.object(agent, "_compress_context") as mock_compress,
|
||||
patch.object(agent, "_persist_session") as mock_persist,
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
):
|
||||
result = agent.run_conversation("hello", conversation_history=self._prefill())
|
||||
|
||||
mock_compress.assert_not_called()
|
||||
mock_persist.assert_called()
|
||||
assert result.get("failed") is True
|
||||
assert result.get("compaction_disabled") is True
|
||||
assert "auto-compaction is disabled" in result["error"]
|
||||
|
||||
def test_context_overflow_does_not_compress_when_disabled(self, agent):
|
||||
"""400 'prompt is too long' must NOT compress when compaction disabled."""
|
||||
agent.compression_enabled = False
|
||||
err_400 = Exception(
|
||||
"Error code: 400 - {'type': 'error', 'error': {'type': "
|
||||
"'invalid_request_error', 'message': 'prompt is too long: "
|
||||
"233153 tokens > 200000 maximum'}}"
|
||||
)
|
||||
err_400.status_code = 400
|
||||
agent.client.chat.completions.create.side_effect = [err_400, _mock_response()]
|
||||
|
||||
with (
|
||||
patch.object(agent, "_compress_context") as mock_compress,
|
||||
patch.object(agent, "_persist_session"),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
):
|
||||
result = agent.run_conversation("hello", conversation_history=self._prefill())
|
||||
|
||||
mock_compress.assert_not_called()
|
||||
assert result.get("compaction_disabled") is True
|
||||
|
||||
def test_413_still_compresses_when_enabled(self, agent):
|
||||
"""Control: with compaction enabled, 413 still triggers compression.
|
||||
|
||||
Guards against the disabled-path guard accidentally swallowing the
|
||||
enabled path.
|
||||
"""
|
||||
agent.compression_enabled = True
|
||||
err_413 = _make_413_error()
|
||||
ok_resp = _mock_response(content="Recovered", finish_reason="stop")
|
||||
agent.client.chat.completions.create.side_effect = [err_413, ok_resp]
|
||||
|
||||
with (
|
||||
patch.object(agent, "_compress_context") as mock_compress,
|
||||
patch.object(agent, "_persist_session"),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
):
|
||||
mock_compress.return_value = (
|
||||
[{"role": "user", "content": "hello"}], "compressed",
|
||||
)
|
||||
result = agent.run_conversation("hello", conversation_history=self._prefill())
|
||||
|
||||
mock_compress.assert_called_once()
|
||||
assert result["completed"] is True
|
||||
assert result.get("compaction_disabled") is not True
|
||||
|
||||
@@ -3903,6 +3903,7 @@ class TestRunConversation:
|
||||
def test_glm_prompt_exceeds_max_length_triggers_compression(self, agent):
|
||||
"""GLM/Z.AI uses 'Prompt exceeds max length' for context overflow."""
|
||||
self._setup_agent(agent)
|
||||
agent.compression_enabled = True # this test verifies overflow→compression fires
|
||||
err_400 = Exception(
|
||||
"Error code: 400 - {'error': {'code': '1261', 'message': 'Prompt exceeds max length'}}"
|
||||
)
|
||||
@@ -3937,6 +3938,7 @@ class TestRunConversation:
|
||||
to the generic 128K fallback tier.
|
||||
"""
|
||||
self._setup_agent(agent)
|
||||
agent.compression_enabled = True # this test verifies overflow→compression fires
|
||||
agent.provider = "minimax"
|
||||
agent.model = "MiniMax-M2.7-highspeed"
|
||||
agent.base_url = "https://api.minimax.io/anthropic"
|
||||
@@ -3982,6 +3984,7 @@ class TestRunConversation:
|
||||
rely on compression — see #33669 / PR #33826.
|
||||
"""
|
||||
self._setup_agent(agent)
|
||||
agent.compression_enabled = True # this test verifies overflow→compression fires
|
||||
agent.provider = "openrouter"
|
||||
agent.model = "some/unknown-model"
|
||||
agent.base_url = "https://openrouter.ai/api/v1"
|
||||
|
||||
@@ -30,6 +30,46 @@ class TestGetDefaultModelForProvider:
|
||||
# Custom providers don't have entries in _PROVIDER_MODELS
|
||||
assert get_default_model_for_provider("some-random-custom") == ""
|
||||
|
||||
def test_nous_silent_default_is_not_the_expensive_flagship(self):
|
||||
"""Nous Portal is a metered aggregator whose curated list is ordered
|
||||
most-capable-first, so entry [0] is the priciest flagship
|
||||
(anthropic/claude-opus-4.8). The silent fallback (provider set, no model)
|
||||
must NOT escalate to it — otherwise an unconfigured profile silently
|
||||
bills the most expensive model. Regression for the billing footgun.
|
||||
"""
|
||||
from hermes_cli.models import (
|
||||
_PROVIDER_MODELS,
|
||||
_PROVIDER_SILENT_DEFAULT_OVERRIDES,
|
||||
get_default_model_for_provider,
|
||||
)
|
||||
|
||||
result = get_default_model_for_provider("nous")
|
||||
assert result, "nous must resolve to a usable default model"
|
||||
assert "opus" not in result.lower(), (
|
||||
f"silent default escalated to an expensive flagship: {result!r}"
|
||||
)
|
||||
assert result != _PROVIDER_MODELS["nous"][0], (
|
||||
"silent default must not be the most-capable/priciest catalog entry"
|
||||
)
|
||||
# The override must point at a model that actually exists in the catalog.
|
||||
assert result == _PROVIDER_SILENT_DEFAULT_OVERRIDES["nous"]
|
||||
assert result in _PROVIDER_MODELS["nous"]
|
||||
|
||||
def test_override_falls_back_to_catalog_when_missing(self):
|
||||
"""If an override model is no longer in the catalog, fall back to [0]
|
||||
rather than returning a stale/absent id."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli import models as models_mod
|
||||
|
||||
with patch.dict(
|
||||
models_mod._PROVIDER_SILENT_DEFAULT_OVERRIDES,
|
||||
{"openai-codex": "does-not-exist-model"},
|
||||
clear=False,
|
||||
):
|
||||
result = models_mod.get_default_model_for_provider("openai-codex")
|
||||
assert result == models_mod._PROVIDER_MODELS["openai-codex"][0]
|
||||
|
||||
|
||||
class TestGatewayEmptyModelFallback:
|
||||
"""Test that _resolve_session_agent_runtime fills in empty model from provider catalog."""
|
||||
|
||||
@@ -838,14 +838,13 @@ class TestBlockedTools(unittest.TestCase):
|
||||
def test_constants(self):
|
||||
from tools.delegate_tool import (
|
||||
_get_max_spawn_depth, _get_orchestrator_enabled,
|
||||
_MIN_SPAWN_DEPTH, _MAX_SPAWN_DEPTH_CAP,
|
||||
_MIN_SPAWN_DEPTH,
|
||||
)
|
||||
self.assertEqual(_get_max_concurrent_children(), 3)
|
||||
self.assertEqual(MAX_DEPTH, 1)
|
||||
self.assertEqual(_get_max_spawn_depth(), 1) # default: flat
|
||||
self.assertTrue(_get_orchestrator_enabled()) # default
|
||||
self.assertEqual(_MIN_SPAWN_DEPTH, 1)
|
||||
self.assertEqual(_MAX_SPAWN_DEPTH_CAP, 3)
|
||||
|
||||
|
||||
class TestDelegationCredentialResolution(unittest.TestCase):
|
||||
@@ -2084,17 +2083,14 @@ class TestMaxSpawnDepth(unittest.TestCase):
|
||||
with self.assertLogs("tools.delegate_tool", level=logging.WARNING) as cm:
|
||||
result = _get_max_spawn_depth()
|
||||
self.assertEqual(result, 1)
|
||||
self.assertTrue(any("clamping to 1" in m for m in cm.output))
|
||||
self.assertTrue(any("below floor 1" in m for m in cm.output))
|
||||
|
||||
@patch("tools.delegate_tool._load_config",
|
||||
return_value={"max_spawn_depth": 99})
|
||||
def test_max_spawn_depth_clamped_above_three(self, mock_cfg):
|
||||
import logging
|
||||
def test_max_spawn_depth_no_upper_ceiling(self, mock_cfg):
|
||||
"""No upper ceiling — high values pass through unchanged (cost is the limiter)."""
|
||||
from tools.delegate_tool import _get_max_spawn_depth
|
||||
with self.assertLogs("tools.delegate_tool", level=logging.WARNING) as cm:
|
||||
result = _get_max_spawn_depth()
|
||||
self.assertEqual(result, 3)
|
||||
self.assertTrue(any("clamping to 3" in m for m in cm.output))
|
||||
self.assertEqual(_get_max_spawn_depth(), 99)
|
||||
|
||||
@patch("tools.delegate_tool._load_config",
|
||||
return_value={"max_spawn_depth": "not-a-number"})
|
||||
|
||||
@@ -176,6 +176,7 @@ class TestProviderEnvBlocklist:
|
||||
"HASS_TOKEN": "ha-secret",
|
||||
"EMAIL_PASSWORD": "email-secret",
|
||||
"FIRECRAWL_API_KEY": "fc-secret",
|
||||
"HERMES_DASHBOARD_SESSION_TOKEN": "dashboard-session-secret",
|
||||
"BROWSERBASE_PROJECT_ID": "bb-project",
|
||||
"ELEVENLABS_API_KEY": "el-secret",
|
||||
"GITHUB_TOKEN": "ghp_secret",
|
||||
@@ -362,6 +363,7 @@ class TestBlocklistCoverage:
|
||||
"EMAIL_SMTP_HOST",
|
||||
"EMAIL_HOME_ADDRESS",
|
||||
"EMAIL_HOME_ADDRESS_NAME",
|
||||
"HERMES_DASHBOARD_SESSION_TOKEN",
|
||||
"GATEWAY_ALLOWED_USERS",
|
||||
"GH_TOKEN",
|
||||
"GITHUB_APP_ID",
|
||||
|
||||
@@ -218,3 +218,27 @@ def test_registering_non_cwd_override_leaves_live_env_cwd_untouched(monkeypatch)
|
||||
terminal_tool.register_task_env_overrides(task_id, {"modal_image": "custom:latest"})
|
||||
|
||||
assert fake_env.cwd == "/workspace/keep"
|
||||
|
||||
|
||||
def test_safe_getcwd_returns_real_cwd(monkeypatch):
|
||||
monkeypatch.setattr(terminal_tool.os, "getcwd", lambda: "/home/user/project")
|
||||
assert terminal_tool._safe_getcwd() == "/home/user/project"
|
||||
|
||||
|
||||
def test_safe_getcwd_falls_back_to_terminal_cwd_when_cwd_deleted(monkeypatch):
|
||||
def _boom():
|
||||
raise FileNotFoundError("[Errno 2] No such file or directory")
|
||||
|
||||
monkeypatch.setattr(terminal_tool.os, "getcwd", _boom)
|
||||
monkeypatch.setenv("TERMINAL_CWD", "/srv/work")
|
||||
assert terminal_tool._safe_getcwd() == "/srv/work"
|
||||
|
||||
|
||||
def test_safe_getcwd_falls_back_to_home_when_no_terminal_cwd(monkeypatch):
|
||||
def _boom():
|
||||
raise FileNotFoundError()
|
||||
|
||||
monkeypatch.setattr(terminal_tool.os, "getcwd", _boom)
|
||||
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
||||
monkeypatch.setattr(terminal_tool.os.path, "expanduser", lambda p: "/home/me")
|
||||
assert terminal_tool._safe_getcwd() == "/home/me"
|
||||
|
||||
+15
-11
@@ -134,7 +134,9 @@ MAX_DEPTH = 1 # flat by default: parent (0) -> child (1); grandchild rejected u
|
||||
# Configurable depth cap consulted by _get_max_spawn_depth; MAX_DEPTH
|
||||
# stays as the default fallback and is still the symbol tests import.
|
||||
_MIN_SPAWN_DEPTH = 1
|
||||
_MAX_SPAWN_DEPTH_CAP = 3
|
||||
# No upper ceiling on spawn depth — like max_concurrent_children, depth has a
|
||||
# floor of 1 and no ceiling. Deeper trees multiply API cost, so the default
|
||||
# stays flat (MAX_DEPTH = 1); raising the config knob is an explicit opt-in.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -392,7 +394,7 @@ def _get_child_timeout() -> float:
|
||||
|
||||
|
||||
def _get_max_spawn_depth() -> int:
|
||||
"""Read delegation.max_spawn_depth from config, clamped to [1, 3].
|
||||
"""Read delegation.max_spawn_depth from config, floored at 1 (no ceiling).
|
||||
|
||||
depth 0 = parent agent. max_spawn_depth = N means agents at depths
|
||||
0..N-1 can spawn; depth N is the leaf floor. Default 1 is flat:
|
||||
@@ -400,9 +402,11 @@ def _get_max_spawn_depth() -> int:
|
||||
(blocked by this guard AND, for leaf children, by the delegation
|
||||
toolset strip in _strip_blocked_tools).
|
||||
|
||||
Raise to 2 or 3 to unlock nested orchestration. role="orchestrator"
|
||||
removes the toolset strip for depth-1 children when
|
||||
Raise to 2+ to unlock nested orchestration. role="orchestrator"
|
||||
removes the toolset strip for spawning children when
|
||||
max_spawn_depth >= 2, enabling them to spawn their own workers.
|
||||
Like max_concurrent_children, there is no upper ceiling — but each
|
||||
extra level multiplies API cost, so raise it deliberately.
|
||||
"""
|
||||
cfg = _load_config()
|
||||
val = cfg.get("max_spawn_depth")
|
||||
@@ -417,16 +421,15 @@ def _get_max_spawn_depth() -> int:
|
||||
MAX_DEPTH,
|
||||
)
|
||||
return MAX_DEPTH
|
||||
clamped = max(_MIN_SPAWN_DEPTH, min(_MAX_SPAWN_DEPTH_CAP, ival))
|
||||
if clamped != ival:
|
||||
floored = max(_MIN_SPAWN_DEPTH, ival)
|
||||
if floored != ival:
|
||||
logger.warning(
|
||||
"delegation.max_spawn_depth=%d out of range [%d, %d]; " "clamping to %d",
|
||||
"delegation.max_spawn_depth=%d below floor %d; using %d",
|
||||
ival,
|
||||
_MIN_SPAWN_DEPTH,
|
||||
_MAX_SPAWN_DEPTH_CAP,
|
||||
clamped,
|
||||
floored,
|
||||
)
|
||||
return clamped
|
||||
return floored
|
||||
|
||||
|
||||
def _get_orchestrator_enabled() -> bool:
|
||||
@@ -1982,7 +1985,8 @@ def delegate_task(
|
||||
f"Delegation depth limit reached (depth={depth}, "
|
||||
f"max_spawn_depth={max_spawn}). Raise "
|
||||
f"delegation.max_spawn_depth in config.yaml if deeper "
|
||||
f"nesting is required (cap: {_MAX_SPAWN_DEPTH_CAP})."
|
||||
f"nesting is required (no hard ceiling, but each level "
|
||||
f"multiplies API cost)."
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -175,6 +175,7 @@ def _build_provider_env_blocklist() -> frozenset:
|
||||
"EMAIL_SMTP_HOST",
|
||||
"EMAIL_HOME_ADDRESS",
|
||||
"EMAIL_HOME_ADDRESS_NAME",
|
||||
"HERMES_DASHBOARD_SESSION_TOKEN",
|
||||
"GATEWAY_ALLOWED_USERS",
|
||||
"GH_TOKEN",
|
||||
"GITHUB_APP_ID",
|
||||
|
||||
+17
-3
@@ -1030,6 +1030,20 @@ def _parse_env_var(name: str, default: str, converter=int, type_label: str = "in
|
||||
)
|
||||
|
||||
|
||||
def _safe_getcwd() -> str:
|
||||
"""Return the current working directory, tolerating a deleted CWD.
|
||||
|
||||
``os.getcwd()`` raises FileNotFoundError when the process's working
|
||||
directory has been removed out from under it (e.g. a scratch workspace
|
||||
that was cleaned up mid-session). Fall back to TERMINAL_CWD, then the
|
||||
user's home directory, so terminal setup never crashes on a stale CWD.
|
||||
"""
|
||||
try:
|
||||
return os.getcwd()
|
||||
except FileNotFoundError:
|
||||
return os.getenv("TERMINAL_CWD") or os.path.expanduser("~")
|
||||
|
||||
|
||||
def _get_env_config() -> Dict[str, Any]:
|
||||
"""Get terminal environment configuration from environment variables."""
|
||||
# Default image with Python and Node.js for maximum compatibility
|
||||
@@ -1042,7 +1056,7 @@ def _get_env_config() -> Dict[str, Any]:
|
||||
# remote home, and everything else starts in the backend's default
|
||||
# root-like cwd.
|
||||
if env_type == "local":
|
||||
default_cwd = os.getcwd()
|
||||
default_cwd = _safe_getcwd()
|
||||
elif env_type == "ssh":
|
||||
default_cwd = "~"
|
||||
else:
|
||||
@@ -1058,7 +1072,7 @@ def _get_env_config() -> Dict[str, Any]:
|
||||
host_cwd = None
|
||||
host_prefixes = ("/Users/", "/home/", "C:\\", "C:/")
|
||||
if env_type == "docker" and mount_docker_cwd:
|
||||
docker_cwd_source = os.getenv("TERMINAL_CWD") or os.getcwd()
|
||||
docker_cwd_source = os.getenv("TERMINAL_CWD") or _safe_getcwd()
|
||||
candidate = os.path.abspath(os.path.expanduser(docker_cwd_source))
|
||||
if (
|
||||
any(candidate.startswith(p) for p in host_prefixes)
|
||||
@@ -2516,7 +2530,7 @@ if __name__ == "__main__":
|
||||
print(f" TERMINAL_SINGULARITY_IMAGE: {os.getenv('TERMINAL_SINGULARITY_IMAGE', f'docker://{default_img}')}")
|
||||
print(f" TERMINAL_MODAL_IMAGE: {os.getenv('TERMINAL_MODAL_IMAGE', default_img)}")
|
||||
print(f" TERMINAL_DAYTONA_IMAGE: {os.getenv('TERMINAL_DAYTONA_IMAGE', default_img)}")
|
||||
print(f" TERMINAL_CWD: {os.getenv('TERMINAL_CWD', os.getcwd())}")
|
||||
print(f" TERMINAL_CWD: {os.getenv('TERMINAL_CWD', _safe_getcwd())}")
|
||||
from hermes_constants import display_hermes_home as _dhh
|
||||
print(f" TERMINAL_SANDBOX_DIR: {os.getenv('TERMINAL_SANDBOX_DIR', f'{_dhh()}/sandboxes')}")
|
||||
print(f" TERMINAL_TIMEOUT: {os.getenv('TERMINAL_TIMEOUT', '60')}")
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"unicode-animations": "^1.0.3"
|
||||
|
||||
@@ -12,10 +12,9 @@ Get Hermes Agent up and running in under two minutes!
|
||||
### With the Hermes Desktop installer on macOS or Windows (recommended)
|
||||
To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) from our website and run it.
|
||||
|
||||
### With the Hermes Desktop installer on Linux:
|
||||
```bash
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh --include-desktop | bash
|
||||
```
|
||||
:::note macOS: no Apple Developer account needed
|
||||
The macOS builds are code-signed and notarized, so a normal install just works — you do **not** need an Apple Developer account or any "developer password." If macOS Gatekeeper ever blocks first launch with an *"unidentified developer"* dialog, right-click the app and choose **Open**. See [Desktop App → Troubleshooting](../user-guide/desktop.md#macos-wont-open-the-app-asks-about-the-developer).
|
||||
:::
|
||||
|
||||
### Without Hermes Desktop:
|
||||
For a command-line only install without Hermes Desktop, run:
|
||||
|
||||
@@ -50,10 +50,6 @@ Pick the row that matches your goal:
|
||||
### With the Hermes Desktop installer on macOS or Windows (recommended)
|
||||
To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) from our website and run it.
|
||||
|
||||
### With the Hermes Desktop installer on Linux:
|
||||
```bash
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh --include-desktop | bash
|
||||
```
|
||||
### Without Hermes Desktop:
|
||||
For a command-line only install without Hermes Desktop, run:
|
||||
|
||||
|
||||
@@ -59,6 +59,24 @@ hermes update --check --branch experimental # preview behindness only
|
||||
|
||||
If your local checkout is on a different branch, Hermes auto-stashes any uncommitted work, switches HEAD to the target branch, and then pulls. Branches that don't exist locally are auto-tracked from `origin/<name>` (`git checkout -B <name> origin/<name>`). Branches that don't exist anywhere fail cleanly — your stashed changes are restored before exit so you're never stranded in a weird state. The `main`-only fork-upstream sync logic is automatically skipped on non-`main` branches.
|
||||
|
||||
### Local changes on non-interactive updates
|
||||
|
||||
When you run `hermes update` in a terminal, Hermes stashes any uncommitted source-tree changes, pulls, then **asks** whether to restore them — exactly as it always has. Nothing changes for interactive updates.
|
||||
|
||||
When the update runs **without a terminal** — from the desktop/chat app's "Update" button or a gateway-triggered update — there's no prompt to answer. The `updates.non_interactive_local_changes` setting decides what happens to your stashed changes:
|
||||
|
||||
```yaml
|
||||
# ~/.hermes/config.yaml
|
||||
updates:
|
||||
non_interactive_local_changes: stash # default: keep + auto-restore
|
||||
# non_interactive_local_changes: discard # throw local source edits away
|
||||
```
|
||||
|
||||
- `stash` (default) — auto-stash, pull, then auto-restore your changes on top of the updated code. Nothing is lost; if a restore hits conflicts they're preserved in a git stash for manual recovery.
|
||||
- `discard` — auto-stash and drop the stash after the pull, so the update always lands on a clean tree. Use this only on machines where you never intend to keep local edits to the Hermes source. It stash-drops (not `git reset --hard` + `git clean -fd`), so ignored paths like `node_modules`, `venv`, and build outputs are never touched.
|
||||
|
||||
In the desktop app this is **Settings → Advanced → In-App Update Local Changes**.
|
||||
|
||||
### Preview-only: `hermes update --check`
|
||||
|
||||
Want to know if an update is available before pulling? Run `hermes update --check` — for git installs it fetches and compares commits against `origin/main`; for pip installs it queries PyPI for the latest release. No files are modified, no gateway is restarted. Useful in scripts and cron jobs that gate on "is there an update".
|
||||
|
||||
@@ -218,14 +218,14 @@ Restricting toolsets keeps the subagent focused and prevents accidental side eff
|
||||
## Constraints
|
||||
|
||||
- **Default 3 parallel tasks**: batches default to 3 concurrent subagents (configurable via `delegation.max_concurrent_children` in config.yaml, no hard ceiling, only a floor of 1)
|
||||
- **Nested delegation is opt-in**: leaf subagents (default) cannot call `delegate_task`, `clarify`, `memory`, `send_message`, or `execute_code`. Orchestrator subagents (`role="orchestrator"`) retain `delegate_task` for further delegation, but only when `delegation.max_spawn_depth` is raised above the default of 1 (1-3 supported); the other four remain blocked. Disable globally via `delegation.orchestrator_enabled: false`.
|
||||
- **Nested delegation is opt-in**: leaf subagents (default) cannot call `delegate_task`, `clarify`, `memory`, `send_message`, or `execute_code`. Orchestrator subagents (`role="orchestrator"`) retain `delegate_task` for further delegation, but only when `delegation.max_spawn_depth` is raised above the default of 1 (floor 1, no ceiling); the other four remain blocked. Disable globally via `delegation.orchestrator_enabled: false`.
|
||||
|
||||
### Tuning Concurrency and Depth
|
||||
|
||||
| Config | Default | Range | Effect |
|
||||
|--------|---------|-------|--------|
|
||||
| `max_concurrent_children` | 3 | >=1 | Parallel batch size per `delegate_task` call |
|
||||
| `max_spawn_depth` | 1 | 1-3 | How many delegation levels can spawn further |
|
||||
| `max_spawn_depth` | 1 | >=1 | How many delegation levels can spawn further |
|
||||
|
||||
Example: running 30 parallel workers with nested subagents:
|
||||
|
||||
|
||||
@@ -67,12 +67,6 @@ The self-improving AI agent built by [Nous Research](https://nousresearch.com).
|
||||
|
||||
To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) from our website and run it.
|
||||
|
||||
### Linux
|
||||
|
||||
```bash
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh --include-desktop | bash
|
||||
```
|
||||
|
||||
### Without Hermes Desktop:
|
||||
|
||||
For a command-line only install without Hermes Desktop, run:
|
||||
|
||||
@@ -1361,6 +1361,22 @@ hermes dashboard
|
||||
hermes dashboard --port 8080 --no-open
|
||||
```
|
||||
|
||||
### `hermes dashboard register`
|
||||
|
||||
Register this install as a self-hosted dashboard with your Nous Portal account, so the dashboard's OAuth (Nous) auth gate can be used. Resolves your existing Nous login (run `hermes setup` first if you're not logged in), creates an OAuth client, writes `HERMES_DASHBOARD_OAUTH_CLIENT_ID` into `~/.hermes/.env`, and prints how to engage the login gate. You can also register, name, and revoke dashboards from the Portal [`/local-dashboards`](https://portal.nousresearch.com/local-dashboards) page.
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `--name` | auto-generated | Human-readable label for the dashboard |
|
||||
| `--redirect-uri` | — | Public HTTPS OAuth redirect URI for an internet-facing host, e.g. `https://hermes.example.com/auth/callback`. Omit for localhost-only use. |
|
||||
|
||||
```bash
|
||||
hermes dashboard register
|
||||
# ✓ Registered dashboard "swift_falcon"
|
||||
# …writes HERMES_DASHBOARD_OAUTH_CLIENT_ID to ~/.hermes/.env
|
||||
```
|
||||
|
||||
|
||||
## `hermes profile`
|
||||
|
||||
```bash
|
||||
|
||||
@@ -418,9 +418,9 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
|
||||
|
||||
### Web Dashboard & Hermes Desktop
|
||||
|
||||
Auth for the [web dashboard](/user-guide/features/web-dashboard) and for connecting [Hermes Desktop to a remote backend](/user-guide/features/web-dashboard#connecting-hermes-desktop-to-a-remote-backend). Per the secrets-only convention, credentials belong in `~/.hermes/.env`; the OAuth `client_id`/`portal_url` are better set under `dashboard.oauth` in `config.yaml` (env wins when set).
|
||||
Auth for the [web dashboard](/user-guide/features/web-dashboard) and for connecting [Hermes Desktop to a remote backend](/user-guide/features/web-dashboard#connecting-hermes-desktop-to-a-remote-backend). Per the secrets-only convention, credentials belong in `~/.hermes/.env`; the OAuth `client_id` is better set under `dashboard.oauth` in `config.yaml` (env wins when set).
|
||||
|
||||
The recommended way to expose a dashboard for a remote Hermes Desktop connection is the bundled **username/password** provider: set the `HERMES_DASHBOARD_BASIC_AUTH_*` vars below and run `hermes dashboard --host 0.0.0.0`. The non-loopback bind engages the auth gate, and Desktop signs in with the username and password.
|
||||
Three dashboard-auth providers ship in the box. For a remote Hermes Desktop connection or any internet-facing dashboard, the recommended provider is **OAuth (Nous Portal)** — set `HERMES_DASHBOARD_OAUTH_CLIENT_ID` (provision it with `hermes dashboard register`). The bundled **username/password** provider (`HERMES_DASHBOARD_BASIC_AUTH_*`) is the quickest option for a backend on a trusted LAN or behind a VPN, but is not suitable for direct public-internet exposure. To authenticate against your own identity provider, use the **self-hosted OIDC** provider (`HERMES_DASHBOARD_OIDC_*`). Either way, a non-loopback bind (`hermes dashboard --host 0.0.0.0`) engages the auth gate. See [Web Dashboard → Authentication](/user-guide/features/web-dashboard#authentication-gated-mode) for the full picture.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
@@ -429,10 +429,12 @@ The recommended way to expose a dashboard for a remote Hermes Desktop connection
|
||||
| `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH` | scrypt password hash for the basic provider (preferred — no plaintext at rest). Compute with `python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('PW'))"`. Overrides `dashboard.basic_auth.password_hash`. |
|
||||
| `HERMES_DASHBOARD_BASIC_AUTH_SECRET` | HMAC key (32+ bytes, base64/hex/raw) signing the basic provider's stateless session tokens. Set explicitly so sessions survive restarts / span multiple workers; blank → random per-process (you'll be logged out on every restart). Overrides `dashboard.basic_auth.secret`. |
|
||||
| `HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS` | Access-token lifetime for the basic provider (default 12h). Overrides `dashboard.basic_auth.session_ttl_seconds`. |
|
||||
| `HERMES_DESKTOP_REMOTE_URL` | (Desktop side) Base URL of the remote backend, e.g. `http://host:9119`. When set, overrides the in-app Gateway URL; you still sign in with your username and password from the Gateway settings panel. |
|
||||
| `HERMES_DASHBOARD_OAUTH_CLIENT_ID` | OAuth client id (`agent:{instance_id}`) for the gated/public dashboard. Overrides `dashboard.oauth.client_id`. Provisioned by the Nous Portal for hosted deploys. |
|
||||
| `HERMES_DASHBOARD_PORTAL_URL` | OAuth portal URL (default: `https://portal.nousresearch.com`). Override only for staging/custom deploys. |
|
||||
| `HERMES_DASHBOARD_OAUTH_CLIENT_ID` | OAuth client id (`agent:{instance_id}`) for the gated/public dashboard, activating the Nous (`plugins/dashboard_auth/nous`) provider. Overrides `dashboard.oauth.client_id`. Provision it with `hermes dashboard register`. |
|
||||
| `HERMES_DASHBOARD_PUBLIC_URL` | Complete public URL the dashboard is reached at, for OAuth callback construction behind reverse proxies. Overrides `dashboard.public_url`. |
|
||||
| `HERMES_DASHBOARD_OIDC_ISSUER` | OIDC issuer URL for the bundled self-hosted OIDC provider (`plugins/dashboard_auth/self_hosted`). Required to activate it. Overrides `dashboard.oauth.self_hosted.issuer`. |
|
||||
| `HERMES_DASHBOARD_OIDC_CLIENT_ID` | Public OIDC client id (authorization-code + PKCE) for the self-hosted OIDC provider. Required to activate it. Overrides `dashboard.oauth.self_hosted.client_id`. |
|
||||
| `HERMES_DASHBOARD_OIDC_SCOPES` | Requested OIDC scopes for the self-hosted OIDC provider (default `openid profile email`). Overrides `dashboard.oauth.self_hosted.scopes`. |
|
||||
| `HERMES_DESKTOP_REMOTE_URL` | (Desktop side) Base URL of the remote backend, e.g. `http://host:9119`. When set, overrides the in-app Gateway URL; you still sign in from the Gateway settings panel (OAuth redirect or username/password, whichever the backend advertises). |
|
||||
|
||||
### Microsoft Graph (Teams Meetings)
|
||||
|
||||
|
||||
@@ -1672,7 +1672,7 @@ delegation:
|
||||
# api_key: "local-key" # API key for base_url (falls back to OPENAI_API_KEY)
|
||||
# api_mode: "" # Wire protocol for base_url: "chat_completions", "codex_responses", or "anthropic_messages". Empty = auto-detect from URL (e.g. /anthropic suffix → anthropic_messages). Set explicitly for non-standard endpoints the heuristic can't detect.
|
||||
max_concurrent_children: 3 # Parallel children per batch (floor 1, no ceiling). Also via DELEGATION_MAX_CONCURRENT_CHILDREN env var.
|
||||
max_spawn_depth: 1 # Delegation tree depth cap (1-3, clamped). 1 = flat (default): parent spawns leaves that cannot delegate. 2 = orchestrator children can spawn leaf grandchildren. 3 = three levels.
|
||||
max_spawn_depth: 1 # Delegation tree depth (floor 1, no ceiling). 1 = flat (default): parent spawns leaves that cannot delegate. 2 = orchestrator children can spawn leaf grandchildren. 3+ = deeper trees.
|
||||
orchestrator_enabled: true # Global kill switch. When false, role="orchestrator" is ignored and every child is forced to leaf regardless of max_spawn_depth.
|
||||
```
|
||||
|
||||
@@ -1686,7 +1686,7 @@ The delegation provider uses the same credential resolution as CLI/gateway start
|
||||
|
||||
**Precedence:** `delegation.base_url` in config → `delegation.provider` in config → parent provider (inherited). `delegation.model` in config → parent model (inherited). Setting just `model` without `provider` changes only the model name while keeping the parent's credentials (useful for switching models within the same provider like OpenRouter).
|
||||
|
||||
**Width and depth:** `max_concurrent_children` caps how many subagents run in parallel per batch (default `3`, floor of 1, no ceiling). Can also be set via the `DELEGATION_MAX_CONCURRENT_CHILDREN` env var. When the model submits a `tasks` array longer than the cap, `delegate_task` returns a tool error explaining the limit rather than silently truncating. `max_spawn_depth` controls the delegation tree depth (clamped to 1-3). At the default `1`, delegation is flat: children cannot spawn grandchildren, and passing `role="orchestrator"` silently degrades to `leaf`. Raise to `2` so orchestrator children can spawn leaf grandchildren; `3` for three-level trees. The agent opts into orchestration per call via `role="orchestrator"`; `orchestrator_enabled: false` forces every child back to leaf regardless. Cost scales multiplicatively — at `max_spawn_depth: 3` with `max_concurrent_children: 3`, the tree can reach 3×3×3 = 27 concurrent leaf agents. See [Subagent Delegation → Depth Limit and Nested Orchestration](features/delegation.md#depth-limit-and-nested-orchestration) for usage patterns.
|
||||
**Width and depth:** `max_concurrent_children` caps how many subagents run in parallel per batch (default `3`, floor of 1, no ceiling). Can also be set via the `DELEGATION_MAX_CONCURRENT_CHILDREN` env var. When the model submits a `tasks` array longer than the cap, `delegate_task` returns a tool error explaining the limit rather than silently truncating. `max_spawn_depth` controls the delegation tree depth (floor of 1, no upper ceiling). At the default `1`, delegation is flat: children cannot spawn grandchildren, and passing `role="orchestrator"` silently degrades to `leaf`. Raise to `2` so orchestrator children can spawn leaf grandchildren; `3` for three-level trees, and higher for deeper ones. The agent opts into orchestration per call via `role="orchestrator"`; `orchestrator_enabled: false` forces every child back to leaf regardless. Cost scales multiplicatively — at `max_spawn_depth: 3` with `max_concurrent_children: 3`, the tree can reach 3×3×3 = 27 concurrent leaf agents. See [Subagent Delegation → Depth Limit and Nested Orchestration](features/delegation.md#depth-limit-and-nested-orchestration) for usage patterns.
|
||||
|
||||
## Clarify
|
||||
|
||||
|
||||
@@ -102,7 +102,14 @@ By default the app starts and manages its own **local** backend. You can instead
|
||||
"Remote backend" means a **`hermes dashboard`** server running on the remote machine — that is the process the desktop app connects to. Nothing in this section works unless that dashboard is actually up and reachable. The desktop app does not start it for you; you (or a `systemd` service) keep `hermes dashboard` running on the remote host, and the app attaches to it. If you also use messaging channels (Telegram, Discord, etc.), the **gateway** is a *separate* long-running process you start independently — see the note after the setup steps.
|
||||
:::
|
||||
|
||||
The connection has two halves: on the backend you protect the dashboard with a **username and password**, and in the app you enter the backend's URL and sign in with those credentials. Binding the dashboard to a non-loopback address automatically engages its auth gate, so the username/password provider is what lets the desktop app through.
|
||||
The connection has two halves: on the backend you protect the dashboard with an **auth provider**, and in the app you enter the backend's URL and sign in. Binding the dashboard to a non-loopback address automatically engages its auth gate, and the provider you configure is what lets the desktop app through.
|
||||
|
||||
**Pick a provider based on where the backend lives:**
|
||||
|
||||
- **OAuth (Nous Portal) — preferred for anything reachable beyond your own machine.** Logins are verified against your Nous account, so this is the option suitable for a VPS, a public host, or any remote backend. Register the dashboard with `hermes dashboard register` (or the Portal [`/local-dashboards`](https://portal.nousresearch.com/local-dashboards) page) to provision its OAuth client, then sign in from the app with **Sign in with Nous Research**. A self-hosted OIDC provider works the same way if you run your own identity provider.
|
||||
- **Username/password — local / trusted-network use only.** The simplest option when the backend is on the same trusted LAN or reachable only over a VPN (e.g. Tailscale). It protects a single shared credential with no external identity provider, so **do not use it for a dashboard exposed to the public internet** — reach for OAuth there instead.
|
||||
|
||||
The rest of this section shows the username/password path because it's the quickest to stand up on a trusted network; for the OAuth path see [Web Dashboard → Default provider: Nous Research](./features/web-dashboard.md#default-provider-nous-research).
|
||||
|
||||
### On the backend (the remote machine)
|
||||
|
||||
@@ -134,7 +141,7 @@ Prefer not to keep a plaintext password at rest? Set `HERMES_DASHBOARD_BASIC_AUT
|
||||
Running the dashboard as a systemd service? Give the unit `EnvironmentFile=%h/.hermes/.env` so the credentials are in the environment at boot.
|
||||
|
||||
:::warning
|
||||
The dashboard reads and writes your `.env` (API keys, secrets) and can run agent commands. Even behind a username and password, never expose it directly to the open internet — put it behind a VPN. [Tailscale](https://tailscale.com/) is the clean option: bind to the machine's tailscale IP (`--host <tailscale-ip>`) and use `http://<tailscale-ip>:9119` as the Remote URL so only your tailnet can reach it.
|
||||
The dashboard reads and writes your `.env` (API keys, secrets) and can run agent commands. The **username/password** setup shown above is for a trusted network — never expose a password-protected dashboard directly to the open internet; put it behind a VPN. [Tailscale](https://tailscale.com/) is the clean option: bind to the machine's tailscale IP (`--host <tailscale-ip>`) and use `http://<tailscale-ip>:9119` as the Remote URL so only your tailnet can reach it. To reach a backend over the public internet, use the **OAuth (Nous Portal)** provider instead.
|
||||
:::
|
||||
|
||||
### In the app
|
||||
@@ -142,10 +149,10 @@ The dashboard reads and writes your `.env` (API keys, secrets) and can run agent
|
||||
**Settings → Gateway → Remote gateway:**
|
||||
|
||||
1. **Remote URL** — `http://<backend-host>:9119` (path prefixes like `/hermes` work if you front it with a reverse proxy)
|
||||
2. **Sign in** — the app detects that the backend requires a username and password and shows a **Sign in** button. Click it, enter the credentials from step 1, and the app authenticates against the backend's login page.
|
||||
2. **Sign in** — the app detects which provider the backend advertises and adapts the button. For a username/password backend it shows a **Sign in** button that opens a credential form (enter the credentials from step 1). For an OAuth backend it shows **Sign in with `<provider>`** (e.g. *Sign in with Nous Research*), which runs the provider's browser sign-in. Either way the app ends up with an authenticated session against the backend.
|
||||
3. **Save and reconnect** — switches the desktop shell onto the remote backend. The session refreshes automatically; you stay signed in across restarts when `HERMES_DASHBOARD_BASIC_AUTH_SECRET` is set.
|
||||
|
||||
You can also set the backend URL without the UI via the `HERMES_DESKTOP_REMOTE_URL` environment variable before launching the app (it overrides the in-app setting); you still sign in with your username and password from the Gateway settings panel.
|
||||
You can also set the backend URL without the UI via the `HERMES_DESKTOP_REMOTE_URL` environment variable before launching the app (it overrides the in-app setting); you still sign in from the Gateway settings panel.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
@@ -158,6 +165,24 @@ For the same setup from the web-dashboard angle, see [Web Dashboard → Connecti
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### macOS won't open the app / asks about the "developer"
|
||||
|
||||
You do **not** need an Apple Developer account, an Apple Developer Program membership, or any "developer password" to install or run Hermes Desktop. The official builds from [our download page](https://hermes-agent.nousresearch.com/desktop) are code-signed and notarized by Apple, so a normal install just works.
|
||||
|
||||
If macOS still shows a dialog like *"Hermes can't be opened because Apple cannot check it for malicious software"* or *"…from an unidentified developer"*, that's **Gatekeeper**, not an account requirement. It usually means the download's quarantine attribute is in an odd state, or you built the app locally with `hermes desktop` (which produces an unsigned build from your own source). It is asking you to confirm you trust the app — there is no developer account or password involved.
|
||||
|
||||
To open it anyway:
|
||||
|
||||
1. **Right-click (or Control-click) the app** in Finder and choose **Open**, then click **Open** in the dialog. macOS remembers this choice, so you only do it once.
|
||||
2. If there's no **Open** option, go to **System Settings → Privacy & Security**, scroll to the **Security** section, and click **Open Anyway** next to the Hermes entry. (The macOS *login* password it may ask for here is your own Mac password to change a security setting — not a "developer" password.)
|
||||
|
||||
If you'd rather not deal with Gatekeeper at all, the CLI install needs no signing prompts:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||
hermes desktop # launches the desktop app, building it locally
|
||||
```
|
||||
|
||||
Boot logs land in `HERMES_HOME/logs/desktop.log` (it includes backend output and recent Python tracebacks) — check it first if the app reports a boot failure. You can also tail it from the CLI:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -118,7 +118,13 @@ The dashboard's auth gate engages automatically when both of the following are t
|
||||
1. The bind host is non-loopback (e.g. the default `0.0.0.0` inside the container), **and**
|
||||
2. A `DashboardAuthProvider` plugin is registered.
|
||||
|
||||
The simplest way to satisfy the second condition is the bundled **username/password** provider: set `HERMES_DASHBOARD_BASIC_AUTH_USERNAME` + `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD` (and `HERMES_DASHBOARD_BASIC_AUTH_SECRET` for restart-stable sessions). For hosted/public deploys the OAuth (`dashboard_auth/nous`) provider activates whenever `HERMES_DASHBOARD_OAUTH_CLIENT_ID` is set. Either way the gate redirects callers to a login page before they can reach any protected route. See [Web Dashboard → Authentication](features/web-dashboard.md#authentication-gated-mode) for both providers.
|
||||
There are three bundled ways to satisfy the second condition:
|
||||
|
||||
- **Username/password** — the simplest for a self-hosted / on-prem / homelab container on a trusted network or behind a VPN: set `HERMES_DASHBOARD_BASIC_AUTH_USERNAME` + `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD` (and `HERMES_DASHBOARD_BASIC_AUTH_SECRET` for restart-stable sessions). Not suitable for direct public-internet exposure.
|
||||
- **OAuth (Nous Portal)** — for hosted/public deploys: the `dashboard_auth/nous` provider activates whenever `HERMES_DASHBOARD_OAUTH_CLIENT_ID` is set.
|
||||
- **Self-hosted OIDC** — to authenticate against your own identity provider via standard OpenID Connect: the `dashboard_auth/self_hosted` provider activates when `HERMES_DASHBOARD_OIDC_ISSUER` + `HERMES_DASHBOARD_OIDC_CLIENT_ID` are set.
|
||||
|
||||
Whichever you choose, the gate redirects callers to a login page before they can reach any protected route. See [Web Dashboard → Authentication](features/web-dashboard.md#authentication-gated-mode) for all three providers.
|
||||
|
||||
If no provider is registered and the bind is non-loopback, the dashboard **fails closed at startup** with a specific error pointing at the missing env var. The `HERMES_DASHBOARD_INSECURE=1` escape hatch disables the gate entirely (the bind host alone never implies `--insecure`), but it serves an unauthenticated dashboard — configure a provider instead unless you have your own auth layer in front.
|
||||
|
||||
|
||||
@@ -214,7 +214,7 @@ delegate_task(
|
||||
```
|
||||
|
||||
- `role="leaf"` (default): child cannot delegate further — identical to the flat-delegation behavior.
|
||||
- `role="orchestrator"`: child retains the `delegation` toolset. Gated by `delegation.max_spawn_depth` (default **1** = flat, so `role="orchestrator"` is a no-op at defaults). Raise `max_spawn_depth` to 2 to allow orchestrator children to spawn leaf grandchildren; 3 for three levels (cap).
|
||||
- `role="orchestrator"`: child retains the `delegation` toolset. Gated by `delegation.max_spawn_depth` (default **1** = flat, so `role="orchestrator"` is a no-op at defaults). Raise `max_spawn_depth` to 2 to allow orchestrator children to spawn leaf grandchildren; 3+ for deeper trees. There is no upper ceiling — cost is the practical limit.
|
||||
- `delegation.orchestrator_enabled: false`: global kill switch that forces every child to `leaf` regardless of the `role` parameter.
|
||||
|
||||
**Cost warning:** With `max_spawn_depth: 3` and `max_concurrent_children: 3`, the tree can reach 3×3×3 = 27 concurrent leaf agents. Each extra level multiplies spend — raise `max_spawn_depth` intentionally.
|
||||
@@ -264,7 +264,7 @@ For **durable long-running work** that must survive interrupts or outlive the cu
|
||||
delegation:
|
||||
max_iterations: 50 # Max turns per child (default: 50)
|
||||
# max_concurrent_children: 3 # Parallel children per batch (default: 3)
|
||||
# max_spawn_depth: 1 # Tree depth (1-3, default 1 = flat). Raise to 2 to allow orchestrator children to spawn leaves; 3 for three levels.
|
||||
# max_spawn_depth: 1 # Tree depth (floor 1, no ceiling, default 1 = flat). Raise to 2 to allow orchestrator children to spawn leaves; 3+ for deeper trees.
|
||||
# orchestrator_enabled: true # Disable to force all children to leaf role.
|
||||
model: "google/gemini-3-flash-preview" # Optional provider/model override
|
||||
provider: "openrouter" # Optional built-in provider
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user