The salvaged fix reserved a fixed 48px drag handle whenever a top-edge tab
strip is visible. That is right when the tabs share the titlebar band (every
tab is `no-drag` and the list scrolls, so a crowded strip covers all the
draggable pixels — #112964), but it also shrank the handle in the layout
6cc51b407c introduced for narrow zones: tabs drop to a second row below the
window controls and the whole titlebar row above them is free. There the
flexible handle must keep spanning the row, or a narrow window loses most of
its drag area.
Scope the fixed width to `headerVisible && tabsInTitlebar`; otherwise keep
the flex-1 handle exactly as origin/main had it.
Tests: replace the contributor's single assertion with two invariants in
tree-group.test.tsx — a crowded titlebar strip renders a fixed-width handle
outside the `role="tablist"` scroller (red on origin/main), and the
below-controls layout keeps a flexible, width-less handle (red on the PR as
submitted). The `TITLEBAR_DRAG_HANDLE_WIDTH === 48` pin in titlebar.test.ts
is dropped: it is a change-detector on a constant, not a behaviour.
The backup deadline callback raised _AUTH_DB_LOCKED on ANY backup that had
not finished by the deadline, so a large auth DB on a slow disk that was
still copying pages got the "a running browser holds a write lock" wording,
and the all-locked message told the user to quit the browser for a cause
that was not a lock. Track `remaining` between progress callbacks: a held
write lock fails every step with `remaining` unchanged, while real progress
shrinks it. Only the former keeps the lock wording; the latter reports a
plain "backup exceeded Ns" reason that falls to the details branch.
Review follow-up on #113080.
On macOS/Linux a running Chrome lets `Cookies` back up but holds `Login Data`,
`Login Data For Account` and `Web Data` with a hot write lock, so their SQLite
online backup misses the five-second deadline. The launch failed closed with a
bare "3 database(s) unavailable" count, and the docs still promised that macOS
and Linux "can copy the profile while the browser is running" (#111647).
- `_copy_auth_file` returns the failure reason (None on success) instead of a
bool; the backup deadline is the named constant `_AUTH_BACKUP_DEADLINE_S` and
raises `_AUTH_DB_LOCKED`, so the caller can tell "browser holds a write lock"
from "file is not a database".
- `_mirror_profile_auth` returns `{db name: reason}`; the new
`_unavailable_auth_dbs_error` renders "chrome is running and holds the
profile's Login Data, Login Data For Account, Web Data with a write lock …
Fully quit chrome and retry" when every failure is the deadline, and the
per-database SQLite error otherwise. No raw-copy fallback is added: the
no-fallback stance from 58d6d5223a (WAL consistency) is unchanged.
- Docs: the Real-profile note no longer says macOS/Linux copy live; it states
which databases need the browser quit and what the error looks like. The
`real_profile_autoclose` comment stops claiming copy-while-running works on
POSIX.
The post-tool quiet timer no longer retires the session (it only warns), so the
codex_runtime retirement comment listed a cause that cannot happen and the
retained test's name/docstring still promised a watchdog that cannot trip.
Comment and test wording now describe the warning semantics; assertions unchanged.
After a tool result, `CodexAppServerSession._run_started_turn` armed a
90s wire-silence watchdog that sent turn/interrupt and retired the
session. On large contexts codex legitimately emits no events for
minutes while it reasons after a big tool output, and the app-server
answers RPCs the whole time (the interrupt was acknowledged in ~25ms).
Silence is not evidence of a wedged process, so the watchdog killed
healthy work and surfaced as protocol_violation / crashed workers.
Keep `post_tool_quiet_timeout` as observability only: past the
threshold log one warning per tool result and keep waiting. Retirement
still happens on the two real signals the poll loop already checks
every iteration -- subprocess death (`_subprocess_died`) and the
overall `turn_timeout` deadline -- so a truly wedged codex stays
bounded.
The existing monotonic-clock watchdog test becomes the invariant for
the new behaviour: tool item, silence past the threshold, then
turn/completed -> no interrupt, no retirement, a warning logged.
Fixes#112928
Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
_force_terminate_known_gateway_pids read get_process_start_time(pid) at
kill time and handed it to terminate_pid as expected_start_time — a
comparison of the process with itself that can never fail. After the
new <=30 s planned-stop drain in stop_profile_gateway, a gateway that
exited mid-wait and had its PID recycled would be taskkill'd /T /F
anyway (the exact svchost.exe hazard the guard exists for, #89614).
Capture the fingerprint before waiting, as _escalate_wedged_gateway
already does, and make _force_terminate_known_gateway_pids take a
{pid: start_time} mapping so no caller can re-read at kill time.
stop()/restart() fingerprint their collected PIDs at collection.
Test: recycled PID (start 100 -> 999 after the drain deadline) now
reaches terminate_pid with expected_start_time=100, so its mismatch
refusal fires; red before this change ((4242, True, 999)).
The Windows guide still described `hermes gateway stop` as a "graceful
SIGTERM equivalent (TerminateProcess)". After #112755 the unsupervised
stop path writes the planned-stop marker and waits for the gateway to
drain before any hard kill, matching the Scheduled-Task `stop()` path.
The survivor warning also named SIGTERM as the only stop request, which
is wrong on Windows where the marker is the request.
Registry connections (SSH, remote URL, cloud) resolve their backend through
connectRegistryBackend(), which never registered the profile's WSL path
bridge state. isWslBridgeActive() then fell back to its default (true), so
POSIX paths from a remote Linux host hit resolveDefaultWslDistro() and
spawned wsl.exe - visible as a flashing black console window on machines
where WSL has no distro installed (and an install-prompt risk on fully
WSL-less machines).
The v1 path (ensureBackend) already disables the bridge for remote
connections at all three of its return sites. Mirror that for pooled
registry backends: SSH and remote/cloud connections are always
remote-hosted, so their profiles must be bridge-inactive.
Refs #102868, mirrors the #66433/#66447 guard for the primary connection.
Replace the five string-matching tests from the salvaged commit with two
invariants that spawn a real child carrying a token in argv and check the
actual persistence boundaries: the /proc summary + warning line + JSON
snapshot, and the detached ps/pstree log written by the real writer (which
must also end up 0600 even when an earlier release created it 0644).
Drop the AttributeError guard around os.fchmod: spawn_async_diagnostic
returns None on win32 before reaching it, so the branch was unreachable.
Shutdown diagnostics captured full command lines via ps auxf,
pstree -a, and /proc cmdline, then wrote them to a world-readable
log. Live tokens on child argv therefore survived the stop.
Keep process identity, CPU, tree shape, load, and OOM context, but
never persist argv. Open the diagnostic log 0600.
Fixes#112459
Bot Mode rows pre-warm their backend on pointer entry. For a row that lives
on an SSH registry source, "warm" meant openGatewayForAgent → main
ensureRegistryBackend → bootstrapSshConnection, i.e. open the tunnel and run
`hermes -p <bot> serve --isolated` on the remote box. Sweeping the pointer
across a roster of specialists spawned one isolated serve per bot and the
cascade knocked the window's own primary chat over (WebSocket 1012).
The docs promise SSH sources stay connect-on-demand until you open an agent,
so the guard lives in the single speculative-warm resolver
(store/profile.ts::prewarmProfileBackend): a registry connection of kind
'ssh' is never dialed by a hover; explicit opens are unchanged and same-box
Remote gateways still warm.
Salvages the hover half of #89769 (@bblicke1). The other half — preferring a
same-install Remote gateway over an SSH primary in the roster collapse — is
not reachable on current main: SSH sources are enumerated without an
install_id (c2c3058eca), so they never join a collapse group, and
re-routing the active primary's rows would re-introduce the doubled-roster
class (#88344) in the renderer merger. Left for a design call.
Part of #89756
Keep the salvaged coverage from #112457 at the repo's 1-2 invariant tests
per fix: one parametrized test over every spec-legal terminator pair cut at
every chunk seam (plus the three-way dribble and two completed events in a
single chunk, which the rfind-based cap lumped into one charge), and one
negative pinning that a CRLF straddling the seam is a line ending, not a
boundary. The non-straddled CRLF negative and the "split boundary before an
over-cap event" case are dropped: the straddled variant subsumes the former
and test_sse_event_over_cap_rejected already pins the latter's invariant.
The per-event body cap searched each streamed chunk in isolation, so an
event terminator split across two chunks was never seen: the finished
event's bytes were charged to the next event and the cap tripped early
on ordinary TCP fragmentation. A last-boundary rfind also lumped
multiple completed events in one chunk into a single charge, and the
separator tuple missed spec-legal CR and mixed line-ending styles.
Scan the carried suffix plus each new chunk with a terminator-pair
regex, walking every boundary left to right so each completed event is
charged once. The lookahead keeps a lone CRLF line ending from parsing
as a boundary, so multi-line events cannot evade the cap.
The Path.cwd() guard added for Windows returned early on any OSError,
which also covers a cwd that no longer exists. That is exactly the
deferred parent-worktree cleanup path (#33774): a scratch-kind child
worker runs with cwd inside its scratch dir, _cleanup_workspace rmtree's
it, and _try_cleanup_parent_workspaces then calls this function from the
same process. main removed the clean, pushed parent worktree; the PR
head silently left it and its wt/<task> branch behind.
A deleted cwd cannot hold the worktree open, so chdir to repo_root and
proceed with `git worktree remove`. Only a failing chdir preserves the
worktree, and it now logs a warning instead of returning silently.
The Add/Edit connection editor now exposes the remote hermes executable
path for SSH entries; the multi-connection guide listed only the SSH host
field, so users following the docs could not find the setting the probe
error text points them to.
_evict_cached_clients now pops without closing (a concurrent caller may be mid-request on the
shared client), so the two credential-refresh tests assert eviction instead of close().
Two review findings on the re-armed _evict_cached_clients:
1. It matched on the provider slot only and ignored the leading hermes_home_key() slot, so an
OAuth rotation/refresh in profile A also dropped (and closed) profile B's cached client for
the same provider in a multiplexing gateway, although B's per-profile credentials did not
change. Callers all run inside the rotating profile's scope, so filter on key[0] too.
2. It closed the shared client inline. A concurrent caller mid-request on the same cached
client (the normal cache-hit case for parallel compressions) got ReadError, and any caller
still holding it got "Cannot send a request, as the client has been closed" - the exact
hazard the #113022 reporter cited for NOT enabling eviction. Pop the entry instead and let
the dropped client retire via GC, as the FIFO overflow path in _get_cached_client already
does; the next call builds a fresh client from the rotated credentials.
Part of #113022
`_send_file` (send_document / send_image / send_video / send_voice, i.e.
the cron `media_files` leg of `_deliver_direct`) awaited the caption and
media `sendmessage` calls without reading `ret`/`errcode`. `_api_request`
only raises on non-2xx HTTP, so a stale-token `ret=-2 errmsg=prepare
failed` (or `errcode=-14`) came back as `success=True` with the token still
attached and the attachment silently never arrived — the previous commit's
claim that the media path shared `_send_text_chunk`'s `_is_session_expired`
handling was wrong; only the text path had it.
Apply the same mechanism inline in `_send_file`: check `ret`/`errcode` on
every item-list send, re-send once without `context_token` (and drop the
cached token) when `_is_session_expired` says so, and raise on any other
non-zero response so `_send_file_result` reports the failure. Clearing the
token covers both the caption and the media item, and bounds the loop.
Also mirrors the `prepare failed` troubleshooting row into the zh-Hans docs.
Part of #112709
The stale-session fallback in `_send_text_chunk` re-sends without
`context_token` via `continue`, which advanced the `for attempt in
range(retries + 1)` loop. With `send_chunk_retries=0` (the setting the
reporter used to surface the raw iLink error) the re-send never happened:
the loop ended with `last_error is None` and `send()` failed with an empty
AssertionError. Count only real failures in `attempt` so the tokenless
re-send is free; backoff timings and log counters are unchanged.
Also documents the `ret=-2 prepare failed` / `unknown error` stale-session
behaviour in the Weixin troubleshooting table.
Part of #112709
_cached_redirect already degraded a list/str/number client.json to
(None, None), but one step later the MCP SDK calls
storage.get_client_info() while building OAuthClientProvider, and
_load_model ran the fixup (data.get) outside its try, so the same payload
still raised AttributeError out of the OAuth flow. Guard isinstance(dict)
in _load_model (warn + None, like any other corrupt file; also covers the
tokens fixup's data.pop) and make _cached_client_info return None for
non-object payloads so CIMD eligibility and redirect reuse see "no cached
registration" rather than a registration that can never load.
Part of #112568
Both invariant tests now share one helper that writes the payload to the
real ``mcp-tokens/srv.client.json`` path via ``HermesTokenStorage(...,
hermes_home=tmp_path)`` instead of hand-building the path under a
monkeypatched HERMES_HOME, and import ``_cached_redirect`` at module
level like the rest of the file. Same eight parametrized cases; four of
them are red on origin/main (ValueError x2, AttributeError, TypeError).
_cached_redirect wrapped urlparse() in try/except to skip malformed
DCR-supplied entries, but .port and .hostname are lazy properties that
raise ValueError on access — outside the try. A client.json containing
e.g. http://127.0.0.1:abc/callback made every OAuth attempt for that
server die in _configure_callback_port until the file was removed.
Same crash class one level up: a truthy non-dict client.json raised
AttributeError on .get, and a non-iterable redirect_uris raised
TypeError on the for. Resolve the lazy properties inside the try and
guard both container shapes so any malformed payload degrades to
(None, None) instead of propagating.
The hermes-bots plugin is compiled into the Desktop binary; bumping the
patch version follows the repo convention for every PR that changes the
bundled plugin, so a rebuilt shell is distinguishable from a stale one.
When host.agents() rejects (e.g. a registered WSL backend's localhost
forwarding went down), the roster query's catch path returned a
local-only snapshot, so every previously painted remote Bot row vanished
from Bot Mode on the next 5s refresh even though the connection stayed
registered (#98844).
Merge the last-known $lastRoster rows through the same
mergeMultiSourceRoster path used for connect-on-demand sources, marking
carried remote rows sourceReachable: false so they paint as offline
instead of disappearing. sources is intentionally omitted from the
fallback payload so the pane keeps its remembered source snapshot.
Empty-previous behavior is unchanged (local-only list stands).
Review noted the error-phase test only exercised the HTTP-503 path; the
branch where geocoding returns `{results: []}` was untested. Extend the
existing error test with a second stub so both failure routes are
asserted without adding a test case.
Settings -> Passwords & Logins dialed every vault RPC through
requestGatewayForProfile(scopeProfile): a bare profile name. gatewayForProfile
resolves a bare name equal to the primary profile onto the PRIMARY socket, so
with two connections both serving `default` the panel shown for "This device"
was answered by the other machine -- "Not detected" for a manager that is
installed locally, and vault.unlock/vault.add landing in the remote vault
(#94811, piskooooo's follow-up). The panel already keys itself by
(connectionId, profile); its RPCs now go through requestGatewayForAgent with
that same connectionId, which keeps a same-named non-primary connection on its
own pooled socket. A null connectionId (single-connection v1 route) still
takes the profile door, so single-source installs are unchanged.
Co-authored-by: Lorencato <lorencato23@gmail.com>
Keep one invariant per owner ladder from #113284: the dispatcher routes a
session whose events arrived on the LOCAL connection back to that connection
while the window's primary is another connection, and knownOwnerForSession
prefers the event-proven (connectionId, profile) over a bare row profile. The
mirror-topology, control and no-session-context variants restated behaviour
that the existing "exact owner rungs" describe already pins.
Two connections both exposing `default` (this device + a remote gateway)
collapse onto the PRIMARY connection whenever a session-scoped RPC's owner
resolves to a bare profile name: the profile door (requestForSessionProfile ->
requestGatewayForProfile -> gatewayForProfile) short-circuits any key equal to
the primary profile onto the primary socket, which for an ordinary session on
the non-primary connection is another machine that answers 4001 session not
found from the second prompt on.
An owner an inbound runtime event already proved (the exact connectionId +
profile of the socket that delivered the session's events) is a more specific
fact than a connection-blind row profile, so it now outranks it in both owner
ladders (resolveSessionRpcOwner and knownOwnerForSession). Durable EXACT routes
— tile, hint, connection-tagged row — still win, and a session with no proven
connection keeps the previous bare-profile answer, so single-source and legacy
topologies are unchanged.
The roster / bot-relay known-profile set / sudo-user profile resolver now go
through named_profile_is_live, so fixtures that mkdir a bare profiles/<name>
dir and expect it to be served or resolved need a real identity marker.
Each fixture touches profiles/<name>/config.yaml right after the mkdir, the
same way production creates a profile. No production code changed.
Files:
- tests/hermes_cli/test_web_server.py
(test_messaging_platforms_profile_scopes_gateway_reads,
test_custom_endpoint_save_scopes_to_the_requested_profile)
- tests/tools/test_bot_retry_policy.py (shared fixture, 4 tests)
- tests/tools/test_bot_turn_lock.py
(test_relay_deliver_returns_target_busy_error,
test_relay_deliver_serializes_then_succeeds)
- tests/tui_gateway/test_relay_live_author.py
(test_live_relay_stamps_the_sender_as_a_delivery_author)
Contract rewrites: none — no assertion pinned the old bare-dir-is-a-profile
contract; every change is fixture-only.
Sweep: every test file referencing _roster / bot_mode_probe / bot_relay /
list_profiles / _resolve_sudo_user_profile_env (52 files, 1687 tests) runs
green with these changes.
The Desktop roster hid @sessions/@logs infra dirs and tombstones after the
identity predicate landed, but the Bot Mode surface still enumerated
profiles/ with a bare is_dir(): the teammate roster injected into every
Bot's system prompt (tools/bot_mode_probe._roster) and the
bot_relay.deliver target check listed them as teammates (the probe even
offered `@.deleted`). Route both through named_profile_is_live; the relay's
known set is now derived from _roster so there is one enumerator.
Same class, same one-line predicate: `sudo hermes -p <bare-dir>` in
hermes_cli/main.py::_resolve_sudo_user_profile_env resolved a marker-less
dir the plain -p path refuses; change_watcher._pairing_sig and
active_sessions.release_orphaned_leases now skip non-profile dirs.
gateway/platforms/base.py::_profile_dirs is left lax on purpose — it feeds
the media DENY side (credential homes), which must over-cover.
Review follow-up on the identity predicate:
- create_profile: the rmtree guard is back to "tombstoned AND no identity
marker". A live profiles/<name>/ without a marker is invisible to
`profile list` but may still hold user files (skills/, memories/,
cron/jobs.json) — main refused it with FileExistsError; the widened guard
silently rmtree'd it. Now fails closed with an error naming the stray dir.
- named_profile_has_identity: `is_file()` follows symlinks, so a legacy
profile whose only marker is a dangling symlinked config.yaml/.env
(clone/migration leftover) vanished from list/serve/-p. A symlink is an
identity claim: accept `is_symlink()` too.
- Docs: faq.md still said "each profile is just a directory under
profiles/"; faq.md + user-guide/profiles.md + hermes_cli/AGENTS.md now
state the identity-file contract.
`named_profile_has_identity` now gates listing/serving/resolution, so a
`profiles/<name>` dir created with a bare mkdir is no longer a profile.
Production always publishes identity files (`create_profile` seeds
config.yaml/.env before the rename), so these fixtures were relying on a
contract that never existed in real installs. Each now writes a minimal
`config.yaml` marker next to the mkdir (config.yaml rather than SOUL.md
or auth.json so tests that own those files stay neutral). Where a file
had a shared fixture/helper it is fixed once.
Files:
tests/gateway/test_multiplex_lifecycle.py
tests/gateway/test_multiplex_session_db_profile_scope.py
tests/gateway/test_status_command.py
tests/hermes_cli/test_apply_profile_override.py
tests/hermes_cli/test_dashboard_admin_endpoints.py
tests/hermes_cli/test_gateway_multiplex_served_record.py
tests/hermes_cli/test_kanban_per_profile_cap.py
tests/hermes_cli/test_kanban_review_surfaces.py
tests/hermes_cli/test_pooled_served_profile_backend_unscoped.py
tests/hermes_cli/test_profiles.py
tests/hermes_cli/test_web_oauth_dispatch.py
tests/hermes_cli/test_web_profile_soul_writes.py
tests/hermes_cli/test_web_profiles_off_loop.py
tests/hermes_cli/test_webhook_cli.py
tests/tools/test_kanban_tools.py
tests/tools/test_local_env_blocklist.py
tests/tui_gateway/test_groups_methods.py
tests/tui_gateway/test_groups_replication_methods.py
tests/tui_gateway/test_hosted_room_settled_retry.py
Contract rewrite (old contract asserted a bare dir IS a profile):
tests/gateway/test_multiplex_session_db_profile_scope.py::
test_profile_home_is_not_memoized_before_the_profile_exists — after the
runtime mkdir the test now asserts `_profile_home_for_key` still returns
None (a bare dir is not a profile) and only resolves once the identity
marker exists; the not-memoized-miss invariant is kept.
A `profiles/<name>` directory that carries none of config.yaml / .env /
SOUL.md / profile.yaml / auth.json / state.db was still enumerated by
`_iter_named_profile_dirs`, so a marker-less shell (a cron-ticker ghost
left behind before tombstones existed, or a stray infrastructure dir)
was listed by `hermes profile list` and the Desktop Bots roster, served
and ticked by the multiplex cron scheduler, and — worst — handed the
default install's `.env` by `backfill_profile_envs` on the next
`hermes update`, which turned the ghost into a real profile with live
credentials (#95188 path D, #94823, #99392). `resolve_profile_env`
(`-p`/`--profile`) and `profile_exists` also accepted such a dir, so a
stale Desktop boot target could still spawn `serve --profile <ghost>`
whose ensure_hermes_home() rebuilt the whole tree.
Add `named_profile_has_identity` / `named_profile_is_live` beside the
tombstone helpers in hermes_constants and gate the one producer
(`_iter_named_profile_dirs`), `profile_exists`, `resolve_profile_env`
and the hosted-room local roster on it. `create_profile` may now replace
any identity-less shell (not only tombstoned ones) so users can take the
name back; dirs with identity files still fail closed.
Test fixtures that mkdir'd bare profile dirs now write a config.yaml
marker; two invariant tests pin the listing/serve/backfill/resolve
contract and the hosted-room sibling.
Based on the marker-gate idea in #99495.
Co-authored-by: Nathan Shan <nathanielcrush51@gmail.com>
Third json.dumps twin of the same class (multi-client fanout of one session):
route through transport.serialize_frame so every peer gets the -32603 frame
instead of the writer raising.
The -32603 error-frame guard was added only to WSTransport.write/write_async;
StdioTransport.write still ran json.dumps outside its try, so the same class
(pool worker dies silently, client waits forever) survived for the Ink TUI /
stdio bridge on any other unserializable payload. One module-level
transport.serialize_frame(obj, peer, log) now serves both transports; the WS
wrapper method is gone.
Durable form of the live repro for #92506: seeds a profile whose profile.yaml
carries an unquoted ISO timestamp under ui_meta and asserts the Bots roster
renders rows instead of spinning forever. Fails on origin/main (0 rows after
60 s, profiles.list never answers); passes with the serialization guard and
producer coercion in this PR (3 rows in 2.2 s).
NFKD tokenised the detached accent, so 'Café Résumé' became 'cafe-re-sume'
and Hangul decomposed to jamo ('한글' -> six tokens); the flat 64-char
slice could also split a u<hex> token into a different code point
('小助手' x7 -> '...-u52a'). Fold each letter through NFKD + combining-mark
strip only when that yields ASCII, emit tokens from NFC code points
otherwise, and cut an over-long id at the last token boundary without a
trailing '-'. 7-bit ASCII ids up to the cap are unchanged.
Group workspace / pane ids share the derivation so two fully-CJK group
names no longer collide on the empty ASCII slug; ASCII group ids are
byte-identical.
Drives the real Electron app: type 小助手, Create Bot enables, the roster
row reads 小助手 and the profile lands at profiles/u5c0f-u52a9-u624b with
the entered name as its title. Red on origin/main (button stays disabled).