#134320 shipped lmstudio / tencent-tokenhub profiles with empty env_vars. The
provider-catalog e2e harness reads key vars from the profile only, so main's
`Python tests / e2e` went red (56 KeyError: '' errors + 2 switch failures);
#134385 restored the env vars. The PR's own e2e lane had been skipped by the
change classifier, which is how it merged green.
This invariant runs on the default test lane, which is never skipped: every
api_key profile backed by an api_key PROVIDER_REGISTRY row declares that row's
key variable first in env_vars. It fails on the pre-#134385 tree naming both
profiles and passes on main.
Branching the open chat calls navigate(child) and then resumeSession(child)
in the same tick. resumeSession captures the route token at entry, but
the router only publishes the new pathname on the next render, so the
token it captured still named the PARENT. When that render landed while
resume was awaiting resolveStoredSession/gateway, isCurrentResume() saw
the token change and returned, abandoning the resume before it bound the
warm branch runtime. The pane was left with the child selected, no
active runtime, an empty transcript and a loader for a row with history;
use-route-resume saw nothing to heal (selection already matched the
route, creatingSessionRef had suppressed the pathname edge), so the
child stayed on the spinner with no composer until the user switched
away and back.
isCurrentResume now treats the route arriving at the session being
resumed as still current (resumeRouteStillCurrent in
session-context-drift.ts); a route to any other chat still supersedes
the resume exactly as before.
De-flakes Desktop core E2E lineage-sidebar ("a branch child is titled
and is its own row"), which hit this ~2 in 10 runs.
restorePendingApproval joins restore-pending-clarify and
restore-pending-connection as a topical sibling of use-session-actions,
so the resume module sheds lines before the next fix touches it
(code-health file cap). No behaviour change.
Review (greptile): after the boot moved before the ack, the tests
checked that a relaunch keeps the marker but none checked that a boot
without a relaunch consumes it. Job children would otherwise inherit it
and repeat the dependency boot.
After the previous commit the worker's pre-ack recovery is the import of
hermes_bootstrap, not run_agent's copy; the module docstring and the
relaunch test still credited run_agent. Also restore the scheduler
comment's original wrap (whitespace-only churn from the first pick).
(Lead fold: quality + thermo + reuse lenses, Low.)
Importing hermes_bootstrap already runs _settle_interrupted_update
(restore_interrupted_pull + relaunch_after_restore) at module import, so the
explicit second call in finish_worker_boot was a duplicate. The worker never
imports hermes_bootstrap before __main__ (probed: not in sys.modules after
`import cron.scheduler`, marked or not), so the import in finish_worker_boot
is what runs the repair, before the payload is read and the ack published.
The interrupted_pull case of test_relaunch_replays_the_worker_before_its_ack
stays green.
run_agent's import also re-execs the process when it restores a tree a killed
`hermes update` half-wrote (_early_recovery.relaunch_after_restore). The
external worker never loads hermes_cli.main, so that check first ran inside
run_one_job, after the ack: the re-exec found its payload deleted, refused the
execution, and the adopted run was left `unknown`, the same class as the
source-update relaunch. finish_worker_boot() now runs the recovery before the
payload is read, so its re-exec replays the whole worker.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit b28779fd3bbaa1b92272448d17ccbb47f2794e0f)
The external worker first imported hermes_bootstrap through run_agent inside
run_one_job, after it had read (and deleted) its payload and published its
ownership ack. When the dependency inputs were not current, prepare_launch
synced and relaunched the worker into an -I interpreter that ignores the
pinned PYTHONPATH, and worker_bootstrap had already popped the boot marker:
the new process died on `import ruamel` and the adopted run was left
`unknown` (#122290 follow-up).
The worker's __main__ now runs hermes_bootstrap (finish_worker_boot) before
it touches the payload, and the marker is dropped only after that, so a
relaunch replays the whole worker and boots its dependencies again. The
call cannot live in worker_bootstrap(): during `-m cron.scheduler`'s package
import sys.argv[0] is still "-m" and __main__ has no spec, so the relaunch
command could not name the module. Covers the POSIX execv and Windows
subprocess.call relaunch branches alike.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit b537b59787acf3b3f5828c0fb6ce82482de40820)
#134320 registered ProviderProfiles for lmstudio and tencent-tokenhub without env_vars. That put
both into the provider-catalog E2E matrix for the first time with key_env=None. The shard fixture
then died on keys[""] (KeyError, every row of the shard errored), TokenHub turns could not find a
key, and LM Studio's /model switch could not reach the native /api/v1/models listing the fake
never served.
The profiles now name the same env vars their hermes_cli/auth.py registry rows use (that row stays
the lookup source of truth). The catalog fake also answers LM Studio's native listing.
The rotation tried to decide whether the current runtime was the one
the legacy mirrors were published from, first by key equality and then
by route-tuple equality. Neither identifies the publisher: a scoped
copy with an identical route still matched, and another session could
publish between the comparison and the write (both reproduced by the
second-model verifier). Nothing needs the republish: no production
code reads the legacy globals directly, and _compat_runtime_main()
ignores them while they equal the compat snapshot, which an unchanged
mirror always does. Rotate the published runtime dict in place only,
as before this PR's mirror handling, and pin that an unchanged mirror
never becomes a runtime input.
The scoped-runtime guard compared the mirror key with the old token. A scoped
runtime that shares the still-published old key (same account, other model or
endpoint) passed that check and overwrote the mirrors with its own route, which
outlived the scope. The guard now requires the runtime's fields to equal the
published compat snapshot, so only the publishing runtime republishes.
The split-out conversation_compression_codex imports only logging/typing, so there is no cycle to
dodge: move the function-local import into the top-level block beside
conversation_compression_telemetry and fix the sibling's docstring to match. Drop the
client_lifecycle comment that repeated rotate_runtime_main_api_key's own docstring.
_refresh_main_credential keeps its api_mode pre-check and _swallow wrapper: the callee returns
False off anthropic_messages but does not swallow exceptions from its holder moves / aux rotation.
Fold finding C15 #4 (gate quality review).
compress_context ran _refresh_main_credential before _announce_compression_start, so an expired
Claude Code token's blocking network refresh left the Desktop working row a bare spinner -- the
exact delay #111294 moved the announce first to avoid. Refresh right after the announce instead;
nothing between the two touches the client, and it still precedes the feasibility probe and the
summary call. The kept compression test now also asserts the announce saw the pre-refresh state.
Fold finding C15 #3 (gate efficiency review).
e9a77f4188's `_RUNTIME_MAIN_API_KEY == old` guard had no test: reverting it left every kept test
green, though rotating under scoped_runtime_main then raises KeyError 'base_url' (or, with the
.get() default, overwrites the turn's mirrors with the scoped copy). Extend the existing aux-route
test with the scoped rotate step; red with the guard removed (mirror model becomes 'm') and red on
the pre-guard code (KeyError), green on HEAD.
Fold finding C15 #2 (gate 2ab toothless guard).
The credential refresh moved _primary_runtime["anthropic_api_key"] to the new token but left
_primary_runtime["is_anthropic_oauth"] at the old token's value, while self._is_anthropic_oauth is
recomputed from the new token. _build_anthropic_client_from_runtime restores the key and flag
together after a fallback, so a refresh that changed token type (Console key -> OAuth) would
restore the new key with the stale flag. Update the flag under the same identity guard.
Fold finding C15 #1 (gate quality review).
Code-health ratchet: the pre-compression credential refresh grew agent/conversation_compression.py
to 4591 lines, over its 4579 cap on main. Pure move (body byte-identical), late-imported at its one
call site; no test or doc references the old path.
Manual /compress and turn-start compaction run before the next main request, so the
holder sync inside the main-request refresh never fired and the summary call carried the
revoked token. Replaces the bare holder-move test (its assertions are now checked through
compress_context), keeping the stack at two tests. Red without the pre-compression refresh.
Co-authored-by: Atul <atul@trycoral.ai>
The main loop re-resolves the Anthropic OAuth token before each request, but a compression that runs
before the next request (manual /compress, preflight compaction) still summarised with the token the
last request used. compress_context now runs the same refresh first.
(cherry picked from commit 63e22d597340aa042c3c736062e1190e31a9f96e, agent/conversation_compression.py hunk only)
Code-health ratchet: agent/auxiliary_client.py was 8421 > its 8407-line cap on main (+14) after this
stack added the key-rotation helper. Pure move of the existing function into a topical sibling;
the body is unchanged and it late-imports the facade's runtime-main state, so it still reads the
live legacy mirrors at call time. The only caller (client_lifecycle) now imports from the sibling.
scoped_runtime_main binds a normalized copy that drops empty fields, so rotating under it raised KeyError and would have written a scoped runtime into the mirrors. Refresh the mirrors only when they still hold the revoked key. Gate r2 quality finding; probe: KeyError 'base_url' before, clean after.
Gate finding B15-1 (Medium): the pre-dispatch refresh runs inside the request
worker's copied Context (_context_thread_target -> contextvars.copy_context().run),
so re-calling _publish_runtime_main rebound the ContextVar only in that copy; the
turn thread's later `auto` aux calls (approvals, goal judge, plugin llm) still read
the revoked token. rotate_runtime_main_api_key mutates the already-published dict
(shared by both contexts) when it still holds the old key, then refreshes the
legacy mirrors. No cross-session leak: set_runtime_main builds a fresh dict per
turn, so the shared object is this turn's only.
The kept e2e test now runs the refresh on a thread under _context_thread_target;
red on the previous head, green here.
turn_context publishes the auxiliary main runtime at turn start, before the
first request can trigger the silent Anthropic refresh. Syncing the agent-side
holders alone left that already-published runtime on the revoked token, so
same-turn `auto` aux calls (approvals, goal judge, plugin llm) still 401d.
Re-call _publish_runtime_main when the published key is still the old token;
identity-gated so a fallback or other-route publish is never overwritten.
Also keep #121757's end-to-end aux-route test, rewritten to production
order (publish, then refresh, then read).
Co-authored-by: SoyMarketing <yo@soymarketing.org>
Co-authored-by: mahdi <mahdi.nikfallah@yarnhub.org>
Claude Code revokes the old OAuth token when it refreshes, but
_try_refresh_anthropic_client_credentials only updated _anthropic_api_key.
agent.api_key, context_compressor.api_key (sent as the aux main_runtime for
compression) and the _primary_runtime restore snapshot kept the revoked
token, so every compression summary 401d for the rest of the session.
Sync each holder that still carries the old token (identity-matched, so a
compressor or snapshot on another route keeps its own key).
Rebuilt by hand on main from #121859 (conflicted in the test file).
Fixes#121756
Co-authored-by: SoyMarketing <yo@soymarketing.org>
_credentials coerced every value with str(), so YAML channel_secret: true
keyed the HMAC with the guessable "True" (likewise 123, lists, maps).
Read a value only when it is a string, matching the webhook adapter's
_is_usable_secret policy; scoped-env values stay first. (Lead fold: Sol
r4/r5 + quality lens, converged; probed: true/0 -> "" on head.)
HMAC zero-pads its key, so a whsec_ secret that decodes to NUL bytes
(whsec_AA==) signs exactly like the empty key b"" the previous fold
refuses; the whitespace-only guard let it through. Strip NULs with the
ASCII whitespace before the emptiness check. (Lead fold, 2c lens Low.)
_is_usable_secret only sees the encoded string, so "whsec_" (decodes to b"")
and "whsec_ICAg" (decodes to three spaces) passed it and keyed the Svix HMAC
with a public value: any caller could sign a request. Apply the same
blank-key rule to the decoded key before computing the HMAC.
`bridge_token:` with an empty YAML value became str(None) == "None" (and a
whitespace-only value stayed " "). Both are truthy, so connect() skipped
the auto-generated token and _authorized() accepted any wake/activity POST
carrying that header. Same class as the null-secret fixes for LINE and
WhatsApp Cloud in this stack.
Reading the standalone-send token through _credentials (earlier in this
stack) strips it, but no test failed when that read was reverted: the only
standalone test used an absent token. A whitespace-only token now has to
stop at "missing token" before the push API.
PlatformConfig keeps a numeric YAML api_key as an int, so the .strip() this
stack added to _standalone_send raised AttributeError where origin/main
returned an error dict. Coerce through str(... or "") like the LINE reader,
and pin both the int key and a whitespace-only key (which also gives the
standalone strip a test that fails without it).
An empty YAML value (`app_secret:` / `verify_token:`) arrives as None, and
str(None) made the inbound signing key the literal "None". The truthiness
guard in _handle_webhook then accepted it, so anyone could sign a POST with
HMAC("None", body). Same class as the LINE null-secret fix in this stack;
`or ""` lets a null read as unset and reach the existing 503 refusal.
_standalone_send still read the access token raw, so a blank or newline-padded token went out as the Bearer header; reuse the hardened _credentials. Pin the token half of the null/blank guard and the env presence strip (both reverted green before). Gate r4 Lows.
Reverting only the check_sms_requirements strip left the suite green; also drop the dead 'or ""' after reads that already pass a default. Gate r3 Lows.
c92ed7e000 wrapped the extra fallback in str(), so a present-but-null config value (an empty YAML key) became the guessable literal "None" and connect passed. Fall through to "" before str(); strip the env presence check too so check_requirements agrees with connect. Gate r3 High (2ab, 2c, quality converge).
Gate r2 findings 3 and 4. Mutating _dynamic_route_allowed to
`if not effective_secret:` stayed green, because no test reached the
hot-reload guard with a blank or non-string secret. The existing
parametrized connect case now asserts the dynamic-route guard rejects
the same secrets, and it goes red under that mutation (4/4). Also
rename the test and its param to match what they cover (unusable, not
only whitespace-only), and refresh the stale docstring to name
_is_usable_secret.
Gate r2 finding 2 (same class as the webhook blank-secret fix):
TWILIO_AUTH_TOKEN was read unstripped, so a whitespace-only token
passed check_sms_requirements and keyed the X-Twilio-Signature HMAC
with blanks, letting anyone forge inbound SMS. Strip the token at
every read (adapter init, standalone send, requirements check).
Stripping alone would leave an empty key, and HMAC with an empty key
is equally public (verified by probe: a signature forged with b''
was accepted). So _check_signature now refuses outright when no
auth token is set. This goes one line past the reviewer's FIX, which
only asked for the strip.
Gate r2 finding 1 (same class as the webhook blank-secret fix):
_credentials returned LINE_CHANNEL_SECRET / extra['channel_secret']
unstripped, so a whitespace-only secret passed the `not channel_secret`
check at connect and in verify_line_signature, and the webhook HMAC
was keyed with blanks, making signatures forgeable. Strip both values
in _credentials (matching whatsapp_cloud) so a blank secret reads as
unset and connect refuses with config_missing.
Fold finding 2 (gate): _is_usable_secret now also rejects non-string
secrets (a YAML int like `secret: 123` fails closed), but the startup
ValueError, the dynamic-route warning, the per-request log and the
comment in _read_authenticated_body still said "no secret" / "missing
or empty". An operator with an int secret would be told it is absent.
All three now say "missing, blank, or not a string"; the connect()
test gains an int row and matches the new wording.
Fold finding 1 (gate, 3 reviewers): is_usable_secret lived in the
experimental gateway/relay/auth.py, so every webhook import pulled in
the relay package (~2.5ms) for a one-line predicate only the webhook
adapter uses. Move it next to _hex_hmac as module-private
_is_usable_secret, revert both relay/auth.py hunks, and move the
parametrized classifier test into tests/gateway/test_webhook_adapter.py.
No behaviour change.
The salvaged tests only covered connect(); extend the existing
missing-secret request test with a whitespace-only route secret signed
with that same secret, which BASE authenticated. Drop the
real-secret-accepted connect case, already covered elsewhere, to keep the
stack at two invariant tests.
A blank or whitespace-only secret now fails closed at webhook startup,
on the request path, and when a dynamic route is admitted.
(cherry picked from commit 05e918d52c41e905d88e6c0a0f55e7612f4d899d)
The code-health ratchet (BLE001) rejects a blind except Exception that
neither re-raises nor logs the traceback. The record path already logs
at debug with exc_info; give the lookup path the same.
Cloud spectrum targets carry no direction and _sent_message_ids is
in-memory, so after a gateway restart a swipe-reply to one of our
bubbles hydrated the quoted text from the sent-text index but was
marked as not ours: the agent saw [Replying to: "..."] instead of
[Replying to your previous message: "..."]. Only our sends are indexed,
so an index hit is itself proof the target was ours.
Polls (clarify), the plain-text fallback resend and standalone attachment
sends reached the sidecar without recording what they sent, so a threaded
reply to those bubbles lost its quoted context. Record once in _post_send
(plus the standalone attachment leg, which has its own HTTP client) and
reuse _normalize_chat_key instead of a duplicate regex.