Commit Graph

28271 Commits

Author SHA1 Message Date
Teknium e4306ac5e2 chore(contributors): map salvaged PR author emails (#60420, #59076) 2026-08-02 10:01:16 -07:00
Frowtek 6dec4bfc88 fix(tui_gateway): install the parent profile's secret scope on session.branch
alongside every set_hermes_home_override() call site on the tui_gateway
path, and converted session.create, session.resume (both the _make_agent
and _init_session scopes), the lazy _build resume and the per-turn submit
handler. session.branch was missed.

The branch handler already binds the parent's HERMES_HOME and its
profile-scoped state.db — its own comment says it mirrors
session.create/resume — but builds the branched agent with no secret scope.
get_secret() then falls through to process os.environ, which in an
app-global/remote backend is the LAUNCH profile's environment: a session
branched off profile X authenticates with profile Y's credentials. That is
the same cross-profile resolution #67605 fixed for the sibling paths, and it
is silent (multiplexing is only activated by the messaging gateway, so the
fail-closed UnscopedSecretError path never fires here).

Install set_secret_scope(build_profile_secret_scope(parent_home)) for the
build and release it in the same finally block as the home override.

Salvage-note: code relocated from tui_gateway/server.py to tui_gateway/methods_session.py (session handlers moved after the PR was opened); test patch targets unchanged — HandlerRegistry rebinds handlers onto server.py globals
2026-08-02 10:01:16 -07:00
Teknium ff89f1b862 fix(email): Slack-pattern helper for unscoped default-profile adapter + scope ports/trust flag
Follow-up to the salvaged #59076 commit:

- Replace the bare get_secret import with a module-level Slack-pattern
  helper (_get_esecret): try get_secret, on UnscopedSecretError fall back
  to os.getenv. The DEFAULT profile's email adapter constructs UNSCOPED
  under multiplexing, where a bare get_secret raises and would crash the
  email path on startup — the exact WhatsApp defect fixed in 5438e9c629
  (whatsapp_common._get_wsecret).
- Extend scope coverage to the remaining scope-blind reads:
  EMAIL_IMAP_PORT / EMAIL_SMTP_PORT / EMAIL_POLL_INTERVAL (_esecret_int
  replacing utils.env_int) and EMAIL_TRUST_FROM_HEADER (_esecret_bool
  replacing utils.env_bool).
- Add tests: default-profile unscoped-under-multiplex construction, and
  scoped ports/trust-flag no-environ-inheritance.
2026-08-02 10:01:16 -07:00
shikanga-hermes f08f403157 fix(email): honor profile secret scope for email adapter env reads
The email adapter (plugins/platforms/email/adapter.py) read
EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_IMAP_HOST, EMAIL_SMTP_HOST,
EMAIL_ALLOWED_USERS, and EMAIL_ALLOW_ALL_USERS via os.getenv()
directly. In a multiplexed gateway, os.environ holds the default
profile's .env values, so every secondary profile inherited the
default profile's email credentials instead of its own.

This was a sibling of the api_server env-leak bug (#52307/#50051):
the same os.getenv→get_secret migration that PR #50094 applies to
gateway/config.py, but for the email adapter itself, which neither
PR #50094 nor #51374 covers.

Changes:
- plugins/platforms/email/adapter.py: replace os.getenv with
  agent.secret_scope.get_secret for all EMAIL_* credential reads
  (adapter __init__, check_email_requirements, _allowlist_in_effect,
  _dispatch_message allowlist gate, _send_email SMTP helper).
- gateway/config.py: add _getenv/_getenv_str/_getenv_int helpers
  (from PR #50094) and replace os.getenv with _getenv for the email
  block in _apply_env_overrides, so config.platforms[EMAIL].extra
  is populated from the scoped value.
- tests/gateway/test_email_secret_scope.py: 5 new tests covering
  scoped credential reads, environ fallback without scope, missing-
  key-no-leak, allowlist scoping, and check_email_requirements scoping.

Related: #50051, #52307, PR #50094, PR #51374
2026-08-02 10:01:16 -07:00
Teknium ed9986873d test(qqbot): mark environ-opt-in isolation case xfail pending authz_mixin gate PR
The cherry-picked #60420 hunks that converted gateway/authz_mixin.py are
dropped here: main's _auth_env/_platform_gate_env supersede them, and the
remaining authz_mixin raw-read conversions (allow-all flag + allowlists at
L459/501/879-885) land in a separate PR. Until that PR flips the allow-all
read to scope-authoritative semantics, the cross-profile environ-opt-in
inheritance case is a known gap — pin it as strict xfail so the separate PR
flips it green.
2026-08-02 10:01:16 -07:00
Da7-Tech 4804c585ac test(qqbot): pin the scoped platform user-allowlist authz read
Follow-up to the review-hardening commit: the existing cases exercised
QQ_ALLOW_ALL_USERS but not the QQ_ALLOWED_USERS read at authz_mixin.py
line 459, so a revert of that line to raw os.getenv would still pass.
Add a scoped-allowlist DM case (scope admits the sender, environ does
not) plus its isolation counterpart (a secondary scope listing a
different user must not inherit the primary's environ allowlist). Both
fail if line 459 reverts to os.getenv.
2026-08-02 10:01:16 -07:00
Da7-Tech e8c5cb5710 fix(qqbot): scope the authz, startup-validation, and direct-send QQ reads
Review follow-up: the adapter-level resolver alone left three paths
reading per-profile QQ_* values from raw os.getenv, so a secondary
multiplex profile's scoped opt-in or credentials were ignored (or the
primary's environ values leaked in):

- gateway/authz_mixin.py: route the per-platform allow-all flag and the
  per-platform/group allowlist + allow-bots reads through the
  scope-aware gateway.config._getenv. Deployment-global GATEWAY_* reads
  intentionally stay on os.getenv. This makes the same fix effective
  for every own-policy platform, not just QQ; unscoped behavior is
  byte-identical to os.getenv.
- gateway/run.py (_own_policy_open_startup_violation): resolve the
  per-platform dm/group policy and allow-all opt-in via _getenv; the
  secondary-profile caller already runs inside _profile_runtime_scope.
- tools/send_message_tool.py (_send_qqbot): the QQ_APP_ID /
  QQ_CLIENT_SECRET fallbacks now honor the active profile scope.

Tests: tests/gateway/test_qqbot_scope_paths.py covers all three paths
end-to-end (scope wins, no environ inheritance for non-opted profiles,
single-profile environ fallback unchanged); the STT suite now asserts
QQ_STT_BASE_URL and QQ_STT_MODEL scoping alongside the API key. All
five scoped-behavior tests fail on the previous commit and pass here.
2026-08-02 10:01:16 -07:00
Da7-Tech 224e59df52 fix(qqbot): resolve credentials under the active profile secret scope
The QQ adapter read QQ_APP_ID, QQ_CLIENT_SECRET, the QQ_STT_* backend
config and the QQ_ALLOW_ALL_USERS policy flag through raw os.getenv,
bypassing the active profile secret scope. In multiplex mode a secondary
profile whose secret lives in its own .env (installed as an isolated
scope, not into os.environ) would silently fall back to the
default/primary profile's value — the same cross-profile collision fixed
for the WeChat/weixin adapter in #59662.

Route these reads through a scope-aware resolver that reads the profile
scope when one is installed (secondary profiles and per-turn inbound) and
falls back to os.environ otherwise. The fallback is deliberate: the
primary/active profile is constructed without a scope and owns
os.environ, so a bare get_secret would raise UnscopedSecretError and
break its startup. Mirrors gateway.config._getenv.

Adds regression tests including active-profile-no-scope construction (the
fail-closed case), plus scope-wins-over-environ, two-profile isolation,
single-profile fallback, explicit-config precedence and STT key scoping.
2026-08-02 10:01:16 -07:00
Teknium 7d4c8f9a54 fix(secrets): scoped BLUEBUBBLES_PASSWORD + API_SERVER_KEY init read; add parametrized regression tests
api_server's __init__ API_SERVER_KEY read now matches the scoped
_expected_api_key path. tests/gateway/test_adapter_startup_secret_scope.py
asserts, for every migrated module: helper exists, scoped read wins, scoped
miss returns default (no environ borrow), unscoped-under-multiplex falls back
to environ without raising, and legacy single-profile reads still work.
2026-08-02 10:00:44 -07:00
Teknium f4b268b785 fix(secrets): Slack-pattern scoped credential reads — Feishu, WeCom, Photon, Buzz
FEISHU_APP_SECRET/FEISHU_ENCRYPT_KEY/FEISHU_VERIFICATION_TOKEN, WECOM_SECRET,
PHOTON_PROJECT_SECRET/PHOTON_SIDECAR_TOKEN (adapter + auth.load_project_credentials)
and BUZZ_PRIVATE_KEY now read through _get_scoped_secret.
2026-08-02 10:00:44 -07:00
Teknium 6333180c9a fix(secrets): Slack-pattern scoped credential reads — ntfy, Home Assistant, SMS, DingTalk
NTFY_TOKEN, HASS_TOKEN, TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN and
DINGTALK_CLIENT_SECRET now read through _get_scoped_secret. Also replaces
the SMS adapter's bare os.environ["TWILIO_AUTH_TOKEN"]/["TWILIO_ACCOUNT_SID"]
__init__ reads (KeyError-prone) with helper reads defaulting to "".
2026-08-02 10:00:44 -07:00
Teknium d7404f197c fix(secrets): Slack-pattern scoped credential reads — IRC, LINE, Teams, Mattermost
Route IRC_SERVER_PASSWORD/IRC_NICKSERV_PASSWORD, LINE_CHANNEL_ACCESS_TOKEN/
LINE_CHANNEL_SECRET, TEAMS_GRAPH_ACCESS_TOKEN/TEAMS_CLIENT_SECRET and
MATTERMOST_TOKEN reads at __init__/availability/standalone-send time through
a module-level _get_scoped_secret helper (get_secret, UnscopedSecretError ->
os.getenv fallback), mirroring whatsapp_common._get_wsecret / Slack #59739.
Scoped miss returns the default — no cross-profile environ borrow.
2026-08-02 10:00:44 -07:00
Ray 062d44bba7 fix(xai): fail closed in xai_http.get_env_value — honor get_secret's verdict
Salvaged from #56982 (@rayjun): the live piece of the PR. The
hermes_cli/config.py get_env_value scope-honoring change and its
test_env_load_cache.py tests were already merged via ed1170cd8b
(#76462) and are dropped here.

tools/xai_http.py::get_env_value wrapped the scope-aware
hermes_cli.config.get_env_value in except Exception + a raw os.environ
fallback — swallowing UnscopedSecretError and borrowing the process
env, so a multiplexed xAI credential read could silently pick up
another profile's XAI_API_KEY. Narrow the except to ImportError (the
only legitimate degraded case) so get_secret's verdict propagates: an
unscoped multiplexed read fails closed, and a scoped miss returns the
default instead of the foreign environ value.

Co-authored-by: rayjun <rayjun0412@gmail.com>
2026-08-02 09:59:52 -07:00
Teknium 44640149f7 fix(secrets): allowlist API_SERVER listener settings as global deployment env
Fixes #69379 (v2026.7.20 Docker multiplex regression: the scoped runner
reload dropped API_SERVER_* set via the container environment, silently
losing the api_server platform) by the canonical mechanism — corrects
the direction of #69524, which patched gateway/config._getenv to fall
through to os.environ on EVERY scoped miss, re-opening the
cross-profile borrow for all credentials.

API_SERVER_ENABLED / API_SERVER_HOST / API_SERVER_PORT /
API_SERVER_CORS_ORIGINS are deployment listener settings (Docker
compose environment: block, systemd Environment=), not profile
secrets: they join _GLOBAL_ENV_EXACT so get_secret reads them from
os.environ regardless of scope. API_SERVER_KEY is deliberately NOT
allowlisted — it IS a credential and stays profile-scoped, which keeps
tests/gateway/test_config.py's secondary-profile isolation semantics
intact (a secondary profile without the key still doesn't bind a
listener).

Ports #69524's regression test in the corrected form: container-env
API_SERVER_* stays visible during the scoped runner reload while the
key resolves through the profile scope; plus unit tests locking the
allowlist membership and the deliberate exclusion of API_SERVER_KEY.
2026-08-02 09:59:52 -07:00
Teknium f7efe3d766 fix(weixin): Slack-pattern secret scoping for WEIXIN_* credential reads
The adapter's __init__ and send_weixin_direct read WEIXIN_ACCOUNT_ID/
TOKEN/BASE_URL/CDN_BASE_URL via bare get_secret, which raises
UnscopedSecretError when the DEFAULT profile's adapter constructs or
sends unscoped under multiplexing (corrects the direction of #66073 /
#68854, which tried to solve this by borrowing os.environ on every
read — a cross-profile leak).

Add a module-level _wx_secret helper following the established Slack
SLACK_APP_TOKEN pattern (#59739) and WhatsApp's _get_wsecret: a SCOPED
miss returns the default (the scope is authoritative — no environ
borrow), while an UNSCOPED read under multiplex falls back to
os.environ, which is the default profile's own value.

Regression tests cover both directions: scoped construction reads the
scope's value and a scoped miss yields empty (no borrow); unscoped
construction falls back to os.environ instead of raising.
2026-08-02 09:59:52 -07:00
Teknium 3fb066c372 fix(agent): dotenv-compatible inline-comment stripping in load_env_file
Unquoted values truncate only at a # preceded by whitespace (so
KEY=foo#bar stays intact); quoted values scan escape-aware for the
matching close quote, keep through it, and drop a trailing '# ...'
remainder. Verified empirically against python-dotenv 1.2.2 on a
10-case corpus (full parity). Supersedes the approach in #57718, whose
scanner corrupted foo#bar-style values.
2026-08-02 09:58:59 -07:00
spfcraze 55abf206bf fix(agent): unescape quoted .env values in secret_scope.load_env_file
save_env_value writes values containing " or \ as escaped
double-quoted strings, and every other .env reader in the repo
(load_env/_parse_env_value, python-dotenv) reverses those escapes.
load_env_file — the parser behind build_profile_secret_scope, which
wraps every cron job and every multiplexed gateway turn — only stripped
the outer quotes, leaving the escapes literal. A credential containing
a double quote or backslash (JSON service-account blobs, generated
secrets) authenticates fine interactively but 401s under scoped
resolution, with no error pointing at the cause. Parse values with the
canonical _parse_env_value so all readers agree byte-for-byte.
2026-08-02 09:58:59 -07:00
Fangliquan 696fae8e78 fix(agent): strip UTF-8 BOM when loading profile .env into secret scope
Windows editors often save .env with a leading BOM; plain utf-8 left U+FEFF
on the first key so multiplex get_secret missed that credential.
2026-08-02 09:58:59 -07:00
Teknium fe497d8722 test(update): make EOL-churn dirtiness deterministic under racy-git stat caching
test_churn_across_more_files_than_fit_in_one_argv (e65ff9625f) asserts all
1200 checked-out files read dirty before normalization. Whether git diff
content-compares an entry (seeing the CRLF churn) or trusts the stat cache
depends on racy-git detection: entries whose recorded stat is non-racy
(mtime older than the index write) read CLEAN. On CI a 1200-file checkout
straddles that boundary nondeterministically — observed 92/1200 and
661/1200 dirty on two unrelated PRs within minutes (runs 30738759530,
30738842393). Empirically reproduced: freezing a non-racy stat cache gives
0/N dirty; bumping worktree mtimes past the index write forces content
comparison and gives N/N deterministically.

Fix: bump every worktree mtime after checkout in _managed_repo so all
entries are stat-stale. Affects only the fixture; the production
_normalize_managed_eol path is untouched.
2026-08-02 09:58:49 -07:00
kshitijk4poor 45c15d33e5 test: update fetch_api_models call-shape assertions for explicit timeout
Three tests pin the exact kwargs of the picker probe call
(test_model_switch_custom_providers + two in
test_user_providers_model_switch, the latter caught by CI slice 2);
the picker-timeout change now always passes timeout explicitly (5.0 on
the non-picker path), so the pinned shapes gain the key.
2026-08-02 22:16:52 +05:30
ZachariahChu de0ce24c2e perf(cli): cap /model picker custom-endpoint probe at 1.5s
The interactive /model picker probes the current custom endpoint live via
fetch_api_models(), which defaults to a 5s timeout. A slow or flaky custom
endpoint blocks the picker for up to 5s on open. The lmstudio picker probe
already uses a 1.5s timeout for exactly this reason; apply the same fast-fail
bound to the three custom-provider probe sites, gated on for_picker so the
non-picker (5s) path is unchanged.
2026-08-02 22:16:52 +05:30
kshitijk4poor 4ea379ca2e fix(gateway): timed-out turn abandonment is an explicit hard stop
_abandon_timed_out_gateway_turn landed on main (eb4772ec2) after this
PR's base and still used the soft interrupt(). Every other inactivity-
timeout surface in this change (cron, gateway executor poll, delegate
children) treats a timeout as an explicit stop that may cancel a
protected compression summary — widen the same fix to this sibling.
2026-08-02 22:15:20 +05:30
Shaun Prince d15b638a88 fix(compression): let explicit interrupts cancel safely
Makes interrupt-protected context compression cancellable by an explicit
user or lifecycle stop, without weakening protection against ordinary
incoming messages, voice interjections, or active-turn redirects.

Separates explicit hard cancellation from ordinary interrupt/redirect
state with a dedicated threading.Event; introduces
AuxiliaryExplicitCancellation as an attempt-local frozen-cause signal;
isolates the synchronous provider callback in a bounded daemon worker
during protected compression; atomically linearizes Codex timeout
cleanup against explicit cancellation; propagates hard cancellation
through child agents and explicit stop surfaces; serializes hard-cancel
admission against compression commit admission with
CompressionCommitFence; aborts before session rotation or late DB commit,
restores in-place transcript mutations and compressor state, and releases
the heartbeat and compression lease.

Based on #74449 by @suparious. Resolved merge conflicts in
agent/context_compressor.py (feasibility check + try/except) and
tui_gateway/methods_session.py.
2026-08-02 22:15:20 +05:30
kshitijk4poor 06b4f64c31 fix(auth): memoize the valid-token fast path too, add memo tests
Follow-ups on the startup-burst memo:

- Populate the memo on the valid-token fast path as well. The startup
  burst usually finds a VALID token, and each check_fn call still paid
  two cross-process file locks + state reads to reach that return; the
  original memo only engaged after a refresh. The token has at least
  refresh_skew_seconds (>=120s) of life at that return, so a 5s memo can
  never serve an expired token.
- Clear the module-level memo in test_nous_portal_staging_allowlist's
  refresh-capture helper: with the fast-path populate, a token memoized
  by an earlier test would otherwise short-circuit the refresh these
  tests assert on (3 tests failed without this).
- Add dedicated memo behavior tests (TTL hit, TTL expiry, insecure
  bypass) — the original PR shipped none. Mutation-checked: all 3 fail
  against main's un-memoized function, pass on this branch.
2026-08-02 22:13:12 +05:30
JeffStone69 9929743b72 fix(auth): memoize resolve_nous_access_token to collapse startup burst
check_tool_availability runs once per managed-tool check_fn (browser,
image_gen, etc.) during banner render. Each one independently triggers a
~15s blocking Nous Portal token-refresh network call when the stored token
is expired. On a slow/constrained host (e.g. a small monitoring CT) that
serial burst stretched startup to many minutes, appearing 'stalled'.

Add a per-process memo (5s TTL) so the burst collapses into a single network
round-trip. Only successful, non-forced resolutions are cached; force_fresh
and insecure/ca_bundle callers bypass and don't populate the cache, so
normal refresh semantics are unchanged.

Verified: 3 rapid resolve_nous_access_token() calls -> 1 underlying refresh.
2026-08-02 22:13:12 +05:30
kshitij ca10ac5c18 Merge pull request #76929 from kshitijk4poor/chore-jeffstone-email
chore: add contributor email mapping for JeffStone69
2026-08-02 22:08:56 +05:30
kshitijk4poor a50bd2ae3f chore: add contributor email mapping for JeffStone69 2026-08-02 22:08:46 +05:30
kshitij 68a7839d8b Merge pull request #76927 from kshitijk4poor/chore/attrib-sparkeros
chore: contributor email mapping for sparkeros
2026-08-02 22:08:14 +05:30
kshitij 5d1875fe3a chore: map rkt.2@hotmail.com to sparkeros 2026-08-02 22:07:59 +05:30
kshitij 9060e3c2d3 Merge pull request #76921 from kshitijk4poor/chore-zachariahchu-email
chore: add contributor email mapping for ZachariahChu
2026-08-02 22:02:12 +05:30
kshitijk4poor 0d9f17ffd4 chore: add contributor email mapping for ZachariahChu 2026-08-02 22:01:54 +05:30
kshitijk4poor 4983c576b1 fix(docker): gate the remaining every-boot chown walks (cron, pairing)
Whole-bug-class follow-up to the profiles/ gate: cron/, platforms/
pairing, and legacy pairing/ ran chown_hermes_tree unconditionally on
every boot with the identical warm-boot cost profile. Same
tree_has_non_hermes_owner gate; find evaluates the top directory first
and -quits on the first mismatch, so a mis-owned tree short-circuits in
O(1) while a clean tree pays one read-only walk instead of a full
chown -R inode rewrite.
2026-08-02 21:54:10 +05:30
LeonSGP43 f1da9d0d66 fix(docker): skip redundant stage2 chown walks 2026-08-02 21:54:10 +05:30
evgyur d080dc24a8 fix(gateway): skip topic recovery for non-DM messages 2026-08-02 21:48:46 +05:30
kshitij dc91ec931c Merge pull request #76907 from kshitijk4poor/chore/attrib-soju06-jabberelf
chore: contributor email mappings for Soju06 and JabberELF
2026-08-02 21:43:44 +05:30
kshitij f090110483 chore: contributor email mappings for Soju06 and JabberELF 2026-08-02 21:43:26 +05:30
kshitij ec4f8419c0 Merge pull request #76903 from kshitijk4poor/chore/attrib-aider4ryder
chore: contributor email mapping for aider4ryder
2026-08-02 21:36:39 +05:30
kshitijk4poor 1e2e69db98 perf(state): truncate FTS index with 'delete-all' instead of plain DELETE
_reset_fts_index_to_empty used a no-WHERE DELETE, whose docstring
claimed FTS5 treats it as an efficient drop-all. That's true only for
ordinary rowid tables — on external-content FTS5 each deleted row's
tokens are regenerated from the content table, making it O(rows)
(measured ~12us/row: 0.22s @100K, 5.2s @400K, ~25s projected @2M) while
holding the write lock. It also corrupts the index when indexed rows
have diverged from messages — exactly the broken-bookkeeping shape this
repair path handles. The FTS5 'delete-all' special command is the
documented O(1) truncate for external-content tables (measured 1.6ms
@100K) and truncates unconditionally regardless of divergence.
2026-08-02 21:36:11 +05:30
kshitijk4poor 2febb5823c perf(state): probe empty FTS index with EXISTS instead of COUNT(*)
_fts_external_index_empty_with_messages runs on every writable open via
the _init_schema fts_storage_version stamp condition. COUNT(*) is a full
b-tree scan on both messages and messages_fts_docsize (~100ms per open
on a 2M-row DB, measured); the function only ever compares against
zero, so EXISTS(SELECT 1 ...) gives the identical boolean in O(1).
2026-08-02 21:36:11 +05:30
Adolanium b2d5995fc6 fix(state): do not stamp empty FTS after interrupted optimize-storage demote
Demote wrote the empty v23 schema via executescript inside BEGIN IMMEDIATE,
which commits early and can leave trash + empty indexes without rebuild
markers. Re-run then tore down trash and stamped fts_storage_version with
docsize=0, permanently losing historical session search.

Stage markers with the demote, create schema only after they are durable,
heal empty-index bookkeeping on resume, and refuse settle until the base
index is populated. Settle refusal returns ok=False instead of raising,
and resume fails fast if the base v23 table cannot be re-created.

Orphan-marker repair only resets a missing fts_rebuild_progress to 0 once
the index is known empty: the chunk worker replays its whole selected id
range without an anti-join, so a partially indexed DB that lost only its
progress key is first reset to a known-empty surface, then rebuilt.

Ported onto the SessionDB mixin split (hermes_state_search.py /
hermes_state_schema.py).
2026-08-02 21:36:11 +05:30
kshitij 9c6e85688c chore: map RyderFreeman4Logos@gmail.com to aider4ryder 2026-08-02 21:35:32 +05:30
Hermes agent 4e0a775580 fix(state): make VACUUM interval configurable 2026-08-02 21:32:13 +05:30
Hermes agent bd856a02f2 [verified] fix(state): throttle repeated VACUUM rewrites 2026-08-02 21:32:13 +05:30
kshitijk4poor e38055a85e fix(state): close the read-only connection when the FTS probe fails
The RO branch's new FTS capability probe raises sqlite3.DatabaseError on
a malformed store (the probe itself only catches OperationalError). The
outer __init__ handler re-raises without closing self._conn, leaking a
tracked connection for the process lifetime — which makes
_backup_db_file refuse its raw-copy, so the writable heal that follows
(web_server's stale-schema/malformed reopen) repairs the store WITHOUT
the forensic backup repair_state_db_schema promises. Close-then-reraise
on any probe failure, mirroring _open_probed's cleanup discipline.

Regression test: corrupt sqlite_master (duplicate messages_fts row),
assert the failed RO open leaves no live tracked connection and the
subsequent writable heal creates its malformed-backup file. Mutation-
checked: no-oping the cleanup handler makes the test fail.
2026-08-02 21:30:54 +05:30
joelbrilliant 9bcc326207 test(dashboard): gate WAL preservation check by runtime
Signed-off-by: joelbrilliant <joelbrilliant1@gmail.com>
2026-08-02 21:30:54 +05:30
joelbrilliant 57197cd48d fix(dashboard): preserve maintenance writes on read polling
Signed-off-by: joelbrilliant <joelbrilliant1@gmail.com>
2026-08-02 21:30:54 +05:30
kshitij 024f3e044b docs(approval): _get_approval_config returns the live cache sub-dict
Review follow-up on the #76194 salvage: the readonly swap makes this
function leak the live config-cache 'approvals' sub-dict to callers.
All current callers are read-only (audited); the docstring now carries
the do-not-mutate contract for future ones.
2026-08-02 21:17:49 +05:30
spfcraze 48e8254567 perf(tools): use load_config_readonly on the approval guard path
The terminal-command guard path loaded config 2-3x per invocation via
load_config(), which pays a defensive deepcopy of the entire config on
every call (~356us of the ~376us warm-cache cost measured on a real
config.yaml). All six swapped call sites were audited read-only — every
caller takes scalar reads or iterates the returned structures; none
mutate (the save path at save_permanent_allowlist keeps load_config) —
so they now use load_config_readonly(), the API built for exactly this
(precedent: #74211, #74322; the one unsafe-site lesson from #56085's
salvage is covered by the mutation audit and a cache-integrity test).

Measured (real config.yaml, warm cache): load_config 376.0us ->
load_config_readonly 19.9us (18.9x); full guard pass
check_all_command_guards('ls -la','local') 930.7us -> 241.8us (3.85x).

Tests: new test_approval_config_readonly.py drives the real functions
against a temp HERMES_HOME — readonly call counts per function, a
no-deepcopy pin for the full guard pass, and cache-identity/integrity
checks. Existing test mocks retargeted from load_config to
load_config_readonly (same injection intent). Note: 6
test_approval_mode_parity failures are pre-existing ordering flakes —
identical with the change stashed on clean main.
2026-08-02 21:17:49 +05:30
kshitij e8656bedfe fix(agent): byte budget for the canon-args memo
Review follow-up on the #76098 salvage: the 4096-entry count bound alone
doesn't bound MEMORY — write_file/patch argument strings run 100KB+, so
a long-lived gateway process under sustained heavy write workloads could
pin ~800MB of evicted-session strings. A 32MB byte budget extends the
existing FIFO eviction; common-case args (0.5-2KB) never hit it. New
guard test mutation-checked (fails with the byte leg disabled).
2026-08-02 21:17:12 +05:30
spfcraze cf803603dc perf(agent): memoize send-path tool-call argument canonicalization
The pre-send normalization pass re-canonicalized every historical tool
call's argument JSON on every API-call iteration — quadratic in session
tool-call count. Route it through a bounded value-keyed memo (the
_MSG_TOKENS_CACHE idiom from agent/model_metadata.py): per-iteration
cost is now proportional to new tool calls, not all of them.

Measured (simulated growing session, repo venv): session-total
canonicalization cost 1056 ms -> 58 ms at 500 tool calls x 2 KB args
(18.3x), 5744 ms -> 79 ms at 500 x 16 KB (72.5x). Byte-parity with the
pre-fix logic is asserted at every iteration (unicode, nested,
malformed, empty, non-string args), and a call-count test proves
json.loads invocations went from K(K+1)/2 to K per session.
2026-08-02 21:17:12 +05:30