Commit Graph
1014 Commits
Author SHA1 Message Date
69ffaf3443 Release: byte-size threshold for reconnect tail optimization (#6260, @webtecnica) (#6263)
* perf: optimize large session reconnect by adding file-size threshold to tail optimization (#6241)

When a sidecar JSON file exceeds 500 KB, the display-path tail optimization
now fires even if the message count is within the raw_budget. This prevents
sessions with few messages but large tool outputs (multi-MB JSON) from
forcing a full-scan merge of all messages on reconnect.

Changes:
- Added _sidecar_file_exceeds_threshold() helper
- Added _SIDECAR_BYTE_TAIL_THRESHOLD = 500_000 constant
- Fall-through to truncation in _state_db_since_timestamp_for_limited_display
  when the sidecar file exceeds the threshold, regardless of message count

* CHANGELOG: byte-size reconnect tail optimization (#6260)

---------

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-18 11:21:22 -07:00
92a7189e61 Release: durable run-journal recovery + full tool args (#6197, @franksong2702) (#6236)
* fix(streaming): prefer durable run journal recovery

* fix(streaming): reject stale recovery stream scenes

* test(streaming): pin todo recovery metadata guard

* fix(streaming): preserve recovery snapshot tool args

* test(streaming): align journal snapshot args contract

* fix(streaming): bound recovery snapshot tool args

* CHANGELOG: durable run-journal recovery + full tool args (#6197)

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-18 03:18:20 -07:00
ab937ef3ec Release: msg_limit ceiling metadata decoupling (#6214, @webtecnica) (#6216)
* feat: expose msg_limit ceiling via /api/session metadata, frontend reads dynamically (#6177)

Backend exposes _MAX_MSG_LIMIT as _msg_limit_max in every /api/session
response. Frontend reads it dynamically, falling back to _MSG_LIMIT_MAX
for older servers. This removes the hand-mirrored coupling between the
two layers.

Removed test_msg_limit_ceiling_drift.py since the mirror pattern that
required the drift guard is replaced by dynamic metadata.

This is the standalone metadata-decoupling piece from #6206,
without the clamp/paging that already shipped in exp-v0.52.98 via #6152/#6154.

* fix(session): declare _msgLimitMax at module scope + CHANGELOG + gate fixes (#6214 follow-up)

The submitted PR used _msgLimitMax at two read sites (boundedReloadLimit in
_ensureMessagesLoaded, useBeforePaging in _loadOlderMessages) but never declared
it and read it before assignment -> undefined on cold load -> full-transcript
fetch every load (regression) + implicit global. Declared `let _msgLimitMax =
_MSG_LIMIT_MAX;` at module scope so the reload-width paths always read a defined
value (the static fallback) until the server's _msg_limit_max lands.

Also: defined the ceiling globals in the two inline node-harness tests that copy
_ensureMessagesLoaded's body (test_cross_session_message_load_isolation,
test_session_unread_dot_on_visit), updated the source-string assertion in
test_webui_external_refresh_frontend, and added 3 decoupling tests (backend field
present, module-scope declaration + fallback, both paths read the live ceiling)
replacing the deleted drift-guard.

Gate: Codex adversarial SAFE TO SHIP (executable probes: cold-load fallback,
mixed-version omission, live update, cross-session isolation, over-ceiling bare
refresh, msg_before row preservation). Full sharded suite green.

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>

---------

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-17 21:21:52 -07:00
b470793473 Release: msg_limit ceiling + row-loss-safe pagination (#6152 + #6154, @rh-id) (#6213)
* fix(routes): clamp GET /api/session msg_limit to a server-side ceiling

Memory: ?msg_limit= had no upper bound — a client could request
msg_limit=1000000 (or the frontend's msg_limit=9999 outline-jump path) and
force the server to assemble and serialize an unbounded message payload. The
frontend's real pagination grows windows by ~30 at a time (initial load 30,
load-older +30, msg_before paging 30), so only the outline-jump and
pathological/hostile cases ask for huge values.

Add _MAX_MSG_LIMIT (500) and clamp at the query-parse site
(max(1, min(int(_msg_limit), _MAX_MSG_LIMIT))). The ceiling is generous — far
above any legitimate visible-row window — so real pagination is unaffected. The
existing _messages_truncated signal already covers the clamped case: when the
request exceeds the ceiling, _message_window_for_display returns
_messages_offset > 0 and the client learns more rows exist (load-older still
works). The bare no-msg_limit path is intentionally unchanged — it is the
documented "give me everything" escape hatch used by branch/undo/jump-to-start
flows that need the full transcript for correct global indices.

Test: tests/test_session_msg_limit_ceiling.py — the ceiling constant is
reasonable (100..2000), an over-ceiling request is clamped and signals
truncation, and legit sizes (5/30/60/100) are unaltered. Existing window/tail
tests (19) unchanged.

Contract Routing
- Task type: memory-bound fix (unbounded msg_limit request payload).
- Touched areas: GET /api/session query parsing (api/routes.py).
- Relevant public docs: AGENTS.md, CONTRIBUTING.md, docs/CONTRACTS.md.
- State layer mutated: none (read-only endpoint response shaping).
- Contract change (additive): ?msg_limit=N is now clamped to _MAX_MSG_LIMIT=500.
  Compatibility: the only frontend caller above the ceiling is outline.js's
  msg_limit=9999, which now receives up to 500 rows + _messages_truncated=true
  (load-older still works). All other callers (30/60) are unaffected. The bare
  no-msg_limit path is unchanged.

Release note: GET /api/session now caps ?msg_limit at 500 rows server-side,
bounding the worst-case payload for oversized requests.

Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).

* fix(frontend): route msg_limit-heavy paths around the server ceiling

PR #6152 clamps GET /api/session ?msg_limit= to a max of 500 server-side. Two
frontend paths relied on unbounded msg_limit and silently broke for sessions
> 500 visible rows:

1. outline.js _jumpToMessage sent msg_limit=9999 as a "give me everything" hack
   so it could scroll to any message by absolute index (msg-user-<rawIdx>).
   Clamped to 500, the target row's DOM id doesn't exist for early messages in
   long sessions, and the jump silently no-ops (line 119 `if (r)` is falsy).
   Fix: drop msg_limit=9999 — use the bare messages=1 path, which #6152 left
   unbounded precisely for callers that need the full transcript. This restores
   exact pre-#6152 behavior for outline jumps.

2. sessions.js _loadOlderMessages grows msg_limit by +_INITIAL_MSG_LIMIT each
   load (30 -> 60 -> ... -> 500 -> clamped). Past 500 the server returns the
   same clamped tail, olderMsgs shrinks to 0, and endless-scroll / "Load
   earlier" silently stalls — the head becomes unreachable. Fix: once the grown
   requestedLimit meets/exceeds the new _MSG_LIMIT_MAX mirror (500), switch the
   primary fetch to msg_before paging (a fixed _INITIAL_MSG_LIMIT backward page
   keyed off _oldestIdx) — the same bounded paging the existing race-fallback
   uses, so the head stays reachable for arbitrarily long transcripts. The
   response is handled by the existing prepend path (tailMatches is naturally
   false for a page, so the !tailMatches branch reuses the fetched data).

Added _MSG_LIMIT_MAX = 500 constant in sessions.js mirroring backend
_MAX_MSG_LIMIT, documented as kept-in-sync.

Verification: node --check passes on both files; the backend window-contract
tests (test_session_tail_payload, test_session_message_window_renderable_tail,
19 tests) still pass. No JS test harness exists in this repo (vanilla JS,
manual browser verification per TESTING.md) — manual verification needed: open a
> 500-message session, (a) click an outline entry for an early message and
confirm it scrolls+flashes, (b) scroll up past 500 loaded rows and confirm
"Load earlier" keeps producing older messages without stalling.

Contract Routing
- Task type: frontend follow-up to a backend memory fix (#6152).
- Touched areas: outline jump-to-message (static/outline.js), endless-scroll /
  load-older (static/sessions.js _loadOlderMessages).
- Relevant public docs: AGENTS.md, CONTRIBUTING.md, docs/CONTRACTS.md.
- State layer mutated: client-side S.messages (the active-session transcript),
  same as the existing load-older path. No backend state change.
- Scope boundaries: does not weaken the #6152 clamp. The bare messages=1 path
  (outline) was intentionally left unbounded by #6152; the msg_before paging
  path (load-older) is bounded by _INITIAL_MSG_LIMIT and never hits the ceiling.

Release note: fixes outline jump-to-message and endless scroll silently
breaking on sessions longer than 500 messages after the server-side msg_limit
ceiling (companion to #6152).

Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).

* test(frontend): update static-source assertion for refactored _loadOlderMessages fetch

The static-source test test_session_message_loads_keep_explicit_longer_timeouts
asserted the exact multi-line shape of the _loadOlderMessages fetch strings.
The #6154 refactor wrapped them in a useBeforePaging ternary (tail-growth vs
msg_before paging), reindenting them — the contract the test guards (both
fetches keep timeoutMs:120000 for large state.db loads) is preserved, but the
literal source shape changed, so the assertion failed on shard 0.

Updated the assertions to match the new source shape while keeping the test's
intent: assert each fetch URL + its {timeoutMs:120000} survive in sessions.js.

Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).

* test(msg-limit): stopgap drift guard for _MSG_LIMIT_MAX === _MAX_MSG_LIMIT

Reviewer guidance (#6154 round-2): the stopgap drift-test belongs in THIS PR
(#6152) because _MAX_MSG_LIMIT is defined here. The frontend _MSG_LIMIT_MAX
mirror lives on #6154 (not master yet), so the assertion SKIPS when the JS
constant is absent and activates automatically once both branches land on master.

The guard: extract _MAX_MSG_LIMIT from api/routes.py and _MSG_LIMIT_MAX from
static/sessions.js via regex; assert equality when both present. If they drift,
load-older silently stalls at the wrong value (the exact regression #6154 fixed).
Retired by #6177 (metadata-exposure), which removes the mirror entirely.

Verified: on this branch the cross-check SKIPS (_MSG_LIMIT_MAX absent, as
expected — #6154 not landed) and the backend-existence test PASSES. Extraction
logic confirmed correct against simulated match (500==500 passes) and drift
(999!=500 fails) cases.

Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).

* fix(session): prevent silent row-loss in msg_limit ceiling paging (gate follow-up)

Adversarial review of #6152/#6154 reproduced two silent row-loss regressions in
static/sessions.js; both fixed here, plus a stale source-string assertion update
and the CHANGELOG entry:

1. _loadOlderMessages: gate the suffix-continuity heuristic on !useBeforePaging so
   a raw-row-heavy msg_before cursor page whose visible text repeats the current
   tail can't be misclassified as the cumulative tail and wholesale-replace it
   (previously dropped 470 of 940 rows). msg_before pages are always prepended.

2. loadSession same-session refresh: only apply the width-hint msg_limit when it
   is <= _MSG_LIMIT_MAX; above the ceiling the backend would clamp and silently
   shrink an already-loaded >500-row transcript to the last 500. Over-ceiling
   reloads now use the bare full-transcript path (no msg_limit / no expand_renderable).

3. tests/test_webui_external_refresh_frontend.py: update the reloadLimitParam
   source assertion to the boundedReloadLimit form.

Re-gate: Codex adversarial SAFE TO SHIP (940/940 rows retained on cursor paging,
1000/1000 on refresh, no seam duplication, small sessions still use the fast tail
window); targeted + sessions/pagination/regression suites green.

Co-authored-by: Ruby Hartono <rh-id@users.noreply.github.com>

* test: fix two node-harness tests broken by the boundedReloadLimit change

- test_issue3162_ensure_messages_loaded: replace the brittle fixed 4500-char
  window (which the boundedReloadLimit lines pushed the carry-forward past) with
  robust brace-balance function-body extraction.
- test_cross_session_message_load_isolation: define _MSG_LIMIT_MAX=500 in the node
  harness env; _ensureMessagesLoaded now references it, and an undefined value made
  boundedReloadLimit=null -> the fetch URL dropped msg_limit/expand_renderable and
  mismatched the ordered api() harness -> stall/timeout.

---------

Co-authored-by: Ruby Hartono <58564005+rh-id@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: Ruby Hartono <rh-id@users.noreply.github.com>
2026-07-17 20:15:40 -07:00
mol 04783d11d8 fix: normalize Codex handoff base URL 2026-07-17 16:33:52 +00:00
mol 6c863ae9c9 fix: omit unsupported Codex handoff token cap 2026-07-17 16:17:19 +00:00
nesquena-hermes 13efcbe06c Merge remote-tracking branch 'origin/master' into p6153b 2026-07-17 01:10:27 +00:00
Ruby Hartono f445c3ae39 fix(routes): gate state.db row backstop on absence of truncation_boundary prefix
Reviewer regression (reproduced): _STATE_DB_DISPLAY_ROW_BACKSTOP (newest-50k SQL
cap) was applied UNCONDITIONALLY to the GET /api/session display path's state.db
read. But merge_session_messages_append_only needs the rows at/around the
session's truncation_boundary (the preserved prefix) to reconcile correctly. A
newest-N-only cap drops those boundary rows for a >N-row session and corrupts
the merge (silently losing the preserved prefix), and also prevents older-page
(msg_before) requests from ever reaching them. So a pathological-but-real >50k-
row compressed session got a corrupted merge / unreachable older history rather
than a clean bound.

Fix: gate the backstop on the SAME conditions
_state_db_since_timestamp_for_limited_display uses to decide a read is
boundary-free — not msg_before paging, and no truncation_watermark /
truncation_boundary. Those cases stay on the full (uncapped) read; the backstop
is now a defensive net for the common uncompressed initial-tail path only.
Extracted into _state_db_backstop_limit_for_display for direct test coverage.

The get_state_db_session_messages(limit=) signature + newest-rows subquery are
unchanged — only the display-path APPLICATION of the cap is gated.

Test: tests/test_state_db_backstop_boundary_gate.py — backstop applies with no
boundary prefix; SKIPPED when truncation_boundary / truncation_watermark set or
msg_before paging; empty-string markers treated as unset (matching the existing
helper). Existing backstop + reconciliation suites (53 tests) unchanged.

Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).
2026-07-17 07:19:47 +07:00
Ruby Hartono dec041997f fix(models): add defensive row backstop to display-path state.db message read
Memory: get_state_db_session_messages had no SQL LIMIT — it full-scanned the
state.db messages table and fetchall()'d into a Python list. The display path
(GET /api/session) already has a careful since_timestamp optimization that
bounds the common uncompressed tail load, but it deliberately bails (returns
None) on compressed sessions (truncation_watermark/boundary) and msg_before
paging — in those cases the read used to full-scan with no cap. A
pathological/huge state.db could then materialize unbounded rows into memory.

Add an opt-in `limit` parameter to get_state_db_session_messages (applied as a
SQL LIMIT that keeps the NEWEST rows via a DESC subquery re-sorted ASC). It is
a BACKSTOP, not a semantic window: the display window counts visible rows
post-reconciliation, so a true window LIMIT here would corrupt the
sidecar/state.db merge (see _state_db_since_timestamp_for_limited_display,
which deliberately does NOT SQL-LIMIT raw rows for exactly that reason).

Thread a generous _STATE_DB_DISPLAY_ROW_BACKSTOP (50000) from the GET /api/session
display caller only. The full-history model-context callers (reconciliation at
models.py:3437, context reconstruction at :8576, new-turn context at
streaming.py:8443) do NOT pass a limit — their semantics require the full
history, unchanged.

Test: tests/test_state_db_read_backstop.py — no-limit returns full history
oldest-first (model-context contract unchanged); limit=N keeps the newest N
re-sorted oldest-first; limit > row count returns all; limit<=0 clamps to 1
(newest); invalid limit falls back to unbounded; limit composes with
since_timestamp. Existing state.db active-filter + reconciliation suites (54
tests) unchanged.

Contract Routing
- Task type: memory-bound fix (unbounded state.db row materialization on the
  display path's bail cases).
- Touched areas: state.db message reader (api/models.py) + GET /api/session
  display caller (api/routes.py). NOT the model-context callers.
- Relevant public docs: AGENTS.md, CONTRIBUTING.md, docs/CONTRACTS.md,
  docs/rfcs/webui-run-state-consistency-contract.md (Model context layer).
- State layer mutated: none (read-only). The model-context read path is
  UNCHANGED (no limit passed) — Invariant 1 (visible current turns enter model
  context) and the reconciliation contract are preserved. Only the display path
  gets the defensive cap, which does not affect what the agent receives.
- Scope boundaries: this is narrower than a naive "add LIMIT everywhere" —
  _state_db_since_timestamp_for_limited_display's docstring explains why a
  semantic window LIMIT would corrupt the merge; this cap is purely a
  pathological-input safety net above any legitimate session size.

Release note: GET /api/session's state.db read now has a defensive row backstop,
capping memory when the bounded since_timestamp optimization bails on
compressed/paged sessions.

Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).
2026-07-16 23:40:26 +07:00
pxxD1998 aae2830459 fix(compression): preserve running job reuse 2026-07-16 22:56:41 +08:00
pxxD1998 798bc5fb51 fix(compression): reject stale runtime before async job 2026-07-16 22:43:19 +08:00
pxxD1998 0bab6a3d7a fix: handle remaining stale runtime entrypoints 2026-07-16 22:28:35 +08:00
pxxD1998 f722f68738 fix: scope stale runtime guard by execution owner 2026-07-16 22:28:35 +08:00
pxxD1998 5f1791e08d fix: reject stale runtime before chat mutation 2026-07-16 22:28:35 +08:00
pxxD1998 5df6b1fe8a fix: stop stale agent runtime after source updates 2026-07-16 22:28:35 +08:00
c39e8edaf1 fix(#6100): clear stale gateway approval attention badge (#6117) (#6125)
Reconcile the gateway pending-mirror in _session_attention_summary()
before counting sidebar approval attention, so a parent session's red
approval dot clears once the underlying gateway approval has drained.
Live approvals stay visible; clarify fallback unchanged. WebUI read-path
mitigation; child-session context-propagation root cause stays in #6100.

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-07-15 22:34:19 -07:00
nesquena-hermes 38119fac25 fix(#6056): extend sidebar title-field redaction to /api/sessions/search (Codex class-fix)
Codex re-gate found the same derived-title leak on the sibling /api/sessions/search
endpoint (3 branches redacted only 'title'). Extract _redact_sidebar_title_fields()
shared helper, apply it in both _sidebar_session_response_item and all 3 search
branches, read api_redact_enabled once per search response (mirrors #4662). +helper
test + source-guard that every search branch calls the redactor.
2026-07-15 21:06:36 +00:00
nesquena-hermes ffbc91343a fix(#6056): redact display_title/_state_db_title/parent_title on the sidebar API
Codex gate found the #6056 derived display_title (from raw user-message content)
bypassed sidebar redaction — the serializer redacted only 'title', so a credential
in a delegated goal leaked to the sidebar even with api_redact_enabled=True.
Redact display_title + _state_db_title + parent_title on the same path; +regression
test (proven to fail on the unfixed serializer).
2026-07-15 20:57:31 +00:00
webtecnica 40d5e17006 fix: allow manual force-check even when auto-update is disabled (#6082) 2026-07-15 12:18:04 -03:00
pxxD1998 73b6c5d604 fix(sessions): cascade delegated subagents on parent deletion 2026-07-14 18:10:03 +08:00
Isla-Liu 2e8915baf8 perf(webui): skip impossible state-db prefix normalization 2026-07-14 05:38:34 +00:00
Rui Xue b2c7578653 fix(sessions): guard all auto-mint paths + strict config/body parsing (gate review)
Gate review follow-ups for the #6022 no-leak rule: every non-deliberate
newSession/session-new call site passes worktree:false; only deliberate
New Chat inherits the config default.

- Strict boolean config read: only YAML true enables the default;
  quoted strings/ints/lists/dicts/null fall to the safe no-worktree
  default instead of truthiness-coercing.
- Presence-based explicit detection: 'worktree' in body — an explicit
  worktree:null is an opt-out, never a fall-through to config.
- Guarded remaining auto-mint call sites with explicit worktree:false:
  profile switch (panels.js), promptWorkspacePath + switchToWorkspace
  blank-page binds (panels.js), promptNewFile + promptNewFolder
  (ui.js), terminal auto-session (commands.js).
- Tests: worktree:null vs config-true (factory must not be called),
  strict-boolean malformed-shape matrix, static regression gates for
  every auto-mint site + a bare-body sweep so future auto-mint paths
  can't silently omit the key; updated the cmdTerminal preflight
  assertion for the new call shape.
2026-07-14 04:35:06 +00:00
Rui Xue 5f638a1694 feat(sessions): honor the agent's worktree config default in /api/session/new
/api/session/new treated an absent 'worktree' key as false, so the
agent's config-level 'worktree: true' default was never consulted for
WebUI sessions even though the WebUI reuses the agent's own worktree
helper. Both callers should agree on same-agent/same-repo isolation.

Three-value model:
- body 'worktree' absent -> agent config default applies (profile-aware
  via get_config_for_profile_home, so a user with worktree:true in one
  profile but not another gets per-profile behavior)
- explicit true/false -> honored verbatim; explicit always wins

Non-git workspaces: a config-default request degrades to a plain
session and surfaces 'worktree_skipped' in the payload; an explicit
worktree:true keeps the hard 400.

Frontend (same change, load-bearing): newSession() forwards explicit
true/false and omits the key otherwise; the boot-time auto-bind
(_maybeBindFreshDefaultWorkspaceSession) and the onboarding session
send worktree:false explicitly so a config default can never mint a
worktree + branch from merely opening the UI. Deliberate 'New Chat'
sends nothing and inherits the default.

Tests: route matrix (config on/off x body absent/true/false), non-git
fallback vs explicit 400, explicit-false-beats-config-true, config
helper (ambient/named-profile/fail-soft), static UI assertions that
auto-bind and onboarding opt out and New Chat paths don't pin a value.

Fixes #6022
2026-07-14 04:35:06 +00:00
rudidev08 34cf2de57a Fix: "Approve once" no longer persists as a session approval (#6017)
In the local (non-gateway) approval path, _resolve_approval_legacy grouped
"once" together with "session" and called approve_session() for both.
approve_session() records the pattern in the session-wide allowlist that the
guard later consults, so a single "Approve once" click silently approved every
later matching tool call for the whole session -- a destructive tool could then
run with no approval card.

Only "session"/"always" persist now. "once" falls through: the
resolve_gateway_approval() call below still unblocks the parked agent thread for
every choice, so the current tool call proceeds, but nothing is written to
_session_approved and the next matching guarded call re-prompts.

Adds tests/test_issue6017_approve_once_no_persist.py, asserting is_approved is
False after "once" and True after "session".
2026-07-13 13:05:52 -07:00
nesquena-hermes 53a4b35555 harden #5979 round 3 (Codex): signature-based explicit-pick provenance
Codex found a verified lifecycle hole: the bare boolean model_explicitly_picked
was not cleared on /api/session/update (api/routes.py:14438-14460 changes+persists
the model/provider without touching it), so after an update+reload the flag stayed
True and a cold #433 stale leftover (openai/gpt-5.4) was wrongly preserved instead
of stripped to gpt-5.4.

Fix (Codex's prescription): replace the unscoped boolean with a SIGNATURE bound to
the routing context.
- api/models.py: new model_explicit_pick_signature(model, provider) ->
  '<model>\x1f<provider>'; Session.model_explicit_pick_signature persisted field
  (replaces the boolean; in METADATA_FIELDS, restored via kwargs, survives restart).
- api/routes.py /api/chat/start: on a fresh explicit_model_pick, stamp the signature
  of the resolved model+provider.
- api/streaming.py: treat the send as a deliberate pick ONLY when the persisted
  signature equals the signature recomputed from the session's CURRENT persisted
  model+provider. Any model/provider change (chat-start, session-update,
  normalization, provider repair) yields a different signature and auto-invalidates
  the stale pick — no per-mutation-site clearing needed.

So: deliberate pick on unchanged context -> preserve (b3nw); any switch away ->
signature mismatch -> unmarked -> legacy strip (#433 stays correct even after a
session-update+cold-restart). Adds signature match/invalidate/persist tests.
75 focused tests pass (resolver + sprint40 + the 2 streaming-mock suites).
2026-07-13 01:58:20 +00:00
nesquena-hermes 45cb5bd3e6 feat(#5979): persisted explicit-pick provenance for cold custom-proxy resolution
Nathan's call: adopt Codex's complete mechanism instead of the unconditional
cold-preserve. The cold-catalog custom-proxy decision is now gated on whether the
user DELIBERATELY selected the model this session, closing the residual where a
stale #433 leftover would preserve+400 on a cold+no-disk send.

Mechanism:
- resolve_model_provider(model_id, *, explicitly_picked=False): in the custom-arm
  cold branch (no warm provenance, not config-declared), explicitly_picked=True
  preserves the vendor namespace verbatim (deliberate pick, proxy routes on it);
  otherwise the legacy redundant-prefix strip runs (stale cross-provider leftover,
  #433). Warm endpoint-advertised provenance still wins over the flag.
- Session.model_explicitly_picked: new persisted field (default False, restored
  from metadata via **kwargs, listed in METADATA_FIELDS so it survives a restart
  — b3nw's exact cold scenario).
- /api/chat/start stamps it: True on a fresh explicit_model_pick; cleared only
  when the resolved model actually CHANGED without a pick (real switch away);
  preserved across plain follow-up sends of the same model (the onchange marker
  is one-shot, so per-send explicit_model_pick alone would drop the intent).
- streaming.py reads s.model_explicitly_picked and passes explicitly_picked= into
  resolve_model_provider (inside the profile scope).

Independent end-to-end matrix (all pass): ben default cold, ben picked non-default
cold+warm, ben stale cold (strips), #433 warm+cold, #3872 bedrock, #548 zai-org.
67 resolver+sprint40 tests pass incl. new explicit-pick + stale-companion +
persistence round-trip.
2026-07-13 01:43:17 +00:00
b2ae71e01a Release exp-v0.52.48: post-compression context metering (#5872, #4685) (#5977)
* fix(#4685): meter post-compression model context

* Release exp-v0.52.48: meter post-compression model context (#5872, #4685)

---------

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-11 20:41:43 -07:00
b7b6b3a4c8 Release exp-v0.52.43: trusted-header auth / reverse-proxy SSO (#5568, #3351) (#5956)
* feat(#3351): add trusted-header auth core

* fix(#3351): reconcile trusted sessions per request

* fix(#3351): preserve valid trusted group mappings

* fix(#3351): defer trusted logout reconciliation until after csrf

* fix(auth): clear _pending_set_cookies in reset_trusted_auth_request_state (keep-alive cookie cross-request leak)

Codex gate CORE finding: a Set-Cookie queued in request N but not flushed
survived onto the reused HTTP/1.1 handler and could be emitted by request N+1
— after trusted-identity rotation on logout this could overwrite a valid login
cookie and 401 the user. Reset the queue at the per-request boundary + add a
keep-alive regression test.

* docs(.env.example): document trusted-header auth / reverse-proxy SSO env vars

HERMES_WEBUI_TRUSTED_AUTH_HEADER + TRUSTED_PROXY_CIDRS (with exact-proxy-IP
security warning) + optional groups-header/JSON group-profile-map/logout-url.

* Release exp-v0.52.43: trusted-header auth / reverse-proxy SSO (#5568, #3351) + keep-alive cookie fix + docs

---------

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-11 16:34:14 -07:00
nesquena-hermes 8c9d3635e2 lint: fix B904 raise-from in share session-resolve + drop unused importlib
CI ruff gate flagged 3 new violations: two api/routes.py KeyError re-raises in an
except-KeyError block (add 'from None') + an unused importlib import in the share
security test. All trivial.
2026-07-11 02:42:59 +00:00
nesquena-hermes e061e8534c Rebase #5337 (public read-only conversation sharing) onto master
Rebased 363 commits forward (branch-point 2026-07-05). Applied the PR's code
diff cleanly across 16 files; resolved api/models.py additively (both master's
process_wakeup_pause/anchor_scene_index fields AND the PR's share_token/
share_created_at fields kept in the Session constructor, metadata list, and
to_dict). CI workflow (.github/workflows/tests.yml) intentionally NOT taken from
the stale branch — master owns it (the branch's apparent removal of Playwright/
Office steps was a PAT-workflow-scope revert; rebase restores master's CI).

Co-authored-by: MicoRobot. Concept approved by maintainer (opt-in public share,
2026-07-11). Note: 4 dynamic share tests fail identically on the original
unrebased head (pre-existing test-env Session.load fragility, not rebase-caused);
to be resolved during the security re-gate in a proper sandbox.
2026-07-11 01:39:42 +00:00
tandrodboev 3188f88599 fix(#4812): break live SSE loop on a terminal event even when deduped at cutoff
Codex round-3 finding #2 [CORE]: when reconciliation replayed the active run's
terminal event at the subscribe cutoff, the live queue's copy hit the cutoff/dedup
'continue' BEFORE the terminal 'break' check — so the handler never broke, stayed
blocked on the dead run's queue, and missed subsequent session runs. Check the
terminal event type before the already-delivered continue: a terminal always ends
the loop (after emitting if not already sent). Regression test asserts the handler
unsubscribes (doesn't hang) when the terminal is deduped. 24/24 SSE tests green.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-07-10 20:47:06 +00:00
tandrodboev 2b22690fa4 fix(#4812): capture resume cursor + journal baseline before SSE headers commit
Codex round-3 finding #1 [SILENT]: baselining after end_sse_headers() left a window
where a run completing between header commit and baseline was absorbed into the
baseline + silently lost. Move resume-id parse + fingerprint baseline before
send_response/headers (both side-effect-free: header read + stat-only fingerprint).

Codex round-3 also flagged finding #2 [CORE] (reconciliation terminal-event-at-cutoff
dedup keeps the handler attached to a dead run queue) — NOT addressed here; escalating
to Nathan per Codex-depth diminishing-returns (3rd round into the same SSE seam class).

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-07-10 20:44:10 +00:00
tandrodboev 3c642e08f1 fix(#4812): hoist idle SSE fingerprint baseline to earliest point (before preliminary lookup + initial replay)
Codex re-gate found the baseline was still after the preliminary
_active_run_stream_for_session() lookup + the initial cursor read_session_run_events()
replay — a run completing during either window folded into the baseline and was
silently missed. Move the fingerprint baseline to immediately after resume_event_id
parsing, before both operations, so no pre-baseline completion window remains. Remove
the now-redundant later baseline. 23/23 SSE tests green.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-07-10 20:36:22 +00:00
tandrodboev 3ac19a5518 fix(#4812): capture idle SSE fingerprint baseline BEFORE first attach (TOCTOU)
Final-gate Codex caught a TOCTOU race in the prior idle-seam fix: the fingerprint
baseline was captured AFTER the first attach_active_stream() lookup, so a run
completing DURING that first attach folded into the baseline and was silently
missed (only : keepalive emitted, no snapshot). Move the baseline capture before
the first attach so any completion from that point on is detected. Add an ordering
regression test (baseline fp precedes the idle-loop attach + advance still emits
one snapshot). 23/23 SSE tests green.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-07-10 20:24:47 +00:00
tandrodboev 2829843a2a fix(#4812): re-sync idle no-cursor SSE subscriber on run completing during keepalive wait
Fable gate found a seam Codex's 3 boundary findings didn't cover: an idle no-cursor
subscriber to /api/sessions/{id}/events misses a run that starts AND finishes inside a
single keepalive tick — the wait loop only looked for a live in-memory STREAMS entry to
attach, never noticing the journal advanced, so the run's transcript was silently missing
until manual refresh.

Add a cheap bounded session_journal_fingerprint() (file-count/max-mtime/total-size from
stat only, never parses bodies) and, inside the idle attach-wait loop, emit a session_snapshot
re-sync whenever the fingerprint advances with no live stream to attach — the same honest
recovery contract used for a failed reconciliation. Re-baselines after each re-sync so a quiet
idle connection never spams snapshots. 2 regression tests; 22/22 SSE tests green.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-07-10 20:15:49 +00:00
tandrodboev 9600e1546a Merge #5677: resumable session SSE stream (#4812)
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-07-10 20:12:27 +00:00
Rod Boev 956c276362 fix(#4812): close session SSE replay recovery gaps 2026-07-10 10:46:47 -04:00
Frank Song 4c2400261a Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-10 21:21:47 +08:00
d7c3c2b734 Release: repair stale session provider binding at chat start (#5873, #5731) (#5881)
* fix(#5731): repair poisoned session provider at chat start

* fix(#5731): fail safe on incomplete catalog ownership

* changelog: session provider-repair on stale binding (#5873, #5731)

---------

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: t <a@b>
2026-07-10 06:11:34 -07:00
Rod Boev b2a9ca28ad fix(streaming): harden session SSE replay (#4812) 2026-07-10 07:31:00 -04:00
Frank Song 67b001e57c Fix wakeup pause recovery races 2026-07-10 07:08:36 +08:00
Frank Song 2ce9801088 Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-10 06:36:26 +08:00
34342b9f4d fix(security): trusted-proxy-verified local-origin gate (fixes #5764 terminal-gate bypass) (#5857)
* fix(security): trusted-proxy-verified local-origin gate (maintainer fix of #5764)

Rebuild _onboarding_request_is_local with a single, symmetric trust model that
closes the embedded-terminal local-gate bypass AND fixes the direct-LAN lockout
regression from the contributor's #5764 attempt (bounced with these findings):

1. SPOOF CLOSED: a forwarded client IP is honored ONLY when the un-spoofable raw
   socket peer is a trusted proxy (loopback, or HERMES_WEBUI_TRUSTED_PROXY_CIDRS).
   A public direct client sending X-Forwarded-For: 127.0.0.1 is judged on its raw
   public peer -> DENY. New _raw_peer_is_trusted_proxy + _forwarded_client_ip_from
   _trusted_proxy consume the FULL XFF chain (get_all, repeated headers), walk it
   right-to-left skipping trusted hops, and fail closed on empty/blank/garbage.
2. NO LAN LOCKOUT: a direct loopback/LAN client with no proxy header stays local
   (onboarding, first-password/passkey, passwordless terminal keep working).
3. COMPAT: TRUST_FORWARDED_FOR=1 stays the opt-in that consults the chain; the new
   TRUSTED_PROXY_CIDRS only WIDENS trust to a non-loopback proxy (loopback always
   trusted). Shipped private-peer-with-forwarded-header deny nuance preserved.

Updated the one existing test that encoded the vulnerable behavior; added a
21-case adversarial truth-table regression (tests/test_cvd3_terminal_local_origin_gate.py).
66 gate tests + 720 auth/terminal regression pass.

Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>

* fix(security): family-aware trusted-proxy membership (IPv4-mapped-IPv6)

Codex re-gate CORE: a mapped-IPv6 address (::ffff:10.9.9.9) never matched an
IPv4 CIDR allowlist via 'addr in net' -> (1) legit proxy treated as untrusted =
lockout; (2) mapped trusted HOP in the XFF chain mis-returned as client -> a
preceding public client admitted to the terminal gate. New _ip_in_networks
helper checks addr AND addr.ipv4_mapped; both the peer check and the chain-walk
_is_trusted_hop route through it. +3 mapped-IPv6 regression cases (incl the
public-client-hidden-behind-mapped-trusted-hop security case).

---------

Co-authored-by: t <a@b>
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
2026-07-09 15:33:16 -07:00
Frank Song 4af5334ebb Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-10 03:33:07 +08:00
2b457f1646 Release: cap StreamChannel offline replay buffer (#5788) (#5849)
Bounded deque(maxlen) drop-oldest so a disconnect-without-cancel can't grow the
per-turn offline replay buffer unbounded (per-abandoned-turn OOM risk, #4633 family).
Dropped frames recovered via run-journal last_event_id replay path.

Deep-review gate: Codex SAFE (journal-recovery verified end-to-end), 20 own + 1106
regression tests green. Crown-jewel streaming path.

Co-authored-by: t <a@b>
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-07-09 12:26:05 -07:00
Frank Song fe656a48ce Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-10 01:42:11 +08:00
7a1a122e6e Release Batch C: hidden-tab dashboard poll + SSE write-deadline + rate-limit map bound (#5840, #5841, #5842) (#5847)
Phase 2 low-risk backend/perf batch (ai-ag2026 reliability cluster). Each byte-verified
vs PR head, Codex SAFE, 33 own + 720 regression tests pass, disjoint routes.py regions.

- #5840: skip dashboard status poll while tab hidden (perf; forced polls unaffected)
- #5841: arm write deadline on session-events invalidation SSE (stuck-client teardown)
- #5842: bound client rate-limit maps by sweeping stale keys (long-uptime memory)

Co-authored-by: t <a@b>
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-07-09 10:38:47 -07:00
Frank Song 3a0c00c880 Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-10 00:22:56 +08:00
91606a15e0 Release Batch B: OPTIONS preflight framing + session-index bytes-parse (#5828, #5834) (#5838)
Phase 1 low-risk backend batch (on top of Batch A). Both byte-verified vs PR head,
Codex SAFE, 64 own + 749 regression tests pass, coexists with Batch A's #5833.

- #5828 (ai-ag2026): frame CORS OPTIONS preflight with Content-Length: 0 (keep-alive hang fix)
- #5834 (ai-ag2026): parse session _index.json from bytes not decoded str (all 10 sites)

Co-authored-by: t <a@b>
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-07-09 09:19:14 -07:00
Frank Song 96af63a95e Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-09 14:41:48 +08:00