The Discord /reasoning command declared a single free-text `effort`
parameter, so the native UI funneled every invocation into that one box
and never surfaced the reset / show / hide subcommands the gateway
handler already supports. Replace the free-text param with an explicit
choices dropdown covering the effort levels plus reset/show/hide,
mirroring the existing /tokens and /voice commands. --global persistence
stays reachable by typing the command as plain text.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
terminal_tool reads all settings from TERMINAL_* env vars, bridged from
config.yaml by the CLI, gateway, and TUI-PTY launchers. Processes that
skip every launcher bridge — hermes serve / the Desktop app backend's
in-process agents, the desktop cron ticker — saw an unset TERMINAL_ENV
and silently ran every command on the host even when config.yaml selects
terminal.backend: docker. A user who configured Docker isolation got
unsandboxed host execution with no warning.
Two layers:
- _ensure_terminal_env_bridged() in _get_env_config(): when TERMINAL_ENV
is unset, backfill TERMINAL_* from config.yaml via
apply_terminal_config_to_env(override=False). Explicit env always wins
(honor explicit choice; only fix the accidental fallback). One-shot,
fail-open to the historical local default.
- cmd_dashboard/serve: run the same bridge at startup so every consumer
in the backend process (in-process agents, desktop cron ticker,
tui_gateway cwd resolution) sees the bridged env directly.
Fixes#63141, #54449, #61115, #65696.
hermes update ran TWO separate pre-update backup mechanisms: the
config-gated full zip (updates.pre_update_backup, default off) and an
unconditional quick state snapshot added for #15733 that ignored the
user's setting entirely. On a large state.db (observed: 24 GB) the
'cheap' snapshot silently added ~60s to every update and ate 24 GB of
disk in state-snapshots/.
Now there is ONE mechanism, gated by updates.pre_update_backup with
three modes:
- quick (new default): state snapshot of critical small files (pairing
JSONs, cron jobs, config, auth, per-profile DBs). Files over 1 GiB
are skipped with a warning so a bloated state.db can never stall the
update again.
- full: the quick snapshot plus the HERMES_HOME zip (old 'true'
behavior; --backup forces it for one run).
- off: nothing runs — an explicit opt-out now disables the quick
snapshot too (--no-backup does the same per-run).
Legacy booleans are honored: true -> full, false -> off.
_run_pre_update_backup() now returns the quick-snapshot id so the
post-update cron-jobs restore safety net (#34600) keeps working; the
snapshot moved from the post-fetch site to the pre-mutation site,
which also covers the zip-fallback update path it previously missed.
Add current-main regression coverage for both the registered provider
profile and core GitHub Responses path while leaving live catalog loading
to the complementary catalog-resolution work in #51953.
The Copilot provider profile unconditionally mapped ``xhigh`` to ``high`` before
checking the model's catalog, so models that DO support ``xhigh`` (e.g. the
gpt-5.x family per the live /models catalog) were silently capped one level
down.
Honor the requested effort when the catalog lists it as supported, and only
downgrade when it does not, choosing the nearest weaker supported level
(xhigh->high, minimal->low, else medium, else the first supported level). This
matches the nearest-down clamp behavior used elsewhere for the ``max`` effort.
Adds tests/plugins/model_providers/test_copilot_profile.py covering forward,
downgrade, and fallback paths (catalog lookup stubbed).
The current code unconditionally downgrades 'xhigh' to 'high' whenever
'high' is in supported_efforts, even if 'xhigh' is also supported.
This prevents users from using extended thinking on providers like
Copilot that list 'xhigh' in their supported efforts.
Fix: only downgrade 'xhigh' to 'high' when 'xhigh' is NOT in the
provider's supported efforts list.
The desktop self-update rebuild (`hermes desktop --build-only`, driven by
the macOS in-app updater) runs `_run_npm_install_deterministic`, which used
plain `npm ci` / `npm install`. Those honor an inherited
`NODE_ENV=production` (or npm `omit=dev`) and silently omit devDependencies.
The desktop build toolchain — tsc, vite, electron-builder — are all
devDependencies, so under `NODE_ENV=production` the install completes (exit 0)
but `tsc` is never placed, and the very next step (`tsc -b && vite build`)
dies with `tsc: command not found` (exit 127). The user sees
"UPDATE DIDN'T FINISH / Rebuilding the desktop app failed (exit 127)" even
though the git/pip update applied cleanly. NODE_ENV=production can leak in
from a shell profile, a parent process, or a packaged-app launch context.
Force `--include=dev` on both the `npm ci` and `npm install` paths. The only
callers are frontend builds (desktop / TUI / web), which always need the dev
toolchain, so this is safe across the board.
Verified empirically: under NODE_ENV=production, `npm ci` leaves tsc MISSING
while `npm ci --include=dev` installs it. Added two regression tests
(test_npm_ci_forces_include_dev, test_npm_install_fallback_forces_include_dev)
— both confirmed to FAIL when the fix is reverted.
When no agent is active, '/queue <prompt>' previously fell through
dispatch with its raw text intact instead of being treated as a
normal prompt. Rewrite event.text to the bare payload (mirroring the
/steer no-active-agent path just below) and return a usage hint when
the payload is empty.
Salvaged from PR #29290 (queue half only — the /footer mid-run
dispatch half already landed on main via #65521).
Three follow-up fixes to the salvaged reasoning_effort support, all verified
live against ollama.com /v1/chat/completions + /api/show on deepseek-v4-pro,
gemma3, and qwen3-coder:
1. Capability-gate on /api/show 'thinking'. The original ignored the
supports_reasoning flag and emitted reasoning_effort for every model. Now
gated: only models whose native /api/show capabilities list contains
'thinking' (deepseek-v4 yes; gemma3 / qwen3-coder no) get reasoning_effort.
Mirrors the LM Studio pattern — capability resolved once per (model,
base_url) in run_agent._supports_reasoning_extra_body via a cached probe
(hermes_cli.models.ollama_model_supports_thinking), threaded into the
profile hook as supports_reasoning. No live HTTP in the per-request path.
2. Disable actually disables. Ollama Cloud defaults to thinking ON and IGNORES
the extra_body.thinking:{type:disabled} shape (verified: still returned
reasoning). The only working off switch is top-level reasoning_effort:'none'.
The salvaged code returned ({}, {}) for enabled:false / effort:none, leaving
thinking ON. Now emits {'reasoning_effort': 'none'}.
3. Omit unrecognized effort. The original forwarded any unknown string verbatim
including 'minimal' (a real Hermes effort level). Ollama Cloud rejects
unrecognized values with a hard HTTP 400 (accepted set: low/medium/high/
max/none), so forwarding 'minimal' would break the request. Now omitted.
Core touches (run_agent.py, hermes_cli/models.py) add the capability probe;
the plugin profile only consumes the resolved flag. 24/24 profile tests green;
194 provider/transport tests unaffected.
LM Studio's request vocabulary tops out at "xhigh", but Hermes' generic
effort ladder has since grown two stronger levels. "max" and "ultra" miss
the _LM_VALID_EFFORTS membership test, keep the initialized "medium"
default, and are thereby conflated with unparseable input -- so asking for
more reasoning yields less than "xhigh":
high -> 'high' xhigh -> 'xhigh'
max -> 'medium' ultra -> 'medium'
This is drift, not a design choice. The valid set was an exact mirror of
VALID_REASONING_EFFORTS when the file was authored; the ladder then grew
"max" and later "ultra", and the sweep that taught every other provider
about the new levels missed this module -- it has never been touched since
it was written.
Clamp the two stronger levels onto LM Studio's declared ceiling instead,
mirroring the ceiling clamp every other provider already applies. Widening
_LM_VALID_EFFORTS would instead assert that LM Studio accepts "max" on the
wire, which is a provider-side claim this repo cannot verify; clamping
consumes only the ceiling the file already declares for itself.
The clamp is kept separate from _LM_EFFORT_ALIASES because that mapping is
also applied to the model's published allowed_options, which must not be
rewritten. A clamped value stays subject to the allowed_options check, so a
model that does not publish "xhigh" still gets the field omitted and falls
back to its own default -- exactly how a directly-requested "xhigh" behaves.
The regression test asserts monotonicity over the canonical ladder rather
than the two values alone, so the next level added upstream cannot silently
reintroduce the inversion.
Replaces the salvaged tests' dict-config path (which required a
GatewayRunner.__init__ dict-coercion hack — dropped from this salvage;
the second commit on the PR branch existed only to support it) with the
established bare-runner test pattern. Also asserts full argument
passthrough to the inner task.
When multiplex_profiles is true, background tasks spawned by /background
command failed with UnscopedSecretError because _resolve_session_agent_runtime()
was called without a profile secret scope. This fix wraps the task in
_profile_runtime_scope, mirroring the pattern used by _run_agent.
Fixes#60726
PR #36019 documented the attachment tools in the kanban-worker skill,
but main removed that skill in #50473 and folded its content into the
KANBAN_GUIDANCE prompt block. Land the same guidance there instead so
every dispatcher-spawned worker sees it.
_download_url_with_cap called urlopen() after only a scheme check, so a
model-controlled URL could reach loopback services, RFC1918/CGNAT hosts,
or cloud metadata endpoints (169.254.169.254), and a public host could
302 to any of those unvalidated.
Route the fetch through the repo's canonical SSRF guard instead:
validate every hop with tools.url_safety.is_safe_url() and follow
redirects manually (httpx, follow_redirects=False, 5-hop limit) so each
Location target is re-checked before it is fetched — the same pattern
as tools/skills_hub._guarded_http_get. The streaming size cap is
unchanged. Local-fixture tests opt in via HERMES_ALLOW_PRIVATE_URLS
(the guard's documented escape hatch); new tests pin rejection of
loopback, cloud-metadata, and private-range URLs, a mocked
public→loopback redirect, and a mocked public happy path.
The salvaged attachment-toolset commit predated main centralizing the
25 MB cap as kanban_db.KANBAN_ATTACHMENT_MAX_BYTES and re-introduced a
private _MAX_ATTACHMENT_BYTES alias. Drop the duplicate: kanban_db's
store_attachment_bytes(), the dashboard upload endpoint, and the
kanban_attach_url tool all reference the one shared constant now, and
the tests monkeypatch that same name.
The kanban board has had full attachment storage and a dashboard HTTP
API (upload/list/download/delete) since #35338, but there was no agent
toolset tool and no `hermes kanban` CLI verb for attachments. Agents and
scripts that don't go through the dashboard server (or can't touch the DB
directly) had no way to create or read real attachments — only links in
comments.
Close that gap by mirroring the existing comment surface:
- `kanban_db.store_attachment_bytes()` — one shared write path (validate
name, enforce the 25 MB cap, write the blob under the per-task dir with
collision-free naming, insert the metadata row, clean up an orphan blob
if the insert fails). `_MAX_ATTACHMENT_BYTES`, `_safe_attachment_name`,
and a new `_collision_free_path` move here so the dashboard, the tool,
and the CLI all share one implementation and can't drift.
- Tools (`tools/kanban_tools.py`): `kanban_attach` (inline base64),
`kanban_attach_url` (server-side http/https fetch with the same cap),
`kanban_attachments` (list). Write tools respect worker task-ownership;
list is read-only. Registered in the `kanban` toolset.
- CLI (`hermes_cli/kanban.py`): `attach <id> <path>`, `attachments <id>`,
`attach-rm <attachment_id>`.
- Dashboard `upload_task_attachment` now imports the shared helpers and
uses `_collision_free_path` — behavior identical (still streams to disk
with the cap, still 413 on overflow).
- Docs (AGENTS.md, kanban-worker skill) and toolset membership updated.
Tests: tool round-trip + oversize + bad base64 + ownership; attach_url
against a local HTTP fixture incl. oversize-mid-stream and non-http
scheme rejection; CLI attach/attachments/attach-rm; shared-helper unit
tests; dashboard parity preserved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The platform callback verifier can do blocking network I/O (e.g. the
google-chat adapter fetches Google signing certs on a cache miss), which
would stall the event loop if called inline. Run sync verifiers via
asyncio.to_thread (await coroutine verifiers directly), and treat a
crashing verifier as a 401 rather than a 500 through the dispatch path —
a broken verifier must never admit an event.
DeepSeek's own API (api.deepseek.com) reports context-cache hits as
top-level usage.prompt_cache_hit_tokens / prompt_cache_miss_tokens
(prompt_tokens = hit + miss), not the OpenAI nested
prompt_tokens_details.cached_tokens shape. Neither normalize_usage()
nor the chat_completions transport's extract_cache_stats() read those
fields, so direct DeepSeek sessions always showed 0 cache-hit tokens:
invisible in accounting, mis-billed at the full input rate, and 0%
cache display.
Both layers now fall back to prompt_cache_hit_tokens when the nested
shape is absent; the nested value wins when both are present (proxies).
Fixes#61871.
Server-side coverage for the critical-CSS shim (PR #36024 salvage):
- user theme → style block emitted with ONLY real bundle variable names
(--background-base/--midground-base from layerVars(),
--theme-font-sans/--theme-base-size from typographyVars()/index.css),
and an html,body rule expressed via those vars so runtime theme
switches never leave a stale canvas/font
- built-in / unknown / non-string active theme → no block
- malformed theme YAML and load_config() exceptions → no crash, index
still serves
- </style> breakout attempt in a theme value stays escaped
- mount_spa integration: block present in <head> for user themes,
absent for built-ins
Review fixes for the inline critical-CSS bootstrap (PR #36024):
1. Variable names now match what the bundle actually consumes.
--color-background, --color-midground, --font-sans and
--font-base-size appear nowhere in web/src; the real tokens are:
--background-base / --midground-base (layerVars(), context.tsx)
--theme-font-sans / --theme-base-size (typographyVars(), and
index.css html{font-family:var(--theme-font-sans);
font-size:var(--theme-base-size)})
2. Stale-rule bug: the injected html,body rule previously baked in
literal hex/font values. Because the <style> block sits after the
bundle's <link> at equal specificity and is never removed, switching
themes in the picker left the old canvas/font until reload. The rule
now references the same CSS variables instead of literals —
applyTheme() writes those vars as inline styles on documentElement,
which outrank this block in the cascade, so runtime theme switches
re-resolve the rule automatically. No frontend change needed.
User themes (`~/.hermes/dashboard-themes/*.yaml`) reach the SPA only
after `/api/dashboard/themes` resolves at React mount. The bundle paints
the first frame with the default Hermes Teal canvas — the
`<link rel="stylesheet">` carries `:root{--background-base:#041c1c}`,
the bundled `presets.ts` defines the same surfaces in JS — and then
`ThemeProvider.applyTheme(<user theme>)` flips the inline CSS variables
on `documentElement` once the API response lands. Visible to the user
as a green canvas behind the loading SPA on every reload when the active
theme is non-default.
Built-in themes do not suffer the same effect because their full
definitions ship inside the bundle, so the SPA already has the palette
before first paint.
This patch closes the gap on the backend side: `_serve_index()` injects
a `<style id="hermes-theme-bootstrap">` block inside `<head>` with the
six critical CSS variables (`--background-base`, `--color-background`,
`--midground-base`, `--color-midground`, `--font-sans`,
`--font-base-size`) plus an `html, body` rule painting the body in the
target palette. Because the inline `<style>` follows the bundle's
`<link>` in DOM order and matches the same `:root` specificity, the
later declaration wins the cascade — the static canvas behind the SPA is
already the right colour before any JavaScript runs.
`_render_active_theme_bootstrap_css()` looks up the active theme through
the existing `_discover_user_themes()` helper. No-op for built-in
active themes (empty string returned, no `<style>` injected). No new
API endpoints, no config flags, no frontend changes.
After `ThemeProvider` mounts and `applyTheme()` writes the same
variables as inline styles on `documentElement`, the values match what
the bootstrap block set, so there is no second-paint discrepancy on the
critical CSS variables.
Review follow-ups on the cherry-picked #36043 commit:
1. Guard the custom:<name> passthrough with a _get_named_custom_provider
lookup. The PR unconditionally kept the full custom:<name> string, which
broke config-less runtime custom providers (#34777 regression — entries
that exist only in the live runtime, not config.yaml): the named arm
found no entry and resolution fell through to Step 2. Now custom:<name>
only takes the named arm when a config entry actually exists; otherwise
it collapses to the anonymous-custom arm with the runtime endpoint,
preserving pre-PR behavior.
2. Drop the dead 'explicit_api_key = runtime_api_key' assignment (and its
misleading comment) in the named-entry branch. resolve_provider_client's
named-custom arm derives the key exclusively from the entry's
api_key/key_env and never reads explicit_api_key, so the assignment was
a no-op. Wiring precedence in was not justified: for a named custom
provider the runtime key IS the entry's key (set_runtime_main sources it
from the same config), so deletion is the honest option.
3. Tighten the Palantir Bearer-auth check from a loose substring match
('palantirfoundry' in normalized) to a hostname match via
base_url_host_matches(..., 'palantirfoundry.com'), so path segments or
lookalike domains containing the string no longer trigger Bearer auth.
Tests: named-custom anthropic_messages end-to-end routing (full name kept,
AnthropicAuxiliaryClient at the original /anthropic URL, no /v1 rewrite)
plus Palantir Bearer-auth positive and substring-false-positive cases.
When the user's main provider is a named custom_providers entry exposing an
Anthropic Messages surface (e.g. Palantir Foundry's
/api/v2/llm/proxy/anthropic, custom LiteLLM/Bedrock proxies), auxiliary
tasks (title generation, compression, web extract, session search, etc.)
returned HTTP 404 NOT_FOUND for every call.
Root cause: `_resolve_auto` collapsed any `custom:<name>` main provider
to plain `"custom"` and passed runtime_base_url as explicit_base_url.
This landed in `resolve_provider_client`'s anonymous-custom arm
(`if provider == "custom":`), which unconditionally calls
`_to_openai_base_url` — that helper strips a trailing `/anthropic` and
substitutes `/v1` (designed for MiniMax/ZAI which expose both surfaces).
The result for Palantir is `/api/v2/llm/proxy/v1`, which does not exist
on the proxy — every auxiliary call 404s. The runtime `api_mode=
anthropic_messages` flag was discarded by this arm.
Fix: split the conditional so only the literal `"custom"` provider takes
the anonymous-custom path; `custom:<name>` keeps its full `custom:<name>`
string when handed to `resolve_provider_client`, where the
named-custom-provider arm (added in earlier work) honours the entry's
`api_mode` and routes through `AnthropicAuxiliaryClient` against the
original `/anthropic` URL.
Also: extend `_requires_bearer_auth` in `anthropic_adapter.py` to
recognise palantirfoundry hosts so the SDK sends `Authorization: Bearer`
instead of the default `x-api-key` (Palantir's proxy rejects x-api-key
with 401).
Verified end-to-end against a live Palantir Foundry deployment with both
claude-4-6-opus and claude-4-7-opus models — `generate_title` returns
real titles instead of 404ing. Regression-tested:
- anonymous `custom` (with base_url) still routes to OpenAI wire
- built-in NVIDIA provider unchanged
- custom-without-base_url still falls through to Step-2 chain
Same bug class as the salvaged #65305/#65307: hmac.compare_digest (and
secrets.compare_digest) raise TypeError when given a str containing
non-ASCII characters, and these call sites feed it raw request input.
Compare as UTF-8 bytes everywhere:
- gateway/platforms/msgraph_webhook.py: clientState from request body
- gateway/platforms/whatsapp_cloud.py: hub.verify_token query param +
X-Hub-Signature-256 header (comment claimed 'works on str' — it
doesn't for non-ASCII)
- plugins/platforms/feishu: verification token + x-lark-signature
- plugins/platforms/raft: bridge token header
- plugins/platforms/line: X-Line-Signature
- plugins/platforms/sms: X-Twilio-Signature
- tools/code_execution_tool.py: sandbox RPC token (both loops)
Regression tests for the two gateway-core sites (msgraph, whatsapp).
The fix routes the Svix v1 comparison through _hmac_str_equal too, but the
existing non-ASCII tests only exercised the GitHub/GitLab/generic V1/V2
branches. Add a Svix case (valid svix-id + fresh svix-timestamp so it
reaches the v1,<sig> compare) with a non-ASCII signature, which raised
TypeError before the fix and now rejects cleanly.
_validate_signature backs the public webhook receiver. It compared each
attacker-supplied signature/token header (GitHub X-Hub-Signature-256,
GitLab X-Gitlab-Token, generic X-Webhook-Signature / -V2, and the Svix v1
header) against a computed hex/base64 digest with hmac.compare_digest on
two str values. compare_digest raises TypeError on a str containing
non-ASCII characters, and the header is raw client input on an
unauthenticated endpoint — so any internet client could POST a single
non-ASCII byte in the signature header and raise out of the handler,
returning a 500 instead of a clean 401. Fail-closed, but an on-demand
crash of the request path.
Route all five comparisons through a small _hmac_str_equal() helper that
encodes both sides to UTF-8 bytes before the constant-time compare
(compare_digest has no ASCII restriction on bytes). Semantics are
unchanged for valid signatures; a hostile non-ASCII header now fails
closed with a rejection instead of raising.
Adds regression tests: non-ASCII GitHub/GitLab/generic/V2 signature
headers return False (no raise), and a non-ASCII configured secret still
matches its exact token value.
Also maps drexux0@gmail.com in scripts/release.py AUTHOR_MAP.
_check_auth gates every OpenAI-compatible API server endpoint. It compared
the client's raw bearer token against the configured key with
hmac.compare_digest on two str values. compare_digest raises TypeError on
a str containing non-ASCII characters, and the token comes straight from
the Authorization header — so a request with a single non-ASCII byte in
the key (a stray unicode char, a smart quote, a pasted BOM) crashed the
handler with an unhandled TypeError. Every endpoint calls _check_auth
without a try/except, so the framework turned that into a 500 Internal
Server Error instead of the intended 401 Invalid API key.
Compare as bytes, matching web_server.py's dashboard-token check
(hmac.compare_digest(auth.encode(), expected.encode())). Encoding both
sides keeps the timing-safe comparison and its semantics identical for
valid keys while making a non-ASCII token fail closed with a clean 401.
Adds regression tests: a non-ASCII bearer token returns 401 (no raise),
and a non-ASCII configured key still authenticates against its exact
value.
Follow-up to the truncate_message split-loop floor. Two review points:
- CapabilityDescriptor.from_json trusted the wire max_message_length
verbatim, so a connector advertising 0 ('no limit') — or a buggy one
sending 0/negative — produced a descriptor whose bound flowed straight
into the adapter's MAX_MESSAGE_LENGTH and truncate_message. Normalize
it to the documented 4096 default (mirrors from_platform_entry's
'or 4096' and docs/relay-connector-contract.md), fixing the degenerate
budget at its source rather than only surviving it downstream.
- Document the truncate_message length contract for a budget too small
for one codepoint (max_length=1 with a 2-unit surrogate pair under
utf16_len): the chunk intentionally exceeds max_length by that one
indivisible codepoint, because emitting it whole preserves content
where the alternatives are data loss or an infinite loop.
Tests: from_json normalizes 0 and negative bounds to 4096 and passes a
real positive bound through unchanged; the sub-codepoint budget emits
whole codepoints with no data loss (all emojis preserved) and a chunk
that necessarily exceeds the 1-unit budget.
BasePlatformAdapter.truncate_message() splits an over-length reply into
chunks. When max_length is 0 or 1 (and the content is longer), the split
loop makes no progress and spins forever, appending empty chunks — an
unbounded hang that pins a CPU and grows the chunk list until OOM:
- headroom = max_length - INDICATOR_RESERVE - ... goes negative, and the
< 1 fallback (max_length // 2) is also 0;
- so _cp_limit is 0, the region is empty, no split point is found, and
split_at falls back to _cp_limit (0);
- chunk_body is remaining[:0] = "", remaining never shrinks, loop repeats.
The same stall is reachable under utf16_len (Telegram) whenever the next
char is a surrogate-pair emoji wider than the whole budget, so _cp_limit
maps to 0 codepoints even for max_length >= 2.
A pathological max_length is not hypothetical: the relay capability
descriptor's max_message_length is taken verbatim from the connector
(gateway/relay/descriptor.py from_json) and assigned straight to the
adapter's MAX_MESSAGE_LENGTH (gateway/relay/adapter.py), and 0 is a
documented "no limit" value there.
Guarantee forward progress: floor headroom at 1, and floor the
final split_at at max(1, _cp_limit) so at least one codepoint is always
consumed per iteration. Normal splitting is unaffected (both floors only
bite when the budget is already degenerate).
Adds regression tests that run truncate_message on a worker thread and
fail if it doesn't return: max_length 0/1/2 terminate and preserve every
character, and the utf16 emoji case terminates too.
Follow-up to the salvaged #65636: if the dedup UPDATE or the retried
CREATE INDEX raises, log and continue — the unique title index is an
optimization and must not block SessionDB initialization.
A fallback chain entry can name its API key via key_env (or the
api_key_env alias) per the fallback-providers docs, but only the gateway
path resolved it — TUI/desktop, cron, and CLI setup fallbacks ignored it,
so a fallback provider whose key lives in a non-standard env var never
resolved on those surfaces.
Centralize the inline-api_key-then-key_env lookup in
hermes_cli/fallback_config.resolve_entry_api_key() and use it at all four
fallback resolution sites (tui_gateway, cron scheduler, gateway runner,
CLI setup mixin); the CLI mixin also gains the base_url passthrough the
other surfaces already had.
Salvaged from PR #43861 (surgical reapply — the original branch predates
the #65264 fallback restructuring).
Rebuilt from PR #61283 onto the /p/<profile>/ routing world (7aa21e336):
_profile_scope(None) now enters the DEFAULT profile's runtime scope when
multiplexing is active instead of returning nullcontext(). api_server is
a port-binding platform living on the default profile, so plain requests
(no /p/ prefix) are the primary path — with fail-closed get_secret they
crashed with UnscopedSecretError on the first credential read (#61276).
All three wrapped call sites (chat-completions executor, /v1/runs agent
construction and _run_sync) inherit the fix through the one seam.
Single-profile gateways keep the no-op. Regression tests ported from
the original PR to the _profile_scope seam.
Fixes#61276
Subset of PR #61985: _make_adapter_auth_check gains a profile_name
parameter and secondary-profile adapters (started in
_start_one_profile_adapters) bind it, so the auth callback's
SessionSource resolves the routed profile's adapter and pairing store
instead of silently falling back to the default profile. This is the
gap left open by the #65629 merge — adapter-internal auth checks (e.g.
Slack thread-context fetch) fire outside the wrapped message handler.
The PR's authz_mixin.py hunks are dropped: main's _auth_env (merged via
PR #65629) already covers the scoped allowlist reads they targeted.
With the mode-conditional check centralized, default (websocket) Feishu
no longer counts as port-binding in the secondary batch report — pin the
fixture to connection_mode=webhook so the test still exercises the
multi-platform report path.
Feishu was unconditionally listed in _PORT_BINDING_PLATFORM_VALUES,
causing the multiplexer to reject ALL Feishu secondary profiles. But
Feishu in websocket mode (the default) uses an outbound WebSocket
connection and does NOT bind an HTTP port — only webhook/callback mode
needs a listener.
Add _platform_binds_port() helper that checks connection-dependent
platforms (currently only Feishu) against their actual config before
raising MultiplexConfigError. Feishu websocket profiles are now allowed;
Feishu webhook profiles still raise as before.
Fixes#52563
Review follow-up: honor source.profile and enter _profile_runtime_scope
only when gateway.multiplex_profiles is on, mirroring the gating in
_run_agent, _reset_notice_session_info, and _resolve_profile_for_key.
When multiplexing is off (the default) a stamped source is ignored and
/profile reports the active profile and default home, byte-identical to
before this PR.
The stamped-source test now enables multiplexing (it previously
exercised the ungated path under the default config), and a new
regression asserts the stamp is ignored when multiplexing is off.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On a multiplexed gateway the process-level active profile is always the
multiplexer's own (usually "default"), so /profile answered "default" in
every chat regardless of which profile actually served it — making
per-chat persona routing look broken when it was working.
Report source.profile (stamped by the /p/<profile>/ URL prefix, a
per-credential adapter, or a room->profile map) and resolve the
displayed home under that profile's runtime scope, mirroring the scoped
/reset banner (#59003). Unstamped sources fall back to the active
profile and default home, so single-profile gateways are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>