Commit Graph

46442 Commits

Author SHA1 Message Date
Brooklyn Nicholson
c69eda0d56 chore(desktop): regenerate the i18n key manifest; type the canonical-chat mocks without import() 2026-09-30 11:05:17 -05:00
Brooklyn Nicholson
e0c0c8c68c test: keep one ordering contract for single-query finalize
The full-order test in test_oneshot_completion_linger plus the real-DB,
real-lease successor test pin the finalize order; drop the lease-before-linger
and memory-before-successor tests that re-asserted it, and let the wait-failure
test assert what it is about.
2026-09-30 10:32:09 -05:00
Brooklyn Nicholson
aecf596db9 refactor(bots): share the stale Bot Chat tile probe between the roster click and tip open
isStaleBotChatTile(canonicalIds) replaces the copy in openStoredBotChat, whose
try/catch duplicated the optional call; the stale-tiles test no longer
contradicts its own comment.
2026-09-30 10:31:18 -05:00
Brooklyn Nicholson
99d7269054 fix(tui_gateway): a closing refusal releases only the lease its own admission claimed
Releasing whatever lease the session held also dropped one it owned from
before the close, skipping _settle_isolated_turn_before_close's deferred-lease
handoff for an isolated compute-host turn still in flight.
2026-09-30 10:18:46 -05:00
Brooklyn Nicholson
457d1d2b7d fix(tui_gateway): carry /reasoning and /fast across agent rebuild too
The capability-sync rebuild kept the /model pick but still dropped
create_reasoning_override and create_service_tier_override; carry them the way
_deferred_build_agent_kwargs already does. /new pops all three first.
2026-09-30 10:16:57 -05:00
Brooklyn Nicholson
fe5b728bd9 fix(bots): size live_message_count with SessionDB's own display projection
The count left out DISPLAY_VISIBLE_SQL, so model-only rows from
micro-compaction were counted while the reader painted none, and the history
wait this count exists to skip still ran. SessionDB.display_message_count now
counts the same set _display_rows_from_conn paints, through _read_one instead
of reaching into db._lock/_conn, and one _live_count_field serves all three
listing paths. The field is declared on SessionListRow, ProfileSessionPreview
and ProfileCanonicalSession (extra=forbid) and the desktop derives it from the
generated contract instead of hand-typing it twice.
2026-09-30 10:12:37 -05:00
Brooklyn Nicholson
a6509d6b8f fix(bot-mode): bound the live DM wait and check the owner with the claim's own match
An owner lookup that kept raising, or a live owner whose receipt never left
queued/claimed, looped the runner forever (~46 MB each, holding the sender's
notify slot). Add an absolute deadline that reports the pending do-not-resend
payload, and treat an unconfirmable owner like a vanished one: one more window,
then release. The owner check now uses bot_live_delivery's claim match (which
also follows the compression tip), and the fresh pre-read before cancelling is
gone since cancel_queued_delivery re-reads under the mailbox lock.
2026-09-30 10:00:37 -05:00
Brooklyn Nicholson
288b7ec2ac chore: map z0zero's contributor email 2026-09-30 09:57:35 -05:00
Brooklyn Nicholson
9a9489ca1b docs(bot-mode): answer "what does this add over profiles?"
The top question since launch. The page said a Bot is a profile and stopped
there; spell out what the roster, Bot Chat, messaging, and routines add.
2026-09-30 09:57:33 -05:00
chelsealong
bb9220ed7b fix(tui_gateway): keep the session's /model pick across agent rebuild
_rebuild_session_agent built the replacement without the live
session model_override, then reset config_model_seen so the per-turn sync
never re-adopted it. Forward the pin unless the caller passes one; /new
still clears it before rebuilding.

Fixes #127449

(cherry picked from commit 484695a6ea813f46324d213740749c3d144ede4a)
2026-09-30 09:57:31 -05:00
z0zero
a94b975831 fix(bot-mode): carry late live DM replies to sender
(cherry picked from commit c274db6ea52504fa661339dd5d1db141db1cfb65)
2026-09-30 09:57:30 -05:00
z0zero
cb375015e3 test(bot-mode): reproduce late live DM reply loss
(cherry picked from commit e2e6e679cb29c2a116026acd2cb9b1cdf6d4fa17)
2026-09-30 09:57:28 -05:00
Brooklyn Nicholson
3ace90ae4b refactor(bots): type the compression-tip tile probe off the SDK host
The stale-tile probe added before the tip open reached the host hook through a
cast and split the forceResume comment. Call the typed optional hook directly.
2026-09-30 09:57:26 -05:00
Iain Lane
33a50ac7c8 fix(tui_gateway): stop a closed session's turn from leaking its lease
Closing a chat while its first message still waits for the deferred
agent build can leave the chat's active-session lease registered.
Reopening the chat in a new runtime is then refused with "This chat is
open in another Hermes window/terminal" until the idle reaper's orphan
sweep removes the lease, which can take about five and a half minutes.

session.close waits up to five seconds for the turn thread and then
finalizes the session, which releases the lease. The turn thread is
still in _wait_agent_for_prompt, and close sets neither
_turn_cancel_requested nor running = False, so the thread continues
into _admit_prompt_turn when the build finishes. Admission finds no
lease on the session and claims a new one, then sees _closing and
returns without releasing it. The session has already left _sessions,
so no later close or takeover finds that lease.

Every turn source (the prompt.submit turn thread, the queued-prompt
drain, crash auto-continue and wake-ups) passes through
_admit_prompt_turn, so the fix is there. A closing session no longer
claims a lease, and when admission refuses a turn because the session
is closing, it releases any lease that the session has. The release
covers a close that finalizes while admission is claiming, and the
auto-continue kickoff, which claims a lease itself before it calls
_run_prompt_submit. Releasing is idempotent, so a finalize that runs
afterwards is unaffected.

Treating _closing as a cancellation in _wait_agent_for_prompt would
cover only the deferred-build path, so the check is at admission
instead.

(cherry picked from commit ac582aa104d51106fa1d451a4c5c645558945ab3)
2026-09-30 09:57:22 -05:00
Zheqing Zeng
82c76be695 fix: run memory-provider session finalization inside ownership too
Review feedback (Enough1122): _run_cleanup -> _shutdown_agent_memory_provider
-> MemoryManager.on_session_end is session-keyed settlement (OpenViking commits
sessions/{sid} synchronously; Supermemory flushes pending turns stamped with the
session id), and at the previous head it ran AFTER the lease release — a
successor admitted during the linger could be mid-turn on the session while the
predecessor committed its remote memory session underneath.

Move the shutdown into phase 1 (after the durable flush — #88583's failure
ordering — before the finalize hook's slot in ownership). It is idempotent
(_memory_provider_shutdown), so _run_cleanup's post-release repeat is a no-op.

Also correct the docstring: phase-1 settlement is best-effort, not a proven
invariant — failures are logged and the release still happens, because an
exited process pinning the lease is worse than a dangling row (session_recovery
reaps stale rows).

Adds a discriminating test: real lease, recording agent stub, successor
acquires during the linger — memory_commit must precede release precedes
successor_acquired. Order tests now pin flush → finalize → memory → release →
linger → teardown.

(cherry picked from commit 06dc026126a473a08f182014d38f5213a842c8a3)
2026-09-30 09:57:20 -05:00
Zheqing Zeng
1d39a2d3d3 fix: settle session-owned state before releasing the lease
Review feedback (andrexibiza): the previous revision released the lease before
_flush_one_shot_session_store, whose unconditional end_session (WHERE id=? AND
ended_at IS NULL, no owner fence) could stamp a successor's open interval when
the successor acquired and reopened the session during the lingering process's
post-release flush.

New order: session settlement (flush + finalize hook) → lease release →
process-only linger + teardown. Early admission is preserved (release still
precedes the linger); the handoff race is closed because the predecessor has
no session-owned writes left once it releases.

Adds a two-owner regression test (real SessionDB + real lease): the linger is
patched to play a successor that acquires and reopens mid-linger; the
successor's open interval must survive the predecessor's finalize. Verified
discriminating against BOTH unsafe orders (previous PR head and origin/main).

(cherry picked from commit e0b4a34a981f6d2bbdcc97be977e64e867987b6a)
2026-09-30 09:57:16 -05:00
Zheqing Zeng
734ac474e5 fix(cli): release the one-shot session lease before the exit linger
A one-shot CLI run (hermes chat -Q, the Bot Mode delivery transport)
claimed the session lease at turn start and released it only via
atexit/finalize-tail, after the bounded exit linger for
notify_on_complete children. A run whose turn had ENDED but whose
process lingered alive-but-idle (observed: 434s drain wait, 0.016s CPU
over 20 min wall) kept the lease valid — its liveness check is
(pid, create_time) — so every new delivery into the session was refused
("Refused active session") for minutes after the turn finished, and
each refused delivery spawned its own lingering process tree
(#118826).

_finalize_single_query runs only after every turn of the run (main
turn, kanban goal loop, notify-completion follow-ups) is done, and none
of its remaining steps — the linger wait, the durable flush, cleanup —
runs a turn. The lease means "a turn may run on this session", so
release it FIRST: the boundary the one-shot path already declares ("the
exit linger is NOT part of the spawner's delivery", #113608). Cleanup
failure propagation is unchanged, and a cleanup failure can now never
skip the release.

(cherry picked from commit 438c68b80898efb9a287d39da32a94b772316d4e)
2026-09-30 09:57:11 -05:00
finn763
f8bf71b06e fix(desktop): discard stale Bot Chat tile before opening compression tip
Every Bot Chat compression left two tabs captioned with the bot name: the
lineage guard from 202997b51d matches through sidebar-attached lineage
metadata, which a hidden Bot Chat lineage never carries. Run the same
owner-scoped stale-Bot Chat probe the roster click runs inside
openStoredBotChat, against the registry id + resolved tip, before
host.openSession. Covers activity-refresh and reclaim opens. Side tabs
with other titles are preserved.

Closes #120810

(cherry picked from commit 60fc979349ae90aefba3465efc13c961da37b556)
2026-09-30 09:57:07 -05:00
kokhlo
80c0d1a2f4 fix(bots): key the Bot Chat history wait on paintable rows
A bot whose canonical Bot Chat session has message_count > 0 but zero
paintable rows can never be opened from the roster: the click demands a
painted transcript (expectHistory), no read mode serves active = 0,
compacted = 0 rows, so the open burns the full 60s hydration budget and
fails closed, and the fail-closed registry guard then refuses to mint a
replacement either.

Report the paintable size next to the denormalized total:

- session.list title lookups and profiles.list canonical/last_session rows
  now carry live_message_count — the count of display-set rows
  (active = 1 OR compacted = 1) a stored-transcript read actually paints,
  distinct display groups counted once.
- the desktop keys expectHistory off live_message_count when present,
  falling back to message_count for older gateways, so the count and the
  paint gate can no longer disagree. An all-folded chat opens as
  expected-empty instead of wedging for the whole budget.

(cherry picked from commit ddee47676f5f0669f7cb84681dc2f6c4a72ee4c6)
2026-09-30 09:57:04 -05:00
liuhao1024
99aa3ca0af fix(desktop): front main when a Bot roster click targets the chat it already holds
A Bot Mode roster click whose owner lost its tile (closing the main tab
promotes a neighbouring tile into the workspace pane, dropping its tile)
resolved to a dead click while another bot's tile held the zone: the chat
already lives in main, focusOpenSession won't claim the 'main' hit for a
Bot scope (a Bot tab for the same stored id must stay mintable), and the
in-place navigate is a no-op because the route already points at the
session — nothing ever fronted the workspace pane.

Extract the main-front into frontMainIfSelected and call it from the
in-place tail of openSession when focus landed nowhere. The Sessions
scope keeps its exact 'main' semantics; Bot tab minting is untouched.

Fixes #125899

(cherry picked from commit 44c0466629a15175499a6180013c23554ff76927)
2026-09-30 09:57:02 -05:00
Jeffrey Quesnelle
47676981f5 Merge pull request #129360 from NousResearch/32-core-runner
ci: Switch from 96 to 32 core runner for test
2026-09-30 10:34:39 -04:00
Maciej Lisiewski
fcdf5f7e7f ci: Switch from 96 to 32 core runner for test 2026-09-30 16:33:03 +02:00
KoNit-K
9cbc6a5ac0 fix(desktop): skip non-primary local bot relay route 2026-09-30 09:15:47 -05:00
Jeffrey Quesnelle
f42f579cf8 Merge pull request #128011 from rroverin/fix/escape-drift-newline-doubling
fix(tools): escape drift newline doubling
2026-09-30 00:59:14 -04:00
chelsealong
ddd0cc6944 fix(desktop): skip transcript reads for a session with no row yet
session.create is intentionally lazy: a brand-new session's state.db row
is only written on the first prompt, so a "New chat" tile opened but
never typed into has a stored id with nothing behind it yet. The
transcript-timeline mount fetch and the periodic active/tile transcript
sync didn't know that and read it anyway, 404ing repeatedly against a
row that cannot exist ("Session not found" x4 in desktop.log before the
first send, #123622).

Track the already-existing "created this run" bookkeeping (used to keep
a flapping-backend resume from being misread as a deleted session) in
its own small module, and skip the transcript/timeline read when a
session created this run still has zero local messages — at that point
there is nothing to fetch, and the empty local view is already correct.
2026-09-29 23:25:40 -05:00
Siddharth Balyan
bddd22be7c fix(desktop): the onboarding tour no longer leaves a preview row for the app's own page (#128211)
* fix(desktop): desktop_ui tools that look at a page no longer record it as a preview artifact

gui_tour targets, drive_preview, annotate_preview and desktop_preview read/close
all carry the page they look at in a top-level url (or url arg). toolPreviewTarget
took that as a deliverable, so after a tour the composer status stack showed the
app's own page (index.html in a packaged build) as a preview row. Only
desktop_preview open still offers its url.

Refs NS-1029 B4

* fix(desktop): a wrapped look at a page no longer records it as a preview artifact

A bridged tool_call reaches the preview check under a name that is not the
real tool. A labelled call renders one row per inner call through
ConnectorExecution, and that row's toolName is the label title ('Gui Tour').
An unlabelled stored part keeps toolName 'tool_call' with the real tool in
args.name or in a one-entry args.calls. Either way the non-deliverable guard
missed a wrapped gui_tour, and its row recorded the app's own page when it
mounted.

ConnectorExecution now passes each row's real tool name as innerToolName (the
label's name for a tool_call entry, else the part's own tool), and
buildToolView judges the preview by it. toolPreviewTarget unwraps a one-call
tool_call to the inner tool and its arguments.

Refs NS-1029 B4
2026-09-30 08:32:11 +05:30
Austin Pickett
9c5da5a1c7 fix(secret-sources): revoke a removed source's value from os.environ
Removing or disabling the last plugin secret source rebuilt the per-home
snapshot but left the value it injected in os.environ, so single-profile
get_secret() kept serving it through its os.environ fallback until
restart.

The process-global apply path now records what each source wrote (and
the value it replaced). A later pass revokes writes whose source is no
longer registered/enabled, or that an enabled source stopped supplying,
but only while os.environ still holds exactly the written value: a value
another owner replaced (profile .env, managed .env, shell, later source)
stays. A source whose fetch failed keeps its last value (fail-open).
2026-09-29 22:57:48 -04:00
Austin Pickett
69a96a7311 fix(cron): detect a routed fire from its home, not a ticker-only marker
The dashboard's manual Run now binds the sibling profile via
web_server_cron._cron_store_scope, not the ticker's _profile_cron_scope,
so the marker-based routed_profile_fire() stayed False there: the routed
profile's .env was reloaded into the shared os.environ and the managed
external-worker handoff serialized multiplex_active=False.

routed_profile_fire() now compares the fire's active HERMES_HOME with
get_routing_process_hermes_home(), so every entry point (ticker,
dashboard Run now, CLI, gateway forward) and the external-worker handoff
share one answer. Regression test drives the real dashboard fire path
A -> B -> A with no_agent jobs.
2026-09-29 22:57:48 -04:00
Austin Pickett
f6250192c2 test(cron): use env bash shebang in the restored routed no_agent tests 2026-09-29 22:57:48 -04:00
John Paul Soliva
136135e653 fix(cron): overlay the routed no_agent scope inside build_subprocess_env
Main moved the no_agent script env to the factory (build_subprocess_env(strip_launch_profile=True),
e1c0896518) so the spawn site takes no raw os.environ snapshot. The restored routed-fire overlay
(installed scope, then managed keys, both before the scrub) now lives behind that same flag instead
of a dict(os.environ) copy at the spawn site: strip -> overlay -> sanitize, unchanged order, and a
no-op outside multiplex semantics, so a single-profile child stays byte-identical. The flag's only
caller is the cron script runner.

(cherry picked from commit 800c37e7323443c4d4719c0d6a43e028018286e3)
2026-09-29 22:57:48 -04:00
kshitijk4poor
b543b1053c fix(secret-scope): compose the managed .env into every profile secret scope
Answers the P1 review on #111187: build_profile_secret_scope() held only
<profile>/.env plus that profile's external-source snapshot, never the
administrator-managed .env. The launch process applies that file LAST with
override (_apply_managed_env), so a managed key beats the user's own value in
os.environ. Under multiplex semantics get_secret() stops falling back to
os.environ on a scope miss, so inside a routed cron fire (and equally inside a
real multiplex gateway turn, which builds its scope through the same function
via gateway/run.py::_load_profile_secret_scope) a managed-only credential
resolved as absent and a managed-vs-user collision resolved to the USER value:
reversed precedence.

Fix at the source: build_profile_secret_scope() overlays load_managed_env()
last, after the profile .env and external sources, skipping process-global
names exactly as it does for the other two layers. Every multiplex-authoritative
scope (gateway turn, routed desktop fire, external worker env build) is built
here, so managed authority is composed once instead of restored per consumer.
No generic ambient-env fallback is reintroduced: only the managed file's own
keys enter the scope, and only with the managed file's values.

Regression (parametrized, two invariants): inside a routed fire a managed-only
key resolves through get_secret(); a managed-vs-user collision yields the
managed value.

(cherry picked from commit cedf4a3d78)
(cherry picked from commit 4a750b4022ff321863d6746ec39ec5ca67885c16)
2026-09-29 22:57:48 -04:00
kshitijk4poor
760785dd86 refactor(cron): reuse _install_fire_secret_scope for the external-worker env build
_launch_external_cron_worker hand-rolled the same hydrate -> set_secret_scope ->
set_multiplex_context(routed) sequence, and the same context-then-scope reset, that
_install_fire_secret_scope/_reset_fire_secret_scope already encode for the in-process
fire. Two copies of the ordering is how the two paths drift apart; the helper is the
one place that owns it. The only observable difference (re-setting an already-active
multiplex context for the span) is a no-op the token reset undoes.

Rename _scope_token in _run_one_job_body to _fire_scope_tokens: it has held the helper's
(scope, context) tuple since the routed-fire change, not a single token.

(cherry picked from commit 840c00c124)
(cherry picked from commit cf5074f092194d8b989ecec588920c2165e9cdc2)
2026-09-29 22:57:48 -04:00
kshitijk4poor
adf3948d76 fix(env-loader): split source_supplied_names() out of secret_source_names()
Widening secret_source_names() to include skipped_existing names silently changed
tools/mcp_tool_config.py::_build_safe_env, an untouched consumer that forwards every
returned name into MCP stdio child envs. That consumer wants only names a source
actually APPLIED (pre-stack semantics), so secret_source_names() goes back to
tuple(_SECRET_SOURCES).

The routed-child scrub in strip_launch_profile_env is the one site that must also see
names a source supplied but lost to a pre-existing process value, so it reads the new
source_supplied_names() accessor instead. tools/mcp_tool_config.py is byte-identical to
origin/main.

(cherry picked from commit dcdbcb8a2b)
(cherry picked from commit b1b07b03440e1e35a2a2c8e33738618399c8b900)
2026-09-29 22:57:48 -04:00
kshitijk4poor
81765d355b refactor(env-loader): one helper records source-supplied names; scope refresh never empties
_hydrate_profile_secret_sources and _apply_external_secret_sources each
rebuilt the same "applied + skipped_existing" set and pushed it into
_SOURCE_SUPPLIED_NAMES; the routed-child scrub depends on both sites
agreeing, so give them one helper.

refresh_installed_secret_scope cleared the installed scope before
refilling it, which left a window where a concurrent reader of the same
fire saw no credentials at all. Update first, then pop the names the
rebuild no longer supplies; stale values still disappear.

(cherry picked from commit 0916191ca1)
(cherry picked from commit 5b28b2b54f4f5020c0da00fa18ac735e479a9418)
2026-09-29 22:57:48 -04:00
kshitijk4poor
84e2ea9f8b fix(cron): scope the handoff multiplex context to the worker env build; gate the no_agent overlay
_launch_external_cron_worker wrapped fifty lines of dispatch, payload
write and scope hydration in the routed-fire multiplex context, though
only build_subprocess_env / strip_launch_profile_env read it. Compute
`multiplex_active` once (process flag OR routed fire), serialize it into
the payload, and set the context for exactly the env build inside the
existing secret-scope try/finally. Drop restore_managed_env on this path:
the worker re-runs load_hermes_dotenv -> _apply_managed_env at import
and strip_launch_profile_env already leaves managed keys in place.

_run_job_script now overlays the installed scope and re-applies managed
keys only under multiplex. In a single-profile process the scope is
os.environ, so the overlay could only re-sanitize values the child
already inherits; the "no-op outside multiplex" comment is now literal.

(cherry picked from commit 0fd59b447a)
(cherry picked from commit abfdbbc3b3e3e49906936d802a96e51e4c4961ef)
2026-09-29 22:57:48 -04:00
kshitijk4poor
03d3c52855 refactor(cron): strip external-source residue in strip_launch_profile_env itself
_run_job_script popped the launch profile's secret-source names in an
inline loop right above strip_launch_profile_env, so only the no_agent
child got that protection; the four other callers of the same helper
(the external cron worker, scheduler_delivery, kanban dispatch and the
byterover plugin) still handed a served profile the launch vault or
1Password names. Fold the names into the helper's residue set, which is
already gated on multiplex and on the target not being the launch
profile, and keeps administrator-managed keys.

(cherry picked from commit f367ebeb5d)
(cherry picked from commit a5cba0db7743b6a927df56bc09df5be6bb45210f)
2026-09-29 22:57:48 -04:00
kshitijk4poor
3b3b61ff4c test(cron): restore the routed-fire handoff test for _launch_external_cron_worker
The earlier trim dropped the only test of the `routed_profile_fire() and
not is_multiplex_active()` branch in the external-worker handoff: the
process flag is OFF (a desktop tick is not a multiplexer) yet a fire routed
to a sibling profile must serialize `multiplex_active=True` and hand the
worker an env without the launch profile's residue, and the context must
not outlive the handoff span. Without it that branch could regress to the
pre-fix behaviour unnoticed.

(cherry picked from commit 5850a50a81)
(cherry picked from commit 440bee6ccf9d48eecf91769787f6daa3fd3ea00c)
2026-09-29 22:57:48 -04:00
kshitijk4poor
27bbbda5c3 test(cron): keep three invariants for the routed-fire isolation
The PR shipped nineteen tests across six files, most of them variations of
one boundary. Keep the three that pin distinct behaviour:

- a routed desktop-ticker fire runs under multiplex semantics for exactly
  its scope: a scope miss returns None instead of the launch credential and
  the parent os.environ is byte-identical afterwards;
- a routed no_agent child never sees a launch-only name, whether the launch
  .env defined it or a launch external source supplied it (applied or lost
  to a pre-existing process value), while its own values come through;
- administrator-managed keys keep policy precedence over the routed
  profile's own value.

Everything else was either a positive control of the same seam, a
set-membership check on a module-level constant, or a re-statement through
a different entry point.

(cherry picked from commit 042b9830b4)
(cherry picked from commit 0b82557fbdbfa6968b2b795e4e63d484ffb582b1)
2026-09-29 22:57:48 -04:00
kshitijk4poor
79edb16b02 refactor(cron): inline the multiplex-context span into _launch_external_cron_worker
The `_inner` wrapper existed only to set and reset the multiplex contextvar
around the whole launch. Every consumer of that context (the payload flag,
`hydrate_profile_secret_sources`, `strip_launch_profile_env`, the scrubbed
`build_subprocess_env`) runs before the worker is spawned, so the span now
covers exactly the handoff-environment build and ends before `Popen` and the
acknowledgement wait. One function, one try/finally, same behaviour.

(cherry picked from commit fb4eaa59ee)
(cherry picked from commit b1d37f3f700f570b6511d36d501494c2caea3679)
2026-09-29 22:57:48 -04:00
John Paul Soliva
65bd3eb336 fix(cron): routed fires are multiplexed at the worker handoff; managed keys keep policy precedence
Review findings on f5f88d5058. Three are defects the previous round introduced.

Managed keys were stripped as launch residue. Recording every dotenv load as
residue swept in the administrator-managed `.env`, which `_apply_managed_env`
applies LAST with override precisely so it beats the user's own `.env`. A
routed child then lost `ORG_POLICY_FLAG=managed-value` to the routed user's
`user-value`. Managed keys are now recorded separately, never enter the
residue set, and are re-applied over the routed scope in both child builders
(`scheduler_script`, the restart-safe handoff) so the child sees the same
precedence the launch process does. `kanban_db_dispatch` and
`scheduler_delivery` strip without any overlay, so for them the exclusion
alone is the guarantee; the test pins the case that exercises it — the same
key defined in both the user and the managed file.

Private hydration did not record supplied names. `_hydrate_profile_secret_sources`
now feeds `provenance` plus `skipped_existing` into the same ownership set the
process-global path uses; the provenance label map stays applied-only.

Removal cleanup cleared its marker before the fallible work. A raising
reload left the removed plugin's credential active with no retry, because the
next no-source discovery saw the flag already false. The marker is cleared
only after reset, reload and installed-scope refresh succeed.

Routed fire not multiplexed at the handoff. `run_one_job` enables the
context in `_install_fire_secret_scope`, which runs AFTER
`_launch_external_cron_worker`, so a routed desktop fire on the managed path
serialized `multiplex_active=False` and built the worker env with launch
residue and no scrub. The handoff now treats `routed_profile_fire()` as
multiplexed for exactly its own span; the worker re-establishes the state from
the payload as before.

Each fix was checked by reverting it and confirming its regression fails,
including the overlay half and the exclusion half of the managed fix
separately.

(cherry picked from commit 329cbd8963d68c45b425e95a5b11ade59f513960)
(cherry picked from commit 62b4488cb5)
(cherry picked from commit d3319102a7e4dcce35ec2676fe22aed41613e893)
2026-09-29 22:57:48 -04:00
John Paul Soliva
66e3090a4f fix(cron): close three launch-residue leaks into a routed no_agent child
Review findings on d8c467f223, each reproduced through its production path.

Stale launch key. `strip_launch_profile_env` built its residue set from a
re-parse of the launch `.env`. A key removed or renamed in that file after
boot is still in `os.environ` with the old value (dotenv never unsets), and
the current file no longer names it, so it survived into the routed child.
`_load_dotenv_with_fallback` — the one chokepoint every dotenv load goes
through — now records the KEY names it put into the process env, additive for
the process lifetime (`launch_dotenv_keys()`), and the strip unions that record
with the current file.

Source name that lost to the process env. `_apply_external_secret_sources`
snapshots every name a source SUPPLIED (`provenance` + `skipped_existing`),
but `secret_source_names()` only exposed `_SECRET_SOURCES`, which is
provenance metadata and names applied values alone. A launch-profile source
that supplied `CUSTOM_VAULT_SECRET` while the process already had it was
therefore invisible to the scrub, and a routed child with an empty scope got
the launch value. Supplied names are tracked separately
(`_SOURCE_SUPPLIED_NAMES`) so the provenance labels stay honest, and
`secret_source_names()` returns the union.

Last plugin source removed. `_refresh_secret_sources_after_discovery`
returned before the cache reset and the installed-scope refresh whenever no
plugin source was enabled — and `discover_and_load(force=True)` unloads the
old registration first, so removing the final plugin source hit exactly that
return with the removed plugin's names still in the per-home snapshot and the
current scope. The manager now remembers that a discovery re-applied plugin
sources and, on the next discovery that finds none, reconciles once. A home
that never had a plugin source is still a no-op (pinned by the existing tests).

Regressions: the stale-key lifecycle and the skipped-existing case through
`_run_job_script` against a real routed child, and the removal case through
the manager. Each checked by reverting its fix and confirming the test fails.

(cherry picked from commit d464f5f6126a394cfb47937f683d3a5e2f141840)
(cherry picked from commit 9d7de6c140)
(cherry picked from commit b0beb731b18edd884e36a30ab91c91687bc7a965)
2026-09-29 22:57:48 -04:00
John Paul Soliva
48a49220ee fix(cron): only strip launch external-source names when multiplexing
The source-name strip added alongside the external-source fix ran
unconditionally. Outside multiplexing there is no other profile to leak from --
os.environ IS this profile's environment -- so popping those names relied on the
routed scope overlay putting each one back, which in turn relies on the
per-home snapshot recorded at boot. Correct today, but it made a single-profile
child's credentials depend on bookkeeping that has nothing to do with isolation.

Guard it the way strip_launch_profile_env guards itself: no multiplexing, no
strip. A single-profile no_agent child now keeps a byte-identical env even if a
source's snapshot were ever missing. Pinned by a regression that runs a real
child with a source-owned name in os.environ and no multiplex context; making
the strip unconditional fails it.

(cherry picked from commit afa429b30a1c9c9b4c011f7d2426a9f099911596)
(cherry picked from commit 3dedff6a12)
(cherry picked from commit 1cb2a8e6eb9a1848fa758fcaef7dce531532258c)
2026-09-29 22:57:48 -04:00
John Paul Soliva
e4bb0ed6a9 fix(cron): strip launch external-source names too, and make the scope refresh replace
Two credential-isolation gaps found in review of the previous head.

1. strip_launch_profile_env() only knows dotenv- and terminal-config-owned names,
but external secret sources (vault, 1Password, ...) also write their names into
the shared os.environ and are tracked in secret_source_names(). A name the LAUNCH
profile's source supplied therefore still reached a routed no_agent child. Drop
every non-global source-owned name from the base; the routed scope overlay that
follows puts back exactly the ones that profile's OWN sources supply, since
build_profile_secret_scope folds get_secret_source_values(home) in.

2. refresh_installed_secret_scope() merged the rebuild with dict.update(), so a
name a source had stopped supplying -- rotated, revoked, source removed -- kept
its old value for the rest of the fire. Replace the mapping contents instead: the
rebuild is the profile's current truth.

Regressions: a routed child sees <unset> for a launch-source name while its own
source value comes through, and a refresh whose rebuild omits a name drops it.
Both fail if the corresponding change is reverted.

(cherry picked from commit ecd51517c4828a75acb5f458ed99aea0bc3e5e9f)
(cherry picked from commit 0943e77136)
(cherry picked from commit 68ab0384e17e06e8eca684c4d8eac15543dc3943)
2026-09-29 22:57:48 -04:00
John Paul Soliva
71303c7082 fix(cron): drop the launch profile's dotenv residue before overlaying a routed no_agent scope
The routed no_agent child env started from all of os.environ and only overwrote the
names present in the installed scope. A name defined only by the LAUNCH profile's .env
and absent from the routed profile therefore reached the routed child with the launch
value instead of unset -- the secret scrub only knows classified names, so a custom or
unclassified secret crossed the profile boundary (review finding on the first head).

strip_launch_profile_env (main, 284d220ba4) is the primitive the external-worker path
already uses for exactly this: it drops the launch profile's dotenv-owned keys and the
bridged TERMINAL_* settings, and is a no-op outside multiplex or when the target IS the
launch profile. Apply it to the base BEFORE the scope overlay (so a shared name keeps its
routed value) and BEFORE the sanitizer (so routed values still pass the scrub and
passthrough rules). Pinned by a child-process negative control: the launch-only name
arrives <unset>, the shared name arrives routed, and the parent process is unchanged.

(cherry picked from commit 69349b527b5144c1f1540a4c10035d5ec798c1db)
(cherry picked from commit 023e4f997f)
(cherry picked from commit cace4f3f2013e9dc60356add245b2bad4b56ab08)
2026-09-29 22:57:48 -04:00
John Paul Soliva
3833773f7f fix(cron): a routed profile's cron fire in the desktop backend runs under multiplex semantics
The desktop backend ticks EVERY local profile's cron store from one process — its own docstring
says "like a multiplex gateway" (hermes_cli/web_server.py) — but never sets the process-global
multiplex flag, and cannot: its own chat turns are unscoped and would fail closed. Every
isolation in the tree keys on that flag — the guard that keeps a routed `.env` out of the shared
`os.environ`, `get_secret`'s fail-closed miss, passthrough resolution, the MCP and kanban
subprocess scrubs — so all of it was inert for a sibling profile's fire. Verified: a secondary
profile's API keys replaced the launch profile's in `os.environ` with `override=True` and stayed
there after the tick, and a scope miss read the launch profile's tokens (#107692).

Give multiplex mode a context-local counterpart. `set_multiplex_context` (agent/secret_scope.py)
is OR'd into `is_multiplex_active()`. `_profile_cron_scope` only MARKS a fire whose home is not
the process's own (`routed_profile_fire`, decided against `get_process_hermes_home()`, the
override-immune resolver); `_install_fire_secret_scope` in cron/scheduler.py installs the
profile's hydrated secret scope and, for a marked fire, the multiplex context — for exactly that
span, dropped again before the scope by `_reset_fire_secret_scope`. Multiplex semantics are
therefore never active in cron without a scope to read: `run_one_job`'s restart-safe handoff runs
before the body's scope and keeps today's semantics (its own scope is #107413 / #106050's seam,
left untouched so this composes with whichever lands). Every existing multiplex-keyed isolation
applies inside the routed fire with no per-site patching; the launch profile's own fires and the
backend's turns keep single-profile semantics; marker and override both reach the pool worker via
`copy_context()`. `get_secret` read the raw global in its miss branch; it now goes through
`is_multiplex_active()`. The dotenv guard keeps its pinned flag-only form (#77970).

Two consequences of suppressing the write are handled rather than left as regressions:
- a `no_agent` script's env is `os.environ.copy()`, which no longer carries the routed `.env`;
  the runner overlays the installed scope onto the base BEFORE sanitizing, so the same scrub /
  passthrough rules apply to those values and the parent process is never mutated;
- plugin secret sources are discovered on the fire's first agent build, after the scope froze,
  and the post-discovery reload is hydrate-only under multiplex semantics; the refresh now folds
  the values into the installed scope in place (`refresh_installed_secret_scope`, the pattern
  `_publish_env_value` already uses for `.env` writes under multiplex).
And the profile's external secret sources are hydrated before the scope is frozen, the order
gateway/run.py and the external cron worker already use.

Tests pin each direction: the marker without the semantics before the scope, the semantics on and
off exactly with it, the marker reaching a copy_context worker; the process's own profile staying
single-profile; the restart-safe handoff's child env building without raising under a routed tick
with a passthrough key registered; a real child process receiving the routed values while
`os.environ` keeps the launch value; a source registered after the freeze reaching the fire
through the real PluginManager refresh. Reverting any one direction fails a distinct test.

(cherry picked from commit 2f87677425d2cca19286ac83bc45cab23e546669)
(cherry picked from commit dbede34f6e)
(cherry picked from commit a7ac197ff12710e045e3f75d960b20be952960f3)
2026-09-29 22:57:48 -04:00
Austin Pickett
dfcbef358a fix(desktop): find a source install through its published user-bin launcher
The PM rework dropped the resolver's "existing hermes on PATH" rung, so
Desktop only accepts HERMES_DESKTOP_HERMES or $HERMES_HOME/hermes-agent. A
healthy install elsewhere (install.sh --dir, a setup-hermes.sh clone) now
falls to bootstrap-needed and the first-run choice, offering to install a
second copy. The old rung also read raw process.env.PATH, which a
Finder/Dock launch inherits without ~/.local/bin.

Add a rung before bootstrap-needed: probe the launcher published at its
fixed user-bin location (~/.local/bin or $HERMES_HOME/bin; Windows:
%LOCALAPPDATA%\hermes\bin), read the install directory its --version
reports, and resolve that root through resolveSourceInstallationBackend,
the same validated path ACTIVE_HERMES_ROOT uses. HERMES_DESKTOP_IGNORE_EXISTING
and repair requests still skip it, and a miss is now logged instead of
silent. The launcher lookup is shared with resolveInstallationLauncher.

Fixes #73014

Co-authored-by: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com>
2026-09-29 22:57:06 -04:00
Austin Pickett
a35f600b8a fix(desktop): resolve the Windows ssh client instead of hard-coding System32 OpenSSH
All ssh spawns (the -G config probe, the ssh-config:resolve IPC, SSH
terminals, and SshConnection) now go through one pure resolver:
desktop.ssh_path in config.yaml, then System32 OpenSSH, then Git for
Windows' usr\bin\ssh.exe, then bare ssh. Non-Windows keeps bare ssh.

A failed local ssh -G probe is now tagged terminal: boot latches on the
failure overlay (naming the client and the ssh_path setting) instead of
re-driving the same probe every ~2s.

Fixes #103288
2026-09-29 22:56:15 -04:00
DavidMetcalfe
57a22675ef fix: only unresolved gateway rows make successor evidence ambiguous
Review round 3 (Gemini 3.8 Flash): counting every planned gateway row per profile
made a dirty baseline exit 1 even when the fleet was fully accounted for. With an
orphan row the restart phase killed (killed_pids) plus an active row that was
restarted, the killed row's own verdict ("stopped") was already in hand, yet its
count still marked the profile ambiguous and blocked credit for the row that did
restart.

The baseline count now skips rows bookkeeping already resolves (killed /
relaunched / name-matched) and counts only rows that still need a verdict, so the
ambiguity guard fires on genuine ambiguity instead of on any multi-row plan. The
debug message no longer claims a successor exists when the probe returned none.
2026-09-29 21:50:16 -05:00
DavidMetcalfe
2594c5786f refactor: count planned gateways by kind, document the deliberate limits
Review round 3 (@GPT-OSS 120B): `kind not in _SERVE_KINDS` was read as
"excludes gateways" — the set is ("serve", "dashboard"), so the behaviour was
already gateway-only (a profile with a gateway plus a serve and a dashboard row
still credits the gateway), but the intent is now explicit: `kind == "gateway"`.
The served-kind test covers both non-gateway kinds for one profile to pin that.

Also documents what this fallback deliberately does NOT do, so the next reader
does not have to re-derive it:
- ambiguity resolves conservatively (pairing a successor to the planned process
  needs a start-time identity neither the plan record nor the fleet row carries);
- credit is not gated on restart bookkeeping (the reported failure already had
  bookkeeping; a self-respawned gateway has none, while the fleet row still
  proves the successor runs the new code).
2026-09-29 21:50:16 -05:00
DavidMetcalfe
f42fd748c8 fix: require an unambiguous baseline for successor-credited gateway restarts
Review (@ehz0ah): one live successor cannot prove that two planned same-profile
gateways both restarted. `_collect_gateway_runtimes()` de-duplicates by PID, not
by profile, so duplicate/orphan states can put two `coder` rows in the plan —
and while the post-restart fleet probe publishes at most one row per profile,
both planned rows were credited from that single successor, letting an untouched
sibling vanish behind it and suppressing the unaccounted tripwire.

Successor credit now requires the profile to hold exactly ONE planned gateway
runtime (baseline identity); an ambiguous profile keeps the name-path verdict and
stays armed. Logged at debug so the reason is visible.

Reported repro, before -> after:
  plan=[coder 76508, coder 76795], live_gateway_pids={"coder": {76796}},
  empty bookkeeping
  before: ['restarted', 'restarted']        tripwire: False
  after:  ['unaccounted', 'unaccounted']    tripwire: True
2026-09-29 21:50:16 -05:00