Skip to content

runtime: cut the fleet to v0.20.5 (2026.8.19) with all fleet patches carried - #20

Merged
BenSheridanEdwards merged 3846 commits into
mainfrom
runtime/fleet-20260822
Aug 25, 2026
Merged

BenSheridanEdwards merged 3846 commits into
mainfrom
runtime/fleet-20260822

Conversation

@BenSheridanEdwards

Copy link
Copy Markdown
Owner

Single consolidated PR for the fleet runtime upgrade v0.20.0 → v0.20.5. Supersedes and closes #4, #10, #11 and #19.

Base is upstream release commit fcbd1076a9, tag v2026.8.19. There is no v0.20.5 git tag; upstream tags CalVer and the semver lives only in pyproject.toml and hermes_cli/__init__.py. Delta from v0.20.0 is 3,820 upstream commits across 4,040 files.

This is a pin cutover, not a casual merge. It requires a venv rebuild and named-interpreter reprovisioning on every host. See the deployment notes at the bottom.

What is in it

22 commits on top of v0.20.5.

  • 19 fleet patches cherry-picked from main: OAuth ownership enforcement (xAI + Codex), Bitwarden shared disk cache, sanitized Telegram runtime receipt, webhook auto-TTS deliver routing, /new EMFILE handling, cron lifecycle guard, computer-use background-only policy, the redact ReDoS fixes, and the CI/test guards.
  • 2 dropped, because upstream absorbed them verbatim: state: pool SessionDB read connections and state: bound PEAK read connections with a permit.
  • 1 skipped: the npm advisories patch. See deferred decisions.
  • 1 new: the redact fix below.

v0.20.5 reintroduces the gateway wedge

Upstream added a lowercase env-assign pass to redact_sensitive_text whose pattern restarts a greedy run scan at every offset of an opaque payload:

[a-z0-9_]+(?:_|^)(?:key|pass|pw|token|secret|...)(?=[^a-z0-9_]|$)

Measured on the pattern alone against token= plus 16K of filler:

pattern time
_ENV_ASSIGN_RE 0.3 ms
_CFG_DOTTED_RE 0.2 ms
_CFG_ANCHORED_RE 0.3 ms
_ENV_ASSIGN_LOWER_RE 1364.1 ms

redact_sensitive_text is on the gateway hot path and a CPU-bound regex holds the GIL, so this is the same shape that previously locked up a whole gateway on compaction payloads. The three ReDoS regression tests from #15 fail on stock v0.20.5.

Fixed here with the same technique as #16: anchor candidate discovery at a left boundary, scan the key possessively, and move the <prefix>_<secret word> rule into a string-only validator that cannot backtrack.

  • pattern alone, same input: 1364 ms → 0.0 ms
  • differential test of old against new over 2066 generated cases covering secret words, prose lookalikes, separators, quoting and os.getenv values: zero divergence in match spans and groups
  • tests/agent -k redact: 131 passed, was 128 passed and 3 failed; suite wall time 81 s → 3.8 s

This one is worth sending upstream.

Rebase resolutions worth reviewing

Four picks needed judgement rather than mechanical merging. Each is explained in its own commit message.

  • Telegram runtime receipt: a genuine semantic clash. Our process-rollover payload reset broke upstream's newer clear_profile_platforms contract, whose fixture writes a payload with no pid. The reset is now gated on the file actually recording a prior process identity, so a real rollover still discards stale receipts and upstream's contract holds.
  • Webhook auto-TTS: upstream rewrote the dispatch block. Upstream's structure is kept and only the deliver-target routing is reapplied. Upstream's re-derivation of reply_anchor/thread_meta from the event is dropped, since it would point the reply back at the webhook source, which is the bug being fixed.
  • test_update_venv_health: upstream bound a new patch to the import-time cli_main alias, which is the exact binding that test exists to remove. Retargeted onto main_mod.
  • STT arity: reduced to tests only. v0.20.5 has source as a real parameter forwarded to the pre_transcription hook, so the shim is dropped and upstream's signature kept.

Deferred decisions

The npm advisories patch was skipped because upstream v0.20.5 already covers 4 of its 6 pins and adds more besides (lodash, yauzl, protobufjs, ip-address, postcss, tar). Keeping package.json and the lockfile coherent was worth more than the remaining delta. Two gaps are given up deliberately:

  • nanoid: upstream 3.3.17 against our 3.3.18
  • electron: upstream 40.10.2 against our 42.9.1

Electron is a major desktop-framework bump against 3,820 commits of upstream desktop changes, which is the wrong thing to force through a runtime upgrade. It remains an unresolved security delta and should be handled on its own.

Verification

The 20 test files this branch touches: 979 passed, 3 failed. All three are pre-existing and were each checked rather than assumed:

  • test_config_device_and_compute_type_passed_to_whisper fails identically on stock v0.20.5 with none of this branch applied. It appears to read real local config rather than a fixture.
  • The two test_run_agent openrouter failures pass standalone and pass with their whole file (276 passed). On stock v0.20.5 those same two error out in a combined run, alongside 305 other problems that this branch's conftest fixes resolve.

Against a freshly built v0.20.5 venv (mcp 2.0.0, httpx2 2.7.0, cryptography 50.0.0):

  • 1029 MCP tests passed, 4 skipped
  • the same 20 touched files give an identical 979 passed / 3 failed, so the mcp 2.x swap changes nothing there

Deployment notes

A venv rebuild is unavoidable: mcp 1.28.1 → 2.0.0 (MCP revision 2026-07-28, moves its HTTP stack to httpx2, which tools/mcp_tool.py and tools/mcp_oauth_manager.py import by name), cryptography 48.0.1 → 50.0.0, nemo-relay 0.6 → 0.7.1.

  • Build with uv pip install -e '.[all]'. A bare -e . yields only 61 packages.
  • [all] deliberately excludes messaging (telegram/discord/slack), which is lazy-installed at first use. A from-scratch venv therefore has no Telegram stack until those extras are installed.
  • Rebuilding replaces the interpreter, so named interpreter binaries must be reprovisioned or host TCC identities break.

What this closes

🤖 Generated with Claude Code

teknium1 and others added 30 commits August 20, 2026 01:46
…ther gateways, and the app itself

Remote-mode installs had every update affordance (About panel Update now,
⌘K Update Hermes, the update-ready toast) pointed at the BACKEND only, so
users updated their VPS forever while the desktop app itself sat weeks
stale — with no signal it was behind (the skew warning only fired the
other way). Reported by Santiago Sarceda: mac app on v0.20.0 kept
repro'ing UI bugs fixed on main because 'update' never touched the app.

- store/updates.ts: applyEverythingUpdate() orchestrates all targets —
  active backend first (detailed progress), every other eligible
  registered gateway via the existing Electron fan-out (cloud rows skip),
  the client LAST (its apply relaunches the app). startActiveUpdate/
  requestActiveUpdate route through it whenever more than one update
  target exists; single-machine installs keep the one-button flow.
- After ANY successful backend update, the client version is re-checked
  and a one-click 'Update desktop app' warning fires if the GUI is still
  behind — the reverse-skew signal that didn't exist.
- electron: hermes:connections:update-all accepts optional excludeIds so
  the flow doesn't double-dispatch the active backend / local runtime.
- i18n: 7 new updates.* keys across en/zh/zh-hant/ja/ar.
- docs: desktop.md Updating section + multi-connection guide.
- tests: 10 new cases (gating, ordering, exclusions, failure isolation,
  memoization, nudge on/off).
…ever sticky

When the chosen/keyed backend fails a web_search or web_extract call
(bad key, upstream outage, 5xx, raised exception), that single call
retries on the keyless free-tier ring instead of erroring. The next
call attempts the chosen backend again — no sticky failover, no state.
Resolves the keyed half of NousResearch#78984/NousResearch#32159 (keyless half landed in the
ring PR).

- tools/web_tools.py: _rescue_eligible (keyed ring vendors + non-ring
  backends eligible; keyless-mode calls excluded — they already walked
  the ring), _rescue_search/_rescue_extract (search annotates
  rescued_from + backend_error naming the original failure and the
  retry-next-call semantics; extract rescues only whole-batch failures,
  partial failures pass through untouched; rescue failure preserves the
  ORIGINAL backend error with the rescue note appended)
- both dispatchers wrap the provider call: failure-results AND raised
  exceptions rescue; ineligible paths re-raise unchanged
- web.keyless_rescue config key (default true; implicitly off when
  keyless_fallback is off); docs updated

Live E2E: keyed Tavily with an invalid key 401'd and the call was
served by the real ring with the rescue annotation; a second call
re-attempted Tavily first (statelessness proven); whole-batch extract
rescue returned real page content. 13 new tests; 67 green across the
keyless suites.
…roster survives outages, spawn failures log, host-key change stops the retry wall

Three fixes from one remote-gateway (VPS) debug bundle, all live-reproduced
and re-verified on a headed Electron seat via CDP:

- Bots roster no longer shrinks during a gateway outage: source enumeration
  is bounded (10s/source instead of wedging the roster IPC >30s behind a
  dead dial) and a bounced remote source keeps painting its last-known
  profile list (was SSH-only), so 4 bots never show as 2 mid-outage.
- Pool backend spawns that die before the child exists (forced-local spawn
  of a profile that only exists on the remote) now log the failure to
  desktop.log, and the profile-exists guard runs BEFORE the Starting line —
  no more orphaned no-READY/no-exit spawn bursts in bundles.
- An SSH host-key change (VPS reinstall) is classified terminal like a
  reauth rejection: it latches, the boot-failure overlay shows the
  ssh-keygen -R guidance, and the renderer stops the infinite boot-retry
  loop (one bundle had 157 consecutive failures over 2.5h). Reset/repair/
  apply-config clear the latch; live-verified Retry-after-fix boots clean.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…d of a generic failure

A GitHub-side HTTP 429 during 'hermes update' printed only
'Failed to fetch updates from origin.' — and the curl
'unable to access ... returned error: 429' shape even matched the
network-error branch, blaming the user's connection for a GitHub
outage.

- new _classify_fetch_failure(): 429/rate-limit -> 'GitHub is rate
  limiting requests or having an outage — try again in 5 minutes';
  5xx -> outage message with githubstatus.com; ordered BEFORE the
  generic 'unable to access' network check
- both fetch-failure sites (update apply + --check) now share the
  classifier via _print_fetch_failure(), and both always print the
  first raw stderr line so the wire error stays diagnosable
- tests: classifier matrix + E2E against a live local HTTP server
  returning 429 through real git

Fixes NousResearch#89287
build_session_key embeds the workspace segment (scope_id) in every Slack
dm/group/thread key, but both cron seed helpers built their SessionSource
without it: the seeded row keyed agent:main:slack:dm:<chat>:<thread> while
a real scoped reply keys agent:main:slack:dm:<team>:<chat>:<thread> — a
row no reply ever resolves to. DMs were rescued only incidentally by the
legacy-key claim-once migration; scoped channels/threads got continuation
amnesia, and identical channel ids in two workspaces could collide.

Capture HERMES_SESSION_SCOPE_ID into the cron origin (_origin_from_env —
the session-context var async_delegation already snapshots), add scope_id
to _seed_cron_thread_session/_seed_cron_channel_session, and pass the
origin's scope at all three seed call sites.

Tests: scoped dm-thread / channel-thread / flat-channel seed-vs-reply key
equality through the real build_session_key, plus a two-workspace
non-collision guard.
…ilure-one-shot-rescue

feat: failing keyed web backends rescue onto the keyless ring for one call, never sticky
…e subcommand

'hermes --version' (and -V) now prints the full version report — banner
version line with upstream SHA, install directory, authoritative install
method, Python and OpenAI SDK versions, and update status — making the
separate 'hermes version' subcommand redundant. The subcommand is removed.

- _startup_fast.print_fast_version_info() is now THE canonical version
  printer: static lines print instantly from stdlib probes, then the
  banner label, install-method resolver, and update check lazy-import
  after the first line is on screen (each degrades gracefully).
- main.py _print_version_info() delegates to it (used by /version in the
  CLI chat surface and the --version flag path); the old duplicate
  implementation is deleted.
- hermes_cli/subcommands/version.py removed; parser wiring, subcommand
  sets, console-engine extraction entry, and tests updated. Hermes
  Console keeps a 'version' command wired to the shared printer.
- Termux fast paths now include update status too (previously
  check_updates=False).
- Docs/i18n, CONTRIBUTING, SECURITY, and nix checks updated to
  'hermes --version'.
…get)

The seed was decoupled from the mirror opt-in (in_channel is the
continuation surface regardless of attach_to_session), but the
thread-id-clearing gate above it still read mirror_this_target. With the
advertised default config (attach_to_session=false, cron.mirror_delivery
unset) and an origin carrying a real thread_id, the brief kept delivering
INTO the origin thread while the flat (thread_id=None) session got
seeded — brief and continuation surface in different places, so a plain
reply never saw it.

Flatten on the same gate as the seed: origin_target (with the existing
live_adapter_ready guard). Fan-out/broadcast targets are unaffected.

Test drives _deliver_result with a thread-carrying origin and default
knobs, asserting on the routed DeliveryTarget.thread_id — RED on the old
gate, GREEN now.
…on send_for_platform

Two gaps in the block-formatting hint stamping:

1. Wrong descriptor: _format_hints gated on self.descriptor — the PRIMARY
   identity's scalar — while one RelayAdapter fronts N platforms. A
   Slack-primary adapter stamped Slack hints onto known Discord chats; a
   Discord-primary adapter suppressed hints for Slack chats whose own
   negotiated descriptor advertised the bit. Resolve per destination:
   send/edit use _descriptor_for_chat (the same seam max_message_length
   already uses) plus the chat's logical platform for the config
   sub-block; the knob lookup is now per-logical-platform
   (platforms.relay.extra.<platform>.*) instead of hardwired to slack.

2. Missing lane: send_for_platform — the scheduled/persisted-home lane
   (gateway/delivery.py), i.e. the CRON delivery path, the flagship
   consumer of the in_channel brief — never stamped hints at all. Stamp
   there too, resolving descriptor_for_platform(logical) off the
   transport; the scalar descriptor is used only when it belongs to that
   exact platform (fail closed).

Tests: Slack-primary/Discord-chat no-leak, Discord-primary/Slack-chat
still-stamps, send_for_platform stamps for capable platform and stays
clean for incapable — all against a two-platform negotiated-descriptor
transport. Existing single-platform suite unchanged and green.
… its main tab (NousResearch#89788 follow-up)

The NousResearch#89788 gate read main-tab ownership from a plain module Map — invisible
to React — and openGroupChat set the selection atom before recording the
tab. Every open therefore rendered BotsPane in a selected-but-unowned
window, painting the in-pane room beside the main tab, and the duplicate
stuck because the later Map write repaints nothing.

- $groupMainTabsRev atom shadows tab-map membership; all mutations go
  through recordGroupMainTab/dropGroupMainTab; BotsPane subscribes, so the
  in-pane gate re-evaluates on tab open/close.
- openGroupChat records the tab BEFORE setting the selection atom; older
  desktops without the main-window door (and a throwing door) still get
  the in-pane fallback.
- Regression tests: gate is false at the instant the selection atom flips
  (fails on the old ordering — sabotage-verified), and rev bumps on tab
  open/close.
…atform's descriptor

RelayAdapter.supports_inchannel_continuable is a scalar adopted from the
PRIMARY identity's handshake descriptor, but one RelayAdapter fronts N
platforms and the connector advertises the bit per platform. Reading the
scalar for every logical platform both leaked a Slack-primary True onto
other fronted platforms (activating the flat surface their descriptor
never advertised) and suppressed a non-primary platform's advertised
True (forcing thread mode on capable Slack behind a Discord primary).

Add supports_inchannel_continuable_for_platform(platform): resolves the
platform's own negotiated descriptor via descriptor_for_platform (the
same Phase 1.5 seam max_message_length uses), scalar fallback only when
the per-platform descriptor is unavailable. The scheduler's D6 gate
prefers the query when the adapter provides it; native adapters keep
the class-attribute path byte-identically.

Tests: two-platform descriptor matrix (primary-True no-leak,
non-primary-True honored, unknown-platform scalar fallback).
_cron_mirror_delivery_enabled still promised 'cron deliveries live only
in the cron job's own session' as the unconditional default, but the
in_channel continuable surface now seeds the target session regardless
of attach_to_session/cron.mirror_delivery (the seed IS the continuation
feature, and in_channel is itself opt-in). State the carve-out where the
guarantee is documented.
…allback nuance

test_flat_key_wins_over_subblock asserted the OPPOSITE of its name (the
sub-block wins, matching _relay_slack_extra). Rename to what it proves.
Also note in _resolve_cron_surface_mode why its fallback differs from
_relay_slack_extra's all-or-nothing sub-dict: the flat key is the legacy
staging shape, and a flat knob applies to every fronted platform, gated
only by the per-platform D6 capability check.
…nent supervised watchers (NousResearch#84558)

NousResearch#84327 excluded _spawn_supervised's permanent watchers (session-expiry,
kanban, reconnect, the scale-to-zero watcher itself, ...) from
_scale_to_zero_has_live_background_work() via a _hermes_supervised_watcher
tag, because counting them made an armed gateway consider itself busy
forever and never go dormant.

Two more permanent, infinite-loop tasks are added to _background_tasks
OUTSIDE _spawn_supervised and were untagged:

- _loop_heartbeat_task (loop_heartbeat_forever, NousResearch#66892): a `while True`
  loop started unconditionally in start() on every gateway boot. Extracted
  the inline spawn block into _start_loop_heartbeat_task() so it's
  independently testable, matching the existing _start_heartbeat_poller()
  pattern.
- _heartbeat_poll_task (_poll_loop in _start_heartbeat_poller): also a
  `while True` loop, started the first time a session registers a
  heartbeat watch, and then permanent for the rest of the process.

Because _loop_heartbeat_task starts on every boot, it alone made
_scale_to_zero_has_live_background_work() return True forever on every
armed instance, regardless of the NousResearch#84327 fix -- confirmed empirically
against the real method with the exact untagged-task shape this task has.

Two new regression tests spawn each task through its real production
entry point and assert the busy check returns False; both fail against
the unfixed code (missing method / real assertion failure).

Co-authored-by: pierrenode <298902573+pierrenode@users.noreply.github.com>
Co-authored-by: Ben Barclay <ben@nousresearch.com>
…he page it opened

The in-app browser was a one-way mirror. open_preview put a page in the pane
and read_preview read its text back, but nothing could touch it. A click meant
falling back to the browser_* tools, which drive a separate Chromium the user
cannot see — so "log into this and pull my invoices" happened in a different
browser from the one on screen, with none of the sessions the user is already
signed into.

Four pieces, and they only make sense together:

  · an in-page engine that inventories what is interactable and performs the
    verb, injected as source because it has to run inside the guest page;
  · the preview.act.request bridge from the gateway into the pane;
  · drive_preview, for acting: elements, click, type, scroll, press, and the
    pane's own back/forward/reload;
  · annotate_preview, for marking without acting.

Those last two started as one tool doing two unrelated jobs. Leaving a mark is
not an action — it outlives the turn that drew it — so it gets its own verb,
and the interaction verb gets a name that says what it does.

Gating is the existing surface rule: desktop_ui folds in on session
source: 'desktop', and the bridge refuses to act for a background session, so a
turn running behind the user's back cannot reach into the page they are working
in.

Two details worth a reviewer's attention. Typing assigns through the
prototype's value setter, because React shadows value with its own accessor and
ignores an input event whose value it believes it already wrote — a plain
el.value = … types into a field that snaps back on the next render. And
clicking replays the pointer/mouse pair before activation, because frameworks
bind to mousedown as often as to click.
A dispatched MouseEvent is untrusted, so hover menus never opened and any
control that gates on isTrusted ignored it. The pane now sends input through
the webview itself: the pointer travels to its target and the page cannot tell
it from a hand.
Driving someone's browser invisibly is unnerving, and this browser is the one
they are signed into. The pane now draws the field the agent can reach, a box
round what it is touching, a cursor that goes there, and a wipe over text it
just read — one cursor primitive and one mark primitive, in a closed shadow
root so the agent's own inventory cannot see them. Marks carry the same handle
the agent addresses them by, so the word on screen and the word in the
transcript are the same string.

The point is supervision rather than decoration: a person glancing at the pane
can tell what is about to happen to their live session, and stop it.

It also has to cover the waiting. The agent flashes through a click in under a
second and then sits idle for the twenty to a hundred seconds the model spends
deciding what to do next, which is most of the wall clock of any task — so the
surface used to look broken during the part where it was working hardest. A
think stage runs off the $busy edge, sparsely flashing elements from the field
the last action left behind, and rest stops it. It guards itself: started
before there is an overlay or a field, it idles until there is one, so it can
be raised on the turn boundary without knowing whether anything has been
inventoried yet.

read_preview had the same hole from the other side. Reading is the cheapest
thing the agent does — hundredths of a second between two model round trips —
so paging through a document left the pane dark for twenty seconds immediately
after the one moment that showed anything. It draws a top-to-bottom wipe over
the text it took, and that is the one stage allowed to be a wipe: reading is
the only thing the agent does to a page in an order a person could follow.

Both go through preview-nudge, which says a single stage to an overlay the page
already has rather than re-shipping the engine to narrate. On a page the agent
never acted on it is a no-op, which is the honest answer — chrome there would
be a lie about what it did.

Everything respects prefers-reduced-motion.
…ole page

Every drive_preview action answered with the entire inventory — around 120
elements of ref, role, label, and an up-to-eight-rung `:nth-child` selector
chain. On a real app shell that was ~24.5k characters, re-sent after every
click, so a ten-step task paid for ten copies of a page that had barely moved.

Handles are now durable and legible. An element is named after what it is and
what it says — `btn-sign-in`, `inp-email`, `srch-search-projects` — minted once
per page and never reused, with duplicates disambiguated as `btn-edit`,
`btn-edit-1`. Each one remembers a stable attribute, its role, its accessible
name, and the nearest landmark it sits in, so when a framework destroys the
node and builds a new one the handle moves across and the agent is told
`rebound` rather than being handed a removal it has to react to and an addition
it has to re-read. The re-bind ladder is anchortree's (Apache-2.0), minus its
geometry rung, which can never clear the threshold on its own.

Because the handles hold, the first look at a page returns the inventory and
every look after it returns only what moved. `changed` carries the ref and
whichever of label/value/disabled actually shifted — role and selector are
absent by construction, since a change in either would mean the re-bind ladder
was looking at a different element. A delta gives way to a full re-read when
half the page is new, where there is nothing left to reuse.

The selector column is gone with it. It was 74% of the inventory on an
85-element page, nothing downstream ever read it, and a positional chain is
wrong the moment a sibling appears. An `#id` or `[data-testid]` survives when
the page offers one; everything else is addressed by handle.

Legibility is what makes the delta work rather than a nicety. `+ btn-sign-in`
on turn nine reads on its own, where `+ @e42` sends the model back to an
inventory twenty thousand tokens ago.

Measured on an 85-element app shell: 18,693 -> 4,930 characters for a baseline,
and a steady turn that moved two things costs ~200.
Agents and others added 18 commits August 22, 2026 12:22
(cherry picked from commit 74cd7eb)
(cherry picked from commit bb5c75d)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit fb06406)
(cherry picked from commit 0c2de40)
test_auth_add_xai_oauth_persists_under_external_owner exercises the
device-login persistence path via auth_add_command with no_browser=True,
but under pytest stdin is not a tty, so the unattended xAI device-login
guard (0c6fc49) raised SystemExit before the behavior under test ran.
Monkeypatch sys.stdin.isatty to True so the test models a deliberate
terminal-driven login; the guard's own rejection test is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 338531b)
…ocking

Salvaged from the pre-quicksilver stash (stash0-pre-quicksilver-20260720,
preserved patch in backups/runtime-upgrade-20260805), re-applied onto
runtime/fleet-20260805 and adapted to the v0.20.0 encrypted-cache drift.

* _cache.py: file_lock() advisory cross-process lock (flock/msvcrt, 30s
  timeout, best-effort on lock-file creation failure) + DiskCache
  .lock_path()/.lock().
* bitwarden.py: _shared_cache_home() (~/.hermes) as an L2b plaintext disk
  cache shared by all profile homes; cold fetches take the shared lock and
  double-check the cache before hitting the API, so 13 concurrent profile
  startups coalesce into one `bws secret list`. Encrypted cache stays
  profile-local (keyed off the bootstrap token) and opts out of sharing.
  Stale network-outage fallback and clear_caches() cover the shared copy.
* tests: cross-process coalescing test (two subprocess profiles, one bws
  call) + fixture keeps the shared cache out of the developer's ~/.hermes.

Dropped from the stash: auth.py/credential_pool.py/config hunks
(superseded designs) and the _apply_fleet_grants env-alias hunk — the
fleet-grants subsystem was not carried onto the v0.20.0 fleet branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 88805bf)
Extend the oauth.refresh_owner=external contract from xai-oauth to
openai-codex. Under external ownership Hermes adopts scheduler-owned
Codex pool tokens but never POSTs the refresh token: resolve gates
force/expiring refresh, _refresh_codex_auth_tokens (and its Codex-CLI
self-heal import) fail closed, pool proactive/reactive rotation adopts
from disk instead, and load_pool stops seeding the pool from the
singleton. Fleet's oauth_tokens.py scheduler remains the sole rotating
writer via oauth_token_write_authority=external-scheduler.

Interactive `hermes auth add openai-codex` passes interactive-login
write authority (single-site principle — no other add path is granted),
and an interactive re-auth under external ownership appends a
device_code pool row when none exists so Fleet recovery can restore an
empty pool.

External ownership with no usable scheduler-owned pool row raises typed
codex_external_pool_unavailable instead of silently rotating. Absent
config keeps runtime-owned behavior; malformed ownership fails closed.
Same-user accidental-writer fence only — not cryptographic.

Re-derived from stash0-pre-quicksilver-20260720 onto the committed xAI
ownership design (c0c0527); the stash's blanket interactive-login
grants across nous/minimax/qwen/dashboard add paths remain rejected.

Refs: NousResearch#77553
(cherry picked from commit e46cbd1)
(cherry picked from commit eff88d7a802b3b2e0c1e5e9f2c1e9d3c2b1a0f9e)

Rebased onto v0.20.5: upstream added an _orphaned_desktop_backend_pids
patch to this test and bound it to the import-time cli_main alias. That is
the exact binding this commit exists to remove, so it is retargeted onto
main_mod alongside PROJECT_ROOT and the subprocess tripwire.

tests/hermes_cli/test_update_venv_health.py: 5 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Telegram /new already started the next turn on the config primary. A
local APIConnectionError (errno 24) then jumped Sol to Luna and wrote
Luna into sessions.model, so /new felt like it did not restore primary.

Classify EMFILE as local_resource, block provider fallback, and stamp
the config default onto the new session row at reset time.

(cherry picked from commit 2f380ba)
Fallback block only when _block_provider_fallback is True. The live
checkout git guard now skips init branch options and honors -C when
init has no positional dest. Reconciles Hermes PR #7 CI slices 2/3/5.

(cherry picked from commit 0b3aa4d)
(cherry picked from commit 6b21ea9)

Rebased onto v0.20.5. Upstream rewrote the voice dispatch block (typed
callables, multi-file actual_paths loop), so this keeps upstream's structure
and reapplies only what this commit is for: send_voice targets voice_chat_id
from _resolve_auto_tts_delivery rather than event.source.chat_id.

Upstream's re-derivation of reply_anchor/thread_meta from the event is
dropped. It would have overwritten the resolved delivery anchors and pointed
the reply back at the webhook source, which is the bug this commit fixes.

tests/gateway/test_webhook_voice_deliver.py: 6 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`migrate()` documents `discover_plugins` as "when True (default), query
`plugin/list` against the live codex CLI ... Set False to skip the subprocess
spawn (for tests or restricted environments)".

Two tests never opt out. They assert config.toml RENDERING, not plugin
discovery, but leave the parameter at its default, so each run spawns the real
codex CLI, which shells out to
`git ls-remote https://github.com/openai/plugins.git HEAD` -- a live network
call to a third-party repository from a unit test.

Every other test in the file already gets this right: the ones that pass
`discover_plugins=True` monkeypatch `_query_codex_plugins` first. These two
just missed the switch.

Both tests PASS either way, which is why it went unnoticed -- the same silent
shape as the update-flow leak fixed in eff88d7. A suite that reaches the
network is non-hermetic (fails offline, depends on a third party's repo
staying reachable) and slower for no benefit.

Verified with a logging `git` shim that records every git invocation including
those from grandchild processes:
  before: 1 call -- `ls-remote https://github.com/openai/plugins.git HEAD`
  after:  0 calls
  20 passed in both cases.

(cherry picked from commit 3cc99d7)
* fix(computer-use): add background-only policy gate

* perf(computer-use): avoid config deepcopy in policy gate

(cherry picked from commit 386ad82)
Prevent pathological redactor backtracking on mixed secret-keyword and opaque payload output. Preserve existing redaction semantics and add a mutation-sensitive performance regression.

(cherry picked from commit e06bd01)
)

#16 made config scanning linear and covered the case where a large opaque
payload sits on its own line, away from the secret keyword. It did not
cover the shape that actually took a gateway down: the unbroken run being
the VALUE the secret key is assigned to.

Adds four regression tests:

- test_long_undotted_value_completes_fast — `token=<32 KB>`, the production
  shape. ~95s before the linear rewrite.
- test_undotted_value_scaling_is_not_quadratic — asserts on the growth RATE
  rather than a budget at one fixed size, so a reintroduced quadratic is
  caught while still cheap instead of only once catastrophic.
- test_long_undotted_secret_still_redacted — linear scanning must not stop a
  real long secret being masked.
- test_midtoken_keyword_still_not_matched — a key that merely embeds a
  keyword must stay untouched, guarding the candidate-scanning rewrite
  against widening what counts as a config key.

Tests only. The pattern fix this branch originally carried is dropped as
redundant: #16 already makes both shapes linear (`token=<64 KB>` went from
~6 minutes to 3ms), so re-adding a key-start lookbehind on top would change
a security-sensitive regex for no measured gain.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit d29eafb)
v0.20.5 added a lowercase env-name pass to redact_sensitive_text:

    [a-z0-9_]+(?:_|^)(?:key|pass|pw|token|...)(?=[^a-z0-9_]|$)

The greedy run scan restarts at every offset of a long [a-z0-9_] payload, so
an opaque base64/hex blob costs O(n^2). Measured on the pattern alone with
`token=` + 16K of filler: 1364 ms, against 0.2-0.3 ms for every other
pattern in the function. `redact_sensitive_text` runs on the gateway's hot
path, and a CPU-bound regex holds the GIL, so this is the same shape that
wedged the whole gateway on compaction payloads.

This is the defect PRs #15/#16 fixed in _CFG_DOTTED_RE, reintroduced upstream
in a different pattern. The three ReDoS regression tests from #15 fail on
stock v0.20.5.

Fix mirrors #16: anchor candidate discovery at a left boundary, scan the key
possessively, and move the <prefix>_<secret word> rule into a string-only
validator that cannot backtrack.

Verification:
- pattern alone, same input: 1364 ms -> 0.0 ms
- differential test of old vs new over 2066 generated cases covering secret
  words, prose lookalikes, separators, quoting and os.getenv values: zero
  divergence in match spans and groups
- tests/agent -k redact: 131 passed (was 128 passed, 3 failed), suite wall
  time 81 s -> 3.8 s

Worth sending upstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…custom

Delegation children frequently log `provider=custom` while still pointed at
the official OpenCode Go host. `recover_with_credential_pool` treated that as
a pool/agent mismatch, skipped the Go credential pool, and hopped model
(ox-alpha -> deepseek) instead of rotating CodeWalnut -> personal.

Match on the official Go base URL so the rotation path is taken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 37837de)
…t act on

Rotating onto a free OpenCode Zen model (x-preview-f-free, mimo-v2.5-free),
and the hop back off it to the configured primary, is routine capacity
shuffling rather than a durable state change. Buffering a status line and a
one-shot notice for it trained operators to ignore the notice entirely.

Suppress both for that pair of transitions. Every other provider/model switch
still buffers the line and records the notice; the logger.info record is kept
unconditionally so the transition stays in the log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit ff77569)
(cherry picked from commit df6521b)

Rebased onto v0.20.5, which reduces this to tests only.

The original commit added an accept-and-ignore `source` parameter so live
gateways calling transcribe_audio(path, None, "gateway") would not TypeError.
v0.20.5 already has `source` as a real parameter, forwarded to the
pre_transcription plugin hook, so the shim is dropped and upstream's
signature is kept.

The tests are still worth carrying. test_live_gateway_caller_stays_arity_safe
is rewritten: it grepped gateway/run.py for the two-argument call spelling and
asserted the three-argument form was absent, which upstream now legitimately
uses. It binds the real call shapes against the real signature instead, which
is the invariant that actually failed in production.

tests/tools/test_transcription_tools.py: 55 passed, 1 failed.
The failure (test_config_device_and_compute_type_passed_to_whisper, expects
compute_type=float32 and gets int8) reproduces on stock v0.20.5 with none of
this branch applied. Pre-existing upstream, tracked separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BenSheridanEdwards and others added 3 commits August 22, 2026 13:08
main carries nothing newer than v0.20.5; its fleet patches are all carried on
this branch already, under new SHAs from the rebase. `git cherry` confirms the
only commits whose patch-id differs are the ones deliberately adapted during
the rebase (telegram receipt, live-git test guard, webhook voice, redact
linear scan, STT arity) plus the one deliberately skipped (npm advisories,
#13 — upstream v0.20.5 already covers 4 of its 6 pins and adds more).

Conflicts therefore resolve to this branch's side in every case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fleet patches carried onto v0.20.5 encoded their rules as dense inline
expressions inside long functions. Same behaviour, extracted and tested.

agent/chat_completion_helpers.py
  The free-Zen quieting rule was eight underscore-prefixed locals inline in
  try_activate_fallback, with no test at all — the one change in #18 flagged
  as unverified. Extracted as _is_routine_free_zen_hop with named endpoints.

agent/redact.py
  The <prefix>_<secret word> rule already lived in _is_lower_env_secret_key
  but nothing exercised it directly; only the end-to-end redaction path did.

gateway/status.py
  The process-rollover check was an inline four-way comparison with a comment
  longer than the code. Extracted as _is_process_rollover, which lets the
  clear_profile_platforms boundary be stated as a test rather than prose.

agent/agent_runtime_helpers.py
  The Go-host check was a bare substring against a lowercased base_url.
  Extracted as _is_official_opencode_go_url with the marker as a constant,
  and it no longer assumes base_url is a str.

New coverage (+60 tests):
  - free-Zen quieting: both quiet transitions, six shapes that must stay
    announced, case/padding handling, and AST assertions that the notice and
    buffered line sit inside the guard while the log line stays outside
  - lowercase env-assign: key rule against the pattern it replaced, validator
    linearity, pattern scaling, and the underscore-run adversarial input
  - process rollover: matching/differing pid, recycled pid via start_time,
    and the no-identity payload that clear_profile_platforms depends on
  - OpenCode Go URL: official hosts, near-miss endpoints, empty and non-str

Touched test files plus the new one: 1039 passed, 3 failed. The three are
pre-existing and reproduce on stock v0.20.5. ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`check-attribution` scans every author between the merge base and HEAD. On a
pin cutover that is the whole upstream range, so 548 distinct author emails
are in scope and 15 had no mapping file.

Handles come from the GitHub commit API for each author's own commit, not
guessed from display names. Three had no login on the API:

- `tranquilflow@users.noreply.github.com` — handle taken from the noreply
  address and commit author name; it lacks the `id+login` form the check
  auto-resolves.
- `agent@Agents-Mac-mini.local`, `agents@Jarviss-Mac-mini.local` — machine
  identities from local git config, no GitHub account behind either.

Re-running the workflow's own logic locally now reports 0 unmapped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@BenSheridanEdwards
BenSheridanEdwards merged commit b664ea9 into main Aug 25, 2026
31 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.