Commit Graph

14240 Commits

Author SHA1 Message Date
Baophan00 3e35661263 fix(gateway): /steer fallback uses _enqueue_fifo to preserve FIFO head (#75164)
Two call sites in _busy_steer_command were assigning directly to
adapter._pending_messages[quick_key], which overwrites the FIFO head
when a message is already enqueued. Changed both to self._enqueue_fifo()
which preserves the pending slot and appends to the overflow tail.

Regression test verifies both the pending-sentinel and no-steer() paths.
2026-07-31 22:35:58 -07:00
joaomarcos 0d87e5d71b fix(api): enforce pagination bounds on session/message list routes
Ports the negative limit/offset fix onto the current router modules
(hermes_cli/web_routers/sessions.py, profiles.py) since the handlers
moved out of web_server.py in 011ec4513e after this PR was opened.

Per review feedback: only add Query(..., ge=0) — no le=500. The
messages route already clamps oversized requests with min(limit, 500)
and must keep that behavior (succeed + cap) rather than reject them;
the two session-list routes never had a public 500 cap and shouldn't
gain a new rejecting one as a side effect of this fix.
2026-07-31 22:35:49 -07:00
Teknium d1cdfcd38a Merge pull request #74348 from JoaoMarcos44/fix/ia-03-codex-post-terminal-retry
fix(codex): stop duplicating billed inferences on post-terminal drain errors
2026-07-31 22:35:44 -07:00
joaomarcos 99f773b136 fix(routing): guard persisted api_mode against provider mismatch in explicit resolution
_resolve_explicit_runtime's generic-provider branch accepted model_cfg's
persisted api_mode unconditionally, letting a stale mode from a previous
provider (e.g. anthropic_messages) leak into a newly-switched provider
(e.g. gemini) and break the transport. Reuse the existing
_provider_supports_explicit_api_mode guard, already used by the copilot
and named-custom-provider resolution paths for exactly this case, so the
persisted mode is only honored when model_cfg's provider matches the one
being resolved.

Closes #74318
2026-07-31 22:35:31 -07:00
joaomarcos d52a1c25e0 fix(auth): resolve fallback api keys through secret_scope, not raw env
resolve_entry_api_key() and the duplicated _fallback_entry_api_key()
read key_env via a raw os.getenv(), bypassing per-profile secret
scoping in the multiplexed gateway. Under multiplexing this can hand
a fallback request another profile's credential. Both now resolve
through agent.secret_scope.get_secret(), which reads the active
profile scope when multiplexing is on and falls back to os.environ
unchanged when it's off, so single-profile behavior is preserved.

Closes #74311
2026-07-31 22:35:26 -07:00
MaxFreedomPollard 87f5c5351a fix(yuanbao): await the forwarded-records loading heartbeat
ForwardedRecordsParseMiddleware.handle() called the coroutine function
_send_loading_heartbeat() without awaiting it, so the coroutine was built
and dropped. The RUNNING heartbeat never reached the client and Python
raised "coroutine was never awaited".

Forwarded-record parsing is the slow inbound path, which is where the
loading bubble matters most: the user sees nothing while the deep parse
runs. Awaiting is safe, since the helper already swallows every exception
and the call sits inside the middleware's own try block.
2026-07-31 22:35:20 -07:00
MaxFreedomPollard 7729c183b4 test: restore four silently shadowed definitions and guard against more
Python keeps only the last definition of a name in a scope, so a duplicate
silently deletes the first. Four had accumulated, and two cost real coverage.

tests/agent/test_auxiliary_client.py grew a second _clean_env autouse fixture
alongside an NVIDIA feature. Being module-level with the same name, it
replaced the original for all 158 tests in the file: the ANTHROPIC_API_KEY,
ANTHROPIC_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, OPENAI_MODEL, LLM_MODEL and
NOUS_INFERENCE_BASE_URL stripping went away, and so did the _aux_unhealthy_*
cache reset between tests. Two tests had already started clearing that cache
by hand to work around the leak, one of them with a comment describing the
pollution. The NVIDIA keys are folded into the original fixture and the
duplicate removed.

tests/gateway/test_mattermost.py had two copies of
test_progress_send_with_invalid_thread_root_never_falls_back_flat. The
surviving copy omitted the recorded 400 "invalid root_id" state, so the case
the name describes never ran. Both now run under distinct names.

The other two were harmless but hid the pattern: an exact duplicate in
test_tts_media_routing.py, and a dead _codex_auth_store in
test_credential_pool.py that nothing calls.

tests/test_no_shadowed_test_definitions.py walks every test module and fails
on any repeated definition in one scope, exempting the property/setter/
register family and throwaway _ callbacks. It fails on main listing exactly
these four.
2026-07-31 22:35:16 -07:00
MaxFreedomPollard 3dee0634c1 fix(cli): dispatch /background inline instead of queuing it behind the turn
/background (/bg, /btw) exists to start independent work while the current
turn keeps running. Typed while the agent was busy it went into
_pending_input like ordinary input, and process_loop is blocked inside
self.chat() for the whole run, so the background task only started once the
foreground turn had finished. That is the one moment it was not needed.

/steer had the identical problem and was fixed the same way, by dispatching
inline on the UI thread. The command's own CommandDef already declares
busy_policy="dispatch"; the gateway honours that, the classic CLI never
consulted it.

The foreground turn is untouched: no interrupt, no steer, and ordinary
non-slash input keeps following the configured busy-input behaviour.
2026-07-31 22:35:12 -07:00
MaxFreedomPollard 221be76e36 fix(sessions): briefly wait out a live compression lock instead of killing the turn
append_message refused immediately when another writer held the session's
compression lock. The conversation loop turns that into
session_persistence_failed and tells the operator to check disk space and
permissions, when the store is healthy and merely busy. The two append
attempts in the reported incident were 3ms apart, so there was no wait at
all before the turn was destroyed (#75083).

The wait is deliberately short (_COMPRESSION_BUSY_WAIT_S, 5s) rather than
the 60s transcript write patience. The lease is a correctness boundary, not
just a busy signal: test_compression_lease_blocks_non_owner_but_allows_owner_flush
pins that a late stale turn must not land in a session being compressed.
Reusing the full write patience made that append succeed once the lease
aged out, which is exactly what the guard exists to prevent. A short budget
saves the common case, where compression publishes in a couple of seconds,
and still refuses a writer locked out by a long-running or wedged
compression.

CompressionSessionBusyError could not simply be retried either: it covers
two conditions with opposite handling. A compressor discovering its own
lease is gone is permanent, and retrying that would spend the whole budget
before failing anyway. Split the transient case into a
SessionCompressionInProgressError subclass, raised only by append_message,
and wait on just that. Existing except CompressionSessionBusyError handlers
catch both unchanged.

The retry jitter is extracted into _sleep_before_write_retry so the lock
path and the compression path share one implementation.
2026-07-31 22:35:07 -07:00
MaxFreedomPollard eeaba3a88d fix(gateway): do not claim a destructive-slash opt-out that was not saved
Answering "Always Approve" on the /clear, /new, /reset and /undo
confirmation calls save_config_value("approvals.destructive_slash_confirm",
False) and then appended "Future /clear, /new, /reset, and /undo will run
without confirmation" unconditionally.

save_config_value catches its own exceptions and reports the outcome in the
return value, so the caller's try/except could never observe a failed write,
and the return value was ignored. On any install whose config.yaml is not
writable the user was told the preference stuck when it had not, and the
prompt returned on the next restart with no explanation.

Check the return value. The approved action still runs either way, but when
the write did not land, say so and point at the config key instead of
promising an opt-out that was never written.
2026-07-31 22:35:03 -07:00
MaxFreedomPollard 65e9ece964 fix(curator): restore the real skills tree when a rollback extract dies part-way
shutil.move() moves into an existing destination directory instead of
replacing it. When the snapshot extract failed after creating some of its
output, the recovery path moved each staged entry onto a path the extract
had already created, burying the user's own skill one level deeper
(skills/alpha/alpha/) and leaving the snapshot's partial content in its
place. rollback() then returned "snapshot extract failed (state restored)".

Clear the failed extract's output before moving the staged copies back:
entries the original tree never had are dropped, and each staged entry's
destination is removed before the move. When an entry still cannot be
restored, keep the staging dir and name the entries in the message rather
than reporting a restore that did not happen.
2026-07-31 22:34:57 -07:00
MaxFreedomPollard 31032b4f51 fix(auth): a transient read failure is not corruption
_load_auth_store() treated every exception from reading auth.json as
corruption and returned an empty store. EMFILE under fd exhaustion,
EACCES, EIO and a stalled network mount all reached that branch. This
module does read-modify-write in roughly fifteen places, so the empty
store was one _save_auth_store() away from erasing every stored
credential.

Separate OSError from parse failure: a file that exists but cannot be
read now raises, naming the real cause and leaving the file on disk
untouched. Only a genuine parse failure takes the preserve-and-start-
empty branch, which is unchanged.

The backup was also unreliable in exactly the conditions that triggered
it: shutil.copy2 opens a file, so under EMFILE it failed too, its bare
except swallowed that, and the log still said "Corrupt file preserved
at ..." when nothing had been written. Track whether the copy landed
and say so accurately.
2026-07-31 22:34:52 -07:00
iso2kx d358edd916 fix(update): snapshot venv launchers before the gateway drain
_venv_launcher_ancestors() ran after
_wait_for_windows_update_gateway_exit(), but the drain stops tracking a
PID exactly when it dies - for the common graceful-drain case the worker
is gone by the time the wait returns, and a dead pid's parent cannot be
recovered, so the launcher stop never fired on that path. Resolve
launcher ancestors before draining and stop the snapshot afterwards
alongside the survivors; a launcher that already exited with its worker
raises ProcessLookupError at the kill and is skipped.

The set-cover invariant test now marks drained workers uninspectable
(construction raises, like psutil.NoSuchProcess), so a post-drain
launcher lookup can never reappear unnoticed.
2026-07-31 22:34:28 -07:00
iso2kx f3edd0e538 fix(desktop): use the canonical gateway matcher for the preflight exemption
_is_pausable_gateway() hand-rolled a second gateway parser and regressed
a valid form: in `--profile gateway gateway run` the profile VALUE
shadowed the subcommand token, so the scan reported that gateway as a
fatal preflight holder. Delegate to
gateway.status.looks_like_gateway_command_line() - profile-selector
aware, shlex-tokenizing, run-only - so the preflight exemption, the
pause discovery, and the updater's guard fallback share one parser.
Non-run gateway subcommands, serve backends, and REPLs still block; the
bare-`gateway` form now classifies as a running gateway, mirroring the
canonical matcher's contract.
2026-07-31 22:34:28 -07:00
iso2kx a31fe8db6e fix(update): stop gateway holders the guard finds after the pause
The pause stops every gateway its discovery maps, but the venv-holder
guard sees the process table as it is now: a gateway respawned by its
supervisor (Scheduled Task, login watchdog) inside the pause-to-guard
window, or one started through a spawn path discovery does not map,
still holds venv .pyds - and the guard dead-ended the update on exactly
the kind of process the pause machinery exists to stop.

When every remaining holder classifies as a pausable gateway - using the
same _is_pausable_gateway matcher the Desktop preflight uses, so the two
views cannot drift - stop them and re-scan once. Any non-gateway holder
(REPL, stray script, Desktop backend) keeps the hard refusal exactly as
before, and a survivor after the stop still aborts.
2026-07-31 22:34:28 -07:00
iso2kx 0bec37aefc fix(desktop): don't report pausable gateways as venv-update blockers
The Desktop update preflight (`scanVenvBlockers` -> `python -m
hermes_cli._scan_venv_blockers`) reports every venv-side python as a
blocker and aborts the handoff:

    main.ts: scanVenvBlockers(...)                  <- aborts HERE
             return { ok:false, error:'venv-blocked' }
             spawnUpdaterProcess(hermes-setup ...)  <- never reached

But a *gateway* is not a dead-end holder. `hermes-setup` invokes
`hermes update --yes --gateway`, and the CLI updater's
`_pause_windows_gateways_for_update()` gracefully drains and stops
running gateways before touching the venv — machinery added for exactly
these processes (#50090 and follow-ups). The preflight replicated the
CLI's *guard* without its *pause*, so a Windows service-mode gateway
(e.g. a Scheduled Task running `gateway run`) made every Desktop update
abort forever with

    [updates] venv-blocked: N process(es) hold the install
      PID ... python.exe ... -m hermes_cli.main gateway run --replace

while the component one layer down was never allowed to run and handle
it. The abort points at a process the updater knows how to stop.

Fix: `_is_pausable_gateway()` exempts `hermes_cli.main ... gateway run`
invocations (both halves of the venv-shim launcher/worker chain match,
since the uv-side worker re-runs the same argv). Everything else keeps
blocking — the Desktop `serve` backend, other `gateway` subcommands,
operator REPLs and stray scripts have no pause machinery downstream.
The CLI updater's own post-pause venv guard is untouched, so a pause
that genuinely fails still aborts before any .pyd mutation.

The JSON gains a diagnostic `pausable_gateways` count. The TS consumer
validates only `ok`/`blocked`/`processes` and ignores unknown keys, so
old and new Desktop builds both accept the new document; no Electron
rebuild is required for the fix to take effect (the scan runs from the
repo's Python).
2026-07-31 22:34:28 -07:00
iso2kx 05504bd9f0 test(gateway): make test_gateway collectable on Windows
`import pty` at module scope pulls in `termios`, which does not exist on
Windows. That raised ModuleNotFoundError during *collection*, so pytest
aborted the whole module with

    Interrupted: 1 error during collection

before any skip marker could take effect. The single PTY-dependent test
was already correctly marked `skipif(sys.platform == "win32")` — the
import crashed ahead of it and took the module's 13 other, entirely
platform-agnostic tests down as collateral. Windows contributors got zero
gateway coverage and, worse, a collection error that masks real failures
in any batch that includes this file.

Two changes:

- Move `import pty` into the `stdin_is_tty` branch that actually uses it
  (the sole `pty.openpty()` call). Nothing else in the module needs it.
- Skip `test_systemd_install_checks_linger_status` on Windows. It drives
  `_systemd_linger_enabled()` -> `os.getuid()`, which does not exist on
  Windows; the production helper is annotated
  "windows-footgun: ok — POSIX systemd helper, never invoked on Windows",
  so the test is Linux-only by nature. It was previously hidden behind
  the collection crash.

POSIX behaviour is unchanged: both guards are `skipif(win32)`, inactive
off Windows, and the local import resolves exactly where the module-level
one did.

    before (Windows): 0 collected, 1 collection error
    after  (Windows): 14 collected, 9 passed, 5 skipped
2026-07-31 22:34:28 -07:00
iso2kx 9507f4382e fix(update): stop the venv-side launcher of each paused Windows gateway
On Windows a gateway started through the venv shim is a two-process chain:

    venv\Scripts\python.exe        (launcher — keeps venv .pyd files mapped)
      └─ uv\python\...\python.exe  (worker  — writes the gateway PID file)

`_pause_windows_gateways_for_update()` builds its pause set from
`find_gateway_pids()`, which reads the PID file and therefore only ever
sees the *worker*. The venv-holder guard immediately downstream
(`_detect_venv_python_processes()`) matches on the venv path prefix, so it
only ever sees the *launcher*.

The two sets are disjoint. A gateway the updater had just gracefully
drained still left its launcher alive, the guard reported that launcher as
a venv holder, and the update aborted — every time. On the Desktop path
this surfaces as the dead-end dialog:

    [updates] venv-blocked: 2 process(es) hold the install
      PID ...  python.exe  ...\venv\Scripts\python.exe -m hermes_cli.main gateway run --replace

Note the reported holder is a gateway the updater believes it stopped.
The Desktop path is affected because `hermes-setup.exe` runs
`hermes update --yes --gateway --force`, and `--force` deliberately does
NOT bypass the venv guard (that needs `--force-venv`), so the abort is
correct behaviour reacting to an incomplete pause.

Fix: after the graceful drain, walk one hop up from each mapped gateway
PID and force-kill parents that live under the project venv.

Deliberately additive, not a substitution:

- The planned-stop marker and the graceful drain still target the worker
  (the PID that wrote the PID file), so clean shutdown is unchanged and
  updates don't get pushed onto the hard-kill path.
- `terminate_pid(force=True)` is `taskkill /T` (tree kill), so killing a
  launcher that outlived its worker also reaps stragglers.
- `_resume_windows_gateways_after_update()` needs no change: the mapped
  respawn argv is rebuilt from the profile name
  (`_gateway_run_args_for_profile`), never from the killed PID, and the
  restart watcher's `_pid_exists()` wait still terminates because the
  tree kill takes the whole chain down.
- Only the venv-side parent is returned. Unrelated ancestors (a Scheduled
  Task's `cmd.exe`, an operator's shell) are ignored, and the caller's own
  process chain is excluded so a CLI `hermes update` never nominates
  itself.

Tests assert the invariant the two PID-resolution paths must satisfy —
the pause's kill set must cover the guard's abort set — rather than
snapshotting PIDs. Verified to fail without the fix:

    AssertionError: pause stopped [] but the venv guard aborts on [400]
    — disjoint sets abort the update
2026-07-31 22:34:28 -07:00
dongjiang cc0146f2de fix(cli): dispatch /indicator to set the busy-indicator style (#50618)
The /indicator command was registered in COMMAND_REGISTRY, listed in
/help, offered by tab-completion, recommended by the tips system, and
even documented in config.py — but it had no actual handler. Running
/indicator in the CLI produced "Unknown command: indicator".

Add _handle_indicator_command to CLICommandsMixin that:
- Shows the current indicator style when called with no args or "status"
- Validates the requested style against the shared INDICATOR_STYLES
  allowlist (ascii | emoji | kaomoji | unicode)
- Persists the choice to display.tui_status_indicator in config.yaml
  via the existing save_config_value helper
- Falls back to session-only when config save fails

The indicator-style allowlist is defined once in hermes_constants as
INDICATOR_STYLES + DEFAULT_INDICATOR_STYLE and imported by all three
consumers (CLI handler, command registry, TUI gateway config handler),
preventing drift between the TUI and CLI validation.

Also adds tests/cli/test_indicator_command.py covering dispatch,
validation, persistence, and registry integration.

Signed-off-by: dongjiang <dongjiang1989@126.com>
2026-07-31 22:33:16 -07:00
dongjiang de6a672168 fix(skills-hub): include owner in ClawHub source URLs and add retry on 429 (#51236)
Two fixes for the Skills Hub "View source" links on ClawHub skills:

1. Source URL generation was missing the required {owner} segment —
   https://clawhub.ai/skills/{slug} → 404. Correct format is
   https://clawhub.ai/{owner}/skills/{slug}. When the owner handle is
   unavailable, source_url is now "" (card omits the button) instead of
   emitting a broken link.

2. _fetch_owner_handle() previously delegated to _get_json() which
   returned None on any non-200 response with no retry. Under HTTP 429
   rate-limiting the "50 consecutive failures" safety rail in
   enrich_owners() fired immediately — the documented claim "Respects
   HTTP 429 rate-limit responses with exponential backoff" was not
   actually implemented. Now has its own retry loop: 3 attempts, honours
   Retry-After on 429, exponential backoff on 5xx/transport errors, no
   retry on 4xx.

Changes:
- tools/skills_hub.py: _coerce_skill_payload carries owner from top-level
  response; inspect() captures owner from detail API; _fetch_owner_handle()
  added with bounded retry/backoff; enrich_owners() batch method with
  safety rails (30 workers, early termination at 50 consecutive failures).
- website/scripts/extract-skills.py: _source_url() reads extra["owner"]
  for ClawHub.
- scripts/build_skills_index.py: batch enrichment step after crawling.
- tests: 35 URL/enrichment tests + 7 retry tests (42 total).

Signed-off-by: dongjiang <dongjiang1989@126.com>
2026-07-31 22:33:11 -07:00
Baophan00 a017297cf1 fix(config): respect --force for bare model key overwrite
--force was silently ignored for 'model' keys — the guard always
redirected to model.default even when the user explicitly asked to
replace the entire section. Now --force triggers a warning and
proceeds with the destructive overwrite for model too, matching
the non-model mapping --force behaviour.
2026-07-31 22:33:08 -07:00
Baophan00 99cfa8f063 fix(config): guard against scalar overwrite of mapping sections (#74995)
Prevent 'hermes config set <section> <scalar>' from silently destroying
an existing mapping. The bare 'model' shorthand is preserved by
redirecting to 'model.default' — all other mapping sections are refused
with a helpful error unless --force is used.

Closes #74995
2026-07-31 22:33:08 -07:00
Zeraphim e050ca97ff test(background-review): reanchor unresolved-failure coverage 2026-07-31 22:33:03 -07:00
Zeraphim 2ace68ad37 fix(background-review): reject unresolved failures as skills 2026-07-31 22:33:03 -07:00
spfcraze b004041498 fix(cron): close GitHub auth-header exemption abuse in prompt scanner
Two holes in _strip_cron_safe_constructs (one a regression from
70411a615, two days old):

1. The [^\n]* tail erased everything after api.github.com on the line,
   so a payload smuggled after ; && or | was never scanned. A cron
   prompt carrying a benign-looking GitHub curl followed by
   'cat ~/.hermes/.env' or 'rm -rf /' passed the scanner and persisted
   (verified end-to-end through the cronjob tool). Bound the tail to
   the URL path ([^\s;&|]*), so same-line payloads survive the strip.
2. The (?:/|\b) host boundary treated lookalike authorities
   (api.github.com.evil.com, api.github.com@evil.com) as the trusted
   GitHub construct, erasing even exfil of the GitHub token itself to a
   non-GitHub host. Require the exact host followed by /, whitespace,
   or end.

Also add SSH private-key files to the read_secrets pattern — a
coverage gap found during adversarial testing (cat ~/.ssh/id_rsa was
invisible to the scanner even outside the exemption).
2026-07-31 22:33:00 -07:00
Xipong 0af8fb05bf fix(delegation): prevent child HERMES_SESSION_ID leak into parent process env
AIAgent.__init__ calls set_current_session_id(self.session_id), which
mutated both the task-local ContextVar and the process-global os.environ.
Because _build_child_agent wraps construction in delegated_child_context(),
the ContextVar write is harmless (task-local), but the os.environ write
clobbered the parent's HERMES_SESSION_ID for the rest of the process —
leaking the child id into parent tools and subprocesses spawned after
the child was built.

Root cause of HermesPRDelegationSessionContext: parent
20260729_212118_5d797e dispatched child 20260730_160515_736ea1; later
parent terminal inherited HERMES_SESSION_ID=the child.

Fix: set_current_session_id() skips the process-global os.environ write
when called from within a delegated_child_context(). The child's own
tools and subprocesses still resolve their id through the ContextVar
(task-local), while the parent's process-wide env keeps the parent's
session identity. Root agents (CLI, gateway, cron) retain both paths.

Adds 7 regression tests covering single child, concurrent children (8
parallel), parent-tool observation after construction, and root-agent
session rotation backward compatibility. All pass; ruff clean.
2026-07-31 22:32:55 -07:00
Xipong abd10ba7c9 test(agent): cover malformed steer budget path 2026-07-31 22:32:52 -07:00
Xipong 0fd0db1a8c fix(agent): preserve /steer through turn-budget enforcement 2026-07-31 22:32:52 -07:00
praneshnikhar 35b44e0d5f fix(auth): apply same source-path write-through fix to non-pool xAI path
_save_xai_oauth_tokens had the identical self-sealing bug as
_sync_device_code_entry_to_auth_store: key-presence check before
_store_provider_state, which unconditionally creates the key.
Use _load_provider_state_with_source to decide write-through from
the actual grant source, not key presence.

Also update regression test per review: use the real
_write_through_provider_state_to_global_root helper and assert
rotated token pair values in the root store after each refresh
instead of just counting mock calls.
2026-07-31 22:32:48 -07:00
praneshnikhar 4e6299af48 fix(credential_pool): use source-path-based write-through to root (#74339)
_sync_device_code_entry_to_auth_store used key-presence on the profile
store to decide whether to write-through rotated tokens to the global
root.  _store_provider_state unconditionally creates that key, so every
refresh after the first self-disabled the write-through — root kept a
revoked refresh token and every other profile died with
refresh_token_reused / invalid_grant.

Fix: use _load_provider_state_with_source to learn where the grant was
resolved from.  When the source is the global root, write back only to
root and skip _store_provider_state so the profile never accrues a
shadowing providers.<id> key that blocks future root fallback.

Add regression test verifying write-through fires on refresh 2+, not
just the first call.
2026-07-31 22:32:48 -07:00
Gille 29eac371d1 fix(context): persist NVIDIA DeepSeek endpoint limit 2026-07-31 22:31:22 -07:00
Teknium 1737741730 fix(copilot): follow-ups for salvaged PR #58743
- Bound ALL reads of the on-disk JWT store through one _read_jwt_store()
  helper (load, eviction, save-merge) — the 1 MiB cap previously only
  covered the load path; eviction and save could still parse an
  oversized/corrupt store and rewrite it back out (sweeper finding).
- Fix the class, not the site: the recovery gates checked the literal
  provider == "copilot" while /model and profile configs can leave the
  alias spelling in place (the reporter's own log shows provider=copilot
  AND provider=github-copilot in one session — the aliased turns would
  have silently skipped recovery). Single owner:
  AIAgent._is_copilot_provider() (slug aliases + Copilot base-URL
  fallback), used by both run_agent recovery methods and both
  conversation_loop gates.
- Update the salvaged 401 test to current main's client-retirement
  contract (release deferred to GC — no synchronous .close()).
- Add copilot_stale_cred_retry_attempted to the TurnRetryState field
  contract test; add bounded-store and alias-gate regression tests.
2026-07-31 22:31:09 -07:00
dstkwll 7779409a76 fix(copilot): recover from stale/degraded token 400 AND expired IDE-token 401
Copilot degrades in two related ways that both abort a turn as non-retryable
and only clear on a gateway restart (a cold process re-runs the token exchange):

1. HTTP 400 model_not_available_for_integrator / model_not_supported — a
   raw/degraded token routes to the restricted copilot-language-server
   integrator whose allowlist omits enterprise-only models (e.g.
   claude-opus-4.8). Because it is a 400 (not 401), the existing 401 refresh
   path never fired. Prevented (retry-with-backoff exchange + on-disk JWT
   persistence + header guard at the client chokepoint) and self-healed at
   runtime (single-shot forced re-exchange + client rebuild + retry before
   fallback).

2. HTTP 401 'IDE token expired: unauthorized: token expired' — the short-TTL
   *exchanged* IDE token expires mid-turn. The clean-401 path DID fire and call
   _try_refresh_copilot_client_credentials(), but that method only re-resolved
   the stable raw ghu_ token and rebuilt the client — it never evicted the
   cached exchanged JWT or forced a fresh exchange, so the retry put the SAME
   expired token back on the wire, 401'd again, and the single-shot guard
   aborted the turn. Fix: force a fresh IDE-token exchange (evict cached JWT via
   evict_cached_exchanged_token + re-mint via get_copilot_api_token) before the
   client rebuild, mirroring the merged auxiliary-path recovery (#59837) and the
   400 recovery in this same PR. Graceful fallback to the resolved token if the
   exchange endpoint is unreachable; picks up the enterprise base_url on
   re-exchange.

Brings main-loop clean-401 recovery to parity with the merged auxiliary path
(#59837), using the newer on-disk-aware evict helper. Companion context: #58743
(this PR, expanded), #51313, #63204 (which assumed the 401 path already
recovered — it reached the method but the method was too weak).

Tests: exchange retry/persist round-trip, restart-blip disk reuse, stale-cred
400 classifier, 400 recovery, and 3 new 401 cases (fresh exchanged token on the
wire; network-blip fallback to resolved token). 58 copilot tests green on
current main.
2026-07-31 22:31:09 -07:00
Teknium df8e42877b Merge pull request #75852 from NousResearch/fix/moa-completed-response-streaming-cluster
fix(moa): stream completed aggregator responses across all layers (#74031 + #74903 + #74543)
2026-07-31 22:30:47 -07:00
AKAZIK-py fdfddb55cb fix(moa): carry completed responses through the managed Relay path
Under managed Relay execution the provider factory runs lazily inside
provider_stream() on the Relay session's event loop. The MoA facade's
auxiliary call_llm(stream=True) is invoked from that callback, so the
eager final_response check added for the non-managed path never fires:
the inner ManagedLlmStream is returned to the outer stream, which then
synchronously iterates it on the same loop thread and dies with
RuntimeError: Cannot run the event loop while another loop is running
(the completed response effectively trapped one level deeper).

stream_current() now detects a running event loop and returns the raw
factory result instead of nesting a ManagedLlmStream: the outer managed
stream already provides Relay tracking for the enclosing attempt, and
its own completed_response_predicate traps the completed response as
final_response — the same contract the main streaming worker consumes
(chat_completion_helpers reads stream.final_response after the chunk
loop). Nested managed streams remain supported for genuinely streaming
providers via the outer stream's own iteration.

Adds managed-execution regressions using the retained relay_turn
fixture: direct completed-response trapping, and the nested
facade-shaped stream_current call.
2026-07-31 22:30:33 -07:00
AKAZIK-py 8fde4ff9d8 test(moa): cover call_llm(stream=True) completed-response unwrap at the outermost seam 2026-07-31 22:30:33 -07:00
AKAZIK-py ada3663ce7 fix(moa): unwrap completed responses in the auxiliary streaming path 2026-07-31 22:30:33 -07:00
Teknium ed77a34730 fix(update): make the ancestor-PID lock handoff cross-platform via psutil
The stale-staged-updater deadlock is not Windows-specific: hermes-setup
under ~/.hermes is only refreshed by a full installer run
(copy_self_to_hermes_home no-ops during --update), so every desktop whose
staged updater predates the HERMES_UPDATE_HANDOFF_PID export (8c76fe19)
runs an old parent that never sends the env var against a new child that
demands it — exit 2 ('Hermes is still running') forever, on macOS and
Linux just as on Windows.

Replace the wmic ancestry walk (deprecated, absent on current Win11,
GBK decode juggling) with psutil.Process().parents() — psutil is already
a hard dependency and is the project's canonical no-kill process probe.
Drop the os.name == 'nt' gate so all platforms heal. Add tests: a marker
owned by our parent process is recognized as our orchestrator; a live
non-ancestor holder is still refused.
2026-07-31 22:28:46 -07:00
Teknium 859573d283 test: import LAZY_DEPS directly and make httplib2 pin a floor invariant
Replace the AST source-parse of tools/lazy_deps.py with a real import
(the module is importable; only setup.py legitimately needs AST since
it is a side-effectful standalone script), and convert the exact
httplib2==0.32.0 snapshot assertion into a >=0.32.0 floor so routine
future bumps don't break the test.
2026-07-31 22:28:21 -07:00
Yorkstone Supplies (sycamoregroupltd) 37e42808e7 fix(security): pin httplib2==0.32.0 in setup.py REQUIRED_PACKAGES (GHSA-j5g9-f88f-gfj3)
The previous fix (904ade32b) pinned httplib2==0.32.0 in pyproject.toml's
google extra and tools/lazy_deps.py's skill.google_workspace, but missed
a third install path: skills/productivity/google-workspace/scripts/setup.py
REQUIRED_PACKAGES. A user following the --install-deps path could still
resolve httplib2 via unpinned ranges.

This commit:
1. Exact-pins all four Google packages in REQUIRED_PACKAGES to match
   pyproject.toml and lazy_deps.py contracts exactly.
2. Adds a focused regression test that parses setup.py's REQUIRED_PACKAGES
   via AST and asserts every pin matches the other two install paths.

Changelog: fix(security), test(security)
2026-07-31 22:28:21 -07:00
Brooklyn Nicholson 24f346ee77 fix(gateway): fail prompt.submit when session storage hits a full disk
Disk-full / ENOSPC / SQLITE_FULL on first-message session persist used to be
swallowed as a debug log while prompt.submit still returned streaming, so the
send vanished with no error. Re-raise those failures, return a real RPC error,
and stamp session_persistence_failed turns with error so clients get a terminal
error frame.
2026-08-01 00:27:17 -05:00
Kyle 48ded07155 fix(codex): handle completed auxiliary responses 2026-07-31 21:52:36 -07:00
knocksen 743c4906d9 fix(moa): restore a valid preset after fallback model drift
build_moa_facade() reused agent.model as the preset name; a session
that had drifted to a fallback model crashed on restore with
MoAPresetNotFoundError. Validate the resolved preset against the
configured presets and fall back to the default preset.

Salvaged from PR #74903 by @liusencomic-cyber.
2026-07-31 21:52:36 -07:00
knocksen 716d274f58 test(moa): cover direct provider streaming for Codex-shim aggregators
Narrow the moa_aggregator Relay bypass to CodexAuxiliaryClient and add
coverage that call_llm(stream=True) returns the provider's direct
create() result for Responses-shim clients.

Salvaged from PR #74903 by @liusencomic-cyber.
2026-07-31 21:52:35 -07:00
knocksen 8e191af0fb fix(moa): stream completed aggregator responses safely
Convert a completed MoA aggregator response into one valid Chat
Completions delta chunk at the MoA facade boundary, normalize completed
message.tool_calls into indexed stream deltas, and classify these local
MoA adapter-shape errors as non-fallback format errors so a local
compatibility bug cannot silently drift the user's MoA route to a
single model (#55933 follow-up).

Salvaged from PR #74903 by @liusencomic-cyber.
2026-07-31 21:52:35 -07:00
AKAZIK-py c23ff21d7c fix(moa): carry completed responses through the managed Relay path
Under managed Relay execution the provider factory runs lazily inside
provider_stream() on the Relay session's event loop. The MoA facade's
auxiliary call_llm(stream=True) is invoked from that callback, so the
eager final_response check added for the non-managed path never fires:
the inner ManagedLlmStream is returned to the outer stream, which then
synchronously iterates it on the same loop thread and dies with
RuntimeError: Cannot run the event loop while another loop is running
(the completed response effectively trapped one level deeper).

stream_current() now detects a running event loop and returns the raw
factory result instead of nesting a ManagedLlmStream: the outer managed
stream already provides Relay tracking for the enclosing attempt, and
its own completed_response_predicate traps the completed response as
final_response — the same contract the main streaming worker consumes
(chat_completion_helpers reads stream.final_response after the chunk
loop). Nested managed streams remain supported for genuinely streaming
providers via the outer stream's own iteration.

Adds managed-execution regressions using the retained relay_turn
fixture: direct completed-response trapping, and the nested
facade-shaped stream_current call.
2026-07-31 21:52:35 -07:00
AKAZIK-py 52705e496f test(moa): cover call_llm(stream=True) completed-response unwrap at the outermost seam 2026-07-31 21:52:35 -07:00
AKAZIK-py 654915f187 fix(moa): unwrap completed responses in the auxiliary streaming path 2026-07-31 21:52:35 -07:00
Gille 991f5f1e9e fix(kanban): deliver notifications from non-dispatch gateways 2026-07-31 21:52:11 -07:00
Victor Kyriazakos 008f1efe08 refactor(providers): delegate is_official_openai_host to base_url_host_matches
Independent review pass: utils.base_url_host_matches already owns the
exact-or-dot-suffix hostname contract (userinfo/port stripped, lowercased,
trailing dot removed), so the predicate delegates instead of hand-rolling
a second suffix match to keep in sync. Also locks in the normalization
behavior the review verified empirically: uppercase+port, trailing-dot,
userinfo-stripped, and IPv6-literal cases added to the contract tests.
2026-07-31 21:31:56 -07:00