Commit Graph

45474 Commits

Author SHA1 Message Date
Yuan Li
fdac8a1896 fix(kanban): PR acceptance runs gh as the assignee profile's login
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
2026-09-28 03:37:09 -07:00
teknium1
89cb27ab6f fix(kanban): unknown-assignee skip is recorded once per card, never once per tick (#122422, salvage #122567)
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).
2026-09-28 03:37:09 -07:00
finn763
39c737465e fix(kanban): write per-task event when dispatcher skips unknown assignee
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
2026-09-28 03:37:09 -07:00
teknium1
5392a83ccd fix(kanban): module-form worker carries the install root on PYTHONPATH (#122299, #122487, #122500, salvage #122420)
_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.
2026-09-28 03:37:09 -07:00
liuhao1024
7e3b34ca84 fix(kanban): carry the module argv's import root to the worker 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
2026-09-28 03:37:09 -07:00
Teknium
399d956903 feat(bot_desktop): Bot Screen, computer_use and the browser run inside the terminal backend (#121169)
* 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)
2026-09-28 03:34:07 -07:00
kshitijk4poor
e59df70a12 refactor(update): name the parked rollback once and reuse the test helpers
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.
2026-09-28 16:00:01 +05:30
kshitijk4poor
9e8202f802 fix(update): never leave a failed branch rollback on broken code
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.
2026-09-28 16:00:01 +05:30
JoaoMarcos44
8ba5d3948c test(update): a fork sync round trip back to the running SHA is a repair
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.
2026-09-28 16:00:01 +05:30
kshitijk4poor
a4ed805290 fix(update): tidy the branch-switch rollback and name a switch-only update
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.
2026-09-28 16:00:01 +05:30
funky-xamarin
593eaf6c07 fix(update): distinguish early fork sync from pending branch repair 2026-09-28 16:00:01 +05:30
funky-xamarin
a58fc4fa8e fix(update): retain branch repair baseline through fork completion 2026-09-28 16:00:01 +05:30
funky-xamarin
495ab057f1 fix(update): verify stale branch repair against its immediate tip 2026-09-28 16:00:01 +05:30
funky-xamarin
57b3868d52 fix(update): preserve running checkout across branch-switch updates 2026-09-28 16:00:01 +05:30
kshitijk4poor
e162360c96 refactor(gateway): move the prompt-pin store API into a session sibling
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.
2026-09-28 15:54:07 +05:30
kshitijk4poor
9dc52c21e4 fix(gateway): rehydrate prompt pins off the event loop
_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.
2026-09-28 15:54:07 +05:30
kshitijk4poor
a618c2a997 test(gateway): drop the CLI-handoff prompt-pin kwarg test
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.
2026-09-28 15:54:07 +05:30
kshitijk4poor
509796a328 fix(gateway): drained follow-ups persist their prompt pins
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.
2026-09-28 15:54:07 +05:30
kshitijk4poor
7823f8771d fix(gateway): compression-crash recovery keeps the prompt pin
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.
2026-09-28 15:54:07 +05:30
kshitijk4poor
f660ba2289 fix(gateway): durable prompt pins never outlive their privacy policy
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.
2026-09-28 15:54:07 +05:30
JoaoMarcos44
4bbbfce3a2 fix(gateway): omit malformed prompt pins from routing state
(cherry picked from commit 8928355d002697108443ac7b0edfcd28b02a2738)
2026-09-28 15:54:07 +05:30
teknium1
72a2271d0e test(gateway): accept preserve_prompt_pin in handoff switch_session stubs
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)
2026-09-28 15:54:07 +05:30
JoaoMarcos44
3275dcead3 fix(gateway): fence prompt pin rehydration
(cherry picked from commit d15d25312dfaf62add09fe36d2203be5288d28fe)
2026-09-28 15:54:07 +05:30
JoaoMarcos44
bf7879bc17 fix(gateway): clear prompt pin on CLI handoff
(cherry picked from commit f38beed0a2de48947aae2086d998148fc409a7b7)
2026-09-28 15:54:07 +05:30
JoaoMarcos44
0ba9e1b7f0 fix(gateway): persist internal prompt pins
(cherry picked from commit 0da37221523633bef2c3454ece033f8eac285893)
2026-09-28 15:54:07 +05:30
kshitijk4poor
0d5aae5e24 fix(gateway): warn when config still declares a retired idle/daily session_reset
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.
2026-09-28 15:54:01 +05:30
teknium1
485979ddf4 fix(review): validate the logsRoot owner name; scope the whole palette and fail 4064 on an unknown profile
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.
2026-09-28 03:13:19 -07:00
teknium1
9f8fb4a5b9 fix(desktop): error card's Open Logs reveals the session owner's logs dir (#119080)
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.
2026-09-28 03:13:19 -07:00
finn763
2b9fdb85d4 fix(desktop): API server card shows a served secondary as enabled (#121125, salvage #122165)
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).
2026-09-28 03:13:19 -07:00
chelsealong
3fe934aabf fix(desktop): scope the new-chat slash palette to the rail-selected profile (#124651, salvage #124845)
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.
2026-09-28 03:13:19 -07:00
teknium1
33c6ab002d fix(tui_gateway): resume on the route in model_config.gateway_runtime (#125942, salvage #125956)
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.
2026-09-28 03:13:19 -07:00
Yuan Li
7fd2f57413 fix(cli): include -p <profile> in TUI exit summary resume hints (#125078, salvage #125079)
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.
2026-09-28 03:13:19 -07:00
John Paul Soliva
46d0801279 fix(tui_gateway): /save writes the transcript under the session's own profile (#125241, salvage #125242)
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).
2026-09-28 03:13:19 -07:00
ahisblessed
332424ce45 fix(tui_gateway): bind the session's profile scope around slash side-effect mirrors (#122655, salvage #122698)
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).
2026-09-28 03:13:19 -07:00
Yuan Li
51b91997a5 fix(tui_gateway): dispatch agent_loop_stopped in the session's profile scope (#125063, salvage #125073)
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>
2026-09-28 03:13:19 -07:00
teknium1
01a5549faf test(e2e): shallow/partial-clone update cells assert the fixed behaviour
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.
2026-09-28 03:08:56 -07:00
teknium1
f338f4830c test(update): keep two invariant tests per fix in the salvaged unit tests
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>
2026-09-28 03:08:56 -07:00
teknium1
010618c767 fix(update): one commit-graph fetch for the pre-fetch heal and the post-update pass
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.
2026-09-28 03:08:56 -07:00
Konstantin Khlopkov
e377309d86 fix(update): heal stale shallow history before the bounded fetch
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
2026-09-28 03:08:56 -07:00
liuhao1024
6b51b0fd4e fix(update): expire fetch reflogs that pin stale shallow grafts
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
2026-09-28 03:08:56 -07:00
Yuan Li
ffa8817345 fix(update): recognize the Windows pack-objects crash stderr in the partial-clone recovery
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.
2026-09-28 03:08:56 -07:00
Yuan Li
fc6144e3c1 fix(update): self-heal git pack-objects crash on partial-clone fetches
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
2026-09-28 03:08:56 -07:00
Yuan Li
5849446b02 fix(update): repeat the clone's own partial-clone filter, not tree:0
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.
2026-09-28 03:08:56 -07:00
Yuan Li
de98f88909 fix(update): do not re-arm partial-clone config on the post-update tag fetch
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.
2026-09-28 03:08:56 -07:00
teknium1
35272ce28b fix(state): /proc/locks readers no longer lose entries to other processes' lock churn
/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.
2026-09-28 02:26:21 -07:00
teknium1
b22631ec4f test(e2e): drop stale gates for #124643 (detached HEAD) and #123424 (duplicate ~/.local/bin)
Both issues are already resolved on main and both cells pass there ungated; the gates only hid a
future regression.
2026-09-28 02:00:41 -07:00
teknium1
a31e618e70 fix(pm): download npm-hosted tools through the user's npm registry
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>
2026-09-28 02:00:41 -07:00
teknium1
86f20a367a test: trim salvaged SSL/config unit tests to two invariants each; flip the #124654 and #119928 E2E gates
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.
2026-09-28 02:00:41 -07:00
John Paul Soliva
2981ca24da fix(config): a refused .env write fails instead of reporting success and half-applying
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.
2026-09-28 02:00:41 -07:00
kokhlo
c70374b561 fix(update): map SSL_CERT_FILE into GIT_SSL_CAINFO for git children 2026-09-28 02:00:41 -07:00