Commit graph

15820 commits

Author SHA1 Message Date
otsune
3fccd698fd feat(kanban): attachment toolset + CLI to match the dashboard surface
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>
2026-07-16 07:33:14 -07:00
Teknium
14f023cd00 fix(api_server): run platform event verifiers off-loop and fail closed
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.
2026-07-16 07:31:05 -07:00
aeyeopsdev
a7ec1b6e39 fix(google-chat): cache callback token cert fetches 2026-07-16 07:31:05 -07:00
aeyeopsdev
1305a690e0 feat(gateway): route platform HTTP event callbacks 2026-07-16 07:31:05 -07:00
Teknium
03c0b00f45
fix(usage): read DeepSeek's native prompt_cache_hit_tokens cache field (#65678)
Some checks are pending
CI / Detect affected areas (push) Waiting to run
CI / Python tests (push) Blocked by required conditions
CI / Python lints (push) Blocked by required conditions
CI / JS & TS checks (push) Blocked by required conditions
CI / Docs Site (push) Blocked by required conditions
CI / Deny unrelated histories (push) Blocked by required conditions
CI / Check contributors (push) Blocked by required conditions
CI / Check uv.lock (push) Blocked by required conditions
CI / package-lock.json diff (push) Blocked by required conditions
CI / Lint Docker scripts (push) Blocked by required conditions
CI / Build&Test Docker image (push) Blocked by required conditions
CI / Supply-chain scan (push) Blocked by required conditions
CI / OSV scan (push) Waiting to run
CI / All required checks pass (push) Blocked by required conditions
CI / CI timing report (push) Blocked by required conditions
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Waiting to run
auto-fix lint issues & formatting / Apply patch (push) Blocked by required conditions
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.
2026-07-16 07:29:53 -07:00
Teknium
7edaaf4682 chore: map nnnet noreply email in release AUTHOR_MAP (PR #36024 salvage) 2026-07-16 07:28:22 -07:00
Teknium
558fcb6146 test(dashboard): cover theme bootstrap CSS render + _serve_index injection
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
2026-07-16 07:28:22 -07:00
Teknium
01bab394cd fix(dashboard): theme bootstrap emits real bundle CSS vars; canvas rule flows through vars
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.
2026-07-16 07:28:22 -07:00
nnnet
72562be961 fix(dashboard): inline critical-CSS bootstrap for user themes to mitigate flash
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.
2026-07-16 07:28:22 -07:00
Teknium
2fba721ab0 chore(release): map antydizajn's commit email for PR #36043 salvage attribution 2026-07-16 07:28:07 -07:00
Teknium
adb647269a fix(auxiliary): apply review fixes to #36043 — guard named-custom routing, drop dead key assignment, tighten Palantir host match
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.
2026-07-16 07:28:07 -07:00
antydizajn
367d3758d5 fix(auxiliary): route custom:<name> through named-provider arm + Palantir Bearer auth
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
2026-07-16 07:28:07 -07:00
Teknium
a6d9d1d2cf fix(security): widen non-ASCII compare_digest crash fix to all sibling sites
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).
2026-07-16 07:22:24 -07:00
Drexuxux
4ccb232af9 test(webhook): cover the Svix v1 branch in the non-ASCII signature regression
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.
2026-07-16 07:22:24 -07:00
Drexuxux
1b69c47e97 fix(webhook): reject a non-ASCII signature header instead of crashing the endpoint
_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.
2026-07-16 07:22:24 -07:00
Drexuxux
efb6c21498 fix(api-server): reject a non-ASCII bearer token with 401 instead of crashing
_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.
2026-07-16 07:22:24 -07:00
Drexuxux
27b31bb7ff fix(relay): normalize a 0/negative max_message_length at the descriptor boundary
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.
2026-07-16 07:21:23 -07:00
Drexuxux
fbf5005a7e fix(gateway): stop truncate_message hanging on a pathologically small max_length
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.
2026-07-16 07:21:23 -07:00
Teknium
9fc8fe2176 fix(state): guard the duplicate-title repair so it can never abort DB open
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.
2026-07-16 07:20:16 -07:00
Tranquil-Flow
3990bdf551 fix(state): repair duplicate session titles without data loss on startup (#65602) 2026-07-16 07:20:16 -07:00
Rage Lopez
998e35313a fix(auth): honor per-entry key_env when resolving fallback providers
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).
2026-07-16 07:19:36 -07:00
Teknium
c3b2af95e3 test: accept profile_name kwarg in auth-check stubs 2026-07-16 07:17:55 -07:00
Teknium
bb853b2e95 chore(release): map rlaehddus302's email in AUTHOR_MAP (PR #61985 salvage) 2026-07-16 07:17:55 -07:00
giggling-ginger
6ff65c4d20 fix(gateway): scope default-listener api_server requests under multiplex
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
2026-07-16 07:17:55 -07:00
rlaehddus302
fef0b2d600 fix(gateway): scope secondary-adapter auth callback to its own profile
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.
2026-07-16 07:17:55 -07:00
Teknium
0cc9426c6d test: feishu port-binding report expects webhook mode after #52563 integration
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.
2026-07-16 07:17:55 -07:00
liuhao1024
9cb3569e97 fix(gateway): allow Feishu websocket mode in multiplex profiles
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
2026-07-16 07:17:55 -07:00
Jonny Kovacs
6b1267c2e4 fix(gateway): gate /profile source scoping on multiplex_profiles
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>
2026-07-16 07:17:55 -07:00
Jonny Kovacs
f7d6f099db fix(gateway): /profile reports the profile serving the source, not the multiplexer's
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>
2026-07-16 07:17:55 -07:00
cresslank
8191f621c3 fix(gateway): preserve multiplex profile in model picker 2026-07-16 07:17:55 -07:00
Christopher
dd9e75335c fix(gateway): skip port-conflicting multiplex profiles 2026-07-16 07:17:55 -07:00
Teknium
fe2d847aca test: valid-shape Telegram token in port-binding guard fixture
The #62803 branch predates PR #64636's Telegram token-shape validation
on the messaging platform PUT endpoint; align the new guard test's
fixture with the validated format.
2026-07-16 07:17:55 -07:00
PRATHAMESH75
e984a61306 fix(dashboard): reject port-binding channels on secondary multiplexed profiles
The Channels API (PUT /api/messaging/platforms/{id}) accepted and persisted
enabling a port-binding platform on a secondary profile while
gateway.multiplex_profiles is on — a config the gateway only rejects on its
next start, aborting startup with MultiplexConfigError for every multiplexed
profile.

Validate before any .env/config.yaml write and return 409 for the enable
attempt. Disabling and clearing env stay allowed so an already-invalid
profile can be repaired. The port-binding platform set moves to
gateway/config.py (PORT_BINDING_PLATFORM_VALUES) as the single source of
truth shared by gateway startup validation and the dashboard, so the two
policies cannot drift. Platform config mutations now get a names-only audit
log line.

Fixes #62791
2026-07-16 07:17:55 -07:00
teknium1
3366204474 chore: AUTHOR_MAP entry for doxe0x (PR #50786 salvage) 2026-07-16 07:11:21 -07:00
teknium1
d34cc4093a fix(mcp): per-flow callback waiters so concurrent OAuth flows cannot cross ports
_wait_for_callback still read the legacy module-level _oauth_port, so
with two concurrent OAuth flows, flow A's callback wait bound flow B's
port while A's redirect URI pointed at A's port — the callback-side
half of the cross-flow collision that #65622 fixed on the redirect
side. _make_callback_waiter(port) closes over each flow's resolved
port; both provider construction sites (build_oauth_auth and
MCPOAuthManager._build_provider) now wire per-flow waiters. The legacy
_wait_for_callback delegates for backwards compatibility.

Direction credit to @LeonSGP43 (#34280) and the #34260 analysis.
2026-07-16 07:11:21 -07:00
doxe0x
454d553d34 fix(mcp): report a clear error when the OAuth callback port is in use
_wait_for_callback catches OSError on bind with a comment claiming the port is
held by a server build_oauth_auth started, and promising to fall back to polling
it. build_oauth_auth never starts a callback server (this is the only listener),
so there is nothing to poll: the branch just raised a misleading "OAuth callback
timed out" when the real cause is a busy port (a concurrent login, a leftover
listener, or a fixed oauth.redirect_port that collided).

Fix the stale comment and raise an accurate, actionable message (names the port,
suggests freeing it or setting a free oauth.redirect_port), chained from the
original OSError. Behavior is otherwise unchanged. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 07:11:21 -07:00
Teknium
bda8bd76a8
fix(api_server): mark port conflict as non-retryable to stop infinite reconnect loop (#65665)
A bare False from connect() on EADDRINUSE made the gateway reconnect
watcher treat a port conflict as transient and retry forever at the
backoff cap — 1568+ retries over 5 days in a multi-profile production
setup, filling errors.log and leaking 2 ResponseStore fds per retry.
Set a non-retryable fatal error (api_server_port_in_use) in the bind
OSError branch so the platform drops from the reconnect queue;
recover via /platform resume api_server after changing the port.

Re-implementation of #52132 by @msalles1 against the direct-bind path
from #65621 (the pre-probe block their patch targeted no longer
exists). Their production diagnosis and test scenario preserved.
2026-07-16 06:29:09 -07:00
Teknium
75c878217d fix(moa): route per-slot reasoning effort through the canonical parser
_clean_reasoning_effort kept its own whitelist that stopped at 'max',
silently dropping 'ultra' from MoA slot configs. Route it through
hermes_constants.parse_reasoning_effort — the same one-source-of-truth
fix the salvaged commit applies to the gateway — so future effort
levels can't drift here either. Docs updated to list ultra.

Follow-up to salvaged PR #64012.
2026-07-16 06:14:58 -07:00
Fli
4ad5036a44 fix(gateway): surface extended reasoning efforts 2026-07-16 06:14:58 -07:00
Dan Schnurbusch
a7a05024c1 fix(auth): key reentrancy by auth store path
Remove the dynamic active-store holder so a profile context switch cannot inherit another auth store's lock depth and skip its kernel lock.
2026-07-16 06:14:56 -07:00
Dan Schnurbusch
6ef13af4be fix(cron): preserve resolver call compatibility
Only fallback resolution needs an explicit target model. Keep the primary resolver call compatible with existing callers and test doubles while retaining atomic provider/model fallback selection.
2026-07-16 06:14:56 -07:00
Dan Schnurbusch
679487b807 fix(auth): enforce complete fallback routes
Skip provider-only setup fallbacks, keep fallback selection explicit for resumed sessions, preserve configured primary identity for cron drift checks, and make the auth lost-update regression deterministic.
2026-07-16 06:14:56 -07:00
Dan Schnurbusch
f68fd80f41 fix(auth): preserve fallback routes and OAuth state
Switch provider and model together after setup-time auth failure. Serialize global auth-store merges under target-specific locks and preserve auth-to-shared lock ordering for profile OAuth refreshes.
2026-07-16 06:14:56 -07:00
teknium1
261b0f8240 docs(mcp): document redirect_uri proxied callbacks + redirect_host WAF workaround
Adds the proxied-callback option to the remote/headless OAuth section,
links the mcp-oauth-remote-gateway skill for fully headless gateways,
and documents the WAF pitfall behind redirect_host.
2026-07-16 06:14:34 -07:00
teknium1
56e06d7ee9 chore(release): AUTHOR_MAP entries for Florian Burka (flewe) and Peter Skaronis (Peterskaronis) 2026-07-16 06:14:34 -07:00
teknium1
f01f0f75fe test(mcp-oauth): redirect_host coverage + adapt salvaged tests to the non-interactive guard
- redirect_host tests: localhost swap, precedence of full redirect_uri,
  empty-value fallback, client-metadata propagation
- The salvaged redirect-uri hint tests predate the #57836
  OAuthNonInteractiveError guard; monkeypatch _is_interactive like the
  sibling tests in TestRedirectHandlerSshHint
2026-07-16 06:14:34 -07:00
Peter Skaronis
dc419d6e80 mcp_oauth: configurable redirect_host (WAF-safe localhost redirect URIs)
Reclaim.ai's AWS API Gateway WAF 403s any /oauth2/authorize request whose
query string contains a literal 127.0.0.1, so the SDK's hardcoded
redirect_uri made the browser flow impossible. New optional oauth config
key redirect_host (default 127.0.0.1, unchanged behavior) lets a server
entry use localhost instead.

Integrated into _resolve_redirect_uri so it composes with redirect_uri:
an explicit redirect_uri wins; redirect_host only rewrites the loopback
default's hostname.
2026-07-16 06:14:34 -07:00
Florian Burka
d0afcb125c test(mcp-oauth): cover configurable redirect_uri + fix misleading SSH hint
The PR added a configurable `redirect_uri` (proxy/Funnel callbacks) but
shipped without tests, and the loopback SSH-tunnel hint stayed hardcoded —
actively misleading the exact proxy user the feature targets.

- Extract `_resolve_redirect_uri(cfg, port)` so the client-metadata and
  pre-registration paths derive an identical callback (a mismatch makes the
  authorization server reject the redirect).
- Make `_redirect_handler` redirect_uri-aware: a configured proxy callback
  reaches this machine on its own, so it no longer prints the `ssh -N -L`
  loopback guidance. Wired via `functools.partial` — no new global state.
- Document `redirect_uri` in the config block.
- 14 new tests (red/green TDD): helper resolution + empty-string fallback,
  metadata + pre-registration for configured/default, AnyUrl normalization,
  no-client_id skip, client_secret combo, and both SSH-hint branches.

ruff clean · 81 passed (tests/tools/test_mcp_oauth.py) · ty baseline unchanged

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 06:14:34 -07:00
Florian Burka
6297634d22 fix(mcp-oauth): allow configurable redirect_uri for MCP OAuth flows 2026-07-16 06:14:34 -07:00
Teknium
7a78342ab3 chore: add AUTHOR_MAP entry for shuangxinniao (PR #40127 salvage) 2026-07-16 05:56:22 -07:00