Commit Graph

45939 Commits

Author SHA1 Message Date
kshitijk4poor
7cdee5d06e fix(agent): join the merged-turn prefix in one place
The merge stamped the prefix with or without the "\n\n" separator
depending on whether the absorbed turn was empty, so a later non-empty
override on an empty-merge row came out as "RX". Store the unanswered
text as-is and join at override time with the same rule the merge uses.
2026-09-29 03:30:52 +05:30
kshitijk4poor
7b7044c086 test(agent): fold the merged-turn override tests into two invariants
The non-empty merge case now covers the gateway-timestamp override, the exact
api_content sidecar and the token estimate in one test; the empty-turn test is
unchanged.
2026-09-29 03:30:52 +05:30
kshitijk4poor
57b0ed0156 fix(agent): keep the merged-turn prefix out of the token estimate
The marker recorded by _merge_consecutive_users repeats the unanswered text
already in the row's content, and the rough token estimator priced it as
payload: a merged row with a large unanswered paste was estimated at ~2x its
real size for the rest of the session, pulling compaction forward.

Name the key once in agent/message_metadata.py and put it in
PERSISTENCE_ONLY_MESSAGE_FIELDS, so the estimator skips it and every outgoing
request copy (all transports, plus the iteration-summary call) drops it —
not only the chat-completions underscore sweep.
2026-09-29 03:30:52 +05:30
Charan Rathore
bc1e29353a fix(agent): preserve unanswered users on empty absorbed turn
Follow andrexibiza review on #124770: refresh the merge prefix even when a legal gateway turn cleans to empty text, with live and replay regression controls.

(cherry picked from commit 63ca36db4f2ca6d3dceeedd72d90b19eddc45240)
2026-09-29 03:30:52 +05:30
Charan Rathore
8bdf129c10 test(agent): cover unanswered ACP prompt across turns
(cherry picked from commit 1eb733fba8ee290c1817452a9a3a36fdd6ce2259)
2026-09-29 03:30:52 +05:30
Charan Rathore
b2cd439ae5 docs(agent): explain merged turn marker replay lifecycle
(cherry picked from commit 9ecd5a478efa146c592335eed2f8ef42a7b686c5)
2026-09-29 03:30:52 +05:30
Charan Rathore
87cefde203 fix(agent): preserve unanswered user text through persist override
(cherry picked from commit 96a1c99a58c04fb835a523d7c056fa9c9cfa7ae3)
2026-09-29 03:30:52 +05:30
kshitijk4poor
e3bfaa2b19 chore: map charan-rathore contributor email (salvage #124770) 2026-09-29 03:30:52 +05:30
kshitijk4poor
d57cf08f23 test(pm): the LSP node-runtime test opts into a trusted workspace
It checks PATH inheritance and env overrides for typescript-language-server.
With deny-by-default trust, a ServerContext built without a decision is
untrusted and the server skips without a Hermes-side TypeScript SDK.
2026-09-29 03:28:46 +05:30
kshitijk4poor
da2b64304b fix(lsp): no automatic trust for scheduled work; lock-free trust lookups
Re-review of the trust-anchor follow-up:

- Cron runs and Kanban workers no longer anchor trust. The cronjob tool
  lets the model pick a job's workdir, which becomes the run's session
  cwd, and kanban_create lets it pick a task workspace, which becomes the
  worker's launch dir and TERMINAL_CWD. Either one let an agent-cloned
  repo become trusted. Both now rely on lsp.trusted_workspaces only.
- A repository at or above $HOME never anchors, not only one at $HOME
  exactly.
- Operator roots are recorded only where a tool thread enters
  (enabled_for) and when the service is created. _trusted() is now a pure
  read. Before, it took _state_lock while two of its callers already held
  it, so a change in the roots could deadlock the LSP loop.
- Client lookups go through _live_key(). A multi-root client that
  started while its root was untrusted is still found and released after
  the root becomes trusted, instead of being orphaned.
- `hermes lsp status` stops listing roots that have since become trusted.
  The lint gate reads the cwd the same way the linter subprocess does.
- The path-list parser returns None for a malformed value, and each key
  logs its own warning. The _node_modules_trees docstring no longer
  mentions Vue.
2026-09-29 03:28:46 +05:30
kshitijk4poor
8ee5305fdb refactor(lsp): declare svelte's untrusted switch on the registry and share the path-list parser
svelte goes back to the declarative _server() form with an untrusted_init
option on _simple_spawn instead of a one-caller spawn factory, the two
lsp path-list keys share one parser (only the invalid-value result and the
message differ), and the docs say the allowlisted servers run no project
code rather than that they only parse files (yaml-language-server fetches
the schemas a file names).
2026-09-29 03:28:46 +05:30
kshitijk4poor
09d14ea130 fix(lsp): keep vue-language-server out of untrusted workspaces
Pinning `tsdk` to Hermes's TypeScript does not stop Vue from running
project code: @vue/language-core 2.2.x reads `vueCompilerOptions.plugins`
from the project's tsconfig and require()s each entry resolved against the
project root (lib/utils/ts.js, the 'plugins' case), so a cloned repo naming
"./x.js" there executes it on the first .vue edit. Vue now waits for trust
like the other servers that evaluate project files, and `_spawn_vue` reads
the project's node_modules again (it only ever runs trusted).
2026-09-29 03:28:46 +05:30
kshitijk4poor
e929702021 fix(lsp): trust the workspace the session was opened in, not only the process cwd
The trust anchor was os.getcwd(), which is the user's project only for a
plain `hermes` launched inside a repo. `hermes -w` sets TERMINAL_CWD to its
new worktree without a chdir, the Desktop/TUI backend runs from the install
tree or $HOME with the project bound as the session cwd, the gateway runs
from HERMES_HOME, and cron binds a per-job workdir. All of them lost
pyright's project venv, rust-analyzer/gopls/jdtls/... and the .ts/.rs lint
fallbacks inside the user's own repository.

operator_workspace_roots() now adds the worktree of resolve_agent_cwd()
(session cwd, then TERMINAL_CWD) to the launch dir's; only surfaces set
those, the agent's `cd` moves the terminal env's cwd, not them. $HOME is
never an anchor: a dotfiles repo there would trust every directory below it.
The service remembers every anchor it has seen, because the loop thread
that spawns servers has no session context of its own.

- The lint gate checks only the linter's cwd: npx and rustup resolve the
  toolchain from there (subprocess cwd=env.cwd), not from the file's dir.
  The try/except that wrapped code which cannot fail is gone.
- Trust is computed once per spawn and handed to ServerContext.
- Multi-root servers (pyright) share one process across trusted roots
  only; an untrusted root gets its own, so whichever root spawned first no
  longer decides the interpreter for the others.
2026-09-29 03:28:46 +05:30
John Paul Soliva
59ec281140 fix(lsp): deny by default in an untrusted workspace and skip the toolchain lint fallbacks there
Pinning settings per server left every other server free to run project
code: rust-analyzer runs cargo check (build.rs, proc-macros) on each
didSave despite the disabled init options, and jdtls, kotlin-language-server,
elixir-ls, zls, clojure-lsp and haskell-language-server evaluate build
files when they start.

In an untrusted workspace only UNTRUSTED_SAFE_SERVERS now start (pyright,
typescript, vue, svelte with their pinned settings; bash, yaml,
dockerfile, intelephense, clangd without --query-driver). Every other
built-in or user-declared server is skipped at the spawn chokepoint and
in enabled_for, logged once per root and listed by hermes lsp status.
The rust-analyzer init tweak is gone: it never starts untrusted now.

On a local backend the npx tsc and rustfmt --check fallbacks are skipped
the same way, since npx resolves the repo's node_modules/.bin/tsc (or its
.npmrc registry) and rustup honours its rust-toolchain.toml.
2026-09-29 03:28:46 +05:30
John Paul Soliva
f5c754d436 fix(lsp): never run a cloned repository's own interpreter or TypeScript SDK in an untrusted workspace
Language servers start with no approval on every write_file/patch inside any
git worktree. Hermes pointed pyright at the checkout's own .venv/venv python
(pyright executes it), let Vue and typescript-language-server load the
checkout's node_modules/typescript, and left svelte-language-server and
rust-analyzer loading project config, build scripts and proc-macros.

A workspace is now trusted only when it is the git worktree Hermes was
launched in or sits under an lsp.trusted_workspaces entry. Elsewhere pyright
keeps VIRTUAL_ENV or the Hermes-managed python, TypeScript/Vue are pinned to
the Hermes-side SDK, svelte gets isTrusted:false and rust-analyzer runs
without build scripts or proc-macros.
2026-09-29 03:28:46 +05:30
kshitijk4poor
2ec7703012 refactor(bot-mode): one helper names the live-delivery intent file
The boot-failure report added a fourth hand-written <dm>.live.json join; it
must agree with the path _admit_live_dm pins, so all four sites now share
_live_intent_file.
2026-09-29 03:01:04 +05:30
kshitijk4poor
9979e56cc1 fix(bot-mode): a Windows relaunch never adds a second delivery outcome
On Windows, hermes_bootstrap relaunches onto the store interpreter with
subprocess.call and exits with the child's status. The child is the same
delivery runner and has already printed its own outcome, so the parent
treating a nonzero status as "could not activate" printed a second JSON
line that could turn a definite failed/rejected into "ambiguous, do not
resend". The relaunch now raises RelaunchExit (a SystemExit subclass, so
every existing handler and the process exit code are unchanged) and the
runner skips its boot-failure report for it.

Also parses the runner argv in one stdlib-only helper, _runner_argv, used
by both _delivery_main and the boot-failure report, so the two can no
longer disagree about where the DM file is.
2026-09-29 03:01:04 +05:30
kshitijk4poor
be0a4147aa fix(bot-mode): only a live-admitted DM stays ambiguous when the runner cannot boot
Narrows the previous commit to the one case where the outcome is really
unknown. When the sender has already pinned a live intent (<dm>.live.json)
it may have admitted the DM and told the model not to resend; a runner
that then fails hermes_bootstrap now prints the same ambiguous payload
(same delivery id, "Do not resend") that _run_delivery prints for an
admission failure. Without an intent nothing was handed over, so the plain
repair hint stays the truth and a resend remains correct.

The payload is shared with _run_delivery via _live_outcome_unknown instead
of a second classifier, and the evidence-probing variants and the separate
test module are dropped in favour of one real -I -S subprocess test plus an
explicit negative assertion on the existing no-intent test.
2026-09-29 03:01:04 +05:30
Neomail2
875635bb18 fix(bot-mode): preserve delivery uncertainty on bootstrap exit
(cherry picked from commit 5fc1e15e4ddec41a388933a2cc77d786d1039a50)
2026-09-29 03:01:04 +05:30
kshitijk4poor
1065cf525b chore: map Neomail2's commit email for release credit 2026-09-29 03:01:04 +05:30
Brooklyn Nicholson
be9fd2a691 fix(bot-mode): keep the reply waiter off dependency activation
The --wait-reply waiter is stdlib only and its stdout is the sender's
wake-up. Booting it through hermes_bootstrap meant an install whose
committed environment cannot be activated killed the waiter with the
repair hint on stderr before it printed anything, so the sender never
learned the delivery outcome. Only the delivery lane boots now.

Port the failure-mode test from #125754: a --run-delivery runner that
cannot activate exits with the `hermes pm repair` remedy and leaves the
DM untouched, instead of the ambiguous "No module named ..." outcome.

Co-authored-by: woriwka-ai <247827632+woriwka-ai@users.noreply.github.com>
2026-09-29 03:01:04 +05:30
Jervaise
6c9420706e fix(bot-mode): the delivery runner and reply waiter activate Hermes dependencies when spawned on the bare store interpreter
message_agent spawns its background runner as
`<sys.executable> tools/bot_mode_dm.py --run-delivery ...`, and the relay's
reply waiter (bot_relay.waiter_command) as `... bot_mode_dm.py --wait-reply ...`.
Both enter through the shared `if __name__ == "__main__":` block, which only
put the repo root on sys.path. Under a PM-managed install sys.executable is the
bare store interpreter: dependencies are activated in-process at boot by
hermes_bootstrap (pm.environments.activate_dependencies), and the terminal
backend strips the Hermes-owned PYTHONPATH, so the child has no third-party
packages. Live, every local Bot Chat DM returned
`{"status": "ambiguous", "error": "Live admission outcome unknown: No module
named 'ruamel'. Do not resend."}` from _run_delivery -> _admit_live_dm.

The __main__ block now imports hermes_bootstrap after putting the repo root on
sys.path (run as a script path, sys.path[0] is tools/). This is the same pattern
as tui_gateway/compute_host.py and agent/transports/hermes_tools_mcp_server.py:
only the script entry bootstraps, so importing tools.bot_mode_dm as a library
stays side-effect free and the module's top-level imports stay stdlib-only.
hermes_bootstrap rather than a bare activate_dependencies() call, because the
runner is a real entry point and needs what every entry point gets: dependency
activation plus early recovery of an interrupted update, the Windows UTF-8 stdio
bootstrap (its stdout carries the teammate's reply as the completion
notification), and import-path hardening against a cwd package shadowing
Hermes modules. The runner argv is safe for the bootstrap: command_argv() reads
`--run-delivery`/`--wait-reply` as flags, so it is neither `update --post-swap`
nor `pm repair`. prepare_launch acts only on a self-updated install whose
dependencies are behind or whose interpreter is not the store Python, and its
relaunch re-runs the same script path with the same argv (runpy.run_path), so
the runner keeps its contract there too.

tests/tools/test_bot_mode_dm_entry.py launches the real script under `-I -S`
with a PM-committed dependency record in a temp HERMES_HOME as the only route to
the packages: the --run-delivery case fails on the parent commit with the exact
live `No module named 'ruamel'` error and passes here; the --wait-reply case
drives the argv waiter_command builds through the same entry.
2026-09-29 03:01:04 +05:30
kshitijk4poor
73f7fc2ca5 fix(state): a busy state.db is not a turn-lease wait
acquire_session_turn_lease treated a write-lock timeout like a lease that
another holder owns. It called on_wait, so the agent announced "Another
Hermes process is using this session", and it then slept a full poll
interval after the attempt had already spent its write patience.

A lock timeout now retries at once. The poll interval moves into the
retry's write patience, so the retry takes the lock as soon as it is free.
It reports through a new on_contended callback instead of on_wait. The
agent does not announce it, but it still reloads the transcript after
admission, because the busy writer may be the previous holder's final
flush. The failed-attempt cadence, and with it the lock-holder log line,
stays at about one per 1.5s.

With the first-turn lease from #126309, this path also runs on first,
delegate, cron and one-shot turns.

Live test on a real state.db, with another connection holding the write
lock for 1.5s: before, admission took 2.11s and on_wait fired once;
after, 1.52s, with no on_wait and one on_contended.
2026-09-29 02:47:39 +05:30
Teknium
9bcbe7b5df feat(i18n): pluggable, layered language packs across core, Desktop and TUI (#126296)
* feat(i18n): layered catalogs — plugin packs and user overlay over bundled locales

* feat(tui): i18n layer — en catalog, nanostore runtime, RPC pack loader, _keys.tui.json emitter

ui-tui/src/i18n/: en.ts (facade over topical siblings under en/), types.ts
(Translations + dotted TranslationKey derived from en), runtime.ts ($locale/
$catalog atoms, translateFrom active→en→key, pack merge with string→fn
wrapping for {0}/{1} placeholders), loader.ts (display.language →
i18n.catalog {lang, surface:'tui'}, English when the method is missing),
useT()/useLocale() hooks, t() for non-React code. useConfigSync feeds the
loader from the existing config.get full hydration. `npm run i18n:keys`
writes locales/_keys.tui.json (sorted flat key list) and runs before build.

* feat(plugins): provides_locales manifest field, ctx.register_locale/register_locale_dir, manifest-only language packs

* chore(tui): split en catalog siblings by lane (slash sibling)

* feat(plugins): validate language packs — parse, text-only, key-subset WARN against en / _keys exports

* feat(tui_gateway): i18n.languages / i18n.catalog RPC + regenerated contracts

* feat(config): display.language accepts any supported_languages() id, refuses unknown ids with the list

* docs(i18n): language packs user guide, pluggable display.language, plugin developer section, AGENTS notes

* feat(plugins): report language-pack layers in the mid-run activation summary

* feat(tui): wire status bar, composer placeholders, hotkey help and approval/clarify/confirm prompts through i18n

StatusRule maps compared state values (ready/running…/summoning) to catalog
text at render via displayStatus(); hotkeys()/placeholder() resolve lazily so
a pack that arrives after boot applies. Catalog grows to 81 keys.

* feat(desktop): pluggable app locales — registry, host.i18n.registerAppLocale, backend packs, keys emitter

- Locale widens to string (BundledLocale keeps the union); TRANSLATIONS stays
  the bundled record and every consumer resolves through the registry.
- src/i18n/registry.ts: registerAppLocale(id, {endonym, rtl, translations})
  layers partial packs (nested or flat dotted) over bundled/en via
  mergeTranslations; a string over a function-valued en entry becomes a
  positional {0}/{1} formatter; $appLocaleVersion bumps so translators
  re-render; per-source disposers + replaceAppLocaleSource for atomic swaps.
- Backend packs: i18n.languages + i18n.catalog {surface:'desktop'} feed the
  registry as source 'backend' (method-not-found is silent); re-synced on
  socket open, display.language change and profile switch. A saved pack-only
  language is promoted once its pack registers.
- SDK: host.i18n.registerAppLocale / languageOptions; ctx.i18n.registerAppLocale
  tracked for unload. Docs in the desktop plugin SDK guide + skill reference.
- Language switcher lists bundled ∪ registered ∪ backend, endonym-only; RTL
  from the registry (applyDocumentLocale takes rtl).
- npm run i18n:keys emits locales/_keys.desktop.json (wired into build).

* i18n(cli): route /topup + /subscription copy through t() (cli.billing.*, cli.subscription.*)

Module-level copy tables and modal choice tuples in cli_billing_mixin.py froze
English at import, before display.language was known. They are now key tables /
builder functions evaluated at call time; every user-facing line in the /usage
balance block, /subscription and the five /topup screens reads the catalog.
Choice VALUES stay English identifiers. Fragment-assembled status lines
(Plan: … → cancels · $x left · renews …) become full templates.

* i18n(gateway): exec-approval card contract + base/run/run_busy/run_inbound replies through t()

- base_exec_approval: EA_* English constants stay; add ea_header_text()/ea_reason_label_text()/
  ea_smart_deny_line_text()/ea_default_reason_text()/ea_action_labels()/approval_timed_out_notice()
  accessors; deadline + timed-out notice resolve via gateway.exec_approval.*
- BasePlatformAdapter._EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/_EA_ACTION_LABELS become
  properties (adapters still shadow them with markup class attrs)
- run.py: provider error replies table holds catalog keys; _CONTEXT_OVERFLOW_REPLY -> _context_overflow_reply()
- run_busy/run_inbound: typed approval + slash-confirm matchers accept English ∪ approval.inputs.* (t())
- locales/en.yaml: gateway.exec_approval/busy/errors/... namespaces

* i18n(cli): wire modal, loops, agent-setup mixins through t() (cli.* keys)

* i18n(platforms): route Slack, Matrix and Feishu user-facing text through t()

Exec-approval markup overrides (_EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/
_EA_ACTION_LABELS) become per-call properties over the shared
gateway.exec_approval.* contract keys, so Slack's 3000-char section budget
measures the resolved template. Slack _APPROVAL_DECISIONS/_CONFIRM_DECISIONS,
Feishu _APPROVAL_LABEL_MAP and Matrix _EA_LEGEND/_EA_TYPED_HINT turn into
key tables resolved at click time; the Matrix typed hints become whole
sentences per offered tier instead of spliced fragments. Slack button labels
are cut to 75 chars and select placeholders to 150 after translation; the
model-facing clarify fallback answer ('choice N') stays English while the
card copy localizes.

locales/en.yaml gains the gateway.exec_approval.* contract keys plus the
platform.shared.* / platform.slack.* / platform.matrix.* / platform.feishu.*
namespaces (and the keys for the other adapters wired in follow-up commits).

* i18n(gateway): run_turn / run_turn_runner / approval-settle copy through t()

- status hints, proxy errors, background task notices, progress heartbeats, session info lines
- tool progress chrome (tool_head/tool_pending/tool_preview/tool_verbose) shared by base.format_tool_event
- run_turn_runner:1406 Chinese clarify placeholder -> gateway.clarify.native_stream_placeholder (zh text kept in zh.yaml)
- _UNEXPECTED_SILENCE_REPLY/_CLARIFY_EXPIRED_NOTICE -> accessor functions

* i18n(platforms): route Google Chat and Teams user-facing text through t()

Google Chat clarify card, typing placeholder, orphan-card labels and the whole
/setup-files reply set (module constants become platform.google_chat.setup_files.*
keys resolved at reply time). The attachment-fallback notice that shipped
hardcoded in Spanish is keyed with an English en value; es.yaml carries the
original Spanish text for those four keys.

Teams approval card header/reason use the gateway.exec_approval.* contract,
_APPROVAL_LABELS becomes a key table resolved at click time, and the meeting
summary writer resolves its section headings/fallbacks per render.

* i18n(platforms): route LINE, WeCom, email, DingTalk, IRC and Home Assistant text through t()

LINE default copy constants become catalog keys resolved in __init__ (the
LINE_*_TEXT / extra.* operator overrides still win); the busy-ack bypass
matcher keys on the leading emoji marker only, so it keeps firing once the
gateway busy heads are localized. WeCom media size/format notices that shipped
hardcoded in Chinese are keyed with English en values and zh.yaml carries the
original Chinese text. DingTalk emotion bubbles resolve per send.

* i18n(cli): route /model switch output and -q status lines through t() (cli.model.*, cli.single_query.*)

Switch-summary labels shared with the gateway reuse gateway.model.* keys
(provider/context/max-output/capabilities/prompt-caching); CLI-only variants
(glyph or no-backtick forms) live under cli.model.*. The hand-padded /model usage
block becomes a (form, description-key) table padded at render time so the
command syntax stays fixed while descriptions translate. -q 'Error:' reuses
gateway.model.error_prefix.

* i18n(cli): route TUI panel/hint/placeholder copy through t() (cli.tui.*)

_APPROVAL_CHOICE_LABELS and _TUI_MODAL_HINTS become key tables resolved at
render time; vault/sudo panel bodies are one catalog value per panel split on
newline; inline plurals use <key>_one/<key>_other. Adds the cli.* namespace
(shared/tui/voice/render/subagents/dock) to locales/en.yaml.

* i18n(cli): voice/wake-word CLI copy through t() (cli.voice.*)

RuntimeError texts raised in _voice_start_recording are human copy (callers
print {e}) and are keyed; the Termux requirement-check match stays English.
Wake state ids stay internal, only their labels localize.

* i18n(cli): live-work dock, subagent monitor and render copy through t()

cli.subagents.* / cli.dock.* / cli.render.*; count fragments pluralize via
_one/_other keys, verdict table holds keys resolved at paint time so width
clipping measures the translated text.

* i18n(gateway): unauthorized/pairing, voice, topics, shutdown, startup, notifications, kanban pings through t()

* i18n(cli): move tips + composer placeholders into the catalog (tips.tNNN / tips.placeholder.pNN)

get_random_tip()/get_random_composer_placeholder() pick a key from the English catalog
(the parity baseline, probed once per process) and resolve it through t() for the active
language, so language packs translate tips like any other string. Also lands the cli.*
en.yaml namespace consumed by the CLI info/help/error-copy wiring in the next commit.

* i18n(cli): wire chat-turn + session mixins through t(); kanban log trimmer matches t() output

* test(cli): assert TUI/dock/voice copy via t(key); prove labels resolve at render time

Pinned-English assertions in the approval-UI, live-work dock and voice tests now
go through the catalog. New test swaps the catalog after import and checks the
approval panel + hint row follow it (the reason _APPROVAL_CHOICE_LABELS and
_TUI_MODAL_HINTS became key tables).

* i18n(cli): route CLI info/help/error copy through t() (cli.* namespace)

cli_info_mixin: /help consumes CommandDef.describe() (added to commands.py: slash.<name>.description
with fallback to .description), section titles/skill/quick-command headers, /tools, /toolsets,
/usage labels, /context, /whoami, /insights, /gateway status, tool-progress labels, bang-shell
denials, MCP config-watch + /reload-mcp confirm/reload lines, /reload-skills, and the session-store
warning all read the catalog at call time (module-level label tables became functions so the
active language is honoured after startup). cli.py: worktree cleanup, tirith warning, show_config
(labels re-padded at print time), quick/plugin/skill slash-command errors, ambiguous-command hint,
stdin error, gateway start, profile warning. cli_chat_error_copy / cli_unknown_command /
cli_output / cli_init_mixin: chat error panel copy, did-you-mean lines, n-more / yes-no prompt
(localized affirmative initial alongside 'y'), unknown-toolsets warning.

* i18n(w1a): wire agent display/explainers/approval + slash registry/help through t()

- hermes_cli/commands.py: CommandDef.describe() resolves slash.<name>.description
  at call time; category labels via slash.category.*; help/alias/usage suffixes
  via slash.shared.*; gateway_help_lines and commands_platforms/slash_exec use them.
- agent/display.py: display.verb.* resolved at call time via get_tool_verb();
  bridge/spinner/thinking-verb/cute-row/failure/preview/diff text via display.*.
- agent/turn_explainers.py: exit-reason / persistence-cause tables become call-time
  lookups (explainer.exit.*, explainer.persistence.*, explainer.file_mutation.*).
- agent/background_review.py, session_activity.py, context_breakdown.py,
  status_output.py: review summaries, iteration progress, context notices.
- tools/approval.py, approval_context.py: approval.summary.*, approval.noun.*,
  approval.window.* pluralized keys.
- gateway/slash_commands*.py: remaining raw strings (busy, whoami, platform,
  bundles, memory, skills, approvals, set_home, diff, update, debug, profile,
  heartbeat, refine, review, subgoal, loop, retry, compress codex path, save,
  sessions, model guard/errors, agents rows, topup, login). HISTORY_UNREADABLE
  keeps its English constant; callers use history_unreadable() ->
  gateway.shared.history_unreadable.
- locales/en.yaml: new approval/display/explainer/slash blocks + gateway leftovers.

* i18n(telegram): route adapter chat copy through t()

Approval card (header/reason/smart-deny as HTML-escaped properties), inline
button labels, callback toasts (cut at Telegram's 200-char cap), model/choice
pickers, clarify/update/slash-confirm prompts, gmail-triage labels and the
inbound-media failure notice now come from the catalog. _UNAUTHORIZED is a
lazy _unauthorized() so the import no longer binds a language. The command
menu carries a language+payload fingerprint (forum scopes re-register on
change) and BotCommand descriptions are cut at 256.

Adds gateway.exec_approval.* (WAVE2 contract), platform.telegram.*,
platform.discord.* and the slash.*.description keys the Discord table shares
with the CLI registry to locales/en.yaml.

* i18n(gateway/platforms): whatsapp_cloud, yuanbao, weixin, signal, api_server copy through t()

- whatsapp_cloud: clarify list/buttons, approve/deny + slash-confirm labels via platform.whatsapp.* (t()-then-truncate at 20/24/72 caps); _EA_HEADER becomes a property wrapping ea_header_text()
- yuanbao: SLOW_RESPONSE_MESSAGE -> slow_response_message() (platform.yuanbao.slow_response_notice; zh keeps the original text); cron-wrapper markers centralized as module constants for strip_cron_wrapper
- api_server: PROVIDER_AUTH_FAILED_LABEL/PROVIDER_RATE_LIMITED_LABEL stay English for run.py matchers; user_text() renders via t()
- signal/_format_wait, weixin voice caption, openai_routes transformed notice
- run_turn: second _UNEXPECTED_SILENCE_REPLY consumer -> accessor

* i18n(discord): route adapter chat copy through t()

Native slash-command table becomes _NATIVE_SLASH_COMMAND_SPECS holding catalog
keys; _native_slash_commands() resolves descriptions, parameter descriptions
and Choice names for the active language, each cut at Discord's 100-char cap,
and the app-command sync fingerprint now includes get_language() so a
display.language change re-syncs. Exec-approval card (gateway.exec_approval.*
contract), slash-confirm / clarify / update views, model+choice pickers,
thread creation, forum titles, voice acks, the response-truncation notice,
the unauthorized-slash security alert and the media upload-size notices all
read from platform.discord.*. Decorator-declared button labels are relabelled
in __init__ (80-char cap); embed titles cut at 256, select placeholders at
150, option label/description at 100. _UNAUTHORIZED is a lazy _unauthorized().

* i18n(cli): wire status-bar, stream, terminal mixins + terminal_input through t(); rename kwargs that shadow t(key)

* i18n: wire hermes_cli/cli_commands_mixin.py slash-command copy through t()

- 431 new leaves under cli.commands.<cmd>.* in locales/en.yaml; 12 rows reuse
  existing gateway.* keys (rollback, diff, resume, branch, btw, model, reasoning)
  via a _gt() helper so CLI and gateway replies stay identical.
- Module-level English tables (_BUSY_MODE_*, _REASONING_TOGGLES, _HATCH_PROGRESS,
  _DIFF_LABELS, _LOCAL_ENGINE_LINES) become call-time catalog lookups keyed by id.
- Verb tables (Enabling/Disabling, Paused/Resumed/Triggered, planned/done,
  Updating/Generating) are one full template per variant; plurals use
  <key>_one/<key>_other via _tn(); hand-padded column labels (/snapshot list)
  translate the value and re-pad at the call site.
- Multi-line usage blocks are single catalog values split with _lines().
- Model-facing system notes and DB-stored reasons stay English (EXCLUDED).

* tests: assert /handoff, /worktree, /login CLI copy via t(key) instead of pinned English

* test(i18n): pin Telegram/Discord adapter catalog wiring

Lazy unauthorized notice, exec-approval contract keys, HTML escaping before
Telegram <b> wrapping, 200-char toast / 256-char BotCommand caps, Discord
100-char app-command text and 80-char button caps, and language-bearing
command-menu fingerprints on both platforms.

* i18n: reconcile cli.shared on/off vs enabled/disabled after lane merge

* i18n: describe() in TUI-gateway slash listings; localize TUI exit resume hint

* i18n(tr): translate bundled catalog + tui pack

* i18n(ja): translate bundled catalog + tui pack

* i18n(ko): translate bundled catalog + tui pack

* i18n(zh): translate bundled catalog + tui pack

* i18n(fr): translate bundled catalog + tui pack

* i18n(af): translate bundled catalog + tui pack

* i18n(uk): translate bundled catalog + tui pack

* i18n(ar): translate bundled catalog + tui pack

* i18n(pt): translate bundled catalog + tui pack

* i18n(it): translate bundled catalog + tui pack

* i18n(es): translate bundled catalog + tui pack

* i18n(zh-hant): translate bundled catalog + tui pack

* i18n(ru): translate bundled catalog + tui pack

* i18n(hu): translate bundled catalog + tui pack

* i18n(hu): translate pre-existing English-valued leftovers (kanban wake, /context, /status, fast labels)

* i18n(de): translate bundled catalog + tui pack

* i18n(ga): translate bundled catalog + tui pack

* test(i18n): fixture matches _normalize_lang(lang, home) signature

* i18n(tui): scaffold userMessages/slashCmd en siblings

* i18n(tui): wire secure prompts + content tables

* feat(tui): i18n — wire billing, subscription, connection-setup and journey overlays

Adds en siblings billing.ts / subscription.ts / connection.ts (namespaces
billing, subscription, connection, journey) and routes every user-facing
literal in billingOverlay, subscriptionOverlay, connectionSetupOverlay and
journey through useT()/messages(). Module-level label tables became lazy
(scopeStillDeniedResult(), verbOf(T, action)); auto-reload rows dispatch on
stable ids instead of label text. Regenerates locales/_keys.tui.json.

* i18n(tui): wire slash ops/wake replies

* i18n(tui): wire pickers (modelPicker, activeSessionSwitcher, petPicker)

* i18n(tui): wire slash core/debug/setup replies

* i18n(tui): wire hubs (agents overlay/panel/controls, skills, plugins)

* i18n(tui): wire slash session/topup/subscription replies

* i18n(tui): wire chat bits (branding, thinking, messageLine, loaders, todo, queued, banner, entry)

* i18n(tui): register t3 siblings (pickers, hubs, secure, content, chatBits) and regenerate keys

* i18n(tui): wire userMessages copy through the userMessages namespace

* i18n(tui): lazy-copy test for userMessages, regenerate _keys.tui.json

* i18n(tui): wire session/gateway/lib text through the TUI catalog (lane t2)

Adds en siblings session.ts, gatewayMsg.ts, libText.ts (namespaces session,
gatewayMsg, libText) and routes user-facing literals in app/{useMainApp,
useSessionLifecycle,useInputHandlers,turnController,createServerRequestHandler,
setupHandoff,createGatewayEventHandler}.ts, gatewayClient displayed reasons,
lib/*, domain/*, hooks/* through t()/messages(). Status-bar state values that
code compares against, backend-matched strings, log lines, model-bound text,
and machine 'error:' prefixes stay literal. Regenerates locales/_keys.tui.json
(232 keys).

* i18n: serve bundled locales/<lang>.tui.yaml under overlay/packs; TUI pack parity test; regen _keys.tui.json (1250)

* i18n: translate pre-existing English stubs in bundled locales (424 leaves, 14 locales)

* tui: i18n-export-en script (English templates for pack translators)

* docs(i18n): bundled TUI packs are the bottom layer of the tui surface

* i18n(ru): translate TUI pack

* i18n(ar): translate TUI pack

* i18n(es): translate TUI pack

* i18n(pt): translate TUI pack

* i18n(ko): translate TUI pack

* i18n(de): translate TUI pack

* i18n(ja): translate TUI pack

* i18n(fr): translate TUI pack

* i18n(tr): translate TUI pack

* i18n(it): translate TUI pack

* i18n(zh): translate TUI pack

* i18n(zh-hant): translate TUI pack

* i18n(hu): translate TUI pack

* i18n(uk): translate TUI pack

1,169 missing keys translated; 81 pre-existing kept byte-identical. Parity OK missing=0 extra=0 placeholder_mismatch=0 empty=0.

Deliberately identical to en: chatBits.branding.mcpSummary ({0} MCP), chatBits.thinking.agentsHint ((/agents)), session.main.voiceStt (◉ STT), session.main.voiceTtsSuffix ( [tts]), slashCmd.core.help.tuiSection (TUI), slashCmd.core.history.hermesTag (Hermes #{0}), slashCmd.debug.heapdump.heapPath (heapdump: {0}), slashCmd.debug.mem.rss (rss), subscription.stepUp.title (Remote Spending — product feature name, as in core catalog), content.faces.* (glyph-only kaomoji).

* i18n(ga): translate TUI pack

* i18n(af): translate TUI pack

* plugin_guard: locale catalogs in language packs step down the agent-config family

A translated status line such as "Updating AGENTS.md" in locales/<lang>.yaml is UI text the loader
reads as a string leaf; it cannot edit a file. The bundled en.yaml itself tripped agent_config_mod
at critical, making any faithful language pack uninstallable. Injection shapes keep full severity.

* plugin_validate_locales: read key exports with utf-8-sig (Windows footgun lint)

* i18n(relay): route relay adapter prompt copy through t(); drop dead import-bound approval header

Adds platform.relay.* (5 keys) to en and all 16 bundled locales, reusing the sibling platform
translations for the confirm buttons and the Other option.

* ci: fix TUI import order, MDX table pipe, main's overflow-warning wording in all locales; fresh-install fixture carries the i18n kernel

- ui-tui/src/i18n/en.ts: perfectionist/sort-imports (slash before slashCmd)
- docs plugins/index.md: escape the | inside the provides_locales table cell (MDX parsed <id> as JSX)
- display.notice.uncompressed_context_overflow: adopt main's wording (names compression.enabled: false
  and /compact) in en + 16 locales; the guardrail test pins that phrase
- tests/scripts/test_fresh_source_install.py: the installer tail now resolves CLI text through
  agent.i18n, so the fixture tree carries the i18n kernel + en.yaml (not the agent runtime)

* docs(desktop-plugin-sdk): double-backtick the template-literal example (MDX evaluated ${n})

* test(e2e): display.language is validated against the live language set; exclude it from the arbitrary-string set property

* commands: keep the localized COMMANDS/COMMANDS_BY_CATEGORY module __getattr__ after the compat block removal

* build: never write locales/_keys.*.json from the desktop/TUI builds; regenerate the committed desktop key export

The desktop build regenerated locales/_keys.desktop.json in the checkout, so a
hermes update that rebuilt the app left the tree dirty (Desktop update E2E:
'M locales/_keys.desktop.json'). The key exports are committed artifacts pinned
to en.ts by apps/desktop/scripts/i18n-keys.test.mjs and ui-tui i18n:keys:check;
builds read them, never write them. Regenerated after main's new desktop strings.

* test: unbreak two main-red timing tests the PR merge-ref inherits

- test_local_runtime racing fake publishes the modern state record (legacy pid-only
  records are rejected since 65ff3ad353; main has been red on this test since)
- test_run_progress_topics ManyProgressLinesAgent waits for the first bubble instead of
  a fixed 0.35s, which a loaded CI runner does not always meet

* chore(i18n): regenerate desktop key catalog for main's new strings (model pricing, copy changelog)

* test(e2e): torture-chamber fd monitor confirms a deleted sidecar is still held before calling it a leak

SQLite's WAL last-close unlinks -shm before closing its descriptor (unixShmUnmap, then
unixShmPurge), so a healthy close shows a (deleted) -shm for microseconds; the 20ms poll
occasionally caught that window on the short-lived opener and failed the episode.

* chore(i18n): regenerate desktop key catalog for main's telemetry/consent strings

* chore(i18n): regenerate desktop key catalog after main sync

---------

Co-authored-by: Teknium <teknium@nousresearch.com>
2026-09-28 14:16:18 -07:00
kshitijk4poor
2b98a469d3 refactor(tests): resolve a guarded path once; tripwire test runs without symlinks
The installed-app check now hands its resolved path to check() instead of
both resolving it, and uses the captured app paths as already normalized.
The symlink case of the tripwire test is added only where the host can
create symlinks, so Windows without Developer Mode still runs the rest.
2026-09-29 02:40:55 +05:30
kshitijk4poor
4c014e4407 test: refuse changing the developer's real installed Hermes desktop app
packaged_gui_app_paths() returns the machine's real install locations
(/Applications/Hermes.app and ~/Applications/Hermes.app on macOS, the
desktop entry and icons on Linux, Programs\Hermes on Windows), and tests
keep HOME intact. A test that runs the real GUI uninstall or update swap
without stubbing it deletes the developer's own app: the variant of
test_uninstall_gui_keeps_userdata_dir_when_told_to in 954d90f15f
(#122553) removed /Applications/Hermes.app on a maintainer machine
during review, and Hermes vanished from Spotlight.

The home I/O guard now captures those paths at conftest import, before
any test can patch sys.platform or HOME, and fails the test on any
delete, rename, replace, chmod, utime or write-mode open inside them,
by the literal or the resolved path. Reads stay allowed (update paths
probe the installed app). The refusal uses pytest.fail
because the uninstall removal helpers catch Exception: an
AssertionError there is logged as a warning and the test passes.
2026-09-29 02:40:55 +05:30
kshitijk4poor
aea6f7a660 refactor(desktop): write the installed-app record only when it changes
Every hermes desktop launch refreshes the installed copies, so the record
was rewritten each time, and recreated as an empty list right after
hermes uninstall. Skip the write when the set is unchanged, check
ownership once up front, and ignore a record whose apps field is not a
list. The test now proves a reinstalled copy is owned by its own stamp
(record deleted first) and that uninstall leaves no record behind.
2026-09-29 02:37:33 +05:30
kshitijk4poor
b52c73c3e4 fix(desktop): hermes update puts back an installed Hermes.app that went missing
hermes update only refreshed installed copies that still existed: once
/Applications/Hermes.app was gone (trashed, deleted by a tool, lost in a
migration) every later update skipped it silently, and Finder, the Dock
and Spotlight never found Hermes again. The ownership stamp that marks a
copy as this checkout's lives inside the bundle, so it left with it.

Record the installed copies the update keeps current in
desktop-installed-apps.json under the default Hermes root. When no owned
copy is left, a recorded path that is now missing still counts as
installed: the update rebuilds for it, reinstalls it and says so. A copy
the user moved to the other Applications folder keeps its stamp, so it
is found instead of doubled. The record is machine-wide, so Hermes' GUI
uninstall deletes it from any profile and an uninstalled app stays
uninstalled. Only the checkout that owns installed apps writes it; a
copy that failed to reinstall stays recorded so the next update retries.

The skip dates from 032cf8438f (#113933), which scoped the refresh to
existing bundles.
2026-09-29 02:37:33 +05:30
hermes-seaeye[bot]
0c380abe7b fmt(js): npm run fix on merge (#126904)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-28 21:01:19 +00:00
Brooklyn Nicholson
ad2d4822e1 feat(desktop): right-click zone menu on the pane body
A zone whose header strip is hidden (or never rendered) stranded its
pane with no discoverable close: the zone menu lived only on the strip
and the edit veil, so a full-width pane had no right-click path to
Close/Minimize either (#92500).

Serve the same ZoneMenu the strip carries from the BODY: PaneBody
gains a wrap slot that receives its own element, so the ContextMenu
trigger attaches to the body div directly - no extra layout box, the
zone's flex geometry and the hidden-viewport retention are unchanged.
floating-panes keeps its bare PaneBody.

Reworked onto the current renderer from the tree-group ZoneMenu wrap
in #93194. New zones.zoneMenuLabel aria string in all seven locales.

Co-authored-by: Axl Ibiza, MBA <andrexibiza@gmail.com>
2026-09-28 15:44:42 -05:00
Brooklyn Nicholson
dbc5ec8f9b feat(desktop): make the browser pane hotkey a true toggle
Cmd+Shift+L (view.showBrowser) only ever opened the Browser pane -
there was no way to close it from the keyboard, so the hotkey read as
half-dead once the pane was on screen and you reached for the mouse
(#92500).

toggleBrowserTab() asks the layout tree whether the mirrored
preview-tile pane is actually on screen (not dismissed, hidden,
minimized, or parked behind a sibling tab): if not, it opens/re-fronts
the Browser with the page it last showed; if yes, it dismisses just
that pane through the tree's own close path. The TAB survives a
close, so the next toggle restores the page instead of landing on
about:blank - the reveal path already handles un-dismissing.

The command palette entry and the en/ar/de/es/fr/ru/zh labels follow:
'Open browser' becomes 'Toggle browser'.

Reworked from #95826 onto the current preview store (pane id built
from PREVIEW_TILE_PREFIX; dismiss through the tree's dismissTreePane).

Co-authored-by: Jason Pollak <jason@runninwithitmarketing.com>
2026-09-28 15:44:42 -05:00
Brooklyn Nicholson
5f4dff1736 chore(contributors): map Treytucker05 email 2026-09-28 15:44:42 -05:00
Brooklyn Nicholson
0c208aa7ec fix(desktop): right-sidebar toggle acts on the physical right column
The titlebar's right-sidebar button and Cmd+J are documented as
POSITIONAL ("shows/hides everything on its physical side of the main
zone"), but the implementation is pane-bound to the files pane. Two
breakages follow: a Browser preview column (whose panes register
placement 'main', so the semantic side derivation skips it) is
invisible to the toggle entirely, and dragging the files pane into the
left stack leaves the toggle pressing a pane that no longer owns the
right side.

Resolve the target from the live tree instead: the rightmost foldable
root-row column (leaf group, no workspace/session-tile surface),
whatever panes live there. Fold/unfold through the tree's own zone
minimize so the column keeps its tab on a persistent rail and
round-trips exactly like the zone chevron. Falls back to the existing
semantic branch when no right column exists, so terminal-on-bottom
layouts keep Cmd+J alive.

Reworked from #92745 onto the current tree store (rootRow is exported
for the walk instead of a local duplicate).

Co-authored-by: Frank S. Tucker III <Treytucker05@users.noreply.github.com>
2026-09-28 15:44:42 -05:00
Brooklyn Nicholson
d6c899ad5c fix(desktop): visible close on the preview browser bar
The in-app Browser can fill the layout with no close control in reach -
no toolbar X, no tab strip when the zone hides it - leaving "ask the
agent to close a panel" as the only way out (#92500).

Put the tab's own Close verb on the browser bar, after the DevTools
glyph: PreviewPane threads its onClose into the bar, so the docked
preview mirror's close (the tab's X verbatim: console cleanup + tab
removal) is reachable from the pane body itself. Embedders that pass no
verb (the popped-out Browser shell) render no dead button.

Reworked onto the current right-rail from the browser-bar close in
#91934 and #93194, whose branches predate the PreviewPane onClose
threading that already landed for error/file bodies.

Co-authored-by: Trounaldo <ryanrtrout@gmail.com>
Co-authored-by: Axl Ibiza, MBA <andrexibiza@gmail.com>
2026-09-28 15:44:42 -05:00
Brooklyn Nicholson
5f477842f3 chore(contributors): map Trounaldo email 2026-09-28 15:44:42 -05:00
Brooklyn Nicholson
defaf5e5d5 chore(contributors): map ajbmachon's commit email 2026-09-28 15:43:06 -05:00
Brooklyn Nicholson
d903f3af42 fix(tui-gateway): keep activity heartbeats out of the sessions digest
The session activity heartbeat restamps last_activity_at and
last_activity_description mid-turn and on idle ticks. With those columns in
the digest, every heartbeat window still broadcast sessions.changed and the
Desktop Sessions panel kept refreshing on its own (#98005). Drop them from
the signature; message_count, title, pin/archive and row create/delete
still move it.

Open the probe through the tracked read-only connect every other live
state.db reader uses, and keep the last digest when a read fails after a
good one so a busy moment cannot flip the signature to mtime and back.

Ports the heartbeat / row-change / busy-store regression tests from #102987.

Fixes #98005

Co-authored-by: 小象 <247313108+shorehefei-ship-it@users.noreply.github.com>
Co-authored-by: salch-cred <salch-cred@users.noreply.github.com>
Co-authored-by: ajbmachon <ajbmachon2@gmail.com>
2026-09-28 15:43:06 -05:00
ajbmachon
0423d5dfea fix(tui-gateway): mtime fallback for sessions digests over foreign state.db schemas 2026-09-28 15:43:06 -05:00
ajbmachon
df4ed4bb61 fix(tui-gateway): digest session rows for sessions.changed
Replace state.db mtime watching with a cached digest of session projection
rows so gateway heartbeat writes no longer trigger false Desktop refreshes.

(Cherry-picked from #121396 without its Desktop busy-turn deferral hunk.)
2026-09-28 15:43:06 -05:00
kshitijk4poor
b86a0af825 test(child-env): keep the home vars in the Windows mixed-case PATH test
The adapter-secret lookup now resolves the profile home. Path.home()
fails on Windows when the patched environ has no USERPROFILE, which a
real process always has.
2026-09-29 02:06:58 +05:30
kshitijk4poor
a0e20b60d9 fix(child-env): a partial plugin scan keeps the denials it already knew
andrexibiza (review on 60bdd5fbe3): skipping an unreadable plugin dir,
or an unreadable or unparsable flat manifest, replaced the home's
cached secret set with the partial result. A secret declared a moment
ago could then reach children while its manifest was unreadable. And
because the partial result was cached under the same file signature,
recovery could keep hitting it.

platform_manifest_secret_scan() now reports whether the scan was
complete. A partial scan unions in the names this home already had,
and is cached with no stamp, so the next spawn rescans and a recovery
is seen at once. A deleted plugin still releases its names, because
that scan is complete. An unreadable plugins/platforms/ manifest still
raises.
2026-09-29 02:06:58 +05:30
kshitijk4poor
9e26d34475 fix(child-env): manifest scan skips only what plugin discovery skips
Per-commit review follow-ups:

- Dot directories are scanned again, matching plugins_discovery. A
  platform plugin that discovery loads from plugins/.x now also has
  its secrets declared.
- Only PermissionError means a plugin directory cannot be searched. A
  symlink loop is treated as missing, so plugin.yml is still checked,
  as discovery does. A plugin.yaml that is not a regular file is
  ignored rather than failing every strict read.
- Dropped a redundant union: the home set is already stripped in
  Tier 1.
- Tests now pin the dunder skip, the unlistable platforms root, and a
  stamp change on an edit that keeps the mtime.
2026-09-29 02:06:58 +05:30
kshitijk4poor
b1206115a2 perf(child-env): one scandir manifest stamp per scrub, file-signature keyed
Per-commit review follow-ups on the per-home plugin declarations:

- The manifest stamp walked each plugin directory with pathlib
  is_dir/exists/stat on every spawn, and _scrub_credentials took it
  twice. It is now one scandir per root and one stat per candidate,
  taken once per scrub.
- The stamp uses utils.file_signature, so a manifest replaced with its
  mtime preserved (cp -p, rsync -t) still invalidates the cache.
- An unsearchable plugin directory, including an unlistable flat
  plugins/ root, is reported once as a directory entry. The stamp walk
  stays quiet; the warning comes from the manifest read, which runs
  only on a cache miss.
- The dot/dunder skip now matches plugins_discovery.scan_directory, and
  the manifest source argument is a Literal.
- Tests: the unsearchable-dir case uses a non-dunder dir, so both
  guards are pinned. New tests cover the bundled read failing closed,
  manifest deletion releasing its names, and a symlinked home alias
  sharing one cache entry.
2026-09-29 02:06:58 +05:30
kshitijk4poor
8ffcd7488f fix(child-env): openviking drops the profile overlay's bot tokens; unprovable manifests don't block spawns
Phase 2 review follow-ups:

- OpenViking overlaid the bound profile's whole .env after the scrub,
  so that profile's bot, dashboard and relay tokens reached the server
  again. The overlaid env is now scrubbed a second time for Tier 1.
  Provider keys still pass, and they still come from the bound profile.
- The strict per-home manifest read raised on any unreadable entry
  under <home>/plugins, which failed every child spawn for that
  profile. It is now strict only where the manifest is known to be a
  platform's: bundled or plugins/platforms/. Dunder and dot
  directories, unsearchable plugin directories and unreadable flat
  plugins/* manifests are skipped with a warning, because they can't
  load as plugins either.
- The per-home cache is keyed on hermes_home_key(), so a symlinked
  alias no longer gets a second entry.
- The modal snapshot test swapped hermes_cli.config for a stub, which
  the policy import can no longer use. HERMES_HOME already points the
  real module at the test home, so the stub is gone.
- The env_passthrough docstring now describes declared names rather
  than the dropped prefix rule.
2026-09-29 02:06:58 +05:30
kshitijk4poor
1ede0b805b refactor(child-env): one secret-suffix rule, one env-table walk, one bundled manifest read
/simplify-code follow-ups on the declared-secret policy:

- The policy and the manifest classifier each defined which env-name
  suffixes make a platform variable a secret, and they disagreed on
  _JSON. PLATFORM_SECRET_ENV_SUFFIXES in hermes_cli/config.py is now
  the only definition.
- The gateway env-override table was walked by hand a fourth time,
  through private gateway.config_env names. The walk is now
  profile_channels.config_env_table_keys(), shared with
  declared_channel_env_keys.
- The bundled platform manifests were parsed twice at import (about
  16 ms). The config injection reads them once, strictly, and keeps
  their secret names. The policy re-reads only if that read hit an
  I/O error.
- The per-home cache was keyed on directory mtimes, so an in-place
  plugin.yaml edit went unseen until restart. It is now keyed on
  every manifest file's mtime.
- The core-declared snapshot is a module-level constant rather than
  a global set inside the injector. The two manifest-source booleans
  are now one source argument. The Tier 1 set is upper-cased once
  at import, not on every spawn.
2026-09-29 02:06:58 +05:30
kshitijk4poor
3a6406137d fix(child-env): per-profile plugin secrets, fail-closed declaration scans, dashboard auth in Tier 1
Review follow-ups on the declared-secret policy:

- A profile's user-installed platform plugins declared secrets into
  one process-wide set, read from the launch home at import. Under
  multiplex that stripped profile B's same-named user variable and
  missed profile A's own declarations. Bundled manifests stay
  process-wide; user manifests are now read per bound home, cached
  until that home's plugin dirs change, and are Tier 1 for that
  profile only.
- The declaration scans no longer fail open. A registry error is no
  longer swallowed into an empty set, non-string required_env entries
  are skipped rather than breaking the set, and a manifest that
  cannot be read raises instead of vanishing from the policy. A
  malformed manifest still declares nothing.
- A plugin manifest can no longer reclassify a core-declared name
  such as OPENAI_API_KEY; the same rule the config form applies.
- The dashboard basic-auth password and signing secret, the OIDC
  client secret and the drain bearer move to Tier 1. Credentialed
  CLIs (claude, codex) no longer receive them.
- openviking-server starts from served_profile_child_env, so it gets
  the bound profile's provider keys, never the launch profile's.
  With no bound profile under multiplex the start is refused.
- NOUS_API_KEY and QWEN_API_KEY are listed statically. Discovering
  provider plugins while the policy module imported re-mirrored them
  over a plugin's own auth registry entry
  (tests/providers/test_auth_registry_import_order.py).
2026-09-29 02:06:58 +05:30
kshitijk4poor
43800d55f2 fix(child-env): the bot desktop and gmail-triage scripts start from the scrubbed env
Two more spawns inherited the gateway's full environment. The bot
desktop launcher copied os.environ minus the display variables; the
agent drives that desktop and its dock opens xfce4-terminal, so every
provider key and bot token was one click away. The Telegram gmail-triage
buttons ran HERMES_HOME/scripts/gmail-triage/*.sh with no env at all, a
path the agent can write. The desktop now builds from
served_profile_child_env() like the agent browser (keeping the user's
HOME); the triage scripts use build_subprocess_env like cron and
webhook-filter scripts.
2026-09-29 02:06:58 +05:30
kshitijk4poor
6c2a53367f fix(terminal-env): block Hermes' own dashboard, anon and Meet secrets
Review of the adapter-secret salvage found Hermes-owned secrets that no
declared source covers, so they reached terminal and execute_code
children and skill passthrough accepted them: the dashboard basic-auth
password and signing secret (enough to forge dashboard sessions; the
session token beside them is already Tier 1), the dashboard drain and
OIDC client secrets, HERMES_ANON_API_SECRET (a provider-category entry,
which the OPTIONAL_ENV_VARS loop never blocks) and the Google Meet
realtime key. Add them to the static blocklist.
2026-09-29 02:06:58 +05:30
kshitijk4poor
e14936b508 fix(child-env): openviking, raft and buzz keep the user's HOME
The scrubbed builder rewrites HOME to the profile home in containers and
profile home mode (the official image does this by default). These are
third-party programs whose own config and logins live under the user's
HOME: ~/.openviking, the raft CLI profile, ~/.config/buzz. The openviking
change copied the parent's HOME back, which is itself a profile home
when Hermes runs under one; use HERMES_REAL_HOME, which the builder
already computes, for all three.
2026-09-29 02:06:58 +05:30
kshitijk4poor
b9c2cc925f fix(tui): the compute host keeps Hermes' full environment again
The compute host runs dashboard agent turns, so it is Hermes itself, not
a third-party child: the os.environ layer is deliberate (#65895). Without
it, keys that exist only in the process env (Docker -e, systemd,
launchd) were gone from turns: HASS_TOKEN (Home Assistant tools),
MODAL_TOKEN_ID / DAYTONA_API_KEY (those terminal backends), auxiliary
keys and platform send tokens, and HOME was rewritten in containers. The
.env ones came straight back anyway, since the host reloads .env at
start. Restore the layer and allowlist the file in the spawn-env guard.
2026-09-29 02:06:58 +05:30