Independent review pass: utils.base_url_host_matches already owns the
exact-or-dot-suffix hostname contract (userinfo/port stripped, lowercased,
trailing dot removed), so the predicate delegates instead of hand-rolling
a second suffix match to keep in sync. Also locks in the normalization
behavior the review verified empirically: uppercase+port, trailing-dot,
userinfo-stripped, and IPv6-literal cases added to the contract tests.
Three catalog-side defects from the same report, all downstream of the
exact-host assumption and the config/env asymmetry:
- Discovery read only $OPENAI_BASE_URL, so a config-set
model.base_url (the supported way to select a data-residency host)
was ignored and /model listed the catalog of api.openai.com, not the
configured endpoint. New _openai_discovery_base_url() resolves
env override -> matching model.base_url -> canonical default, the same
precedence inference uses.
- _credential_fingerprint() hashed env vars only, so hermes config set
model.base_url kept serving the previous endpoint's cached catalog
until TTL expiry. The effective endpoint is now folded into the
fingerprint for openai/openai-api.
- is_default_openai matched two literal URLs, so regional hosts (which
serve the identical 120+ entry dump) bypassed the curated intersection
and flooded the picker with whisper/tts/embedding/dall-e rows. Now uses
the shared official-host predicate; custom OpenAI-compatible proxies
keep the verbatim live list.
- validate_requested_model's curated-catalog soft-accept (#46850) no
longer applies on official OpenAI hosts: their /v1/models listing is
access-scoped and authoritative, so accepting an absent model
manufactures a selection that 400s at first use. Custom proxies and
other providers keep the #46850 fallback. The #37404
empty-intersection -> curated picker fallback is deliberately left
unchanged.
The P1 from the enterprise data-residency report: with
model.base_url=https://us.api.openai.com/v1, every tool-calling turn 400'd
('Function tools with reasoning_effort are not supported ... use
/v1/responses') because the runtime resolvers hardcoded
api_mode=chat_completions and consulted URL detection only. openai-api
declares codex_responses in its overlay; the declaration was never
consulted, so any OpenAI host that wasn't literally api.openai.com landed
on the wrong wire protocol.
New _fallback_api_mode(provider, base_url, model): URL detection first
(host-mandated wire shapes keep priority), then
providers.determine_api_mode() (the provider's declared transport), then
chat_completions only for genuinely unknown providers. All three runtime
fallback sites route through it: the pool-entry path, the explicit-runtime
path, and the API-key-provider path, so the lanes cannot drift apart.
Blast radius beyond openai-api: minimax, minimax-cn, and copilot-acp were
the other overlays whose declared non-chat transport fell through to
chat_completions on the same paths (same latent bug class). openrouter is
unaffected (declares openai_chat). _detect_api_mode_for_url also now uses
the shared official-host predicate, so regional hosts detect as
codex_responses on the direct-URL lane too.
Pointing openai-api at OpenAI's documented regional hosts
(us.api.openai.com / eu.api.openai.com, mandatory for customers with
data-residency obligations) silently degraded Hermes because three
subsystems tested 'is this OpenAI' with exact-hostname equality against
api.openai.com.
Adds providers.is_official_openai_host(): canonical host plus dot-suffix
subdomains of api.openai.com, hostname-parsed only. Lookalike hosts
(api.openai.com.attacker.test) and path spoofs (proxy.test/api.openai.com/v1)
stay rejected, preserving the #32243 hardening: a genuine *.api.openai.com
subdomain requires control of openai.com DNS.
host_mandated_api_mode() now routes through the predicate, so regional
hosts mandate codex_responses exactly like the canonical host.
Port from nanocoai/nanoclaw#2748: Docker's built-in 64 MB /dev/shm silently
breaks shared-memory-hungry workloads inside the sandbox — Chromium/Playwright
renderers crash tabs, and PyTorch DataLoader workers die with 'bus error' /
'insufficient shared memory'. tmpfs is lazily allocated, so the higher ceiling
costs nothing until actually used, and usage still counts against the
container's --memory cgroup limit.
- tools/environments/docker.py: --shm-size 1g in resource args (not
cgroup-gated; tmpfs mount option). Skipped when docker_extra_args already
sets --shm-size, or when configured empty/'0' (Docker default).
- terminal.docker_shm_size config key (DEFAULT_CONFIG + all three
config->TERMINAL_DOCKER_SHM_SIZE env bridges: CLI, gateway, config.py map)
- tests: default emit, custom value, opt-out, extra_args precedence,
helper edge cases (sabotage-verified: default/custom tests fail without
the emit)
Port from openclaw/openclaw#112954. The redactor knew GitHub, Slack,
Google, Stripe, AWS access-key-ID and ~25 other vendor prefixes but had
zero GitLab coverage — glpat-/gloas-/gldt-/glrt-/glrtr-/glcbt-/glptt-/
glft-/glimt-/glagent-/glsoat-/glffct-/glwt- tokens and legacy GR1348941
runner registration tokens passed through display and log surfaces
verbatim. Follow-up explicitly invited when #4541 was closed.
Each pattern keeps a full literal prefix so the _PREFIX_SUBSTRINGS
pre-screen (derived at module load) stays false-negative-free; routable
runner tokens allow dotted segments. Sibling site: skills_guard's
credential-exposure scan gains a gitlab_token_leaked pattern.
Surgical reapply of PR #46747 by @tank321 onto the current reusable-workflow
form of osv-scanner.yml (the original targeted the old direct-action layout).
Fixes#46738.
Two tests read cli.py's source to prove handlers exist. Root AGENTS.md bans
that outright: it passes when a handler is wired wrong and fails on a correct
rename. The #4771 rebase-loss regression is guarded by the state-machine
tests every handler delegates to.
Finishes the input stash started in the preceding commit from PR #4771.
That PR shipped only the panel renderer: its `@kb.add('c-s')` handler and
stash-state initialization were lost in a rebase, so the panel predicate
read undefined `_stash_panel_open` / `_stash_list` and the feature was
unreachable. This adds the missing half and the tests the PR never had.
Resolves the review feedback on #4771:
- Rebuilt the stash on current main's keybinding setup. The `c-s` key was
unbound repo-wide, so there is no conflict.
- Extracted the state machine into `hermes_cli/prompt_stash.py` as pure
functions (no prompt_toolkit import) so it is directly unit testable —
the PR was cli.py-only with zero tests.
- Dropped the PR's unrelated changes: delegation `supervisor_model` /
`execution_model` config aliases, and stale reverts of the banner
builder, worktree pruning, logging setup, and MCP toolset validation
that its 14k-commit-old base dragged along.
- Fixed the 📌 double-width measurement for real. Three commits in the PR
("subtract 1 from len()", "use bare len()", "subtract 1 again") were
chasing this by tweaking `len()`; all horizontal math now goes through
`_status_bar_display_width` (prompt_toolkit `get_cwidth`), which also
keeps CJK previews inside the border. Narrow terminals fall back to
compact header/footer labels instead of overflowing — caught by a
parametrized width test, not by eyeballing.
Gesture (the contributor's design, kept):
- Composer has content → push onto the stash, clear the input.
- Composer empty, one stashed → pop it straight back.
- Composer empty, 2+ stashed → open the browse panel (↑↓ / Enter / D / Esc).
- Panel open → Ctrl+S closes it.
Pushing onto a stack rather than a single slot is what makes repeated
Ctrl+S safe: a second stash never silently overwrites the first, and with
2+ parked the panel asks rather than guessing which to restore. A `📌 N`
status-bar badge and a composer placeholder advertise the parked draft so
it cannot be silently forgotten.
Deliberate departures from the PR:
- No auto-restore after the agent responds, and no `display.stash_auto_restore`
config key. The PR itself had already defaulted this to false as
"avoids surprising the user"; a keystroke the user pressed should not
cause text to reappear on its own, so the dead default is dropped
rather than carried as config surface.
- Nothing is persisted to disk. Drafts routinely contain pasted
credentials and NDA material, so the stash is session-scoped and
in-memory only. Any future persistence must route through
`get_hermes_home()`.
- Suppressed while a modal prompt owns the composer (sudo / secret /
approval / clarify / slash-confirm / model picker) so Ctrl+S can never
stash a password.
- Restoring images extends `_attached_images` instead of replacing it, so
an attachment added since the stash was taken is not silently dropped.
- `buf.reset()` on stash (not `text = ""`) clears completion state,
selection, and undo stack with the text.
Tests: 95 new tests across two files — 66 on the state machine (empty
buffer is a no-op, exact round-trips including newlines/tabs/CJK/fences,
no-clobber ordering, cap eviction, indicator states, panel cursor
clamping and deletion, the full resolve_ctrl_s decision table) and 29 on
the cli.py wiring (per-instance stash, keybinding registration guard,
layout slot, panel bounded at 8 widths, status-bar indicator lifecycle).
The keybinding-registration test asserts the `c-s` handler exists in
source specifically so the rebase loss that broke #4771 cannot recur.
Verified: 153 passed, 0 failed across the two new files plus
tests/cli/test_cli_init.py and tests/cli/test_cli_extension_hooks.py.
ruff check clean; check-windows-footguns clean.
Docs: Ctrl+S added to the CLI keybindings table.
Co-authored-by: CK iRonin.IT <cyprian@ironin.pl>
Ctrl+S pushes/pops/browses a stash stack instead of a single slot:
- Buffer has content: push to stash
- Buffer empty + 1 item: pop immediately
- Buffer empty + 2+ items: open panel browser
Panel: ↑↓ navigate, Enter restore, D delete, Esc/Ctrl+S close.
Status bar shows 📌 N count, 📌 N ▲ when panel open.
Port from openclaw/openclaw#112325: multibyte UTF-8 characters split
across a 4096-byte pipe or PTY read boundary were decoded statelessly
per chunk with errors='replace', corrupting both halves into U+FFFD
mojibake in background process output (poll/log/wait/completion
notifications). The foreground path already used an incremental decoder
(tools/environments/base.py::_wait_for_process); this applies the same
treatment to the background reader loops:
- _reader_loop (select and blocking paths): one
codecs.getincrementaldecoder('utf-8') per reader holds partial
sequences across chunks; the finally block flushes a truncated tail
as a single U+FFFD instead of dropping it.
- _pty_reader_loop: same treatment for ptyprocess byte chunks
(pywinpty str chunks pass through unchanged).
Genuinely invalid bytes keep errors='replace' behavior.
Inspired by Claude Code v2.1.219: MCP config string values with hidden
leading/trailing whitespace (pasted tokens with trailing newlines, URLs
with leading spaces) now trigger a startup warning naming the server and
the dotted key path, instead of failing later as an opaque auth/connect
error.
Advisory only: values are never mutated, secrets are never logged (only
key paths), and warnings dedupe to once per process per (server, path).
Checked after ${VAR} interpolation so whitespace inside referenced env
vars is caught too.
The hit targets are display:contents buttons now, so the branch label and the
counts stay the same flex children of the row with the same classes; the glyph
button fills the existing 3.5 leading slot. Only the hover background is gone.
Evict the runtime the backend just reclaimed instead of waiting for a
resume to 404, and refresh the lists whose ended_at moved. The stored
row is untouched, so reopening resumes from the DB.
The idle-TTL reaper, the LRU cap, and the WS-orphan reap tear down a
live session without the client asking. Nothing was pushed, so a client
kept a runtime id the backend had already forgotten and only found out
by failing a later prompt. Broadcast session.reclaimed with the runtime
id and the reason; client-initiated closes stay silent.
The composer's coding strip made the whole bar a button, so a click anywhere
along it — including the dead space between the branch and the counts — opened
the review pane. Only the two things that name the diff are clickable now: the
branch glyph + label, and the ahead/behind + ±lines cluster. The strip itself
is inert and no longer paints a hover state.
* fix(vision): mount images/ upload dir into sandboxes and permit host read (#69575)
Desktop, clipboard, and PDF uploads land in the flat top-level
HERMES_HOME/images/ dir, but Docker sandboxes only mounted the cache/
subtree and the vision resolver only permitted host reads from the media
caches. So vision_analyze on any desktop-app upload failed under a Docker
backend with "not reachable inside the sandbox".
- Add ("images", "images") to _CACHE_DIRS so the uploads dir is bind-mounted
into sandbox containers through the existing profile-scoped cache-mount and
reverse-mapping mechanism.
- Add home/"images" to _media_cache_roots() so the non-local host-read
allowlist permits reading uploads directly from the host filesystem.
- Cover the mount entry, the container path mapping, and the Docker-mode
resolver read for a profile-scoped upload.
Co-authored-by: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com>
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
* fix(tui_gateway): write image uploads under the session's profile home (#69575)
The attach RPCs (image.attach_bytes, clipboard.paste, pdf.attach) wrote
uploads to the gateway's module-cached launch home via _hermes_home/"images".
Those RPCs run before prompt.submit installs the session's profile HERMES_HOME
override, so in a multi-profile / root-gateway deployment the file landed in
the launch home while the sandbox mount and the vision host-read allowlist
both resolve the session profile's images/ at run time — the agent could
never see the upload it was handed.
Add _session_images_dir(session), which anchors the write on the session's
stored profile_home when present (matching the mount/read scope) and falls
back to the launch home otherwise. Route both write sites through it, keeping
per-profile isolation.
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
---------
Co-authored-by: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com>
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
The retag gate was global, so once one board reclaimed its legacy rows a
second board on the same state.db never got swept. Key the state_meta gate
on the workspaces root and skip reopening state.db on every spawn via an
in-process set. Align the dispatcher-spawn test with the worker's own
`kanban` source tag and cover the per-board gate.
Staging re-test (2026-07-31, post-74482 image roll): auto-created
threads still stuck on their initial titles; connector telemetry shows
zero thread_rename ops. Root cause is an ordering flaw in the 74482
consume path: BOTH the title-callback registration gate and the
schedule gate read the send-result feedback cache
(_relay_auto_thread_info) — but registration runs BEFORE delivery on
the non-streaming lane, and the auto-title thread races delivery even
when registration survives. The cache read can only succeed AFTER the
connector answers the send, so the rename lane deterministically
disqualified itself on the title turn.
Fix — decide shape early, facts late:
- New _is_relay_discord_channel_lane: SHAPE-only predicate (relay
Discord channel event, no thread) used by the registration and
schedule gates; no cache read before delivery.
- _rename_discord_auto_thread_for_session_title: on the relay lane,
poll the adapter's feedback cache (0.5s ticks, ≤10s) — delivery is
typically right behind the title. True miss (connector didn't
auto-thread: policy off, DM, send failed) no-ops exactly as before.
Tests: shape-gate matrix; late-arriving feedback -> rename fires with
only_if_current_name guard; never-arriving feedback -> no-op. Relay
suite 174 passed.
Workers spawn as `hermes chat -q "work kanban task <id>"` without
HERMES_SESSION_SOURCE, so every attempt persisted as an untitled `cli`
row. Tag them `kanban` and register it as a local, non-messaging
surface.
* fix(tui): expand collapsed paste tokens before submission
* fix(tui): show resolved interpolation, not raw {!...}, with paste tokens
The interpolation branch of dispatchSubmission passed the pre-interpolation
composer text as the transcript display, so a paste token combined with a
visible {!...} rendered the literal interpolation syntax instead of the
resolved output main shows today. Pass interpolate()'s resolved text as the
display override: it still carries the compact paste label while the model
payload expands the paste. Add a dispatch-level regression for the combined
interpolation + collapsed-paste route.
Co-authored-by: teknium1 <teknium1@users.noreply.github.com>
---------
Co-authored-by: UltraInstinct0x <gokhansarapevi@gmail.com>
Co-authored-by: teknium1 <teknium1@users.noreply.github.com>
Two things made a tool panel tab feel unclosable.
Cmd-W was a dead key over the terminal and the logs pane. The keyboard
close ladder resolved its target with focusedSessionGroup, which only
matches zones hosting a CHAT strip, so a focused tool panel fell through
every rung and Cmd-W emptied the main tab instead. Add a tool rung that
resolves through the same hover/focus ladder the number keys use.
Right-click Close was missing or inert. The zone menu's target was only
resolved by the tab strip's own onContextMenu, so a right-click anywhere
else in the zone (pane body, collapsed rail, edit veil) reused the
PREVIOUS target -- landing on the uncloseable workspace dropped Close
from the menu entirely. Resolve the target on the zone instead, so every
surface that opens the menu names the chip under the pointer.
Close on a tool panel now takes the tab out of the strip and syncs its
owning store, so the ctrl-backtick toggle and the Cmd-K row stay
truthful and bring the pane back; the toggle's open path reveals
(un-dismiss + re-adopt) rather than un-collapsing a pane that has left
the tree.
The logs (and terminal) tab ✕ dismissed the pane from the layout but
never synced the owning store — so the ⌘K toggle was stale and its open
listener called setPaneCollapsed, a no-op when the pane isn't in the
tree. The tab was gone with no way back short of a layout reset.
Route the tab ✕ through closeCollapsePane (dismiss + store sync) so the
toggle stays truthful, and make bindPaneCollapse's open listener call
revealTreePane (un-dismiss + re-adopt) instead of setPaneCollapsed.
The repo's .npmrc sets engine-strict=true and package.json pins
engines.npm, so an npm outside that range aborts every npm ci /
npm install we run inside the checkout:
npm error code EBADENGINE
npm error notsup Required: {"npm":"<11.10.0 || >=12.0.0"}
npm error notsup Actual: {"npm":"11.10.0"}
Our callers made that worse: _run_npm_install_deterministic sees
`npm ci` fail and falls through to `npm install`, which fails
identically, so the user got a buried EBADENGINE and no remedy.
React to the failure instead of predicting it. npm states the
required range in its own error, so there is no need for a version
probe on the happy path or a semver range matcher — the recovery
reads the constraint out of the output it just produced, upgrades,
and retries once.
Scope is deliberately narrow. Hermes only upgrades an npm inside its
own managed Node tree ($HERMES_HOME/node), installing with --prefix
so bin/npm keeps resolving to the upgraded lib/node_modules/npm; a
managed install writes prefix=~/.local into node/etc/npmrc, so
without the override the "upgrade" would land elsewhere while the
managed npm stayed stale. A system / nvm / brew / Nix npm belongs to
the user, so that case prints the exact command and lets the original
failure stand.
The upgrade runs from a temp cwd with npm_config_min_release_age=0,
otherwise the checkout's own min-release-age gate would refuse the
npm release we need.
_run_npm_install_deterministic's capture_output=False callers (the
desktop install) streamed npm output and returned stderr=None, which
would leave the recovery nothing to read — stderr is now teed, so
live output is unchanged and the text stays inspectable.
Verified end to end against real npm binaries on copies of a managed
tree: managed npm 11.10.0 -> EBADENGINE -> upgraded to 12.0.2 ->
retry exits 0; a foreign npm 11.10.0 hard-fails with the manual
command and is left untouched.