* 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.
update_completion.py arms the pending marker, then imports hermes_cli.main,
which runs prepare_launch. While the updater's lock claim is live the
ancestor check returns early, but an unwritable claim or one past the
20-minute ceiling sends it into _finish_source_update: a full
source_completion tail runs inside the update and clears the marker before
the update's own build has finished.
Exempt both tail scripts through one identity check. The launch check
already resolves its root, so the comparison is exact on the process's
own argv[0], which the processes a tail spawns do not inherit.
- _deepen_shallow_repo marks packs in its finally without a git config probe
that could itself raise and turn a finished unshallow into False; markers
are inert in a clone without a promisor remote.
- Installers warn instead of aborting the stage when a marker cannot be
written, matching the Python helper.
- Docs: the manual commands follow HERMES_HOME, and the missing-object
backfill feeds IDs through `fetch --stdin` so a large list cannot exceed
the Windows command-line limit.
- Test and docstring comments describe the contract, not the incident.
Manual marker one-liners for installs whose own updater cannot fetch the fix,
and the missing-object backfill for checkouts that were already turned into
full clones by hand.
An install whose updater dies on the git 2.53+ pack-objects BUG (#124272)
never fetches the release that heals it: the fetch runs in the installed
code. The installer rerun (install.sh / install.ps1, served fresh) is the
path that runs new code, so when origin is a promisor remote it now marks
the checkout's unmarked packs before its existing-checkout fetch. Marking is
idempotent and never rewrites objects, so it runs up front rather than as a
retry that would first print a failed-fetch report.
git 2.53+ `index-pack --promisor` hands every object outside the promisor
packs to pack-objects, which BUG()s (should_include_obj) on the missing
objects those objects lead to. A partial clone with unmarked packs therefore
keeps crashing its fetches.
The retry added for this ran `git -c remote.origin.promisor= fetch`. That
override does nothing: git registers promisor remotes additively, so the
repo's own `true` still wins (GIT_TRACE on 2.50 and 2.55 shows
`index-pack --promisor` and `filter tree:0` under the override). The retry
only passed when the crashed fetch had already stored the pack; a retry that
has to transfer again crashed again.
- On the crash, give every pack without a `.promisor` marker one, then retry
the same fetch once. The clone stays partial, no refetch, no config change.
If git still crashes, the updater points at the docs section.
- The two fetches that turn a full clone partial now mark the clone's old
packs afterwards: the depth-1 `tree:0` unshallow in
`fetch_full_commit_graph`, and `worktree_ops._deepen_shallow_repo`
(`--filter=blob:none`). git writes the partial-clone config before it
fetches, so this also runs when the fetch fails. Without it, a converted
depth-1 install crashed every later fetch on git 2.55 (3 of 3 rounds in a
local repro).
is_partial_clone_pack_objects_crash() required "index-pack failed" next to the
pack-objects assertion, but git 2.55 builds dropped that string: Homebrew git
2.55.0 (macOS) only reports "fatal: fetch-pack: invalid index-pack output", so
the recognizer always returned False there, fetch_with_partial_clone_recovery()
never retried with the promisor machinery disabled, and `hermes update` exited 1
on the crash (#125138 reports the same bare-assertion failure on 2.55.0.windows.5).
Fingerprint on the assertion text — the only version-stable part of the failure
— and accept the 2.55 wrapper as a terminator. The POSIX/Windows shapes and the
"fingerprint without a terminator" rejection behave as before.
(cherry picked from commit 74103b15ceb3eacb2346fca669c3a3dc81457201)
The salvaged matcher accepted any cmd_update/_cmd_update_impl frame in
hermes_cli.update_cmd/main, so the CURRENT updater's entrypoints (which
carry pre_update_version on opts, not as a local) would also hand off and
exit the process mid-`hermes update`. Extract
_old_updater.in_historical_update() sharing _historical_context's exact
frame matcher (module, name, and pre_update_version in co_varnames) so the
shim and the context detector cannot drift apart.
- tools/lazy_deps: use the shared predicate; accept the constraints/
dry_run kwargs historical updaters pass so they reach the handoff
instead of dying on TypeError; drop the vague message advice.
- tests: collapse 29 parametrize cases to the real invariants and add the
missing one — the frame walk is now exercised through a nested helper
(a getframe(1)-only implementation fails), and a current-updater-shaped
frame (no pre_update_version local) is asserted NOT to hand off.
The standalone ownership test duplicated the parametrized boot migration
(same wrapper, weaker check), was the only test in tests/hermes_cli calling
the private _publish_conveniences, and asserted a mtime-derived "changed"
flag immediately after writing the file, which is flaky on filesystems with
coarse timestamps.
Keep its one unique invariant, another checkout's venv/bin/hermes is never
adopted, by pointing the migration test's existing foreign hermes-agent
wrapper at another checkout's venv. It still also mentions this install in a
comment, so both rejection cases stay covered through public expose_cli().