Every `terminal(background=true)` mints a new process id, and the agent-terminal
stream module never forgot one. `writers` was unregistered when a tab unmounted,
but `backlog`, `lastSnapshots`, `commandHeaders` and `seededCommands` are keyed
by process id and had no eviction at all, so every command an agent ever ran kept
its output until the window closed.
Replaying each process back through `registerAgentTerminalWriter` measures the
retention directly, without depending on GC: 256K chars held per finished process
(~512 KB, JS strings being UTF-16), growing linearly — 100 processes 25.6M chars,
200 51.2M, 400 102.4M (~195 MB). With this change it is flat at 1.79M chars
(~3.4 MB) for every one of those counts.
Bounded the way the other renderer caches already are (`lib/lru-cache`,
`chat/shiki-highlight-cache`): an entry ceiling and a total-character ceiling,
because one busy process can hold as much as twenty quiet ones. `backlog`
insertion order is the LRU clock, so a chunk write re-inserts to move its process
to the tail — a plain re-set keeps the original slot and would let the
oldest-first sweep evict a process that is still streaming.
A process whose terminal is MOUNTED is never evicted: that is on screen, not a
cache. Everything dropped is regenerable — the next `syncAgentTerminalSnapshot`
re-seeds the tab from the registry's rolling tail. The four maps are evicted
together because `lastSnapshots` is the delta fence for `backlog`; dropping one
without the other makes the next snapshot diff against a tail that is gone and
paint only the delta.
Each guarantee is pinned by a regression that fails when that guarantee alone is
reverted: no eviction, evicting a mounted process, losing the recency refresh,
and keeping a stale delta fence.
* fix(tools): inline agent tools get the shared argument coercion
Inline agent tools (todo_list, clarify, memory, session_search, and the
rest of INLINE_TOOL_EXECUTORS) never went through handle_function_call.
They did not get coerce_tool_args, so a JSON-encoded string for an array
parameter failed unless the tool parsed it by hand.
Every entry of INLINE_TOOL_EXECUTORS now runs coerce_tool_args first.
The sequential path and the concurrent invoke_tool path both read that
table, so one wrapper covers both.
todo_tool no longer parses a JSON string by hand. The todo_list schema
declares todos as an array, so coercion covers it. The existing test now
calls the dispatch table, not todo_tool directly.
* chore: retrigger CI (zero-job dispatch failure, auto-heal)
#127828 changed CARD_TOOL_NAMES in apps/desktop/src/lib/tool-render-class.ts
from `new Set([...])` to `[...] as const`. The pytest that pins that list
against the gateway's _TOOL_LIFECYCLE_UI_TOOLS located it with a regex that
only matched `new Set([`, so it has failed on main since with
"CARD_TOOL_NAMES not found in tool-render-class.ts". The gateway set still
covers every desktop card name; only the test's parser was stale.
The PR merged green because it touched only apps/, which skips the Python
lane, so the one test that reads that file never ran on it.
- The parser accepts both declaration forms and fails on an empty list
instead of passing a vacuous subset check.
- tool-render-class.ts joins _PY_RELEVANT_CONTRACT_FILES so an edit to it
runs the Python lane, with a classifier case for the entry.
saved_toolset_resolver built the hermes-<platform> bundle set eagerly, so
every migrate_config run (hermes update, config migrate, doctor) and every
config check paid a full bundled+user manifest scan even with no saved
platform_toolsets. The scan is now cached and lazy, like the plugin lookup.
The stale-toolset test stubs the plugin lookups so host-installed plugins
cannot change its verdict.
Follow-up to the config check diagnostics from #127392.
- Saved platform_toolsets are validated by one resolver,
toolset_validation.saved_toolset_resolver, shared with
`hermes config migrate`. The two commands previously disagreed: migrate
flagged MCP server names and plugin-platform bundles (even suggesting
'hermes-teams' for 'hermes-teams'), while config check accepted them.
- A configured MCP server is known even when disabled (enabled: false);
it is inactive, not a typo.
- known_plugin_toolsets is no longer treated as proof that a toolset
exists. It records what a platform was offered at save time, so it hid
toolsets of uninstalled plugins, the stale case this check is for.
- Plugin-provided names are consulted only for names nothing else
explains, so a clean config no longer runs plugin discovery.
- A malformed mcp_servers (a list) no longer crashes the command.
- The top-level `toolsets` check is dropped: the CLI resolves its
toolsets from --toolsets or platform_toolsets, and reads the top-level
key only for the legacy kanban opt-in.
- Disabled platforms are read with the runtime's plugins.disabled reader,
so a non-list value (which the runtime ignores) is no longer reported
as disabling a platform. Required credentials come from
_platform_manifest_env_entries, which now takes optional=False.
- Tests use a synthetic platform manifest instead of the real Telegram
one, and assert config check and migrate report the same names.
The tool-group slot folded consecutive manage_connections calls into one
connection card only when guided onboarding was enabled, while MCP targets
were folded for every user. Connector calls now fold the same way for
everyone, and the onboarding check is gone.
stable-release.yml called desktop-bundled-release.yml, docker.yml and
termux-verify.yml without `secrets: inherit`, so every job inside them that
declares `environment: release-signing` (or container-publish) attached its
deployment and read the environment's `vars`, but resolved `secrets.*` to the
empty string. The signed-candidate legs died at "Archive every pinned input"
with `missing env CLOUDFLARE_R2_ACCESS_KEY_ID`; the docker publish legs and
the termux packaging checks would have failed the same way.
A called workflow receives no secrets by default, and a job-level
`environment:` inside it is required but not sufficient: a caller job that
`uses:` a reusable workflow cannot declare `environment:` at all, so
`secrets: inherit` is the only mechanism that can carry them
(actions/runner#1490).
`secrets: inherit` is added only where the callee reads a protected
environment's secrets. ci.yaml, nix, pm-bundle, windows-venv-e2e and the
install-e2e chain reference none, and ci.yaml must stay secret-free by its own
header (it runs PR-controlled code).
* refactor(desktop): route tool cards through a table keyed on the card-tool names
The name-keyed if ladder in ChainToolFallback becomes TOOL_CARDS, typed
Record<CardToolName, FC>. CardToolName comes from the same list isCardTool
reads, so a card added to one list and not the other fails the build.
Behaviour is unchanged.
* refactor(desktop): dispatch desktop bridge events through a handler table
The six-branch event.type ladder becomes DESKTOP_BRIDGE_HANDLERS, one row
per event with its body and comments unchanged. Behaviour is unchanged.
* chore: retrigger CI (zero-job dispatch failure, auto-heal)
* refactor(desktop): split clarify-tool.tsx into a clarify/ folder
Pure moves, no behaviour change. Parsing, the question-card core
(shell, choice rows, question block), the delivery watchdog, and each
pending/settled card get their own file so the core can be reused.
* feat(clarify): one questions[] shape with per-question status and one outcome
The clarify tool now takes only questions=[{question, choices?, multi_select?}].
A wrong shape is a tool error that names the right one, so a model that
sends the old top-level question/choices corrects itself on the next call.
Every result has the same shape on every surface: each response carries
status (answered, skipped, unanswered) with user_response null unless
answered, and the result carries one outcome (submitted, cancelled,
timed_out, undelivered) plus an optional surface-written notice. The
timeout and cancel sentences that used to pose as the user's answer are
gone, and the compressor reads status instead of matching their prefixes.
The callback contract is callback(questions) -> {answers, outcome, notice?}
(None = skipped, missing qid = unanswered). tui_gateway settles a batch the
same way for the last lock, a cancel, an interrupt and the deadline, and
clarify.lock keeps a null answer as a skip. A multi-select answer that is
not a JSON array counts as one typed ("Other") answer. The tool-row preview
reads the first question.
* feat(clarify): every surface asks through the one question card
CLI: the batch panel is the only panel; Enter on an empty field skips a
question, Ctrl+C cancels and keeps the locked answers, the deadline returns
timed_out. -q and -z return undelivered with a no-user notice.
Ink TUI: the single-question prompt is gone; the card supports multi-select
(Space or a digit toggles, Enter locks, typed Other text joins the picks),
an empty submit skips, Esc cancels, and "Batch" names are dropped.
Desktop: the single-question card is deleted and its keyboard handling moved
into the one card (arrows, letters and digits, auto-advance, Enter picks then
confirms). Confirm enables at one answer; blank questions lock null; Skip and
a composer message cancel; the settled card shows No answer for unanswered
questions. The bots room card follows the same contract.
Messaging: one card per question with a "Reply skip to skip" line in every
locale; timeouts and delivery failures map to timed_out and undelivered.
* fix(clarify): cancelled for a released messaging card, labels win over the skip word
A prose reply to a messaging choice card, /new and session end released
the wait with "", which read as timed_out. They now resolve with a
CANCELLED marker and the tool gets outcome cancelled.
A typed reply that matches a choice label ("Skip") now resolves to that
choice; the skip word applies only when no choice matches.
The bots room card sends the picked labels as they are; the tool already
strips the recommendation label. Unused OUTCOMES and a new docstring and
comment are removed.
* fix(tui): re-editing a multi-select answer keeps its picks
The Ink card stored a multi-select answer as display text ("A, B"), so
revisiting the question put the whole string into Other and re-locked it
as one item. The answers map now keeps the raw JSON array, the card
restores the picks on revisit, and only the display lines format it.
Comments that earlier edits reworded are restored to their original
words, minus the phrases the change made wrong. The compute-host clarify
lock accepts None for a skipped question.
* test(clarify): align three checks with the one-answer confirm and the regenerated keys
The desktop Cmd+Enter test now expects a send with one answer (the blank
question locks null), the compressor test expects the single-answer summary
the code gives, and locales/_keys.desktop.json is regenerated for the
removed and added clarify strings.
* test(tui): the one-question card header is singular
hermes debug share, gateway /debug, the dashboard share and the desktop
diagnostics upload sent credential query parameters (?token=, ?api_key=,
?access_token=) and user:pass@ userinfo from logged URLs unchanged, to
the paste service or to Nous storage. Log lines keep these values
because log-time redaction leaves web URLs alone, and Firecrawl, for
example, logs the full URL it scrapes at INFO.
The cause is hermes_cli/debug.py:_redact_log_text, the one redactor on
every share path. It called redact_sensitive_text(force=True) without
redact_url_credentials=True, so the strict URL pass that compaction,
memory context, cron errors and the kanban notifier already use never
ran on the upload copy.
Pass redact_url_credentials=True there. Parameter names, hosts and
paths stay readable; values of credential-named parameters and URL
userinfo become ***.
(cherry picked from commit f522171b836bfd842354f5a34c8c2bdd46b39467)
Review follow-ups on the live strict-identity tests:
- keep the holder as the psutil.Process captured at spawn, so a PID
recycled before class teardown is refused instead of killed;
- suppress psutil.Error (AccessDenied on an exiting Windows process),
not only NoSuchProcess, so one failed kill cannot leave the rest of
the tree alive and wedge communicate();
- bound the handshake at 20s (a normal start takes ~1s);
- garble the lock in binary mode like the restore does, and say why
overwriting without truncating is safe;
- drop spawns_gateway_lookalike from the non-gateway holder test (its
argv carries no `gateway run`) and note that the class holder spawns
before the per-test guard is armed.
The live lock-holder tests could hang or leak:
- the handshake readline had no deadline and stderr went to DEVNULL,
so a child stuck on import blocked the worker with no diagnostics;
- the holder slept 120s, outliving a killed pytest while holding
gateway.lock;
- `assert acquire_gateway_runtime_lock()` vanishes under -O;
- kill() hit only the Windows venv launcher, never the self-reported
interpreter, and pipes were never closed (ResourceWarning per test);
- the discovery test relied on list_profiles() resolving to the temp
HERMES_HOME and compared unresolved paths (/var vs /private/var on
macOS, 8.3 names on Windows).
The holder now blocks on stdin, reports failure via exit + captured
stderr, and teardown kills the launcher tree plus the reported PID and
reaps via communicate(). The four class tests share one class-scoped
holder with per-test restore of gateway.pid / gateway.lock (5 spawns ->
2), and discovery pins the profile roots like the named-pipe live test.
Also read gateway.pid with utf-8-sig (windows-footguns lane).
hermes update on Windows maps running gateways to profiles through
get_running_pid_identity_strict, which raised "active gateway lock has no
PID metadata" whenever the lock was held but gateway.pid was missing. The
whole update aborted before touching anything.
That state is reachable on Windows: status cleanup unlinks gateway.pid and
gateway.lock together, and the lock unlink fails silently while the live
gateway holds the file open, so only gateway.pid disappears (#96582).
The gateway writes the same identity record into the lock when it acquires
it. The strict probe now takes the PID from the lock record, requires a
PID file that exists to agree with it, and runs every existing liveness,
start-time and command-line check against what is present. A garbled lock,
a disagreeing PID file, or a lock holder that is not a gateway still aborts.
(cherry picked from commit 1d6bfbbf94605fcc4c84cb52636887e1e8ae36c1)
A supervised start that also relaunches onto the store interpreter printed the
notice in both processes; it now prints only after the relaunch decision.
The _supervised_child docstring claimed the neighbouring truthy set, which it
does not match, and prepare_launch said a supervised child "never" retries the
tail although stale dependencies still do; both reworded. The supervised test
also pins a clean stdout and a single notice.
The supervised exemption keeps the pending marker for `hermes update`, but
nothing reported it: a headless host running only the gateway would keep
the unfinished tail (products, skills sync, config migration) indefinitely.
One stderr line per start lands in the unit's journal; stdout stays clean
for commands that emit machine-readable output.
Fold the marker off-value table into the supervised-launch test so 0/false
are proven to still repay the tail through prepare_launch, instead of a
separate test of the private parser. Keeps the stale-dependency sync test.
Review follow-up: the early-bail also skipped the one-shot stale-dependency
sync, so a supervised child on an out-of-date tree booted in place (or
crash-looped under Restart=always) instead of syncing and relaunching.
Gate only the pending-tail half, and parse the markers with the repo's
truthy set so 0/false do not suppress anything.
(cherry picked from commit 0838ce2a916faee5fe7f6a6fa30db3d0a6146647)
A supervised child (systemd unit, launchd plist, Windows task, s6 — all
export HERMES_SUPERVISED_CHILD or HERMES_S6_SUPERVISED_CHILD) is
relaunched on every manager start. prepare_launch() treats the
source-completion-pending marker as a debt to repay immediately, so a
sticky marker re-runs the completion tail (and its environment builds)
on every boot until the disk fills (#123340). Skip the launch-time
repair for launcher-marked children; the tail stays the CLI's to finish
via hermes update / hermes pm install. INVOCATION_ID is deliberately
not read: systemd exports it to every descendant, so an ordinary hermes
command inside a CI runner would skip a legitimate repair.
(cherry picked from commit 4203c068b8b2df5f6b74b4ea610de15bfa2ef54a)
The sentinel local is never read, so a well-meaning unused-variable
cleanup could delete it and silently re-arm the mid-update takeover.
Assert it in co_varnames of update_cmd._cmd_update_impl and
main.cmd_update (unwrapping update_receipt_scope).
The pre_update_version co_varnames check added in af26acab73 rejected the
current updater's entrypoints, but it also rejected historical updaters
from 2026-07-26..08-16, whose _cmd_update_impl/cmd_update never declared
that local — their in-process dependency installs would have continued on
the old interpreter instead of handing off to the takeover child.
Invert the gate: the CURRENT updater declares the
_hermes_current_updater_frame sentinel local in both entrypoints; no
historical on-disk version can contain it. in_historical_update() now
hands off on entrypoint match unless the sentinel is present, restoring
the handoff for every historical window while still refusing current
frames. Verified live for all four frame classes; mutation check: with
the sentinel check removed, the current-updater refusal test fails.
Pick the tools the bridge sentence names from the same availability
conditions that gate the note's sentences instead of substring-searching
the rendered prose, and assert the bridge clause by tool membership
rather than exact wording (simplify-code quality review).
Since #97979 put the desktop GUI tools on the default tool_search defer
list, a Desktop session's valid_tool_names no longer contains
read_window_below (it sits behind the tool_call bridge), so
hud_surface_note() withheld the whole note on every HUD turn: the model
stopped being told that "this"/"here" means the app behind the HUD.
hud_surface_note() now takes the session's deferred-but-callable names
(the same agent/tool_executor._tool_search_scoped_names set the tool_call
dispatch validates against) and counts them as available, adding one
sentence telling the model to call the deferred ones through the
tool_call bridge — a direct call to a deferred name is rejected as an
unknown tool. The defer list, its token saving and the note text for
non-deferred sessions are unchanged.
- relay-deliver-budget: the ceiling/timeout composition is checked
against the imported constants (which the mirror tests already tie to
the Python defaults) instead of re-reading config_defaults.py.
- terminal env sync: the key recorder also traps get()/[] and is an
instance, the probe fails loudly if the bridge looks up no keys (so the
gateway-only-key check cannot go vacuous), one _cli_env_map() helper
serves both tests, and the docstring says what is actually driven.
- live_system_guard bypass reuses _live_system_guard_is_active().
- retaindb no-client prefetch asserts no thread is spawned through the
module's spawn_context_thread seam, not a private list.
Each changed assertion re-proven red by its production mutation.
An independent review of every deleted test against its named keeper
found contracts whose only proof had gone, or that no test had ever
pinned. Each restoration is one assertion in an existing test, proven red
by the production mutation named:
- gateway /model reply shows the context length the async resolver
returns (the deleted async-matches-sync test was its only check);
red when resolve_display_context_length_async drops the value.
- parse_model_switch_args("sonnet") has scope "default" (the deleted
tuple test covered the no-flag case).
- shared metrics runtime sends nothing when collection is on but send is
off; red when the gate reads resolved.enabled instead of resolved.send.
- the terminal env-sync test also probes every key gateway.run's bridge
looks up, so a gateway-only key (which the old source parse caught)
still fails it.
- desktop: the deliver ceiling equals the backend's lock wait plus every
attempt, read from the Python defaults.
Also: the tools/bot_relay.py mirror comment names relay-budget.ts, and a
comment orphaned by the Windows encoding-test deletion goes with it.
relay-deliver-budget.test.ts read relay.ts as text: it regexed each
constant ("must stay a literal so this test can read it") and matched the
deliver call shape within 400 characters of 'bot_relay.deliver', which
AGENTS.md bans. The regex passes with the identifier wired into the wrong
argument and fails on a correct refactor.
The #93911 budget constants move to relay-budget.ts and are imported by
relay.ts and both tests. The mirror checks against the Python defaults are
unchanged. The call-site check becomes a behavioural one in relay.test.ts:
the mocked requestProfile for bot_relay.deliver must receive
RELAY_DELIVER_TIMEOUT_MS (red when the call passes any other deadline).
Each removed test re-asserts a contract a stronger test already owns at
the real boundary; the keeper is named per group.
- api_server CORS: `_origin_allowed` for an allowlisted origin and for an
Origin-less client -> the configured-origin preflight test and every
Origin-less /health request. The Idempotency-Key allow-header assertion
moves into the preflight test instead of running a second identical
preflight.
- api_server room grants: the execution-policy predicate's two outcomes ->
test_api_server_runs' HTTP grant-refresh tests (403 on drift, 200 on the
authorized digest).
- api_server multimodal: the pure input_image canonicalization -> the
/v1/responses test that sends the same parts and asserts the same shape.
- credential pool: matched-hint rotation (401) and second-429 rotation ->
test_credential_pool_routing's real-pool hint test and TestPoolRotationCycle,
which assert the same steps plus the exact rotate kwargs and swap.
- shared metrics: two runtime cases that replay the send resolver's truth
table -> TestSendRequiresCollection; the e2e gzip-retry test (both
attempts compress in the same second, so it cannot see mtime) ->
TestCompression::test_gzip_is_deterministic_across_time; e2e outbox
untouched -> TestOutboxIsNotAQueue.
- mcp oauth: provider reuse across reconnects -> the manager restore test;
the non-tty private predicate -> build_oauth_auth's fail-fast test; the
manager disk-watch test -> the integration test, which gains its one
unique assertion (an unchanged file returns False).
- startup watchdog: disarm-without-arm -> test_disarm_is_idempotent, whose
second disarm runs in the same state and branch.
Each test passed with the behaviour it names removed (proven by mutating
the production owner on the old and new test):
- s6 hooks test_register_noop_on_host: the hook swallows the
AssertionError into a printed warning, so reaching the manager never
failed the test. Assert nothing was printed, as the sibling test does.
- live_system_guard bypass: signal 0 to our own PID passes the guard too.
Assert os.kill is the unpatched builtin.
- retaindb queue_prefetch without a client: a missing guard fails only in
background threads. Assert no prefetch thread was started.
- test_config golden migration parity: drop the `_config_version == 33`
exact-match branch, which cannot run again now the version is 49; the
subset check it fell through to is unchanged.
test_terminal_config_env_sync now drives gateway.run's
_bridge_terminal_config_to_env with every known terminal key instead of
parsing gateway/run.py's source for a dict literal. It asserts each key
reaches the same TERMINAL_* var as the CLI, which the key-set comparison
could not check, and removes the last inspect.getsource in tests/.
Every /model surface parses through parse_model_switch_args. The legacy
5-tuple wrapper had no production caller; its remaining references were two
tests of the tuple shape, an unused import, and two tui_gateway patch()
calls on a function tui_gateway no longer calls.
The en-dash --session normalization those tests covered moves into the
parse_model_switch_args keeper. test_model_switch_once_flags and
test_model_switch_context_offload are removed: the first re-tests the
private parser the keeper already drives, the second the one-line to_thread
wrapper that tests/gateway/test_model_command_context_offload.py proves off
the event loop through the real /model handler.
Each removed test either asserts nothing reachable or repeats another
test's exact body:
- auxiliary_client empty-base_url: body only writes config.yaml; a merge
(890bbbda1f) restored the header of a test b3d4f67b20 had purged. The
non-Anthropic-host test beside it drives the same default fallback.
- background_review no-session-db: _record_review_usage_to_parent swallows
every exception and there is no DB to observe, so removing the guard
still passes; the path has no observable effect to pin.
- code_execution Windows "would have failed" pair: every branch returns,
skips or passes; they exercise CPython defaults, not Hermes code.
- cron test_should_use_direct_api_call_*: byte-identical copy of the test in
tests/agent/test_cron_inline_api_call.py (0213b8b77b had to edit both).
- cron liveness no-multiplexer: identical body to test_no_lock_no_pids_is_false;
its multiplexer patch is never reached.
- browser console/get_images "private URLs allowed": identical to the
local-backend test in each file; both mock the predicate the name promises.
- hermes_state TestTitleUniqueness: the duplicate-title case is a strict
subset of TestSessionTitleLineage::test_unrelated_session_still_conflicts;
the NULL-title case never writes a title.
The stale-profile recovery paragraph claimed uninstall only runs in a
mode that keeps user data. Plain interactive `hermes uninstall` is let
through and its menu still offers Full uninstall of the default root,
behind its confirmation prompts. Say so, and note in the predicate why
--full is refused (it wipes without asking) while the menu is not.
When active_profile names a deleted profile, the error still said
"Create it with: hermes profile create <name>", a command that exits with
the same error. Name the command that works instead:
`hermes profile use default`. An explicit -p keeps the create hint.
The recovery allowlist checked for the literal string "--data", but the
uninstall subparser accepts argparse abbreviations: `hermes uninstall --dat`
passed the guard and removed the default root's data. `--full` was allowed
outright, and in the default root it rmtree's every other profile under
profiles/ as well, a wider wipe than the --data mode the guard refuses.
Parse the uninstall argv with the real subparser (output silenced; the
handler reports errors itself) and allow it only when neither data nor full
resolves true. `uninstall --help` exits 0 during that probe and is let
through so the handler prints help once instead of help plus an error.
The predicate lives in a topical sibling (main_profile_recovery.py) rather
than growing the main.py facade.
Reuses the repr-field secret-name policy (is_secret_field_name) so token, password, secret and credential-suffixed fields are masked by field in hermes dump, debug share and /debug. The regression test asserts on what actually reaches the paste service first.
Text redaction of str(fallback_providers) missed api_key values that the
runtime still uses (resolve_entry_api_key str()s them): an unquoted numeric
key, a mapping, or a key containing "***" (treated as already masked).
Mask the field before serialising; keep the independent upload-boundary
pass and cover it with #126168's boundary test.
Co-authored-by: John Paul Soliva <soliva.johnpaul@icloud.com>
Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
The strict URL-credential pass that hermes dump and debug uploads use
redacted X-Amz-Signature but not X-Goog-Signature (GCS V4 signed URLs)
or sig (Azure SAS), so a fallback base_url signed that way still reached
the dump and the upload. The documented promise is signed-URL signatures
in general.
Covers userinfo, ?key=, X-Amz-Signature= and #access_token= through the real config.yaml, hermes dump stdout, the share bundle and the /debug report. Docs (EN + zh-Hans) say the dump redacts credentials in fallback URLs.
A fallback_providers base_url can carry its credential in the URL (userinfo, ?key=, #access_token=, X-Amz-Signature=). The force pass leaves those to the log policy, which spares OAuth/magic-link URLs, so hermes dump printed them and debug share / gateway /debug uploaded them. The dump's fallback rendering and the upload copy of the dump now opt into redact_url_credentials.
The strict canonicalizer also turned x-amz-signature into x_amz_signature before the policy lookup, so strict mode never masked it; hyphenated policy names now match as stored.
Ported from #126168.
hermes dump prints config_overrides.fallback_providers as the raw list,
api_key values included, and the upload-time redaction of hermes debug
share and the gateway /debug report covered only the log snapshots, not
the dump text quoted at the top of every paste.
Force-redact the captured dump like the logs (--no-redact still opts
out), and have hermes dump itself redact fallback_providers so a pasted
dump never carries a key.
The lock-ancestry comment still claimed it was what stops a tail recursing;
the tail scripts now return before it, so it describes the processes an
update spawns instead. Also note why the identity reads sys.argv[0] rather
than the argv parameter.