Merging main brought in the RFC 8252 native sign-in path for password
providers (#75808), added while this PR was open. Its loopback-code
branch calls clear_pkce_cookie() without use_https, which is now a
required keyword-only argument — so /auth/native/password-login raised
TypeError on the success path.
This is the same call-site class the PR already fixed at the other three
sites: the deletion must mirror the shape the setter emitted for the
active origin, or the browser keeps the stale PKCE cookie.
Caught by CI running the merge commit against main's newer
test_dashboard_auth_native_flow.py suite, which does not exist on the
branch. Three tests failed there and pass with this change.
The SameSite=None change updated the website docs but left two
source-level contracts asserting the opposite:
- base.py: LoginStart.cookie_payload said cookies set there "MUST"
be SameSite=Lax.
- cookies.py: the module docstring said all three cookies are
SameSite=Lax.
Both now describe the actual behaviour: session cookies stay Lax, the
short-lived PKCE cookie is SameSite=None; Secure over HTTPS and Lax
over plain HTTP. A provider author following the old base.py contract
would have had a documented reason to undo the fix.
Also records the forwarded_allow_ips caveat in cookies.py: uvicorn only
honours X-Forwarded-Proto from a peer inside forwarded_allow_ips
(default 127.0.0.1), so a TLS terminator reaching the dashboard from a
non-loopback address (a reverse proxy in its own container) leaves the
request looking like HTTP and the cookies written in their HTTP shape.
Docstrings only; no behaviour change.
macOS Chinese pinyin IME: pressing Enter to confirm a candidate word in
the group-chat composer submitted the draft as a message mid-composition.
The GroupMentionInput onKeyDown checked only `event.key === 'Enter' &&
!event.shiftKey` with no IME guard, unlike the core composer which guards
isComposing + keyCode 229 (#44135).
Add the same guard to the three Enter handlers in the bots plugin:
- GroupMentionInput (group composer + reply box) — the reported bug
- GroupClarifyCard free-text answer input — same premature-submit
- skill-hub search input — same premature-trigger
Closes#93528
The hosted-provider misfire catch-up (fire_overdue_jobs) fired any runnable
overdue job with no one-shot grace check, so a stored past-due one-shot
bypassed ONESHOT_GRACE_SECONDS and executed arbitrarily late after downtime.
Sibling site of the due-scan gate from #89571; pins both directions with
tests.
create_job / update_job / resume_job all reject a one-shot whose run time is
more than ONESHOT_GRACE_SECONDS in the past ("will never fire"), and
_recoverable_oneshot_run_at never recovers such a schedule — but
_get_due_jobs_locked dispatched ANY one-shot whose *persisted* next_run_at was
in the past, even hours later (gateway down past the window, host asleep,
hand-edited jobs.json). A wall-clock one-shot then ran hours late, violating
the "will never fire" contract enforced everywhere else.
- Grace gate: a once-kind job whose next_run_dt is more than
ONESHOT_GRACE_SECONDS in the past is never appended to the due list.
- If no run_claim/fire_claim exists (nothing was ever dispatched), retire the
record with a diagnostic file so it stops being scanned and the miss is
operator-visible.
- If a (possibly stale) claim exists, a run may still be in flight in another
process: skip this scan but KEEP the record so its mark_job_run can land
(avoids re-introducing mid-flight record deletion).
- Manual re-trigger still works: trigger_job sets next_run_at=now (inside
grace) so an explicitly re-run stale one-shot fires.
Tests (tests/cron/test_oneshot_grace_due_scan.py): stale-not-due+retired,
within-grace-still-due, stale+claim-skipped-but-kept, retriggered-is-due, and
recurring-jobs-unaffected.
Behavior-contract tests for sanitize_task_id_for_path (colon/separator
removal, verbatim pass-through for existing safe ids, determinism,
collision-freedom incl. the a:b vs a_b digest case, traversal and
oversized-id bounds) and for the singularity persistent overlay path
(sanitized, verbatim for safe ids, distinct dirs for colon-vs-underscore
ids).
Co-authored-by: chelsealong <chelsealong@126.com>
Co-authored-by: Parker Fawcett <259203091+Parker-Fawcett@users.noreply.github.com>
Hoist the sandbox-directory sanitizer into tools/environments/base.py as
sanitize_task_id_for_path() and route BOTH host-path consumers through it:
the docker persistent sandbox (get_sandbox_dir()/docker/<id>) and the
singularity persistent overlay (hermes-overlays/overlay-<id>). One helper,
one mapping, whole bug class fixed in one place instead of per-backend
copies (#92414, #92640, #93044).
docker.py keeps _sandbox_dir_name as an alias of the shared helper so the
sanitized mapping (safe ids verbatim, digest suffix on rewrite for
collision safety) is unchanged for existing sandboxes.
Co-authored-by: salch-cred <salch-cred@users.noreply.github.com>
Co-authored-by: Parker Fawcett <259203091+Parker-Fawcett@users.noreply.github.com>
Drives the real DockerEnvironment constructor with a Telegram DM session key
and asserts every persistent -v spec is a two-field bind whose source holds no
colon — the assertion that reproduces exit 125 on the unfixed path.
The derivation's own contract is covered separately: ids that already work stay
verbatim (no sandbox migration), docker's separator and the path separators
never survive, ids differing only in rewritten characters keep distinct
directories, the mapping is stable across calls so cross-process container
reuse still resolves, pathological keys stay inside the per-component length
limit, and "."/".."/empty cannot resolve to the docker sandbox root.
With terminal.backend: docker and container_persistent: true, every gateway
session failed on its first tool call: docker run exited 125 with
"invalid spec ... too many colons" and no command could execute.
_resolve_container_task_id() returns "session:<key>" whenever a session key
is present, and gateway session keys are colon-delimited
(session:agent:main:telegram:dm:<chat_id>). DockerEnvironment joined that id
into the persistent sandbox path verbatim, so the -v spec became
".../docker/session:agent:main:telegram:dm:<id>/home:/root" — docker splits a
spec on ':', read the extra fields as extra mount options, and refused the
run. The container label a few lines below already guards this exact value
class via _sanitize_label_value(); the bind-mount source did not.
Derive the directory name through _sandbox_dir_name() instead. Ids that are
already bind-mountable are returned verbatim, so the shared "default" sandbox
and RL/benchmark rollouts keep their existing directory and no installed
package or /root state moves; only ids that could never have produced a
working mount are rewritten. A rewrite carries a digest of the original id,
because ':' -> '_' alone is not injective and would otherwise collapse two
chats onto one persistent /root.
Follow-up hardening on #92977 (issue #92976). The cherry-picked retry
wrapped every verb, so an ECONNRESET arriving after the backend had
already processed a POST (prompt submitted, session created) would
silently double-submit on retry.
- Extract the transport policy into electron/api-transport.ts so it is
unit-testable without Electron: keep-alive agent pools, transient
error classification, and a verb-gated withRetry.
- Retry rule: GET/HEAD/OPTIONS retry on any transient transport error;
POST/PUT/PATCH/DELETE retry only when the request provably never
reached the server (connect-phase failures like ECONNREFUSED /
ENOTFOUND, or an error thrown before the body was flushed —
requestState.bodySent === false). Ambiguous resets after the body
went out surface to the caller; when in doubt, don't retry.
- Separate keep-alive pools for JSON calls vs streaming downloads so
long downloads can't starve latency-sensitive JSON calls.
- Destroy pooled agents on app will-quit.
- Tests: shouldRetryRequest truth table, withRetry behavior, plus LIVE
transport tests against real misbehaving node HTTP servers: a GET
burst where the server resets keep-alive sockets (bare attempt fails,
retried succeeds) and a POST whose socket is RST after server-side
processing (hit counter stays 1 — no double submit).
Combining both PRs for issue #92993: #93149 makes set_pinned() return a
bool and _cmd_pin/_cmd_unpin exit 1 on a no-op write; #93002's tests
stubbed set_pinned with a None-returning lambda, which the combined
_cmd_pin now reads as failure. The stub reports True (write landed) so
#93002's messaging assertions exercise the intended success path.
`hermes curator pin` guarded on is_agent_created (a filesystem-shape
check), but the flag only matters when the skill carries the
curator-management marker: curated_report() walks marker-carrying skills
only, so auto-transitions never consider an unmanaged (pre-marker)
skill at all. Pinning one recorded the flag and then printed
"will bypass auto-transitions" — an effect that does not exist.
Keep the write (the flag becomes meaningful after `hermes curator
adopt`) and branch the message on is_curator_managed: unmanaged pins
now say the skill is unmanaged and point at adopt. Unpin gets the
symmetric wording.
Review feedback on #93149:
- _cmd_unpin now checks set_pinned's return (same false-success defect
existed symmetrically on the unpin path)
- curated_report() pinned-visibility branch requires a local skill dir,
so stale records for deleted dirs don't render as ghost rows
- test 2 asserts rc==0 unconditionally instead of vacuous-passing
- error message points to list-unmanaged (status doesn't render reasons)
`hermes curator pin <skill>` printed success even when the underlying
write never landed. set_pinned() routes through _mutate() with
require_curation_eligible=True, which silently returns None for skills
that pass is_agent_created() but fail is_curation_eligible() — e.g. a
user-created skill named "plan", which PROTECTED_BUILTIN_SKILLS blocks
by name. The CLI then announced a pin that does not exist (#92993).
Also, a pin that DID land on an eligible-but-unmanaged skill (no
created_by marker) was invisible: curated_report() only iterated
list_agent_created_skill_names(), which requires the management marker,
so the skill showed up under 'unmanaged' with no trace of its pin.
- set_pinned() now returns bool write success; _cmd_pin() checks it,
exits nonzero and explains the refusal when the write did not land
- curated_report() additionally includes curation-eligible skills whose
usage record carries pinned=true, so their pins are visible in status
Fixes#92993
While a fullscreen app owns the screen, the HUD becomes an in-game chat frame:
the idle bar steps back to a glanceable opacity, and the transcript is held
open for as long as the game is there rather than fading on a timer — you look
back at a chat log during a lull, not while the text happens to be fresh.
Detection is a pure pass over the same front-to-back window enumeration
read_window_below uses (electron/hud-game-overlay.ts); main polls it while the
HUD is open and pushes changes to the renderer, which owns the treatment. Two
details the enumeration forced:
- Hysteresis. Entering needs the game to be what the user is actually looking
at, so a windowed app on top vetoes it. Staying only needs the game to still
exist: clicking the HUD to type de-foregrounds the game and floats every
other open window above it, which otherwise dropped overlay mode at the
moment the user engaged with it.
- The last state is replayed on did-finish-load. The watch pushes only on
change and its first tick fires at window creation, before the renderer has
mounted its listener, so a HUD opened over an already-fullscreen game
consumed its only message and sat at 'no game' forever.
The band itself is reworked for living over someone else's window:
- Light-on-dark unconditionally. The theme's near-black body ink is unreadable
over a dark game, and every attempt to gate the light ink on some
condition — focus, then the game flag — produced a state where it evaluated
false and the words went black on black. The sheet is a dark scrim in every
theme so white is always right; anything that paints its own light surface
(a clarify question, an approval card, a code block, a form control) opts
back into theme ink by re-pointing the ink variable, matched on the fill it
paints rather than the feature it belongs to.
- Your own lines are gold rather than bubbled. With no card the log otherwise
reads as one voice; blue and purple are what most game UIs use for their own
text, so they disappear into the background.
- The scrollback ramps out at the top instead of being cut off, masked on the
scroller (the band is a static box — its rows overflow the thread viewport
nested inside it, so a mask on the band ramps over empty space).
- The sheet is inset under the bar, so its square top corners no longer poke
out past the bar's rounded ones.
setBackgroundMaterial on a transparent window permanently kills per-pixel
alpha on Win11 — every transparent pixel composites as opaque white, so the
HUD showed a white slab instead of the desktop behind it. Verified against a
minimal repro on Electron 40.10.2: the break happens with ANY material value
including 'none', which is exactly what the idle HUD asks for, and neither
'auto' nor a follow-up setBackgroundColor('#00000000') restores it.
The DWM backdrop and window transparency are mutually exclusive, so the
Windows HUD keeps the CSS tint its sheet already paints and skips the native
frost. macOS is untouched: setVibrancy composites correctly.
window-below asked node_modules for get-windows, whose lib/windows.js locates
its native binding through preGyp.find() — by HOST platform. When the tree was
installed on one OS and Electron is running on another (a WSL-hosted dev run
driving a win32 Electron), pre-gyp picks the host's slot, ignores the correct
binding sitting beside it, and upstream's fail-soft path returns no-op stubs.
Enumeration then reports 'unavailable' on a machine that answers perfectly
well, which silently disables read_window_below.
scripts/stage-native-deps.mjs already writes a staged lib/windows.js that
requires its binding directly, so prefer it and keep the bare import as the
fallback.
message_agent callers previously got provider prose (a raw 401
paragraph, a missing-provider essay) and could not branch on the
failure class. Now the #93091 item-1 reason enum rides the whole relay
roundtrip:
- Desktop relay drain forwards bot_relay.deliver's error.data.reason
into bot_relay.reply (and prefers it for the attention badge over
free-text re-parsing);
- write_reply already persisted reason / classified fallbacks;
- the sender-side waiter prints "[reason: <code>]" ahead of the free
text, so the completion notification the sending agent receives is
machine-branchable.
Additive everywhere: healthy replies unchanged, reasonless errors
classify to a code, old consumers keep working.
Consolidates the 429-quota-classifier cluster on top of the merged #93419
Anthropic core. Three independent contributor findings salvaged into one
coherent change to the single 429 branch:
- Broaden the 429 usage-limit check from the narrow 'usage limit' string to
the full _USAGE_LIMIT_PATTERNS ('quota', 'limit exceeded', 'key limit
exceeded') and add _BILLING_PATTERNS detection on 429 ('insufficient
credits' wrapped in a 429 instead of 402), guarded by a _RATE_LIMIT_PATTERNS
exclusion so an explicit 'Rate limit exceeded' never promotes to
non-retryable billing. (credit @Pluviobyte, #39441 — earliest submitter)
- Add 'resets in' to the transient signals: Codex's 'Weekly usage limit
reached. Resets in 6hr 29min.' wrongly read as terminal billing because
main only had 'reset in' (no substring match). (credit @LeonSGP43, #63021)
- Add 'reset after' / 'available in' / 'per minute' / 'per second' transient
signals. (credit @jtstothard, #74785)
Supersedes #65633 (defective branch placement, no tests). The aux-client
path already covers these shapes (_is_payment_error catches weekly/quota
walls; _is_rate_limit_error treats 'resets in' as transient), so no change
there.
Tests: 6 new cases (generic quota wall, insufficient-credits 429, rate-limit
guard, Codex resets-in, extra transient phrases). Guard sabotage-verified.
Co-authored-by: Pluviobyte <Pluviobyte@users.noreply.github.com>
Co-authored-by: LeonSGP43 <LeonSGP43@users.noreply.github.com>
Co-authored-by: jtstothard <jtstothard@users.noreply.github.com>
Salvage of #93214 (5 commits squashed onto current main; agent_runtime_helpers.py
diverged since the PR base and was 3-way reapplied). The credential-rotation
guard in recover_with_credential_pool and both restore_primary_runtime paths
only tolerated the custom-naming split when the agent carried the literal label
'custom', so a named custom provider (agent.provider='gemini-no-filter', pool
'custom:gemini-no-filter') tripped the mismatch guard and skipped rotation on
every 401/429. Now all three guard sites use the canonical
credential_pool_matches_provider boundary predicate + resolve_runtime_pool_key,
which recognizes configured named-custom aliases and validates endpoints.
Fixes#93188.
_auth_env fell through to os.environ on a scoped miss, so one profile
could inherit another profile's allowlists and allow-all flags.
bot_relay.waiter_command put connection_id into python -c source. A
quote in the id broke the waiter. A crafted id could run extra Python
in the sender gateway.
_run_delivery now captures output to drive the retry policy; the
turn-lock test's minimal _P fake predates that contract. Sibling-test
blast radius fix, no behavior change.
Maintainer ruling (2026-08-23): a retried bot turn never mints a fresh
session. retry_action() maps the #93091 item-1 reason enum to one of
resume / compress_then_resume / none:
- transient classes (runtime_offline, delivery_timeout, rate limit,
server error) re-run the same Bot Chat session once;
- context_overflow also re-runs the same session — the retried turn
goes through the pre-API compaction pass in conversation_loop.py,
which compacts the over-threshold transcript first (the one
sanctioned context mutation); no fresh-session escape hatch exists;
- auth/quota/config/model classes never auto-retry.
Wired at both delivery surfaces (fix the class, not one site):
bot_relay.deliver (relay handler) and _run_delivery (local
message_agent runner). Failed deliveries now carry the classified
reason in the structured error payload (error.data.reason).
Sabotage-verified: with the retry blocks removed, 3 consumer tests
fail; with them present, 22/22 pass.
When the bind is loopback and the only gate trigger is
dashboard.public_url, the startup refusal now says so explicitly and
gives both exits (configure a dashboard auth provider, or remove
dashboard.public_url if the proxy no longer exists). Prevents the
stale-public_url mystery-locked-dashboard upgrade trap.
Adds a truth-table regression suite for should_require_auth and the
fail-closed message shape.
`_init_schema` decided whether the FTS triggers needed repair by comparing
the live trigger count against `len(_FTS_TRIGGERS)`, the full six-name set.
Three of those six are the `messages_fts_trigram_*` triggers, and they are
declared only inside `FTS_TRIGRAM_SQL` / `LEGACY_FTS_TRIGRAM_SQL`, whose
`CREATE VIRTUAL TABLE ... tokenize='trigram'` needs a tokenizer SQLite only
gained in 3.34.
On an older build `_ensure_fts_schema` soft-fails that DDL by design (via
`_is_trigram_unavailable_error`) and returns False, so those three triggers
can never be created. The count is therefore pinned at 3, `3 < 6` is
permanently true, and the repair path ran on every single `SessionDB` open,
forever, while holding the SQLite write lock. It never converged: every
`hermes` command, gateway start, dashboard request and cron tick paid a full
re-index of the message corpus. That is ordinary LTS territory — Ubuntu
20.04 ships 3.31, RHEL/CentOS 8 and Alibaba Cloud Linux ship 3.26, and
Hermes has no minimum-SQLite gate precisely because it is supposed to
degrade gracefully here.
The v23 repair also ends by clearing `fts_rebuild_high_water` and
`fts_rebuild_progress`, which is correct after a genuine full rebuild but
means an interrupted `hermes sessions optimize-storage` silently lost its
resume point on the next open, restarting the chunked backfill from zero
every time.
Fix: keep `_FTS_TRIGGERS` as the single source of truth and derive two
subsets from it, then measure each half against the DDL that can actually
create it. `_fts_trigger_count` takes an optional `names` sequence
(defaulting to the full set, so no caller changes), and both branches gate
on `base_triggers_missing or (trigram_enabled and trigram_triggers_missing)`.
The counts are still taken before the DDL runs so they describe the
pre-repair state, while `trigram_enabled` is only known afterwards — hence
the combination at the `if` rather than at the assignment.
Behaviour is unchanged wherever the tokenizer exists: a genuinely missing
trigram trigger on a capable host still triggers the rebuild. Only the
permanently unsatisfiable comparison changes.