Commit Graph

2719 Commits

Author SHA1 Message Date
teknium1 3272fb35aa docs: profile-scope invariant in AGENTS.md — one process serves many profiles; out-of-turn code binds its scope
Root AGENTS.md § Code Shape Rules replaces "module-level constants are fine — they cache after
_apply_profile_override() sets HERMES_HOME" (true for `hermes -p x <cmd>`, inverted under the
multiplex gateway and the Desktop/dashboard `serve` backend, where os.environ holds the LAUNCH
profile) with the invariant: a profile = home + secret scope + terminal scope, bound per profile
ACTIVITY, and every execution point with no turn on the stack binds it explicitly. Names the real
seams: gateway/run.py::_profile_runtime_scope, tui_gateway @_profile_scoped +
_session_profile_runtime_scope (+ _profile_runtime_scope_tokens, launch_profile_policy ->
set_multiplex_active), cron/scheduler_provider.py::_profile_cron_scope,
gateway/run_agent_cache.py::_run_release_in_profile_scope, tools/environments/local.py::
served_profile_child_env, agent/memory_provider.py::spawn_context_thread. Adds a routing-table row
for profiles / multiplex / secret scope.

Area AGENTS.md paragraphs, one per seam, for gateway/ (activity-not-turn binding, hooks per
profile, adapter YAML never reaches os.environ, unserved shared-ingress reported via
_note_unserved_secondary_platform + needs_attention at the single writer), tui_gateway/ (RPC
binding is home AND secret AND terminal; HOME-only is half-bound; teardown chokepoint), cron/
(per-home tick lock, ticker scope incl. pre-loop code, kanban notifier routing, worker liveness by
(pid, worker_started_at) fingerprint, descendant fence as a path), hermes_cli/ (DEFAULT_CONFIG
key <-> reader parity, service-install matrix, -p vs multiplex home binding), tools/ (check_fn
reads through get_secret and is cached per hermes_home_key, one env builder per spawn, MCP trust
per profile), plugins/ (lifecycle hooks are bound by the caller; never cache the home from
initialize()), apps/desktop/src/ (pooled serve per (connection, profile); remote topologies),
agent/ (end-of-session flush is caller-bound; set_multiplex_active gates fail-closed).

Corrects the statements the multiplex model made wrong, in the same PR: root module-constant
sentence; hermes_cli "sets HERMES_HOME before any import" (+ cli-internals.md);
ADDING_A_PLATFORM.md §2 raw os.getenv loader (now an _ENV_STEPS row through config.py::_getenv)
and §4 platform_env_map in gateway/run.py (now _PLATFORM_ALLOWLIST_ENV in pairing.py + registry
allowed_users_env); platform_registry.py "may set os.environ (guard with not os.getenv)";
cron/AGENTS.md hardcoded ~/.hermes/cron/.tick.lock; gateway-internals.md agent:main as THE key
format, ~/.hermes/hooks/, single-profile `gateway stop`, plus a new "Multiplexed profiles"
section; tools/AGENTS.md os.getenv check_fn sample; "installed per turn" wording; "one temp
HERMES_HOME" E2E wording; multi-profile-gateways.md intro lists system units, Windows tasks, s6
and the Desktop backend.
2026-09-15 10:59:22 -07:00
teknium1 804707bea6 fix: checkpoint store gc never runs inside a tool call or gateway startup
Symptom: `hermes update` sat for ~40s after "Refreshing cua-driver" and ended
with "Fleet version check returned no rows" (exit 1); the restarted gateway
took 26s to reach "Starting Hermes Gateway" instead of the usual 3s. The
gateway constructor was running `maybe_auto_prune_checkpoints` synchronously,
before the control socket, adapters and the code_sha stamp, and on a 1.2 GB
store its `git gc --prune=now` (a full repack) takes 20-28s — twice, because
the size-cap shrink gc'd again even when it could drop nothing.

The same defect sat on the tool-call path: `CheckpointManager._take` ran
`_enforce_size_cap`, whose `_shrink_store_to_cap` returned True without
dropping anything and triggered a 20-28s gc on the first file-mutating tool
call of every turn once the store was over the cap. That loop also re-measured
a pack size that cannot move without a gc, so a single over-cap checkpoint
dropped 20 rounds of history and flattened every project to one snapshot.

- `_take` never gcs: `_prune` and `_enforce_size_cap` rewrite refs (cheap),
  drop at most one snapshot round, and mark the store `.gc-pending`.
- `prune_checkpoints` gcs only when a ref moved (project deleted, or the
  pending marker), and its cap loop is drop -> gc -> re-measure.
- `maybe_auto_prune_checkpoints` claims the interval marker before the run
  so a failing prune costs one day, not a gc per housekeeping tick.
- `auto_prune_from_config` is the one config-driven entry point; the gateway
  calls it from the housekeeping tick (last chore), the CLI from a daemon
  thread. Nothing on either startup path waits for git.

Live A/B on a copy of a real 1.2 GB / 224-ref store: checkpoint 20.5s ->
1.2-1.6s (0 inline gc); the single repack (19.6s) now runs in the prune.
2026-09-15 10:57:16 -07:00
teknium1 d84ece48b8 fix(mcp): Figma OAuth login completes despite the omitted iss parameter
Figma's authorization-server metadata advertises
authorization_response_iss_parameter_supported and its redirect omits iss,
so the mcp SDK's RFC 9207 check discarded every valid code and login never
finished. For that one issuer the provider fills a missing iss with the
discovered issuer and warns; a mismatching iss still fails and every other
server keeps the strict rule.

Fixes #111135
2026-09-15 09:29:00 -07:00
teknium1 043632e283 feat(desktop): hideable profile rail with a statusbar profile dropdown stand-in
For people who run profiles as bots, the colored profile strip at the sidebar
foot duplicates the sessions list (community request). Add a persisted
`hermes.desktop.profileRailVisible` preference (on by default) toggled from the
Sessions view menu ("Profile rail"), the shell right-click menu, ⌘K
("Toggle profile rail") and an unbound `view.toggleProfileRail` keybind.

While the rail is hidden the statusbar grows a `ProfileSwitcher` dropdown
beside the gateway switcher ("This device ⌄ · Profiles ⌄") offering the same
choices the rail does: this gateway's profiles, All profiles, every other
gateway's agents in fleet mode, New / Import / Manage. It also answers the
`profile.create` hotkey the rail used to own, so no door is lost.
2026-09-15 08:50:18 -07:00
teknium1 6bc3628ce8 fix(desktop): drag gateway/profile groups by their header, not only the hidden handle
The Gateway & profile sidebar sections carried dnd-kit listeners on the lead
glyph alone, and that glyph only reveals its grabber on hover — so a press on
the row's name or empty space did nothing and users fell back to the ⋯ menu's
Move up / Move down. Bind the sortable to the whole header (same shape as a
project row), with the handle and the ⋯/caret cluster keeping their own
gestures; a sub-threshold press on the label is still the fold click.

Live: Playwright pointer drag on the header label reorders sections (before:
unchanged, after: reordered); handle-drag still works. Test drives dnd-kit's
keyboard sensor on the label and is red on the old binding.
2026-09-15 08:50:18 -07:00
Robin Fernandes 1034215ae8 docs(free-tier): drop the rehearsal page and its references
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 20:44:42 +05:30
Robin Fernandes d89cacc25f chore(free-tier): keep the rehearsal server out of the repo; the docs page explains the stand-in instead
The fault-injecting server served one-off manual rehearsal only and would
drift silently from the real services; the doc now says how to point the
desktop at any local stand-in (the three env overrides) and what such a
stand-in has to speak. The dev-only HERMES_EXTRA_WELCOME_HOSTS override stays,
pinned by a test in test_anon_failure_modes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 20:44:42 +05:30
Robin Fernandes 51e39af967 feat(free-tier): ruled behaviour for every welcome-api failure, with friendly copy and a fault-injecting rehearsal server
The free tier depends on the account service (NAS) and the welcome inference
host, and Hermes had no honest answer for most of the ways either can refuse
or fail: the NAS codes it matched were never sent, the tier-dark 403 carried
no message to match, a single boot-time blip disabled minting for the whole
process, and a structured rate-limit refusal never reached the cross-session
guard, so the "sign in for a bigger allowance" prompt was dead code.

Backend
- anon_auth: classify what NAS actually sends (404 not_found, 503
  temporarily_disabled, 429 + Retry-After, 428 pow_*, 403 account_locked)
  into one ANON_* code each, carrying retry_after / retryable on AuthError.
- Replace the process-lifetime mint memo with a per-profile cooldown that
  honours the server's wait, climbs a short ladder when the service is
  unreachable, never retries terminal codes, and yields to the user's own
  retry (force=True).
- Bootstrap record carries error_code / retryable / retry_after; a bounded
  background loop retries transient failures and re-announces setup.ready.
  setup.status and free_tier.status expose the block; free_tier.provision is
  the forced retry.
- Inference: a generic 403 from a welcome host is the tier refusing (keyed on
  the route); model_not_free moves onto the gateway's alternate once;
  anon_on_paid_host re-reads the route once; a long rate_limited refusal
  trips the cross-session guard; a locked account is retired but never
  replaced; terminal copy on the free route is one plain sentence.
- Sign-in: Failed keeps the service's code and wait; account_busy is
  retryable; the OAuth poll reports retryable / retry_after.
- All user-facing copy rewritten for first-time users: never "the free
  service is off" (what is unavailable is using Hermes without signing in,
  and signing in is free), no jargon, spoken waits.

Desktop
- A setup-failure notice above the provider picker: one sentence per code,
  a retry when the backend says one can work, the sign-in pointer only when
  the account service answered at all. The overlay re-checks readiness on
  setup.ready so a background success dismisses it.
- Sign-in dialog gains busy / unreachable / unavailable screens.

Rehearsal
- scripts/free_tier_fault_server.py stands in for both services with the
  real wire contract and a CORS-open scenario switch; HERMES_EXTRA_WELCOME_HOSTS
  (dev-only, env-only) lets the route rules treat it as the welcome host.
  Walkthrough in website/docs/developer-guide/free-tier-fault-rehearsal.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 20:44:42 +05:30
teknium1 4eb69cbce5 fix(bot-mode): Bot Chat identity + one-shot DM transport apply the silence rule; trim tests
Follow-up to the salvaged #110786 commit:

- `_is_bot_mode_session` mirrors the system-prompt gate (`agent._session_title_hint`
  first, then the live DB title) instead of reading `pending_title`/`title` off the
  session dict: `pending_title` is cleared after turn 1 and the record never carries
  `title`, so the contributor's gate matched only the very first Bot Chat turn.
- `tools/bot_mode_dm.py::_run_local_turn` (the `hermes -p X chat -c "Bot Chat" -Q`
  transport behind `message_agent` when no live owner holds the target) re-emits ""
  for a successful bare marker — the third delivery path of the same class.
- Tests trimmed to one invariant per surface (live completion, relay RPC, one-shot
  transport), each proven red on origin/main sources.
- Bot Mode docs gain a "Staying silent" line pointing at the shared token list.
2026-09-15 06:31:31 -07:00
teknium1 97d3d75fdc fix: ignore every cron SQLite store and per-launch marker on flat installs
The flat-install block only ignored the bare cron/executions.db. All three
cron stores (executions, deliveries, notepad) are opened through
sqlite_util.open_db in WAL mode, so executions.db-wal/-shm exist whenever
the scheduler is live, and deliveries.db / notepad.db were not ignored at
all. `git stash push --include-untracked` therefore still unlinked the
live WAL/SHM and the whole deliveries/notepad stores under the running
gateway - the same mechanism this PR closes for the root state.db.

Switch the cron entries to the by-class shape already used at the root
(/cron/*.db plus -wal/-shm/-journal/retired-wal sidecars) and also ignore
the cron lock/heartbeat/output files and the per-launch root markers
(.update_check, gateway-starts.log, .clean_shutdown, active_profile,
.hermes_history, slack_tokens.json) that otherwise force every update
into the stash step. `git ls-files -i -c --exclude-standard` is unchanged
(no tracked file newly hidden). The test's FLAT_INSTALL_RUNTIME_STATE
gains one representative per new class; red before, green after.

Review finding: cron/executions.db-wal, cron/deliveries.db and cron/notepad.db were unignored and swept by the flat-install autostash.
2026-09-15 06:29:09 -07:00
teknium1 a172d76f2d fix(update): also ignore backups/ and the vault on flat installs; document the flat-install rule
`backups/` is where the pre-update snapshot the updater restores a swept
state.db FROM lives — leaving it unignored means the recovery copy is
swept together with the live database. `vault.key`/`vault.json.enc` are
the local secret vault.

Docs: website/docs/getting-started/updating.md explains that on a flat
install (checkout root == $HERMES_HOME) runtime state is git-ignored and
never enters the autostash.
2026-09-15 06:29:09 -07:00
teknium1 6b6dd16de6 docs(security): file reads of secret-bearing files mask credential assignments 2026-09-15 06:26:29 -07:00
teknium1 6f24245532 fix(kanban): gate create-with-parents like link; archived parent is terminal
create_task(parents=[open parent]) — the reporter's actual incident path —
parked the card in todo with only a `created` event, and kanban_create's
payload carried no `gated`, so the board still showed an unexplained todo
while only the link surface was fixed. create_task now appends the same
dependency_wait {reason: parent_not_done, parent} event and kanban_create
returns gated/gated_by, mirroring kanban_link.

link_tasks gated on `status != 'done'`, but _parents_satisfied and
recompute_ready treat `archived` as terminal: linking a ready child under an
archived parent demoted it to todo with a false parent_not_done event and the
next recompute promoted it straight back. Gate on not in ('done','archived').

Review finding: create_task(parents=...) emitted no dependency_wait/gated; link under an archived parent flapped ready->todo->ready with a false reason.
2026-09-15 06:25:42 -07:00
teknium1 89ef145254 fix(kanban): dashboard link reports the gate; docs; trim to two invariant tests
The dashboard's POST /links is the fourth writer of link_tasks (CLI, tool,
dashboard, plus the graph builder); return the same ``gated`` flag so every
surface that can create the deadlock can see it. Document the
``dependency_wait`` payload the link path emits and the delegation rule the
reporter derived (never link a support card under the card it unblocks).

Drops the CLI output test (a change-detector on prose); the two DB-level
invariants (event emitted on demotion / none for a done parent) stay.
2026-09-15 06:25:42 -07:00
Konstantin Khlopkov 35b1609fc3 fix(kanban): surface the link-time demotion of a ready child to todo
A ready child linked under an unfinished parent drops to todo with no
event and no operator signal; the only trace used to be claim_rejected
after a forced promote. Record a dependency_wait event when the demotion
fires, return the gate from link_tasks, warn in the CLI link command,
report gated in the kanban_link tool, and document the gate.
2026-09-15 06:25:42 -07:00
teknium1 fa915784cb fix: merge shipped skill categories instead of replacing them on update
The per-root merge in _copy_dist_payload treated the first level under
skills/ as the replace unit, but skills live at skills/<category>/<skill>.
A distribution shipping one skill inside a category rmtree'd the whole
category directory, wiping every skill the user (or hermes update) had
placed there — the exact scenario of issue #25120 that this PR claims to
fix. A shipped directory with no files of its own is now a container:
its children are merged one level down, and the replace unit becomes the
nearest directory that holds a file (a skill dir always holds SKILL.md).

A symlinked category (skills/devops -> shared dir) is a container too,
so the pre-write symlink check now covers it instead of silently
unlinking the link and dropping the shipped copy in its place.

Review finding: categorised skill payload rmtree'd skills/<category>/,
destroying sibling user and bundled skills; symlinked category was
unlinked rather than refused.
2026-09-15 06:22:15 -07:00
teknium1 ab7b97f55e docs(distribution): describe per-root merge of skills/ and cron/ on update
The user guide, command reference, `hermes profile update --help` and the
install-over-plain-profile warning all said skills/ and cron/ are overwritten
wholesale; they are now merged per skill / cron job, so say so, and document the
symlinked-container refusal.
2026-09-15 06:22:15 -07:00
teknium1 00bb2c3522 fix: docs no longer claim project .env files are write-denied
_BLOCKED_PROJECT_ENV_BASENAMES is consulted only by get_read_block_error;
project .env / .env.local / .envrc are read-denied but writable on both main
and this branch, so the write-safety table row saying "anywhere on disk" was
wrong. Denying writes would stop the agent from creating .env files for the
user, so the doc is corrected rather than the behaviour changed.

Review finding: security.md write-safety table listed project .env files that are only read-denied.
2026-09-15 06:20:15 -07:00
NUXER 1c0d95badb fix(security): write-deny HERMES_HOME secret stores, keep control files writable
Narrow the fix to secret material and stop reverting #45947.

The earlier revision added every read-denied name to the write denylist,
which re-blocked auth.json and webhook_subscriptions.json and rewrote the
test guarding them. #45947 freed those control files deliberately:
containment belongs in Docker/remote backends and OS permissions, not an
expanding hardcoded denylist.

What #45947 kept blocked is secret material, and that list had drifted:
auth/google_oauth.json (OAuth token store), the plaintext Bitwarden cache,
vault/ (key + ciphertext side by side) and browser-profile/ (copied
cookies / Login Data) were writable via write_file / patch.

- Write-deny those four; control files stay writable and read-denied.
- _WRITE_DENIED_SECRET_DIRS is its own tuple rather than _READ_DENIED_DIRS,
  so a future read-only convenience deny cannot silently become a write deny.
- tests/tools/test_write_deny.py is restored unchanged from main.
- New tests pin both halves: secrets denied, control files writable, and
  write denies stay a subset of read denies.

Fixes #110464
2026-09-15 06:20:15 -07:00
NUXER e7cd1848c9 fix(security): deny writes to read-blocked Hermes credential stores
Read guards already refuse auth.json, webhook HMAC secrets,
google_oauth.json, the Bitwarden plaintext cache, vault/, and
browser-profile/. The write denylist only covered .env, the Anthropic
PKCE store, and bws_cache.enc.json, so write_file/patch could replace
the rest.

Drive the write denylist from the same credential names and
read-denied directories. Keep the extra write-only entries
(bws_cache.enc.json, state.db, sessions, pairing).

Fixes #110464
Related: #108716
2026-09-15 06:20:15 -07:00
teknium1 a919c414ce docs(kanban): worker sessions are named after their card, no title model call 2026-09-15 06:08:20 -07:00
teknium1 e89605b4e6 docs(cron): record the due-only occurrence identity in the missed-occurrence contract
Also map the two salvaged contributor emails (kleros109, fangliquanflq).
2026-09-15 06:07:32 -07:00
teknium1 738d63a34d fix(kanban): automatic stale-claim reclaims count toward the failure breaker
A claim that expired without a worker ever spawning (worker_pid NULL) was
reclaimed and immediately re-claimed on every dispatcher tick, with
consecutive_failures stuck at 0 — nothing could trip the breaker. Route the
reclaim through _record_task_failure (own txn after the reclaim commit, same
shape as enforce_max_runtime) instead of the salvaged raw counter increment,
so per-task max_retries / kanban.failure_limit and the gave_up event apply
and last_failure_error carries the stale lock. reclaim_task (operator path)
still resets the counter; the live-worker extend path never reaches it.

Trims the salvaged tests to one invariant that walks the breaker to its trip.
2026-09-15 06:06:32 -07:00
teknium1 7b44106174 fix(gateway): agent-cache pressure valve measures the cgroup's anon charge, not just the gateway's own RSS
The budget in _sweep_agent_cache_under_pressure comes from the gateway's own
cgroup memory.high/memory.max, but read_anon_rss_mb() read /proc/self/status
RssAnon: only the main process. Every child in the unit (execute_code kernels,
terminal commands) is charged against the same limit, so a kernel at 4.7 GiB
pushed the unit to MemoryHigh while the valve saw <1.6 GiB and never fired;
systemd's stop then SIGKILLed the gateway mid-flush (the #80764 signature).

Under a capped cgroup v2, read own memory.stat `anon` (the same scope as the
budget); uncapped, or when the file is unreadable, keep the self reading.
_finite_limit is factored out of _cgroup_limit_bytes so the cap check and
the budget agree on what "unlimited" means.

Fixes #110549
2026-09-15 05:40:15 -07:00
teknium1 238720e928 test(journey): one end-to-end invariant for foreground-created skills; docs + list-unmanaged label
Replace the predicate unit test with an invariant on the real builder: a skill recorded
by a foreground create is in build_learning_graph() with zero uses, and an unmarked
never-used local skill is not. `hermes curator list-unmanaged` prints the actual
marker (created_by:learn) instead of hard-coding created_by:null. Docs: curator.md and
memory.md describe the learn marker and what the journey shows.
2026-09-15 05:38:31 -07:00
fangliquan ebb6dc6e70 fix(stt): prefer cached local whisper models 2026-09-15 05:36:37 -07:00
teknium1 6f87575009 test(approval): keep two invariants for unattended allowlist keys; document rule keys
Drop the session-only negative (session grants were never consulted on the unattended
path, so it pins pre-existing behaviour rather than the fix). Keep the permanent-key
positive and the Tirith-not-bypassed negative. Docs: command_allowlist rule keys are
honored in cron/-q/unattended sessions.
2026-09-15 05:33:41 -07:00
teknium1 3147ccb1ad docs(session-storage): guard raises, not warns; database-location section follows get_hermes_home(); zh-Hans mirror
Corrections on top of #110617: hermes_state._ensure_test_isolation raises RuntimeError (the
draft said 'warning'); the older Database Location section still told readers the default
path is ~/.hermes/state.db, which the new section says never to hard-code. Mirror the new
section into the zh-Hans page.
2026-09-15 05:30:32 -07:00
KoNit-K 71dade7e34 docs(website): document profile state isolation 2026-09-15 05:30:32 -07:00
teknium1 a9a8a3fa2e fix(config): hermes config get masks credentials on every path; --raw opts out
`hermes config get providers`, `config get providers.<p>.api_key`, `config get
<PROVIDER>_API_KEY` (the .env-routed branch) and `config get mcp_servers.<s>.env.X_API_KEY`
all printed the full credential. The agent runs this command from sessions whose transcripts
persist and get forwarded (a Gemini key surfaced in a Discord DM log), so `print` output is a
leak path the logging redactor never sees.

`get_config_value` now applies the structural masker used by `config show` before printing,
honouring `security.redact_secrets` (default on), with a `--raw` flag for operators/scripts
that need the real value. `_is_secret_config_key` extends the exact-name set with the same
`*_API_KEY / *_TOKEN / *_SECRET / *_PASSWORD` suffixes `_is_env_config_key` already routes to
.env, so env-map leaves under `mcp_servers.*.env` mask too, and the `config set` echo uses the
same predicate.

Slim redo of #84153 by @webtecnica (same direction: mask in get_config_value; dropped the
redact_url_query_params re-export and the separate redaction-enabled reader in favour of
agent.redact._redact_enabled, which already resolves the profile-scoped policy).

Fixes #110758
Fixes #84106
2026-09-15 05:08:55 -07:00
teknium1 2034126e0d fix: warn about existing WAL on cross-VM fs on the vulnerable-SQLite path too
apply_wal_with_fallback returns via _apply_delete_for_wal_reset_bug on
WAL-reset-vulnerable SQLite builds (Debian 12 / Ubuntu 22.04 system Pythons,
the reporter's pinned image) before the #110848 existing-WAL cross-VM check,
so exactly the deployment class the fix targets still got zero startup signal
while doctor flagged it. Share one helper between both early-return paths and
key the once-per-process dedupe on the DB path instead of the label so a
gateway serving several profiles hears about each database. The docker doc
remedy now uses the image's python3 (it ships libsqlite3 but no sqlite3 shell).

Review finding: vulnerable-SQLite early return skipped the cross-VM ERROR; dedupe per label; sqlite3 shell absent from image.
2026-09-15 05:02:30 -07:00
teknium1 60d94fd8f4 fix(state): warn when an existing WAL state.db sits on a virtiofs/9p mount; doctor + docs
d8dcdfd620 (v2026.9.14) made apply_wal_with_fallback refuse to ENABLE WAL
on a fresh database whose directory is on a cross-VM bind mount (virtiofs/9p),
but a database that was already WAL on such a mount kept WAL — correctly, we
never live-downgrade under other openers — and emitted nothing. The operator
in #110848 ran exactly that shape (Podman applehv virtiofs bind mount) and got
"database disk image is malformed" within a minute with no prior signal.

- apply_wal_with_fallback: in the on-disk-WAL branch, log a once-per-process
  ERROR ('cross_vm_fs_existing_wal') when the DB file is on a cross-VM
  filesystem, naming the two remedies (offline PRAGMA journal_mode=DELETE
  after stopping every process + database.journal_mode: delete, or move the
  database to a native/named volume). The fresh-DB refusal is unchanged.
- hermes doctor: _report_database_journal_modes flags a WAL database on a
  cross-VM filesystem with check_warn and the same remedy (ranked above the
  WAL-reset exposure warning; the exposure bookkeeping is kept).
- docs: docker.md gains "Filesystem requirements for state.db in containers";
  configuration.md's database comment no longer implies operators must set
  delete by hand on virtiofs.

Detection stays /proc/self/mountinfo-based (runs inside the Linux container
on macOS/Windows hosts). locking_mode=EXCLUSIVE is deliberately not adopted:
gateway, cron and workers open state.db concurrently.

Fixes #110848
2026-09-15 05:02:30 -07:00
teknium1 1f08821bd0 fix: derive suggest globs from the raw command, class-key when redaction hits them
Redaction ran before glob derivation, so a mined `GITHUB_TOKEN=ghp_… git push`
became the pattern `GITHUB_TOKEN=*** git *` and `--apply` persisted it to
config.yaml. In the permanent-allowlist matcher `***` is three fnmatch
wildcards, so that entry pre-approved any `GITHUB_TOKEN=… git …` command
(`sudo git push --force`, `chmod -R 777 /etc git x`) ahead of the dangerous
command detector. Globs now come from the raw normalized command; when the
redacted form would yield a different glob the command is proposed under its
dangerous-class key instead. Redaction stays for example rendering only.

Review finding: masked `***` inside a persisted glob widened the allowlist to arbitrary `KEY=… git …` commands.
2026-09-15 04:57:29 -07:00
Teknium c60fab351f fix(cli): hoist redactor import, trim suggest redaction tests, document masking
- Move the `agent.redact` import out of the per-record loop in build_proposals.
- Keep the two display-boundary invariant tests (rendered `e.g.` line, --json
  examples), drop the unit-level duplicate that asserted the same masking.
- website/docs: state that mined examples are masked at display time only;
  state.db itself is unchanged.
- contributors/emails: map kokhlo's commit email.
2026-09-15 04:57:29 -07:00
teknium1 57c4e1d963 fix(mcp): served profiles keep their own MCP connections without the multiplex flag
A dashboard/desktop backend (and the per-profile cron ticker) serves sessions of
several profiles through the HERMES_HOME contextvar override while
gateway.multiplex_profiles stays off. _mcp_registry_scope() keyed every MCP
connection by the bare server name in that mode, so the first profile to
discover `zernio` owned the only connection and every later served profile —
including one whose config carries a different Authorization header — called
the server through it and got the other account's data back (#111151).

The registry scope now follows the served home: a routed profile (an override
naming a home other than the process home) gets the same per-profile overlay
the multiplexer uses, so a same-named server with other credentials is a
separate connection, discovery for profile B is a connect candidate instead of
"already connected", and status/tool views stay per profile. Single-profile
processes (no override) keep bare keys, byte-identical to before.

Fixes #111151

Credit: #111158 by @KoNit-K located the inert flag on hermes_cli surfaces; its
fix (activating fail-closed multiplex secret scoping from config.yaml on the
dashboard) is not taken — the connection-key seam, not the secret-scope mode,
is what leaks the connection, and flipping the process-wide mode from the
dashboard would change credential resolution for every code path in it.
2026-09-15 04:56:40 -07:00
teknium1 0d1a3e704a docs(vault): manager items fill on every saved website origin 2026-09-15 04:56:01 -07:00
teknium1 7bb52c0b74 feat: profile clone can opt into staying synced with its source (--sync-imports)
`hermes profile create <name> --clone` copies whatever `hermes import-agent`
had pulled into the source profile, but leaves import-sync.json behind, so
the clone can never run `import-agent --sync` itself: its imported skills and
memories freeze at clone time.

`--sync-imports` (with --clone / --clone-from) also copies the manifest. It
is deliberately narrow: the manifest points at EXTERNAL Claude Code / Codex
trees, never at the source profile, so both profiles remain independent
islands (root AGENTS.md ruling) — config.yaml, SOUL.md and skills are still
one-off copies. Opt-in, one-directional, explicit; --clone-all already
carries the file as part of the full copy. Refused without a clone source.
2026-09-15 04:44:09 -07:00
teknium1 4da4cc5c37 fix(gateway): SIGUSR2 dumps thread stacks without killing the gateway
faulthandler.register(SIGUSR2, ..., chain=True) writes the stack dump and
then re-raises the signal to its previous handler. SIGUSR2's default
disposition is "terminate", so the diagnostic hook added for #70344 kills the
very process an operator is trying to inspect (rc = -12), which is what the
#110437 reporter hit while introspecting a long-lived gateway. Nothing else
in the gateway installs a SIGUSR2 handler, so there is nothing to chain to.

Test: a child interpreter runs the real _start_install_faulthandler, receives
SIGUSR2, and must still be alive with a dump in gateway_faulthandler.log.
Red on origin/main (rc=-12), green with chain=False. Docs: the stack-dump
signal is now documented next to the event-loop watchdog.
2026-09-15 04:42:20 -07:00
teknium1 71cac9426d fix(gateway): retire native clarify cards on timeout, reset and prose cancel
One adapter-facing seam replaces the Slack-only callback: an adapter whose
clarify prompt is a persistent card (Slack Block Kit) defines
`retire_clarify_card(clarify_id, notice)`, and the gateway calls it from
every path that ends a clarify without a button click:

- TurnRunner._clarify_callback_sync: when the bounded wait returns a
  sentinel (timeout, /new or run-end clear_session), schedule the retire
  with the expired notice on the gateway loop (#110821).
- run_inbound TEXT_REJECTED_PROSE: retire with the cancelled notice before
  the prose is routed as a follow-up (#111019). Lookup is on the adapter
  class so MagicMock doubles cannot fabricate the method; no platform ==
  SLACK special-case.

The Slack map is keyed by clarify_id and popped before the first await, so
a late timer cannot touch a newer prompt and the button handler's ts-keyed
guard makes a racing click a no-op. Gateway-restart-orphaned cards stay
out of scope: nothing is waiting on the new process, and the click path
already renders them expired.

Tests trimmed to invariants: the runner-level timeout probe (card adapter
vs no-card adapter), the inbound prose retire, and one Slack test covering
buttons-dropped + late-click-noop. Docs updated for the new in-place edit.
2026-09-15 04:40:04 -07:00
teknium1 08bb2273e4 fix: make the allow_all_users env bridge own what it writes and re-derive it on restart
The bridge wrote GATEWAY_ALLOW_ALL_USERS into os.environ only when unset and
never cleared it. In-process restart paths (gateway restart watcher, dashboard
profile actions) copy os.environ into the child, so a config.yaml grant became
a sticky env var: flipping allow_all_users to false and restarting left the
gateway OPEN. The bridge now tracks its own write (module flag), overwrites or
clears it on reload, exports only a truthy grant (presence-based readers such
as the Telegram intake prefilter treated "false" as configured auth), and the
two restart env builders drop the bridge-owned value so the child re-derives
the posture from its own config.yaml. Under multiplex_profiles the DEFAULT
profile's events are authorized inside its secret scope, where gate readers
never fall to os.environ; the bridged grant is now seeded into that profile's
scope mapping only (secondaries never inherit it).

Review finding: bridged GATEWAY_ALLOW_ALL_USERS survives restart and overrides a flipped config.yaml; inert for the default profile under multiplex; "false" exported as configured auth.
2026-09-15 04:37:41 -07:00
teknium1 5083d5f78e fix(gateway): honour allow_all_users from config.yaml by bridging it to GATEWAY_ALLOW_ALL_USERS
`gateway.allow_all_users: true` (and the top-level spelling) in config.yaml
was a silent no-op: `_TOPLEVEL_BRIDGE` never forwarded it, GatewayConfig has
no field, and every allow-all reader (authz mixin default-deny branch,
startup access check, own-policy adapters, Discord/Matrix/Email plugin
gates) consults the GATEWAY_ALLOW_ALL_USERS env var only.

Bridge the YAML key into that env var in `bridge_core_env_settings`, the
one seam every reader already shares, instead of threading a new config
attribute through ten readers:
- first-writer-wins: an explicit env var beats YAML (matches every other
  {PLATFORM}_* gate);
- skipped inside a multiplexed secondary profile's scope (#80099 class):
  the secondary's config.yaml must not become the default profile's policy,
  and the isolation test now asserts GATEWAY_ALLOW_ALL_USERS stays unset;
- a startup warning names config.yaml as the grant source, because the key
  was inert until now and a forgotten `true` flips the posture to open.

Tests: both spellings authorize a stranger through `_is_user_authorized`;
`false`, absent key, and env=false over YAML=true all stay denied.
Docs: security guide, env-var reference, gateway internals.

Fixes #110690
2026-09-15 04:37:41 -07:00
teknium1 71d229f5cd fix(cron): the silence instruction names [SILENT] as an untranslatable ASCII token; docs + trim tests
Follow-up to the salvaged #110940 commit:

- `cron/scheduler_prompt.py::_CRON_HINT` tells the model the sentinel is a literal
  ASCII control token that must never be translated or rephrased — the prompt-side
  half of the fix, so a lane answering in any other language is steered to the
  canonical token instead of relying on the filter knowing that language.
- Docs: the supported-token list ('Intentional Silence Tokens') gains the zh-Hans
  forms in the English page and the zh-Hans mirror gets the section it lacked.
- Tests trimmed to two invariants (translated forms match in every shape the English
  ones do; prose that mentions the word is still delivered), proven red on origin/main.
2026-09-15 04:30:31 -07:00
teknium1 4db4650a01 fix: exempt the hidden canonical Bot Chat from the idle auto-archive sweep
The archived-holder carve-out in _set_session_title lets the next Bot open
claim the "Bot Chat" title from an archived canonical row. That is correct
for a deliberate sidebar archive, but archive_stale_sessions (the opt-in
sessions.auto_archive sweep) could also archive an idle hidden Bot Chat with
end_reason NULL, which unarchive_recoverable_session refuses; the next Bot
click then stripped the old row's title, retiring the bot's whole history
on an idle timer with no way back.

Skip the hidden canonical Bot Chat in the sweep SELECT using the same
hidden + exact-title predicate set_session_pinned already uses to protect
it, so only an explicit archive retires a Bot Chat. Reword the bot-mode doc
so it no longer promises the retired (still hidden) chat is reachable from
the archive view.

Review finding: auto_archive sweep could irreversibly retire the canonical Bot Chat; docs over-promised archive-view reachability.
2026-09-15 04:27:32 -07:00
teknium1 a4fad54aa7 docs(bot-mode): archiving a Bot Chat retires it; the next open becomes the new canonical chat
Companion to the archived-title release in hermes_state_titles.py (#110871).
2026-09-15 04:27:32 -07:00
kshitijk4poor 569b4242a3 fix(auth): a Portal-returned inference host is accepted only when the operator named it
#111809 accepted any *.nousresearch.com https host once the operator's Portal override
pointed at a non-production Portal. That let a network-provenance value — the Portal's
refresh response — pick any Nous-owned host as the bearer recipient, including hosts that
are not inference gateways. Owning the DNS suffix is not the same as being an authorized
recipient, and the validator's threat model (an injected refresh response) is exactly the
case a suffix rule fails to bound.

The recipient is now the operator's own NOUS_INFERENCE_BASE_URL: a non-production host
returned by the Portal is accepted exactly when it equals that override's host, otherwise
the strict production set stands. The Portal override grants nothing by itself. What the
operator gains over plain use of the override is that the Portal's value is then persisted
and used for the pricing scope and proxy instead of being healed to production, and the
per-turn "refusing inference URL host" warning stops. No environment is named in code.

Raised on #111809 review. Tests: recipient match accepted, unrelated Nous host refused, no
override refused, Portal override alone grants nothing, the match follows the profile scope
under multiplexing; the widening cases are red on main. Docs row for NOUS_INFERENCE_BASE_URL.

Co-authored-by: Ben Barclay <ben@nousresearch.com>
2026-09-15 16:55:43 +05:30
wang2 fa12d7556c fix(telegram): allow opt-in CJK rich messages 2026-09-15 04:24:53 -07:00
teknium1 8bc5894a3a docs(skills): ip-as-logo follows the modern section order; add skill tests
Authoring standard 5 wants `# <Skill> Skill`, then When to Use,
Prerequisites and Procedure; the port kept the upstream layout with the
trigger sentence in the intro and no prerequisites section. Body text is
unchanged; the docs page is regenerated for this skill only.

Standard 7 asks for tests/skills/test_<skill>_skill.py: two invariants —
frontmatter/section structure, and generation routed through the native
`image_generate` tool with no residue of the upstream harness.
2026-09-15 04:20:15 -07:00
teknium1 febea3e28d docs(skills): pin ip-as-logo upstream attribution to author, repo and commit
The ported skill carried the upstream MIT text but the LICENSE file did not
say where it came from, and the frontmatter only pointed at the repo via a
non-standard `homepage:` key. Reviewers asked for proper attribution.

- LICENSE: header naming the upstream repo, the pinned upstream commit
  (b1bf517c54a4…) and the copyright holder (s1dashu) above the verbatim MIT text.
- SKILL.md: `metadata.hermes.upstream: <repo> (pinned b1bf517c)` — the same
  shape mono-color and pr-lens use — and the adaptation-notes blockquote now
  links the upstream repo and commit so the generated docs page links the source.
- Regenerated website/docs/user-guide/skills/optional/creative/creative-ip-as-logo.md
  with website/scripts/generate-skill-docs.py (scoped to this skill).
2026-09-15 04:20:15 -07:00
Teknium 398279fd6a feat(skills): add ip-as-logo optional skill (minimal cute IP mascot marks)
Ports s1dashu/ip-as-logo-skill (MIT, 3.2k stars in 48h, snapshot of
commit b1bf517c) into optional-skills/creative/. Generates extremely
simplified, cute IP mascot characters readable at 32x32 — 3-color
discipline, corner-emergence composition, complexity budget, and a
copy-paste prompt skeleton.

Hermes adaptations (blockquote header + inline edits, upstream body
otherwise intact):
- image path routed through the built-in image_generate tool
  (square aspect, main-prompt constraints mode — no negative_prompt
  parameter exists)
- subagent parallelization mapped to delegate_task, optional
- delivery per platform file conventions; no auto-QA (per upstream's
  own one-pass-draw rules)
- live-test friction fixes folded in: reduced-batch labeling branch,
  proposal-round skip for pre-authorized batches, dimensions-reporting
  rule when the backend returns only a URL, limbless-subject note

Validated via a cold subagent run (2 candidates for a real brief):
both generations succeeded first-draw, verdict SHIP; its three
friction findings are addressed in this commit.

Docs: catalog row + sidebar line + generated skill page (scoped to
this skill only; regen drift for unrelated pages reverted).

Credit: s1dashu (https://github.com/s1dashu/ip-as-logo-skill)
2026-09-15 04:20:15 -07:00
teknium1 b531023622 fix(gateway): drop the redundant whole-block lease; one invariant test per atom; document the watchdog env vars
The per-step leases inside maybe_auto_archive / maybe_auto_prune_and_vacuum
(archive, prune, sweep, vacuum) cover every long step of the construction-time
block, and each renews right before the step it protects, so the extra
report_startup_progress(900) at the top of GatewayRunner._init_session_db
added nothing but a stale phase label ("gateway_startup_state_maintenance"
would outlive the archive step and mask the phase name in the fired record).
Dropped; gateway/run.py is back to origin/main.

Tests: the two contributor tests monkeypatched report_startup_progress in the
module and asserted phase names (change-detectors on the strings). Replaced by
one test that arms a REAL StartupWatchdogHandle and asserts the maintenance
block renews it four times with lease_until in the future — the property the
poller's `lease_until > now` branch actually needs (#111092). Red on
origin/main: lease_count stays at the schema-init lease.

Docs: HERMES_STARTUP_WATCHDOG / HERMES_STARTUP_WATCHDOG_TIMEOUT_S existed only
in the module docstring; add them to website/docs/reference/environment-variables.md
next to the respawn-storm variables (existing env vars only, no new surface).
2026-09-15 04:19:19 -07:00