Commit Graph

36993 Commits

Author SHA1 Message Date
teknium1
6ed8bea80b fix(agent): refusal handler reports Anthropic stop_details instead of "(no text)"
A stop_reason=refusal from Anthropic's streaming classifier arrives with an
empty body; the reason lives on the message's stop_details (category and,
when present, an explanation). handle_content_policy_refusal now uses that
explanation (or the category) as the refusal text shown to the user and
recorded in the content_policy_blocked error, and the warning line carries
native_stop_reason + stop_details so a classifier refusal is
distinguishable from a Bedrock guardrail block (both map to content_filter;
that mapping is unchanged because turn_response_check routes it here).

The main-loop Anthropic stream returns the SDK's get_final_message()
snapshot, which copies only stop_reason/stop_sequence from message_delta;
the accumulator now keeps stop_details and _call_anthropic restores it on
the snapshot, mirroring the aux-client path fixed in the previous commit.

Part of #113689. The native-stop-reason logging follows the direction of
PR #113699 by @liuhao1024, minus the refusal content block, which the
Messages API does not emit.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-09-18 09:52:36 -07:00
teknium1
c5cd426a03 test(delegation): trim the goal-duplication test to its two invariants
Asserting the literal "YOUR TASK" heading is absent pins wording, not
behaviour; the contract is that the goal text itself is not repeated in
the system prompt.
2026-09-18 09:52:36 -07:00
Kyzcreig
92392218bf fix(anthropic): carry stop_details from the Messages response into provider_data
Anthropic reports the reason for a stop_reason=refusal on the message's
stop_details (category + optional explanation), not in a content block.
The pinned SDK (0.87.0) exposes it only as an extra field and its stream
accumulator copies just stop_reason/stop_sequence from message_delta, so
the final snapshot loses it. Capture it while consuming the stream and
surface it as provider_data["stop_details"] so the refusal handler can
log and show it instead of "(no text)".

Cherry-picked from PR #108682 (the transport + adapter hunks only; the
compression half stays with that PR).
2026-09-18 09:52:36 -07:00
KoNit-K
0aa178736a fix(delegation): avoid duplicating child goals in system prompts 2026-09-18 09:52:36 -07:00
teknium1
119e30627b fix(cli): Super+<printable> under modifyOtherKeys types the character
Ghostty encodes Super+o as ESC[27;9;111~ (Super+Shift as modifier 10) once
modifyOtherKeys=2 is pushed; the CLI has no Super bindings, so the sequence
leaked as literal escape text (#114242). Map the tilde form for modifiers 9
and 10 over the printable range to chr(cp), the same produced-codepoint rule
the Shift rows use and what the Ink TUI already does.
2026-09-18 09:52:03 -07:00
teknium1
df44a6e4df chore: map contributor email for @sanastasiou (Co-authored-by on #114828) 2026-09-18 09:52:03 -07:00
teknium1
0f5c208e9e docs(cli): note which produced codepoints xterm/Ghostty tilde-encode
Answers the reporter of #114242: shift+digit symbols like ! still arrive as
plain text because xterm only tilde-encodes produced codepoints 0x40-0x7E.
2026-09-18 09:52:03 -07:00
teknium1
066da16c6b chore: map contributor email for @ssep4u (salvage #112526) 2026-09-18 09:52:03 -07:00
teknium1
df97ee7956 fix(cli): cite xterm's key table for the Shift+symbol mapping; trim tests
Follow-up to the salvaged #112526 hunk. xterm's own modified-keys reference
(us-pc105 table) sends Shift+[ as ESC[27;2;123~ — the tilde-form codepoint is
the PRODUCED character, resolved through the user's keymap — and Ghostty
follows that spec. The "layout-specific, deliberately NOT mapped" rationale
from b353ac39a2 described Kitty CSI-u (unshifted codepoint), which never uses
the tilde spelling; the comments and docstring now say so.

Tests trimmed to two invariants: the tilde form parses to the produced
character while the CSI-u twin of the same codepoint stays unmapped, and the
KeyPress data carries the character rather than the raw escape.

Co-authored-by: Stefanos Anastasiou <stef.anastasiou@protonmail.ch>
2026-09-18 09:52:03 -07:00
SSep
e368bff165 fix(cli): map Shift+symbol keys under modifyOtherKeys=2
When the terminal runs in modifyOtherKeys=2 (e.g. Ghostty or xterm,
pushed by _enable_extended_enter_keys), Shift+symbol keystrokes such as
Shift+- ('_'), Shift+= ('+'), Shift+[ ('{') are encoded as the tilde
form ESC[27;2;<produced_cp>~.

Previously, only Shift+letter was mapped for modifier 2. Unmapped
Shift+symbol keys were not recognized by ANSI_SEQUENCES, causing the
escape prefix to be consumed and the raw remainder (e.g. [27;2;95~) to
leak into the prompt.

Map the modifyOtherKeys tilde form ESC[27;2;<cp>~ -> chr(cp) for printable
ASCII codepoints (33..126). Unlike Kitty CSI-u, the tilde-form codepoint
is the layout-resolved produced character, making this layout-safe.
Existing control mappings (such as Shift+Enter) are preserved via setdefault.
With install_keypress_data_normalization(), the produced character is
inserted into the buffer instead of raw escape bytes.

Closes #102683
Relates to #93633, #94921
2026-09-18 09:52:03 -07:00
teknium1
5bb41cecef fix(desktop): name the REST hide path in the hide-sweep comments
The sweep and bot-state comments still said room member sessions are hidden
through a core session.set_hidden RPC; the code hides them through the source
primary's REST PATCH /api/sessions/{id} via host.setPersistedSessionHidden
(a 404 prunes the seat). Comment-only.
2026-09-18 09:51:32 -07:00
teknium1
8c5b66b6a7 fix(tui-gateway): session.set_hidden no longer logs a stored-id hide as a rejected RPC
session.set_hidden is two-tier by design: live runtime id first, then a stored id/key
resolved in the profile db (the Bot Mode sweep and any plugin reconciling sessions it
owns hold stored ids for chats that are not live). The first tier went through
_sess_nowait, which logs "session-scoped RPC rejected: … not in memory (detached/reaped
runtime; client should resume the stored session)" — a warning meant to make a vanished
prompt.submit diagnosable — for every hide that was then fulfilled from the db. A
startup sweep over a handful of stored ids thus wrote a burst of false "rejected" lines
and buried the real stale-runtime-id signal (#114694).

Look the live session up quietly; an id neither live nor stored still returns 4001.
2026-09-18 09:51:32 -07:00
KoNit-K
8b5e0ce0e8 fix(desktop): prune stale bot session references 2026-09-18 09:51:32 -07:00
teknium1
1d46d0305b fix(cli): --help lists gateway status and the --profile long form; trim epilogue tests
Follow-up to the salvaged #114497 rows: the issue asks for the profile-scoped
gateway lifecycle, and `status` is the verb an operator reaches for right
after start/stop, so it joins the epilogue; the `-p` row now names the
`--profile` long form too. The install row moves next to its siblings so
the gateway verbs read as one block.

Tests are trimmed to two invariants on the RENDERED help (the wiring, not
the constant): the profile-scoped form is documented while `-p` stays a
pre-argparse flag (never a parser option), and every gateway service verb
is listed.
2026-09-18 09:50:52 -07:00
kokhlo
f1196428dc test(cli): assert the rendered --help carries the epilogue rows 2026-09-18 09:50:52 -07:00
Konstantin Khlopkov
bd72c4205d fix(cli): document -p <profile> and the gateway service verbs in --help 2026-09-18 09:50:52 -07:00
teknium1
20e48f87c0 fix(kanban): keep diagnostics --json a list; allowlist rides as a trailing home-scope row
The slash-command surface and tests/hermes_cli/test_kanban_review_surfaces.py
index payload[0]["diagnostics"], so wrapping the list in an object broke an
existing consumer. Per-task rows are byte-identical to before; the resolved
kanban.dispatch_profiles value is appended as {"task_id": null,
"dispatch_profiles": ..., "diagnostics": []}.
2026-09-18 09:50:19 -07:00
teknium1
6545fb8246 fix(kanban): show the resolved dispatch_profiles allowlist in hermes kanban diagnostics
Closes the third ask of #113620: an operator on a shared board can see what
this home believes it may claim. Text output gains a leading
`kanban.dispatch_profiles: <any | names | none (fail-closed: …)>` line;
--json now returns {"dispatch_profiles": ..., "tasks": [...]} with the
per-task rows unchanged. Resolution reuses _dispatch_profile_allowlist so the
CLI and the dispatcher can never disagree.
2026-09-18 09:50:19 -07:00
teknium1
8994bb6c75 fix(kanban): warn when kanban.dispatch_profiles is present but empty; rename test ids
The bare-key / empty-string spelling now claims nothing (fail-closed) — say
so once in the log, mirroring the unreadable-config WARNING, so a home that
copied the old `dispatch_profiles: null` example is not silently idle after
upgrading. Test ids renamed to `empty_string` / `bare_key` to state what
each case pins (#113620).
2026-09-18 09:50:19 -07:00
teknium1
c432cd2723 fix(kanban): warn when the dispatch allowlist is unreadable; document the fail-closed spellings
Follow-up to the salvaged #113623 hunk: a config read that raises now logs a
WARNING naming the error before claiming nothing, so a corrupt config on a
shared board is visible instead of silently changing this home's claim
scope. Docs: the example no longer shows `dispatch_profiles: null` as the
"any profile" default (null is now fail-closed like `[]`); omit the key for
"any". Trimmed the contributor's positive-control test (the two invariant
tests cover the fixed class; the listed-profile control is the live probe).

Fixes #113620
Salvages #113623
2026-09-18 09:50:19 -07:00
KoNit-K
e3d377a5a5 fix(kanban): fail closed on invalid dispatch allowlist 2026-09-18 09:50:19 -07:00
teknium1
eeb382837a test(cli): pin the main.py HERMES_HOME normalizer via a raw-reader observable
The subprocess test only asserted 'config path', which hermes_constants
already expands; reverting the main.py hunk stayed green. Assert the env var
itself after importing hermes_cli.main so the entry-point hunk is covered.
2026-09-18 09:49:46 -07:00
teknium1
bfa2c49196 fix(gateway): route identity-file HERMES_HOME readers through get_process_hermes_home
python -m gateway.run never passes through the CLI's
normalize_hermes_home_env(), so the raw Path(os.environ['HERMES_HOME'])
readers for PID/lock/status, lifecycle ledger and heartbeat files kept a
literal ~ relative. Fallbacks are unchanged; the forensics marker probe
expands inline to stay import-light.
2026-09-18 09:49:46 -07:00
teknium1
235622a98d fix(desktop): expand a literal ~ in HERMES_HOME before resolving the backend home
normalizeHermesHomeRoot ran path.resolve() on the raw env value, so a
fish-style HERMES_HOME='~/.hermes' became <cwd>/~/.hermes (absolute) and was
handed to the Python backend as HERMES_HOME, where no expansion could recover
it. Expand a leading ~ against os.homedir() first; homedir is injectable for
the test.
2026-09-18 09:49:46 -07:00
teknium1
fc94ff56e1 fix(cli): expand HERMES_HOME once at process entry; sweep raw readers
Follow-up to the salvaged hermes_constants expansion (#109212): the CLI has
~30 raw `os.environ["HERMES_HOME"]` readers (fast --version path, early
display.interface probe, profile re-home, dotenv loader, subprocess-home
helper) that never go through get_hermes_home(). A literal `~` (fish, or
any quoted value) left them resolving `~/.hermes` against cwd while the
resolver now expands it, so the process would disagree with itself.

Normalize the env var once, at the earliest point of hermes_cli/main.py
(stdlib-only, before the fast paths), and expand it in the one raw
hermes_constants reader (`_profile_home_path`). A relative value that is
not tilde/variable-shaped is deliberately left alone: rejecting it would
break `HERMES_HOME=./tmp-home` in tests and CI for no user-facing gain.

Tests: real-CLI subprocess with HERMES_HOME='~/.x' under a fake HOME asserts
`config path` lands in the fake home and that no literal `~` directory
appears under cwd (the reporter's acceptance criterion, #114353), plus a
unit test for the normalizer. Docs: environment-variables reference.
2026-09-18 09:49:46 -07:00
KoNit-K
138ae22a24 fix(skills): expand variables in Hermes home paths
Replay #109212 onto latest main.
2026-09-18 09:49:46 -07:00
teknium1
a37293a2ce fix(compression): probe aux feasibility before the first compaction; clear stale clamp notice on un-clamp
A fresh AIAgent whose main model already had the larger window still got
its first compaction at the main-window threshold, and only then did the
lazy probe in compress_context clamp the trigger to the aux window — so
the oversized compaction request was already sent (#114707 steps 1-6;
every instance recycle restarted the cycle).

ensure_compression_feasibility_checked(agent, estimated_tokens) runs the
probe once a request first reaches MINIMUM_CONTEXT_LENGTH — the smallest
window any summariser may have — from both compaction gates
(run_preflight_compression, compress_after_tool_results), so the clamp
lands before the main-window threshold fires. Requests below that size
stay probe-free, preserving the cold-start deferral of 6cb9917c73
(#28957); a failed probe leaves the latch unset for the lazy probe.

Symmetric un-clamp: when a re-probe finds the aux fits again,
_compression_warning / _last_feasibility_notice are cleared so
replay_compression_warning does not resend an "auto-lowered" notice for
a session that is no longer clamped.
2026-09-18 09:49:14 -07:00
teknium1
c97c7dbdb9 fix(compression): fallback/restore re-probe only refreshes an existing feasibility verdict
The eager aux feasibility re-probe ran on every fallback activation and primary restore, resolving an
auxiliary client inside the provider error path. Sessions that never probed gain nothing from that (the
compaction-time probe still owns the first verdict) and the extra client resolution broke the fallback
chain on stubbed resolvers (tests/agent/test_provider_fallback.py, test_fallback_api_mode_preservation.py,
test_fallback_429_after_timeout.py). Explicit /model switches keep the unconditional re-probe (#114707).
2026-09-18 09:49:14 -07:00
teknium1
9f5b7ea02e fix(compression): keep the aux ceiling and re-probe feasibility on every main-runtime change
The aux-window clamp installed by _lower_threshold_to_aux_context() was a one-time
assignment to threshold_tokens; ContextCompressor.update_model() recomputed the trigger
from the main model and discarded it, and the _compression_feasibility_checked latch was
never reset, so after a mid-session switch to a larger main model the trigger sat at the
main-model value (450K) while the pinned summariser accepted 272K (#114707).

- ContextCompressor holds the aux window as a durable _aux_context_ceiling that
  _apply_threshold_tokens_cap() honours on every recomputation; update_model() voids it
  only when the main runtime changes (an "auto" aux route follows the main model).
- revalidate_compression_feasibility(agent) resets the latch and re-probes eagerly at
  every runtime change: switch_model (outside the rollback guard), fallback activation
  and primary restore. Symmetric: a runtime whose aux fits restores the main trigger.
- Feasibility notices emit once per distinct verdict so /model --once restores and
  fallback cycles do not re-announce an unchanged verdict.
- Rewrites the switch-time hunk salvaged from #114710: unconditional, outside the
  rollback try, so a catalog hiccup never undoes a good switch. Test kept and extended.

Co-authored-by: KoNit-K <konit.block@protonmail.com>
2026-09-18 09:49:14 -07:00
KoNit-K
8d51d488a4 fix(agent): retain auxiliary compression limit on model switch 2026-09-18 09:49:14 -07:00
teknium1
957bf63dc5 fix(kanban): dashboard /orchestration docstring no longer claims decomposer parity
The decomposer now prefers the root card's assignee for unrouted children and
falls back to the active profile only for cards without an assignee, so the
endpoint's "fallbacks filled the same way the decomposer does" was stale. State
what the endpoint actually resolves; no routing change.
2026-09-18 09:48:41 -07:00
liuhao1024
1ea94b3745 fix(kanban): decomposed cards fall back to the root task's assignee, never the dispatcher's own profile
The decomposer resolves both `kanban.default_assignee` (unrouted children)
and `kanban.orchestrator_profile` (the root after fan-out) as
"explicit config, else the active profile". The active profile is whatever
HERMES_HOME hosts the dispatcher — in the report an incognito `private`
profile with no credentials — so every unrouted child AND the root card
itself were silently re-owned by a profile that can never do the work.

The resolver now tries the root task's own assignee before the active
profile: explicit config → root assignee (if it names an existing profile)
→ active profile. Slim redo of #114303's decompose hunk at the resolver so
it covers the orchestrator fallback too; tests and docs from #114303.

Fixes #114294
Salvages #114303

Co-authored-by: Christopher <210261288+Christopher-Schulze@users.noreply.github.com>
2026-09-18 09:48:41 -07:00
teknium1
27a30d8515 fix(setup): xAI TTS wizard checks XAI_API_KEY before OAuth to match runtime
_tts_xai_step still announced OAuth-first ordering (docstring and printed
message) while the synthesis and availability paths now prefer an explicit
XAI_API_KEY over the subscription OAuth bearer. Check the key first and fix
the copy; regression test under tests/hermes_cli.
2026-09-18 09:47:06 -07:00
teknium1
ca3c425627 fix(tts): pin _xai_requirements to key-first credential resolution in tests
Reverting tools/tts_tool.py to main left tests/tools/test_tts_streaming.py
green, so the availability probe (_BUILTIN_REQUIREMENTS['xai']) could regress
to OAuth-first silently. Fold one assertion into the existing
prefer_api_key test using the same fake tools.xai_http module.
2026-09-18 09:47:06 -07:00
teknium1
d7865939af fix(tts): xAI TTS availability probe prefers XAI_API_KEY like the synthesis paths
`tools/tts_tool.py::_xai_requirements` (the provider check text_to_speech_tool
dispatch consults) still resolved xAI credentials OAuth-first, while both TTS
synthesis paths now pass `prefer_api_key=True`. Truthiness is unchanged, but a
configured key no longer routes the availability probe through the OAuth pool
(pool select / refresh) for a bearer the metered `/v1/tts` endpoint rejects.

Docs: the streaming-TTS provider table now states the ordering.

Part of the #113727 salvage of #113728 (@beardthelion); sibling site the PR
missed.

Co-authored-by: beardthelion <beardthelion@users.noreply.github.com>
2026-09-18 09:47:06 -07:00
beardthelion
3b0aae77ee fix(tts): prefer explicit XAI_API_KEY over subscription OAuth in streaming TTS
The sync xAI TTS path resolves credentials with prefer_api_key=True
(#87045/#88040): the subscription OAuth bearer authorizes chat but
returns 403 on the metered TTS API. The WebSocket streaming path
(XAIStreamer, salvaged from #47588 before that ordering existed) calls
resolve_xai_http_credentials() with the default OAuth-first order at
both sites, so a user holding both credentials gets the 403ing bearer,
and XAIStreamer.available() reports True on a credential that cannot
stream.

Pass prefer_api_key=True at both sites — identical availability
semantics (OAuth remains the fallback), correct preference order, parity
with _generate_xai_tts.
2026-09-18 09:47:06 -07:00
teknium1
66d79311e1 fix(api_server): resolve the model_routes alias for the in-process wake turn
The HTTP wake self-post resolves the virtual model's model_routes entry via
_select_request_route before running the turn; the in-process wake passed
route=None, so an aliased served profile woke on the global default route.
Resolve it the same way and pass route plus the request overrides.
2026-09-18 09:46:35 -07:00
teknium1
76257d1fd2 fix(api_server): move the in-process wake turn body to api_server_runs
run_internal_session_turn belongs with the run machinery that already owns
_resolve_live_session_id, not in the api_server.py facade. Pure move: the
adapter keeps a thin delegating method, behaviour unchanged.
2026-09-18 09:46:35 -07:00
teknium1
d8edd60399 fix(gateway): wake a served profile's api_server session in-process for background-process completions
A served (multiplexed) profile's api_server turn binds the raw session id
as its session key, so its watch/completion event names no profile and the
wake path self-posted it unprefixed with the primary key — resuming the
session in the DEFAULT profile's store — while an event whose source did
name a route-only served profile resolved no adapter and was deferred
forever.

_self_post_api_server now proves ownership through the served profile's
own session store (the rung the Kanban notifier already applies), scans
served stores when the raw event carries no hint, runs the wake under that
profile's scope via deliver_wake(profile=...), and fails closed for a hinted
profile that does not own the session. The ownership helper moves to
gateway/wake.py so both callers share one function.
2026-09-18 09:46:35 -07:00
teknium1
5dd426b774 test(kanban): trim the served api_server wake suite to two invariants
Replace the fifteen scenario tests from #114680 with two invariant tests:

1. notifier tick: the served profile's owned session wakes in-process under that
   profile's scope with no HTTP self-post and one shared adapter; unknown / foreign-store /
   other-stamped sessions, unserved profiles and a connected-secondary boundary all fail
   closed; the default profile's own api_server subscription still HTTP self-posts.
2. adapter: `run_internal_session_turn` binds the proven profile, adopts the compression
   tip, delivers history as wake-capable, restores the request binding, and refuses both
   a session outside the owner's store and a missing profile.
2026-09-18 09:46:35 -07:00
teknium1
8be66c558d fix(kanban): in-process served wake requires the proven profile; drain fails at once
Follow-up to the salvaged #114680 (@phoebsie):

- `APIServerAdapter.run_internal_session_turn` takes the owning profile as a required
  argument and raises without it. The dropped fallback (ContextVar, then the process-active
  profile) had no caller and would have let a missing ownership proof silently bind a
  wake to whatever profile the process happened to run as — the exact class #114679
  reports.
- A draining gateway refuses the in-process wake immediately, mirroring the HTTP
  self-post's 503 (>= 400 → raise → the notifier rewinds the claim and the next tick
  retries). The concurrent-run cap keeps the 429-style backoff.
- Contributor attribution mapping for builder@phoebie.local -> @phoebsie.
2026-09-18 09:46:35 -07:00
Phoebie Builder
eeada33fa1 review: retry the cap, adopt the compression tip, scope the in-process route to multiplex
Independent review findings addressed:

- adopt the live continuation tip (api_server_runs._resolve_live_session_id)
  before the in-process wake, exactly as the HTTP self-post does: a rotated
  (compressed) origin must be woken on the transcript that is actually live,
  not the retired parent slice (CompressionSessionClosedError otherwise);
- retry a saturated concurrent-run cap with the HTTP path's own backoff
  (gateway.wake._RETRY_DELAYS_SECONDS) instead of failing on the first check,
  so a busy listener no longer burns one notifier failure per tick toward the
  12-strike drop of a durable subscription;
- keep the historical HTTP self-post on a standalone (non-multiplex) gateway:
  the in-process route is now chosen by a served-profile helper that requires
  multiplex_profiles, and the adapter is told the authorized profile explicitly
  instead of re-deriving it from HERMES_HOME;
- skip the ownership store read for the default profile (the fallthrough already
  authorizes it) and run the delivery-time recheck off the event loop;
- tests: standalone named-profile gateway, missing store, failed-connect
  boundary, adapter without in-process delivery, platform-wide api_server route
  vs the default profile's destinations, cap retry/exhaustion, compression tip,
  and the profile handed to the adapter (8 -> 15 cases).
2026-09-18 09:46:35 -07:00
Phoebie Builder
5579fd5cdf fix(kanban): wake a served profile's api_server session in-process under its own scope
A Kanban notify+wake subscription whose destination is a raw session id on the
shared api_server (platform=api_server, chat_id=<session id>, notifier_profile=
served secondary) was never delivered under gateway.multiplex_profiles: no
profile_routes entry can anchor a session id, so _adapter_for_subscription()
returned None and _claim_for_sub skipped the row before claiming (cursor frozen,
no delivery attempt). A platform-wide api_server route is not the fix — it
matches every api_server destination, so the default profile's own api_server
subscriptions would fail closed instead.

The wake leg had the same shape: _self_post_chat_completion() always POSTs to
the unprefixed /v1/chat/completions with the PRIMARY adapter's key, so a served
profile's wake turn would resume the session in the default profile's store.
/p/<profile>/v1/... requires that profile's own API_SERVER_KEY, which a
route-only profile legitimately does not have.

Authorize the shared adapter only when the subscription's session id is a row in
the served profile's own state.db stamped for that profile (NULL legacy stamp =
the store's own profile, the same rule the dashboard session routes apply) and
run the wake in-process under that profile's runtime scope through a new
APIServerAdapter.run_internal_session_turn(). Unknown session, foreign stamp,
unserved/deleted profile and unreadable store all fail closed; the concurrent-run
cap defers the wake (cursor rewind, retry) instead of bypassing it.
2026-09-18 09:46:35 -07:00
liuhao1024
e0c078c958 test(agent): assert pool purity instead of echoing the ambient credential dict
Drop the raw reader-is-None assertion: on a non-hermetic host its failure
message would print the live credential dict into the test output. The
manual-only sources assertion below already pins the invariant the test
cares about, and still fails if the stub ever stops holding (#114431).

Co-authored-by: Nick Coleman <154543891+Digitally-Challenged@users.noreply.github.com>
2026-09-18 09:46:03 -07:00
liuhao1024
3bc6250664 test(agent): keep the auxiliary cache suite hermetic on hosts with an ambient Claude Code login
The isolated_home fixture only redirects HERMES_HOME, but the borrowed
Claude Code reader (agent/anthropic_credentials.read_claude_code_credentials)
still consults ~/.claude/.credentials.json and the macOS Keychain. On a
host with an ambient login, _seed_anthropic_singletons upserts a second
un-cooled-down pool entry, peek() selects it during the cooldown test,
and _pool_cache_hint returns 'anthropic:<id>:<digest>' instead of
'anthropic::<digest>' (fails exactly as reported in #114424).

Stub the reader to None in the fixture and add a regression test that
asserts the stub holds, the pool stays manual-only, and the hint keeps
the 'anthropic::' form.

Fixes #114424
2026-09-18 09:46:03 -07:00
teknium1
9dd36c56cf fix(mcp): remote-session OAuth hint names the configured redirect_host
The loopback SSH hint hard-coded http://127.0.0.1:<port>/callback while a
pre-registered client (Asana) redirects to http://localhost:<port>/callback,
the URL the app must register verbatim. Thread redirect_host from the oauth
config into the redirect handler so the hint prints the same host the
provider will use. Live pass copy nit #6 on #113907.
2026-09-18 09:45:32 -07:00
teknium1
b0cd35e259 fix(mcp): dashboard/Desktop Authorize honours a pre-registered client's pinned loopback redirect
A no-DCR entry (client_id + redirect_port, e.g. the Asana manifest) has
http://localhost:<port>/callback registered with the vendor, which matches
redirect URLs exactly; the dashboard flow overrode it with its own callback
URL, so the in-app Authorize button could never complete for such entries.
The pinned loopback listener now wins (over the dashboard URL and any cached
registration URI); the dashboard flow only publishes the authorization URL,
and the stdin paste reader stays off under a dashboard flow. Docs/post_install
say the approving browser must run on the Hermes machine.
2026-09-18 09:45:32 -07:00
teknium1
e8aa69c1a1 fix(mcp): a running server adopts a changed config.yaml definition on rebuild
run() kept the dict it was started with, so a url/oauth change made while
the process ran (catalog migration, dashboard re-auth to the new endpoint)
left the loop probing the OLD URL — and MCPOAuthManager, seeing a different
URL, evicted the fresh provider the dashboard had just authorised for the
new one, dropping its tokens/DCR client (#113907, atom b). Before each
transport rebuild a remote server re-reads its definition and rebinds when
url/auth/oauth/headers/transport changed.
2026-09-18 09:45:32 -07:00
teknium1
c553df915c fix(mcp): Asana catalog installs a working V2 pre-registered OAuth client
The V1 beta server https://mcp.asana.com/sse is retired and Asana's V2 server
(https://mcp.asana.com/v2/mcp, Streamable HTTP) has no Dynamic Client
Registration: every client must be an Asana "MCP app" the user registers in
the developer console. The salvaged manifest fixed the URL and documented the
manual `oauth:` block; this commit makes the catalog install itself produce
that block so no hand-edit of config.yaml is needed.

- hermes_cli/mcp_catalog.py: manifests may pin a closed `auth.oauth` mapping
  (client_id, client_secret, redirect_host, redirect_port, scope) that
  `_build_server_config` copies to `mcp_servers.<name>.oauth`; every `${VAR}`
  it references must be declared in `auth.env`, mirroring the api_key header
  contract, so a placeholder can never reach the token endpoint as a literal.
  `install_entry` now prompts `auth.env` for OAuth entries too (dashboard and
  Desktop already render `required_env` regardless of auth type).
- optional-mcps/asana/manifest.yaml: declare ASANA_CLIENT_ID/SECRET, pin the
  client + `http://localhost:27890/callback` (Asana matches the redirect URL
  exactly), and rewrite post_install around the MCP-app registration steps.
- tests: shipped-catalog invariant (no manifest installs the retired `/sse`
  URL; the Asana client credentials are declared `${VAR}` refs; callback
  pinned) + install-path invariant for the new `auth.oauth` block, including
  the undeclared-reference rejection.
- docs: catalog section on entries that need a user-owned OAuth app.

Live: `hermes mcp install asana` + `hermes mcp login asana` under a temp
HERMES_HOME. Before: config `url: …/sse`, no oauth block, authorize URL on the
V1 server (mcp.asana.com/authorize) with a DCR client and 127.0.0.1 redirect.
After: config `url: …/v2/mcp` + oauth `${ASANA_CLIENT_ID}` refs, .env holds
the values, authorize URL on app.asana.com/-/oauth_authorize with the
configured client_id, redirect_uri=http://localhost:27890/callback and
resource=https://mcp.asana.com/v2/mcp — the flow Asana's guide documents.
2026-09-18 09:45:32 -07:00
KoNit-K
7954418d90 fix(mcp): migrate Asana catalog to V2 2026-09-18 09:45:32 -07:00