collect_acceptance's gh api subprocess inherited the calling process's
environment, so on a multi-profile host the gate read the contract repo as
the ambient (launch/default) gh login: a private repo in another org
returned 404 and the broad except classified it infra with 'check gh
authentication and retry', blocking green cards and holding them via the
blocker_auth respawn guard (#122689).
- Thread the assignee's profile home into every _api call and build the
child env with served_profile_child_env(inherit_credentials=True): the
profile's own GH_TOKEN/GH_CONFIG_DIR overlay, launch credential residue
scrubbed (GH_CONFIG_DIR is a path, not a credential, so no scrub list
saw it — drop it from the base for routed targets).
- Classify a refused read (gh HTTP 401/403/404, or GraphQL resolving the
repository to null) as 'auth' naming the repository, distinct from
retryable infra; persist only the status code + endpoint, never stderr.
- Unassigned/uninstalled cards and single-profile hosts keep the ambient
env, unchanged.
Fixes#122689
The per-task `skipped_nonspawnable` event names the missing profile so
`hermes kanban show`/`tail` explain why a card sits in `ready`. Unlike a
respawn guard the condition never expires, so the event is written only when
the card's latest event is not already this skip: one row per card, not one per
dispatcher tick forever, and not one per foreign home per tick on a shared board
(#101015).
Cards assigned to a nonexistent profile landed in the aggregate
skipped_nonspawnable bucket with no per-task event, so the card sat in
ready forever with zero board diagnostics (show/tail silent; the bucket
prints only on manual dispatch). Follow the respawn_guarded pattern:
append a skipped_nonspawnable event naming the assignee on every
non-dry-run skip.
Closes#122422
_resolve_hermes_argv proves hermes_cli importable in the GATEWAY, where the
store-python shim / launcher bootstrap put the repo root on sys.path in-process.
The worker env scrub (build_subprocess_env / served_profile_child_env) strips
Hermes-owned PYTHONPATH entries, so the bare `sys.executable -m hermes_cli.main`
child could not import the package the parent just proved importable and every
worker died at birth (ModuleNotFoundError: hermes_cli) -> crashed x2 -> gave_up.
Reuse cron's pin_hermes_tree_on_pythonpath (#112729) on the module argv only:
a resolved shim path owns its imports. hermes_cli.main's bootstrap then
activates the committed PM dependency generation as usual, so a store Python
with an empty site-packages still boots. Same class in the Bot Chat delivery
runner (cron/scheduler_delivery.py), which builds the module argv after
served_profile_child_env.
The dispatcher's _resolve_hermes_argv proves hermes_cli importable in the
gateway process, where a store-python shim has the repo root on sys.path
in-process; the spawned bare 'sys.executable -m hermes_cli.main' child
runs from the task workspace with a scrubbed PYTHONPATH and cannot import
the package, dies before any work, and the board auto-blocks (#122299).
Keep the module-over-PATH ordering (da18c20226, #111569) and instead
propagate the running install's package root onto the worker's
PYTHONPATH; a resolved shim path keeps owning its own imports.
Fixes#122299
* feat(docker): publish nousresearch/hermes-sandbox:desktop for terminal backends
The terminal backends (docker, modal, daytona, singularity) all default to
nikolaik/python-nodejs:python3.11-nodejs20, a bare Python+Node base. For Bot
Screen, computer_use and the browser to run INSIDE that sandbox instead of on
the gateway host, the sandbox image needs the display stack.
docker/sandbox-desktop.Dockerfile is that base plus:
- the everyday tools it lacked (jq, ripgrep, fd, tmux, less, nano, vim,
zip, rsync, tree, procps, htop, sudo for the base's uid-1000 `pn`)
- the exact package set the Hermes -desktop image installs (TigerVNC,
Xfce components, dbus, xauth, fonts)
- Playwright's headed Chromium (same build as the -desktop image)
- cua-driver 0.28.2 from its pinned release tarball
No Hermes inside; the default user stays root like the base so nothing
changes for people who just switch docker_image. Desktop processes run as
`pn`. 4.27 GB on amd64.
docker.yml gains a `sandbox` variant with its own cache scope and repository
(nousresearch/hermes-sandbox:desktop, :main-desktop, :<release>-desktop);
the docker-integration suite is skipped for it (no Hermes to test) and
docker/sandbox-desktop-smoke.sh runs instead: as `pn`, every launcher and
cua-driver binary resolves, the real launcher.sh publishes :20, the RFB
socket completes the 3.8 handshake relayed over `docker exec -i` stdio, and
a headed Chromium maps a window on that display. hadolint lints the new
Dockerfile in docker-lint.yml.
* feat(docker): sandbox desktop base on python3.13-nodejs26
Matches the Hermes image (Python 3.13 / Node 26) and the top of requires-python;
the default docker_image tag it inherited was Python 3.11 / Node 20. Same pn
uid 1000, Debian 13; smoke (launcher, RFB relay, headed Chromium) passes.
* feat(docker): bake agent-browser into hermes-sandbox:desktop
The browser tools drive the agent-browser CLI; when the browser follows the
terminal backend that CLI has to exist inside the sandbox. Pinned to the same
^0.26.0 range the gateway resolves, --ignore-scripts like the gateway's npx path.
* feat(bot_desktop): the screen, computer_use and the browser follow the terminal backend
A user who sandboxes `terminal` (docker/ssh/singularity) had the agent's
screen, cua-driver and Chromium running on the gateway HOST beside that
sandbox: Bot Screen gave a headless host a display, the Xfce panel carries
xfce4-terminal, and `computer_use` could open a shell outside the boundary
the sandbox exists for.
Now the desktop lives where the terminal lives:
- tools/environments/streams.py: one primitive per spawn-per-call backend, the
local argv prefix that runs its remainder inside the sandbox with stdio open
(`docker exec -i`, `ssh`, `apptainer exec`). SDK backends (modal, daytona,
vercel) have none and report so.
- tools/bot_desktop/sandbox_host.py: launcher.sh runs inside the sandbox as
the image's `pn`; the pane's RFB bytes ride a 12-line python relay over that
prefix; `cua-driver mcp` is the prefix + the sandbox image's own driver.
- tools/bot_desktop/placement.py + `bot_desktop.placement` (auto|terminal|
gateway). `auto` follows the backend; a sandbox that cannot host a screen
REFUSES with the opt-in named instead of silently using the host.
- runtime.start/stop/status/published_env branch on placement; the pane,
lease, epoch fencing and CLI are unchanged.
- cua_backend: the MCP invocation is the sandbox one when the screen is
there; the host driver's runtime contract is irrelevant then; check_fn is
true under a terminal placement without a host binary.
- browser_tool_session: agent-browser invocations are wrapped in the prefix
with the daemon, socket dir and profile inside the sandbox; screenshots are
fetched back so MEDIA: paths keep working; recycle closes the sandbox
daemon.
- web_routers/display.py: the bridge pumps a relay's stdio when the screen is
in a sandbox, a unix socket otherwise.
Live on docker with nousresearch/hermes-sandbox:desktop: start/observe/RFB
handshake through the dashboard bridge, human takeover fences the agent
(HumanHasControl) and keystrokes reach the sandbox Xvnc, handback restores,
three start/stop rounds leave zero desktop processes; computer_use capture
and list_windows see only the sandbox's Xfce; browser_navigate/snapshot/
vision run with Chromium and agent-browser inside the container and zero
host processes on the bot profile; modal + auto refuses naming the opt-in.
* feat(desktop): Screen pane shows where a sandbox-placed screen runs; Install is host-only
DesktopStatus gains placement ('gateway' | 'terminal:<backend>'). A sandbox
image lacking the stack is a blocker naming hermes-sandbox:desktop, shown in
place of Start; install_command stays None there because the pane's Install
button runs the package manager on the gateway host, the wrong machine, and
display.install refuses for the same reason. The pane header carries
'Screen runs inside the docker sandbox, with the terminal' (4 locales).
* fix(bot_desktop): "is the screen in the sandbox" is a disk check on hot paths, never a config read
Every browser command and CUA spawn asked in_sandbox(), which resolves placement by
loading config, which initializes HERMES_HOME. Under a test's fake home that raised
HomeInitializationError from _run_browser_command; on a real host it read config per
click. Hot paths now ask sandbox_screen_running(): the start marker on disk, written
only by a sandbox start. Policy (in_sandbox) stays for start/install, where config is
the question. display.observe gates on "an RFB endpoint exists" for either placement.
* test(moa): late-accounting sink test asserts the wedged slot's row, not sink order
Under CI load the poll loop can see the interrupt before collecting the fast slot, so the
fast slot also arrives late and first; the test then failed on sink_calls[0]. The
contract is that the wedged slot's real usage reaches the sink.
* chore: retrigger CI (zero-job dispatch failure, auto-heal)
* fix(bot_desktop): a sandbox that died under a live screen fails loudly, never falls to the host
sandbox_screen_running() drops a start marker whose terminal environment is no longer
registered (stale after a process restart). When the environment object outlives its
container, the browser's sandbox wrap now checks the published DISPLAY and raises
"the screen inside the terminal backend's sandbox is gone; start it again" instead of
KeyError('AGENT_BROWSER_PROFILE'). Live: fresh sandbox navigate ok; docker rm -f the
container; next navigate returns that error; zero host Chromium either way.
* feat(terminal): nousresearch/hermes-sandbox:desktop is the default container sandbox
Every container backend (docker, modal, daytona, singularity) now defaults to the
sandbox image with the desktop stack, so Bot Screen, computer_use and the browser
run inside the sandbox for everyone who never chose an image; Python 3.13 / Node 26
match the Hermes image. One constant (DEFAULT_SANDBOX_IMAGE) replaces six copies of
the old literal. Migration 47 moves saved configs still holding the OLD default and
never touches an image the user pinned. Docker reuse recreates a container built
from another image, or the flip would silently never take effect for anyone with a
persisted container (live: old container removed, new one on 3.13 / Node 26 with
Xvnc, cua-driver, agent-browser present).
* chore: retrigger CI (zero-job dispatch failure)
* chore: retrigger CI (zero-job dispatch failure, auto-heal)
* chore: retrigger CI (zero-job dispatch failure, auto-heal)
* chore(config): template stamps v47 and shows the new default sandbox image
The template is what install.sh / docker / doctor --fix seed; a stamp behind
DEFAULT_CONFIG makes every fresh install migrate on first run.
* feat(sandbox): the default image change is a decision, not a surprise
A persisted Docker sandbox on another image is kept when docker_image is unset;
only a written docker_image (an explicit pin) recreates it. The pin verdict
travels as TERMINAL_DOCKER_IMAGE_PINNED through both terminal bridges (process
env and per-profile scope) and the container-config allowlist.
Approval surfaces, all through hermes_cli.sandbox_image_switch: the interactive
CLI asks once at startup (y = pin the new image, n = pin the current one, Enter =
ask later); the Screen pane shows the same choice with Switch / Keep buttons via
display.switchSandboxImage; `hermes config set terminal.docker_image …` is the
same answer from any shell. Gateways and cron never decide: they keep the sandbox
and log the notice.
Migration 47 now unsets a saved image equal to the OLD default instead of
rewriting it to the new one — that value was the template copied, not a pin, and
rewriting it would have made the runtime recreate existing sandboxes unasked.
Modal restores its snapshot and Daytona reuses its labeled sandbox regardless of
the configured image, so existing sandboxes there were already untouched.
* fix(config): both plain defaults that preceded the desktop sandbox image are template copies
main pinned nikolaik/python-nodejs:python3.14-nodejs22 (cd0f97f833) without a migration;
a saved config holding either literal is unset by migration 47, so it follows the default
and existing sandboxes get the keep-or-switch decision instead of a silent recreate.
* ci(docker): build the sandbox image on release/dispatch, not every main push
Leaves docker.yml exactly as on main. The sandbox image carries no Hermes code,
so two 4 GB multi-arch builds per merge bought nothing. sandbox-image.yml builds
and smokes on a PR that edits its own Dockerfile/smoke, and publishes only on a
release or a manual dispatch with publish=true. Stable tag stays :desktop.
* fix(config): keep main's config.py/config_defaults.py edits under the sandbox-image delta
The rebase resolved both files wholesale with the branch side, dropping main's move to
hermes_yaml (the 3.14 runtime venv has no PyYAML) and the 3.14 base pin. This is main's
version plus exactly the branch's own changes: DEFAULT_SANDBOX_IMAGE, the pin verdict in the
env bridge, placement defaults and the v47 stamp.
* test: sandbox-image tests read config.yaml through hermes_yaml (no PyYAML on the 3.14 runtime)
* fix(bot_desktop): read the sandbox marker BOM-tolerantly (windows footgun lint)
* fix(bot_desktop): placement is the authority; sandbox screen survives restarts
Review findings on the sandbox-hosted Bot Screen, each reproduced live first.
Authority. The browser preflight and the CUA invocation keyed off screen
LIVENESS, so `placement: terminal` with the screen not yet up handed the tool an
unchanged host command. `runtime.tool_placement()` is now the one resolver:
terminal placement starts the sandbox screen on demand (no auto_start opt-in
inside the user's own sandbox), refused placement raises its reason, and neither
ever yields the host. placement.resolve() answers a local backend from env alone
so the common case costs no config load on the spawn path.
Restart. sandbox_screen_running() deleted the marker whenever the process-local
terminal registry was empty, i.e. after every gateway restart, while Xvnc kept
running in the container; stop() then returned False and left it. The marker
now records the owning container; liveness comes from `docker inspect` on it,
stop/status re-attach to the recorded owner (even after the placement setting
moved), and only a container that is gone drops the marker.
SSH. remote_argv emitted `bash -c <script>` as three words; OpenSSH joins them
and the remote login shell ran `bash -c export` and the rest itself. The script
travels as one quoted word for ssh (remote_command knows the backend); docker
and apptainer keep argv.
CDP reach. agent-browser inside the sandbox reports the sandbox's loopback;
the Browser Use harness, browser_exec and the vault supervisor connect from the
host and got connection refused. streams.forward_port() proxies a local port
over the exec stream (same relay as the RFB bridge) and the CDP URL is rewritten
to the local end.
pids limit. --pids-limit 256 counts threads; measured on the desktop image the
desktop stack is 44, one Chromium tab 212, the agent's browser with two tabs
488. Past the cap every further docker exec died with "procReady not received".
Default is 2048 with the measurements in the comment.
Replacement. An approved image switch force-removed the old container before
`docker run` tried the new image; a private tag or registry outage left nothing.
The image is inspected/pulled first and the old container kept on failure.
Desktop integration. The sandbox start never passed the dock's browser launcher
(no Browser icon) and the thumbnail needed a host launcher pid + host ImageGrab
(always None). The dock runs the sandbox's Playwright Chromium on the shared
profile; the thumbnail is grabbed inside the sandbox (Pillow baked into the
image). The browser profile moves from /tmp — a 512 MB tmpfs emptied on every
container stop — to the desktop user's home, so logins follow the container.
Pin provenance. A TERMINAL_DOCKER_IMAGE written in a routed profile's .env is a
pin even when it spells the default; the scope compared values before.
* docs(bot-screen): no literal tmp path in the profile-location note
* fix(bot_desktop): docker inspect liveness probe closes stdin (TUI subprocess guard)
* fix(bot_desktop): adopting a screen the sandbox kept records the marker
Live ssh probe: after the host's state was lost while the sandbox kept its
Xvnc, start() took the idempotent early return (display already published)
and never wrote the host marker, so status/thumbnail/stop lost the screen.
Record the adopted display like a fresh launch.
Docs: what an ssh host of your own must carry, and why a Dockerfile ENV is
not enough for a login session (PLAYWRIGHT_BROWSERS_PATH via /etc/environment).
* docker(sandbox-desktop): login sessions find the browser (PLAYWRIGHT_BROWSERS_PATH via /etc/environment)
* docs(bot-screen): what the Apptainer path inherits from the image and what it does not
* chore(config): sandbox-image migration is 47→48 (main took 47 for compression.threshold_tokens)
* chore: retrigger CI (zero-job dispatch failure, auto-heal)
Review follow-up: compute the parked-branch case once in the rollback,
fold the baseline comments into one, reuse pull() and a new complete()
helper in the tests, and say in the docs that a rollback restores the
commit the checkout ran before the pull on every update path.
Review follow-ups for the branch-switch rollback:
- If the parked branch cannot be checked out again (for example, another
worktree holds it), restore its commit detached. Before this, the install
stayed on the broken update branch, and the recovery hint repeated the
failing command.
- Any `merge-base --is-ancestor` result other than "contained" keeps the
strict baseline. rc 128 used to fall back to the lenient one, which
contradicted its own comment.
- `_pull_updates` returns the baseline it verified, and
`_apply_pulled_update` checks against it. Before, that function
recomputed the baseline with a second rev-parse and merge-base against a
hardcoded `origin/<branch>`, which a fork push in between could move.
- Drop a `commit_count == -1` check that could never be false.
- Update the rollback description in `updating.md`.
- Tests share the fixture `git` helper, a repo-init helper and a `pull()`
wrapper instead of repeating those calls.
Ported from #125771. origin/main moves first and the upstream sync then
returns HEAD to the SHA that was running before the update. That is a
successful branch repair, not a no-op, and the update must accept it.
Rollback picks its command in one if/elif and names the branch it
restores, and `_pull_updates` passes `rollback_branch` straight through.
`_CheckoutPlan` always has the field, so `_apply_pulled_update` reads it
directly instead of through getattr.
A switch that changes the running code with no new commits to count set
commit_count to -1, which printed "commit count unknown on this shallow
checkout" on non-shallow checkouts. The plan now records that case, and
the update says it switched the running checkout instead.
A regression test pins the stale-local-main case. A merge that returns 0
without moving HEAD must still be refused, which rules out both a
running-code baseline and a "the switch already moved" bypass.
gateway/session.py is the SessionStore facade assembled from session_*
mixins; new behaviour belongs in a topical sibling, not the facade.
PROMPT_PIN_VERSION, sanitize_prompt_pin and get/set_prompt_pin move
verbatim to gateway/session_prompt_pin.py (SessionPromptPinMixin).
SessionEntry keeps only the field and its (de)serialization; the agent
cache imports the schema from the defining module. No behaviour change.
_rehydrate_prompt_pins read the store synchronously from inside the
prepare-turn coroutine. get_prompt_pin takes SessionStore._lock, which
worker threads hold across routing-index DB writes, so the first
internal event after a restart (startup resume, kanban wake) could stall
the loop behind another session's save.
Rehydrate is now async, reads through async_session_store, and runs in
_hmwa_prepare_turn before the sync context-pin helper, which goes back
to its main-branch signature. _persist_prompt_pins hands the raw
snapshot to set_prompt_pin, the one validator.
It asserted only that switch_session received preserve_prompt_pin=False,
which test_telegram_topic_mode's handoff test already pins with an exact
call assertion.
A queued human follow-up re-pins its channel inputs in memory but never
wrote them to the session store, so a crash during the drained turn let
the next internal event after restart reuse the older channel prompt.
Persist them the way a first turn does, fenced on the session the
follow-up runs under.
If the gateway dies after the compression child is published but before
the route advances, the restart prune finds the entry on an ended parent
and repoints it to the recovered child. That entry is rebuilt minimal, so
the pin was dropped and the first internal turn flipped the system prompt
again.
Carry the pin over when the recovered session is the parent's compression
tip, which proves it is the same conversation.
The persisted pin carried rendered session-context bytes but not the
privacy.redact_pii they were rendered under. After a restart, the first
internal event restored and reused them verbatim, so bytes rendered with
redaction off reached the model after redaction was switched on. Base
re-rendered under the current setting after a restart.
The pin now records redact_pii (the in-memory ephemeral_pin becomes
(change_key, text, redact_pii); the durable schema requires the flag).
Bytes from a different policy are never reused, including by internal
events in the same process, which had the same bypass until the next
human turn.
Simplified on the way:
- sanitize_prompt_pin is the one validator; the snapshot builder and
rehydrate no longer re-check the shape by hand, and use
PROMPT_PIN_VERSION instead of a literal.
- Rehydrate runs once, from the context path, and only when this process
holds no pins for the key (a restart). Eviction clears only the context
pin, so an evicted agent still re-renders instead of reviving the
snapshot. The channel path no longer rehydrates or takes a session id.
- _persist_prompt_pins drops its synchronous get_prompt_pin pre-check,
which took the store lock on the event loop; set_prompt_pin already
no-ops an unchanged snapshot. Its call-count test goes with it.
- set_prompt_pin builds its candidate with dataclasses.replace like
set_model_override, and no longer accepts None (no caller cleared).
- The read-fence test folds into the restart/reset test.
The CLI-handoff commit passes preserve_prompt_pin=False to switch_session, so
the two hand-written stubs that fake the store on that path (secondary-profile
adapter, Telegram DM topic lane) raised TypeError on the new kwarg and the
exact-call assertion no longer matched. Widen the stubs and the assertion;
no production change.
(cherry picked from commit 679eacc84a044696421b7d0de358eb65126b88da)
Time-triggered conversation rotation was removed in 1d5d059410 and core
has read nothing under session_reset since. Nothing told users whose
config still asked for it: their gateway conversations silently stopped
resetting, and a household that never types /new pays for an
ever-growing context.
Gateway startup (for every served profile) and `hermes doctor` now
report a session_reset block whose mode is idle, daily or both, and
point at the hermes-session-reset-policy catalog plugin, which reads
the same top-level block unchanged. The notice stays quiet while that
plugin is enabled. The config key is reported, never rewritten, since
the plugin consumes it. The gateway: form the old loader also accepted
is detected too, with a hint to move it to the top level.
Independent-review findings on #126159:
- MAJOR: `hermes:fs:logsRoot` joined a renderer-supplied profile name into
`profiles/<name>` unvalidated (session-owner data; from the remote backend in
remote mode), so `..`/absolute values escaped the profiles root, were created
and revealed. `localPluginsRoot` now accepts only `default` or a
`DESKTOP_PROFILE_NAME_RE` match — the gate `localSkinProfileKey` and main.ts
already apply — and otherwise falls back to the active Desktop profile.
- MINOR (b): `commands.catalog` scoped skill discovery to the routed profile but
read quick_commands and plugin commands unscoped, listing the LAUNCH profile's
quick commands in a profile-routed palette. All loaders run inside the one
`_session_home_scope` block.
- MINOR (c): `commands.catalog` swallowed ProfileUnavailableError into a warning
(ok + empty skills) while complete.slash returned 4064; `_profile_scoped_rpc`
re-raises it so dispatch maps it to 4064 (also fixes skills.reload's 5025).
- MINOR (a): the shared `session_gateway_runtime` reader's new merge (top-level
base_url/api_mode + billing_provider) gets a CLI-side test; the CLI-visible
precedence change is disclosed in the PR body.
Root cause: `hermes:fs:logsRoot` resolved `readActiveDesktopProfile()` — the
pooled backend's launch/active profile — so a failing turn in a chat owned by
a served secondary profile revealed `profiles/<launch>/logs` instead of the
`profiles/<owner>/logs` where that turn's agent.log/errors.log live.
The IPC now takes the owning profile; the error card passes its composer
scope's owner profile (falling back to the active gateway profile) and
Electron joins `<HERMES_HOME>/profiles/<owner>/logs` (`default` → the home's
own `logs/`). No caller → unchanged active-profile resolution.
Red-on-base: electron/fs-ipc.test.ts › hermes:fs:logsRoot › "resolves the
logs dir of the profile that owns the session" fails on origin/main with
Expected ".../profiles/finex/logs", Received ".../profiles/launch-profile/logs";
green on head (5 passed).
Profile-scope row: T7 (Desktop pooled serve, several homes per process) —
storage identity: lock/PID/state path under the OWNING home.
Root cause: in multiplex mode `_messaging_platform_payload` read enabled/
configured from the secondary profile's own never-configured config, so the
Desktop API server page printed Disabled / "needs setup" over the live
shared-listener mirror even though `/p/<profile>/v1` answered. A live
`mirrored_from` entry (only present while the shared gateway runs) now
projects as enabled + configured; own-gateway, stopped-gateway and default
profile paths are unchanged.
Red-on-base: tests/hermes_cli/test_served_profile_mirror_platforms.py::
test_messaging_card_shows_mirrored_api_server_enabled_without_local_config
fails on origin/main at `assert payload["enabled"] is True` (line 53);
green on head: 5 passed.
Profile-scope row: T7 (Desktop pooled serve, several homes per process).
Salvaged from #122165 by @Finn763 (cherry-pick, authorship preserved).
Root cause: a draft has no session_id, so use-slash-completions sent commands.catalog /
complete.slash with empty params and tui_gateway's _session_home_scope (session-only
profile_home) scanned the LAUNCH profile's skills; CompleteSlashParams also rejected a
`profile` key outright (extra=forbid -> 4000).
Fix: the hook sends the tile's routed profile when there is no session (cache keyed by
session-or-profile); _session_home_scope resolves a session-less `profile` through
_profile_home; `profile` added to CompleteSlashParams and SkillsReloadParams (same
session_id-or-empty shape in skills.reload) and the TS contract regenerated.
Red-on-base: tests/tui_gateway/test_protocol.py::test_sessionless_slash_palette_follows_profile_param
fails on origin/main (complete.slash -> 4000 "profile: Extra inputs are not permitted");
use-slash-completions.test.tsx "scopes a sessionless catalog and typed lookup to the routed
profile" fails on origin/main (commands.catalog called with {} not { profile }). Both green on head.
tui_gateway/server.py::_stored_session_runtime_overrides read only the top-level model_config
keys and then fell back to billing_provider (frozen at the first accounted call), never the
nested gateway_runtime dict gateway/run_turn.py persists per turn — so a Telegram-driven session
resumed on Desktop paired the current model with the first-call provider and 404'd. Route the
Desktop/TUI reader through the canonical SessionDB.session_gateway_runtime (nested gateway_runtime,
then top-level TUI keys, then billing_provider), the same reader CLI --resume uses; the reader now
fills a missing top-level provider from billing_provider so the pre-existing TUI contract
(top-level base_url/api_mode + billing_provider) is unchanged. Redone slim from #125956, which
kept a second hand-rolled reader and inverted the precedence.
red-on-base: tests/tui_gateway/test_resume_switched_provider_endpoint.py::
test_resume_honors_gateway_runtime_over_first_call_billing_provider and
test_resume_prefers_gateway_runtime_over_stale_top_level_keys fail on origin/main with
`assert ('anthropic', None, None) == ('openai-codex', None, 'codex_responses')`; green-on-head.
hermes_cli/main_tui_launch.py::_print_tui_exit_summary printed `hermes --tui --resume ID` /
`hermes --tui -c "TITLE"` without the `-p <profile>` suffix that the classic CLI sibling
(cli_session_mixin.py::_print_exit_summary) already appends, so a hint copied from a named
profile's TUI resolved against the default profile and failed with "No session found".
Trimmed from #125079: no try/except fallback (get_active_profile_name does not raise), tests
folded into the existing tests/hermes_cli/test_exit_summary_resume_hint.py.
red-on-base: tests/hermes_cli/test_exit_summary_resume_hint.py::TestTuiExitSummaryResumeHint::
test_tui_hints_include_profile_flag_for_named_profile fails on origin/main with
`assert 'hermes --tui --resume 20260524_000001_abc123 -p dev' in '...hermes --tui --resume
20260524_000001_abc123\n hermes --tui -c "My TUI Session"...'`; green-on-head: 7 passed.
Root cause: tui_gateway/methods_session.py session.save built saved_dir from get_hermes_home()
in an unscoped @_session_method handler, so a secondary-profile session's /save landed in the
launch profile's sessions/saved/. Use session["profile_home"] like the other session-bound
handlers (session.history / session.create); the compute-host path is unchanged. No other
handler in methods_session.py builds a session-bound path from get_hermes_home().
Red-on-base: tests/tui_gateway/test_tui_gateway_server.py::
test_session_save_lands_in_the_sessions_own_profile_a_b_a fails on origin/main at
`assert parents == [saved, work_home / "sessions" / "saved", saved]` (index 1 is the launch
home); green on head (3 session_save tests passed).
Root cause: tui_gateway/methods_slash.py::_mirror_slash_side_effects ran mirror(...) off-turn
(slash.exec RPC pool / compute-host control reader) with no profile runtime scope bound, so on a
multiplexed gateway /model's switch_model -> resolve_runtime credential read raised
UnscopedSecretError ("live session sync failed") and the live session kept its old route. The
wrap at this chokepoint covers every mirror (/model, /prompt, /personality, ...) and all callers.
Red-on-base: tests/tui_gateway/test_multi_profile_hosting_fail_closed.py::
test_model_switch_mirror_binds_runtime_scope_under_multiplex fails on origin/main at
`assert invoke(b) == ""` with "live session sync failed: Hermes could not read this profile's
A_ONLY_TOKEN ..."; green on head (1 passed).
Root cause: tui_gateway/session_lifecycle.py::_interrupt_session_turn invoked the
agent_loop_stopped hook with no profile runtime scope bound, so on a pooled serve /
multiplexed gateway an observer's get_hermes_home() named the launch profile instead
of the interrupted session's. Binding _session_profile_runtime_scope(session) inside
the function covers every caller (session.interrupt RPC, WS orphan reaper, idle reaper,
lease takeover, compute-host close); same rule _finalize_session already applies.
Trimmed from #125073: no hook-payload change, no plugins.py doc change, one A->B->A test.
Red-on-base: tests/tui_gateway/test_interrupt_agent_loop_stopped_hook.py::
test_interrupt_hook_observer_sees_the_sessions_profile_a_b_a fails on origin/main with
`assert seen == [launch, routed, launch]` (index 1 is the launch home); green on head (4 passed).
Co-authored-by: Yuan Li <dskwelmcy@163.com>
The full-clone shape (#122353), the second update after the unshallowing
update (#124272) and the --depth pre-fetch (#123346) cells run HEAD code and
now assert plainly. #123254 and #124645 live in N-1's pre-swap fetch and
check/pull code, so those gates stay until a release carrying the fix is N-1;
their reason text now says so.
The upgrade E2E cells are the regression proof; the unit tests keep one
invariant per behaviour: full clone stays full / partial clone keeps its own
filter (#122353), crash recognizer + single promisor-disabled retry (#124272),
heal of a depth-1 clone / of a --depth-grafted full clone (#123254, #123346).
Co-authored-by: salch-cred <salch-cred@users.noreply.github.com>
The pre-fetch heal (#123254) and the post-update tag fetch now share
fetch_full_commit_graph, so both follow one clone-mode rule: a partial clone
repeats its own filter, a full clone fetches unfiltered (#122353), and only a
depth-limited clone whose history is really missing unshallows as tree:0. A
full clone grafted by a user's `git fetch --depth 1` already has its history
locally, so it unshallows unfiltered and stays full.
Also: the network-timeout message no longer claims "no response from the
remote" (a large transfer on a slow link hits the same cap), without adding a
classifier rule that would assert the opposite.
A depth-1 installer checkout far behind main could never update: the plain
main fetch pulled nearly the full object graph (merge-heavy history drags in
side-branch ancestry past the shallow boundary), hit the 300s network cap
mid-transfer every time, and the unshallow step that makes later fetches
incremental only ran after a successful update. Unshallow the checkout first
(commit-graph-only fetch with the same 900s budget the post-update tag fetch
uses, pulling the update branch along), non-fatally, and stop reporting a
wall-clock kill as 'no response from the remote'.
Fixes#123254
prune_stale_shallow_grafts dropped grafts no live ref points at, but the
remote-tracking reflog still naming a dropped fetch tip made the --reflog
fail-safe walk fail (its parent was never fetched), rolling the prune back
on every run — grafts kept accumulating and the next update fell into
orphan divergence (#124645). Expire exactly the fetch reflogs that pin a
dropped graft before writing the trimmed file; user reflogs (HEAD, local
branches) are never touched, so the fail-safe rollback still guards them.
Fixes#124645
Git for Windows 2.54 reports the same partial-clone pack-objects BUG()
without the POSIX 'pack-objects died of signal 6' line (#124293 field
evidence): the BUG assertion is followed directly by 'fatal: could not
finish pack-objects to repack local links' and 'fatal: index-pack failed'.
The recognizer demanded the signal wording, so the recovery retry missed
this Windows occurrence entirely.
Require the BUG fingerprint plus EITHER terminator of the same abort —
the POSIX signal line or the Windows repack fatal — and pin both stderr
shapes plus a fingerprint-without-terminator negative in the regression.
On a partial clone (clone --filter=tree:0), git 2.53/2.54 crashes every
fetch: index-pack --promisor runs repack_local_links(), which feeds
pack-objects --exclude-promisor-objects-best-effort, and pack-objects
BUG()s (SIGABRT, 'should only be called on existing objects') when the
link traversal hits a legitimately missing promisor object. The crash is
deterministic, so every fetch — and with it the CLI's update/check flows
— stays broken until the user applies the manual workaround themselves.
The recovery is the reporter's verified workaround, wired in: on this
exact crash (all three stderr markers), retry the fetch once with
-c remote.origin.promisor= (per-invocation only; the user's filter
choice stays in their config). Wired into both fetch sites — the update
fetch and the check fetch (which also serves forks via upstream).
Unrelated fetch failures are returned untouched; if the retry still
crashes, the update path prints the manual heal command.
Fixes#124272
Hardcoding tree:0 for partial clones would silently tighten a checkout the
user cloned with --filter=blob:none. Read the existing
remote.origin.partialclonefilter instead and pass it back verbatim; full
clones still fetch unfiltered.
git fetch --filter=tree:0 writes remote.origin.promisor=true and
remote.origin.partialclonefilter=tree:0 even when those keys were
deliberately removed, so every hermes update converted a de-partialised
checkout back into a partial clone and re-armed the should_include_obj
fetch failure (#122353). Pass the filter only when the checkout already
is a partial clone; full clones fetch tags unfiltered.
/proc/locks is host-wide and the kernel serves it over several read() calls,
resuming by record index. When other processes add or drop locks between
those reads the table shifts and entries are skipped, so a lock that is
still held can read as gone.
- tests: the live-DB preview and header-probe lock tests read their own
process's locks from /proc/locks and flaked on busy CI hosts (1 in 12 on
a quiet box; 9 in 12 with six processes churning locks). A shared
tests/posix_lock_probe.own_posix_locks() reads /proc/self/fdinfo instead,
which lists this process's locks per open file in one read.
- hermes_state_lockowners: state_db_write_lock_holders() names the writer
in the "state.db write lock unavailable" warning. One pass could skip the
holder and log "no write-class lock held at the deadline". It now unions
three passes; a skip rarely repeats.
pm/lock.json and the Npm/AgentBrowser templates record registry.npmjs.org, and
pinned_source handed that URL straight to the downloader, so ~/.npmrc /
npm_config_registry were ignored and closed networks could not provision npm or
agent-browser. pinned_source now routes public-npm URLs through the configured
registry (npm's own precedence: npm_config_registry, then the user npmrc); the
lock's SHA256 still verifies the bytes and progress keeps the lockfile URL.
npm_dist_tags reads from the same registry.
The E2E cell test_npm_registry_mirror_serves_pm_npm_download drops its gate.
Co-authored-by: funky-xamarin <30426178+Wenfengcheng@users.noreply.github.com>
The E2E cells (test_update_with_corporate_root_only_in_ssl_cert_file, test_p2_env_lock_refusal_is_not_reported_as_success)
now assert the fixed behaviour directly.
The .env write lock (a package-managed install, or a key the administrator
pins in the managed-scope .env) refuses inside save_env_value /
remove_env_value by printing to stderr and returning. The caller cannot tell
that apart from a successful write, and the four multi-store writers built on
them carried on:
- save_provider_env_credential still rewrote config.yaml api_key mirrors of
the old value, reloaded the pool and returned ok:true.
- remove_provider_env_credential still pruned the env-seeded credential_pool
entry, recorded env:<VAR> in suppressed_sources and dropped config.yaml
mirrors.
- save_env_setting / remove_env_setting still dropped the top-level
config.yaml copy.
So `hermes config set|unset <PINNED_KEY>` printed the refusal, then "✓ Set" /
"✓ Unset", and exited 0. PUT and DELETE /api/env answered 200. With the user's
.env holding an old key and model.api_key mirroring it, a refused set left
.env on the old key and moved config.yaml to the new one.
The lifecycle writers now check the lock before any side effect and raise
ValueError with the refusal (require_env_writable). The CLI set/unset env
routes turn that into exit 1, and _env_write_errors already maps ValueError to
400, so the dashboard and Desktop get the reason. save_env_value and
remove_env_value keep their return shape for their other callers.
model.disconnect (TUI/Desktop) loops over every env var of a provider, so
it asks the lock about all of them (env_write_refusal) before removing any:
a pinned key refuses with nothing touched, where it used to strip the
user's own key first. A locked var that holds nothing is skipped, so
Disconnect keeps clearing the auth store on a package-managed install,
where every .env write is locked but api keys live in auth.json. The CLI
secret prompt prints the refusal instead of only the skill setup's log line.