Commit Graph

36028 Commits

Author SHA1 Message Date
teknium1
1aa296fbcd fix(desktop): keep the free titlebar band draggable when tabs drop below the controls
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.
2026-09-16 16:54:00 -07:00
KoNit-K
5aa1e353ef fix(desktop): reserve titlebar drag region 2026-09-16 16:54:00 -07:00
teknium1
713a9377cc fix: only call an auth DB backup "locked" when it made no progress
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.
2026-09-16 16:53:40 -07:00
teknium1
864e9dccfc fix(browser): real-profile snapshot names the locked auth databases and the reason
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.
2026-09-16 16:53:40 -07:00
teknium1
9cb1a65978 fix(codex): drop the stale 'watchdog tripped' retirement cause and re-scope the reset test
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.
2026-09-16 16:53:20 -07:00
teknium1
2992ed4b3a fix(codex): healthy app-server sessions survive long post-tool reasoning silence
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>
2026-09-16 16:53:20 -07:00
teknium1
d809057a12 fix(gateway): guard Windows post-drain taskkill with the pre-drain start time
_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)).
2026-09-16 16:52:59 -07:00
teknium1
eb0903176b docs: Windows gateway stop is marker+drain, not TerminateProcess
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.
2026-09-16 16:52:59 -07:00
KoNit-K
fd1582f63b fix(gateway): drain Windows profile stop before kill 2026-09-16 16:52:59 -07:00
soyami
1f091cb4e4 fix(desktop): disable WSL path bridge for registry SSH/remote/cloud profiles
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.
2026-09-16 16:52:46 -07:00
teknium1
c45ca8855d test(gateway): two real-process invariants for argv-free shutdown forensics
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.
2026-09-16 16:52:25 -07:00
joaomarcos
1d291102d6 fix(gateway): persist shutdown forensics without process argv
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
2026-09-16 16:52:25 -07:00
bblicke1
3d9ddb725e fix(desktop): hovering an SSH bot never dials the tunnel or spawns a remote backend
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
2026-09-16 16:52:09 -07:00
teknium1
3367adfa21 test(mcp): fold the SSE boundary regressions into two invariant tests
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.
2026-09-16 16:52:05 -07:00
beardthelion
b1dfaf40b0 fix(mcp): count SSE events across boundaries split between chunks
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.
2026-09-16 16:52:05 -07:00
teknium1
cf5287b040 fix(kanban): remove worktree when the process cwd is already gone
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.
2026-09-16 16:51:45 -07:00
KoNit-K
76901e9abb fix(kanban): release worker cwd before worktree cleanup 2026-09-16 16:51:45 -07:00
Theta Sigma
5eb062735e feat(catalog): add hermes-t3-control v1.5.0 2026-09-16 16:51:39 -07:00
teknium1
c7e469be13 docs(desktop): document the SSH Hermes path field in the connections editor
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.
2026-09-16 16:51:26 -07:00
Yags
3093e98506 fix(desktop): expose custom Hermes path for SSH connections
Let registered SSH connections set or clear remoteHermesPath using the existing localized field and backend contract.
2026-09-16 16:51:26 -07:00
teknium1
fad77444e0 test(agent): eviction tests assert the entry is dropped, not that the client is closed
_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().
2026-09-16 16:51:06 -07:00
teknium1
ff1f5d425a fix(agent): scope auxiliary client eviction to the calling profile and stop closing in-flight clients
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
2026-09-16 16:51:06 -07:00
KoNit-K
10684127cd fix(agent): evict rotated auxiliary provider clients 2026-09-16 16:51:06 -07:00
teknium1
97066a0eb5 fix(gateway): Weixin media sends honour iLink ret and re-send without a stale token
`_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
2026-09-16 16:50:46 -07:00
teknium1
2995063bcd fix(gateway): Weixin tokenless re-send no longer consumes the retry budget
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
2026-09-16 16:50:46 -07:00
KoNit-K
46ab37ce35 fix(gateway): retry Weixin prepare failures without token 2026-09-16 16:50:46 -07:00
teknium1
e166791f9f fix(mcp): treat a non-object client.json as corrupt instead of crashing auth init
_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
2026-09-16 16:50:27 -07:00
teknium1
d4f7f90c8f test(mcp): seed malformed client.json through HermesTokenStorage in redirect tests
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).
2026-09-16 16:50:27 -07:00
beardthelion
f8f89b20d2 fix(mcp): skip malformed cached OAuth redirect_uris instead of crashing
_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.
2026-09-16 16:50:27 -07:00
NanPan
6950959fa5 fix(lsp): attach reused multi-root client outside state lock 2026-09-16 16:50:07 -07:00
NanPan
0d941c53c8 test(lsp): reproduce multi-root attach under state lock 2026-09-16 16:50:07 -07:00
teknium1
bcb1520f10 chore(desktop): bump bundled desktop version for the Bot Mode roster fix
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.
2026-09-16 16:49:55 -07:00
liuhao1024
72dd05cc31 fix(desktop): keep last-known remote roster rows when the union probe fails
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).
2026-09-16 16:49:55 -07:00
teknium1
04ded3b145 fix: cover the geocoding 'location not found' branch in the weather test
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.
2026-09-16 16:49:48 -07:00
Vitor Cepeda Lopes
b5821a578d fix(tui): move weather widget to Open-Meteo 2026-09-16 16:49:48 -07:00
teknium1
924e470030 fix(desktop): Passwords & Logins asks the connection it shows, not the primary
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>
2026-09-16 16:49:20 -07:00
teknium1
891a2eb573 test(desktop): trim the same-named-profile routing coverage to two invariants
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.
2026-09-16 16:49:20 -07:00
finn763
02b6802c8d fix(desktop): session RPCs route by the owning connection, not a bare profile name
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.
2026-09-16 16:49:20 -07:00
teknium1
693430d010 test: give bare profile-dir fixtures an identity marker (#113377 red-fix, pass 2)
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.
2026-09-16 16:48:42 -07:00
teknium1
a49865e263 test(desktop): e2e — Bots roster never lists profiles/ infra dirs or tombstones (#99392) 2026-09-16 16:48:42 -07:00
teknium1
85d0288737 fix(bot-mode): teammate roster and every profiles/ enumerator use the profile identity predicate (#99392)
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.
2026-09-16 16:48:42 -07:00
teknium1
30bde46510 fix(profiles): profile create never deletes a live marker-less dir; dangling symlink markers count as identity
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.
2026-09-16 16:48:42 -07:00
teknium1
0a62d4564e test: give bare profile-dir fixtures an identity marker (#113377 red-fix)
`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.
2026-09-16 16:48:42 -07:00
teknium1
19e984ae2a fix(profiles): a profiles/ dir without an identity file is not a profile
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>
2026-09-16 16:48:42 -07:00
teknium1
532608db63 fix(gateway): fanout transport uses the shared serialization guard too
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.
2026-09-16 16:48:05 -07:00
teknium1
1ccd1b3e41 fix(gateway): stdio transport shares the JSON-RPC serialization guard with WS (#92506 follow-up)
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.
2026-09-16 16:48:05 -07:00
teknium1
9f959d68ae test(desktop): e2e — Bots roster loads with timestamp metadata in profile.yaml
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).
2026-09-16 16:48:05 -07:00
fangliquanflq
87c5658cc8 fix(gateway): surface JSON-RPC serialization failures 2026-09-16 16:48:05 -07:00
teknium1
1b722403b9 fix(desktop): fold accented Latin, keep NFC tokens and cut CJK profile ids on a token boundary
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.
2026-09-16 16:47:24 -07:00
teknium1
51fc09ddd1 test(desktop): live e2e for CJK bot names in the New Bot dialog
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).
2026-09-16 16:47:24 -07:00