A one-component gitlink cannot catch separator or component-splitting
mistakes in the scoped restore guard. Pin refusal for the exact gitlink,
its ancestors and descendants in every spelling (including Windows
separators on Windows), and unrelated recovery beside it.
Co-authored-by: FroRaut <79215537+Froraut@users.noreply.github.com>
The scoped restore guard resolved the requested path through the live
filesystem, so a captured file later replaced by a symlink into a nested
repository was refused even though the checkpoint holds its bytes.
Normalize the selection lexically; resolved-path confinement stays in
_validate_file_path. A recorded gitlink replaced by an unrelated symlink
is still refused.
Resolve selective restores against a disposable index before any checkpoint mutation, preserving unrelated-file recovery and refusing uncaptured gitlinks. Keep NUL-delimited filenames lossless, including surrogateescaped POSIX bytes. Add real-Git regression coverage and document the capture limitation.
Fixes#127073
Salvages #127077
Co-authored-by: Yuan Li <dskwelmcy@163.com>
Post-turn rehydrates can race the gateway commit: the refreshed page shares the anchor but ends before the just-settled reply. preserveLocalPendingTurnMessages only protected stream-id rows, so a reply that settled onto a non-stream id (interim id, appended assistant-<ts> bubble) was dropped after it already painted. Extend the #70209 only-copy rule to the single newest settled, non-interim, unpersisted assistant row when the refreshed page carries no identical text.
That row is skipped when the refreshed page brings its own assistant row for the same reply ordinal: a rotated or compacted page supersedes it, so keeping the cached copy would render the pre-compression answer next to its replacement.
Closes#121613
* fix(security): the first tirith scan no longer waits on its download
A process that never ran the CLI startup install (desktop serve, the
gateway, a newly routed profile) claimed the install on its first scan and
ran pm.ensure("tirith") inline. The first terminal call then waited on the
PM worker, the runtime toolchain and the tirith download before it ran.
The cold scan now starts the same background install as startup and scans
with the default path, so the call fails open at once like any scan during
a startup download.
* fix(security): a scan during the tirith download never trips the breaker
The previous commit let a cold scan run while the background install was
still downloading. That scan spawned the bare default "tirith", got
FileNotFoundError and counted it as a crash. Three terminal calls during
the download opened the circuit breaker, and every scan then returned
allow unscanned for 300 s, even after the download finished. With
tirith_fail_open: false the first three commands were blocked and the
breaker then let commands run unscanned.
A scan that finds the default binary missing while this home's install
thread is alive no longer spawns or counts a crash. Fail-open allows the
command at once ("tirith installing"). Fail-closed waits for the install,
as the inline install did before, and then scans with the real binary.
So only the fail-open case skips the scan during a download; the earlier
commit's "fails open at once" holds only for fail_open.
The PM consumer test now joins the background install before it reads
the recorded download requests.
The layout-tree renderer (63a9bde77) introduced PaneTabLabel with a
text-[9px] override that contradicts the parent PaneTab's baseline of
text-[0.6875rem] (11px). The old ChatHeader/TitleMenuTrigger used 0.75rem
(12px), making 9px a clear regression.
Drop the explicit size class from the label span so it inherits 0.6875rem
from the parent — consistent typographic hierarchy, still a meaningful
improvement over 9px.
An empty bridge answer produced one string that blamed a closed tab
('The action timed out, or no GUI window answered. Open a page with
open_preview first.') even when the pane was open on an app older than
this backend — two cases with different remedies. drive_preview /
annotate_preview now lead with 'no preview tab is open' and name the
older-app possibility; read_preview / read_terminal name both the
nothing-open and bridge-timeout cases and the update-the-app remedy.
Refs #94272
Refs #95104
Refs #119333
Co-authored-by: lesseradmin <veeman961@gmail.com>
drive_preview / annotate_preview blocked the full 45s deadline on every
call when nothing could answer preview.act — an app build older than the
tools (the renderer ships in the desktop bundle, the backend offers the
tool), or a session no attached window hosts — and the tool then blamed a
closed tab. Port PR 94300's fail-fast ladder onto the JSON-RPC
server-requests path, mirroring _tour_request: first action per session
gets a 10s probe deadline; unanswered → bridge marked unavailable for
that session with an actionable update-the-app error, and later actions
short-circuit instead of stacking waits. Unlike tour support, a preview
pane can open later in the session, so an unanswered probe only condemns
the bridge for a cooldown window, with a single in-flight reprobe per
session; concurrent callers fail fast. Once answered, the full deadline
is back and a transient miss never disables the bridge. A window-owned
request that every window declines settles as a non-empty answer
(main e33f1a0faf): the refusal passes through unchanged.
Fixes#94272
Co-authored-by: lesseradmin <veeman961@gmail.com>
A reconnect can replay a pre-clarify session.info snapshot with
running=false while the server bridge is still parked on the open
clarify request — the same wipe class as the message.complete/error
clears the previous commit guards, just arriving through the
session-info recovery edge instead of a turn signal.
Fixes the sibling named in #83319 review.
In the voice conversation loop, the normal (silence-detected) submit path set
`awaitingSpokenResponseRef` and submitted without consuming the reply that
had just finished playing. The turn-drive effect then treated that reply as
unspoken, opened speech for it, fell back to whole-text playback and spoke
the OLD answer while the model was still thinking — until the new reply
arrived and barged in. The barge-in submit path already calls
`consumePendingResponse()` before `onSubmit`; give the normal path the same
treatment. A submitted turn makes any existing reply stale by definition.
Adds a regression test that fails on main (`expected [ 'submit' ] to deeply
equal [ 'consume', 'submit' ]`) and passes with the fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fold #107808 into the fan-out architecture: on webglcontextlost the addon
is disposed but the viewport could stay black while the buffer is intact —
now a fit + full-row repaint is forced on both the user terminal and the
agent mirror, and every remaining clearTextureAtlas path (fan-out clear,
live font change) is guarded so a half-dead GL context cannot abort the
repaint path. Also rebuild every registered terminal when the main process
broadcasts powerMonitor resume/unlock-screen: macOS can evict glyph
textures on wake without firing webglcontextlost (#76102).
GottZ triage (PR #76463) correctly identified that the sequential
clear+refresh per terminal could let a later clear invalidate an earlier
terminal's rebuilt glyph model. Split into two phases: all atlas clears
run first, then all terminal refreshes, guaranteeing every terminal
rebuilds against a clean shared atlas.
Added behavior-oriented regression test proving clear-before-refresh
ordering across multiple terminals sharing one atlas.
xterm WebGL terminals that share one glyph atlas (same font/theme/DPR)
keep per-terminal render models over a SHARED texture. clearTextureAtlas()
on one terminal wipes the shared texture pages and xterm 0.19 never tells
the sibling renderers to rebuild their models, so their stale cells draw
whatever glyphs land in the freed atlas rows - garbled text until a
resize forces a full refresh (#76102). Upstream fixed this in
xtermjs/xterm.js#6055; we stay pinned on 0.19.0.
Every atlas-mutating call site (theme switch, tab activation) now fans
out through redrawAllTerminals(), and each xterm registers its own
redraw+atlas-rebuild on mount, unregistering on dispose. Covered by a
unit test asserting the registry invariant.
Main claims Ctrl/Cmd+R on every window (installPreviewShortcut's
before-input-event + preventDefault) so Chromium's default host
reload cannot fire, and sends preview-nav reload to the renderer.
The renderer's routing only knew browser panes, so a terminal-
focused chord fell through to window.location.reload() — and the
preventDefault meant xterm never saw the keystroke either, killing
readline reverse-i-search (#96482).
Route it the way ⌘W already routes: main keeps claiming blindly,
and the renderer adds the missing terminal rung. The terminal
registry keyed its handles by the inner xterm host while both
resolvers walk up to the [data-terminal] scope, so no handle was
ever found in production (only tests registered on the scope div
itself) — the registry now keys by that scope. The user terminal's
handle re-delivers the ^R byte to its PTY; the read-only agent
mirror swallows the chord (no PTY, and focus there must still stop
the app-level reload). The app-reload escape hatch elsewhere in
the app is unchanged.
The suite's tests came from #98780 written against an invented holder shape
(handles/node); the engine resolves through holder.book/holder.url after an
elements inventory. Address elements the way the agent does (firstRef), stub
document.execCommand (undefined in jsdom), and add the execCommand-fails
fallback case.
Co-authored-by: salch-cred <salch-cred@users.noreply.github.com>
drive_preview type/press/submit silently failed on React controlled inputs,
contenteditable rich-text editors, and Enter-to-submit forms — the tool
reported success but the text never landed and no DOM events fired (#98048).
Three fixes in the injected type handler:
1. contenteditable: use document.execCommand('insertText', false, text)
instead of el.textContent = text. execCommand fires beforeinput/
insertText composition events that Lexical/ProseMirror/Draft editors
(DeepSeek, ChatZhipu, Kimi) subscribe to. Fallback to textContent
+ InputEvent for sandboxed pages where execCommand fails.
2. INPUT/TEXTAREA: replace new Event('input') with new InputEvent('input',
{data: text, inputType: 'insertText', bubbles: true}). React 16+
controlled-input trackers compare lastTrackedValue against the event's
.data property — a plain Event has no .data, so the value-change is
deduplicated.
3. submit: add keyCode: 13 and which: 13 to the KeyboardEvent init dict.
Modern React forms check both event.key AND event.keyCode for Enter;
constructed KeyboardEvents read keyCode as 0 (read-only), so the
event was silently swallowed.
Regression tests added in act-in-page.test.ts:
- contenteditable execCommand path
- InputEvent data property set correctly
- submit KeyboardEvent keyCode/which === 13
Fixes#98048
The cold-start navigation restore in use-desktop-integrations only excluded HUD
and browser windows, so Ctrl+Shift+N's peer window (?peer=1) restored the
profile's remembered last-session route and opened the same chat as Window 1.
Add isPeerInstanceWindow() to the early-return gate, mirroring the source
restoration gate in connections.ts.
Fixes#74948
syncAgentTerminalSnapshot checked the stale lastSnapshots prefix-delta before
the authoritative backlog (body) prefix-delta. Live agent.terminal.output
chunks advance the backlog but never lastSnapshots, so once any chunk has
streamed since the last snapshot, body is strictly longer than previous and
both are prefixes of a catch-up process.list snapshot. The fence-first branch
then emitted output.slice(previous.length), re-appending bytes the chunks had
already written and duplicating them on screen and in the replay backlog.
body is the authoritative on-screen state and is always >= previous, so its
delta is the correct, smaller one; check it first. Regression case added to
agent-terminal-stream.bound.test.ts (red without the swap).
Co-authored-by: briandevans <252620095+briandevans@users.noreply.github.com>
The snapshot reset path re-set the backlog in its original LRU slot and never
ran evictColdProcs, so a just-synced process could be the next one evicted and
resets alone could hold past the character ceiling. The delta paths now update
the lastSnapshots fence before the write, so that write's eviction pass counts
it and never re-creates a fence for a process it just forgot.
Every `terminal(background=true)` mints a new process id, and the agent-terminal
stream module never forgot one. `writers` was unregistered when a tab unmounted,
but `backlog`, `lastSnapshots`, `commandHeaders` and `seededCommands` are keyed
by process id and had no eviction at all, so every command an agent ever ran kept
its output until the window closed.
Replaying each process back through `registerAgentTerminalWriter` measures the
retention directly, without depending on GC: 256K chars held per finished process
(~512 KB, JS strings being UTF-16), growing linearly — 100 processes 25.6M chars,
200 51.2M, 400 102.4M (~195 MB). With this change it is flat at 1.79M chars
(~3.4 MB) for every one of those counts.
Bounded the way the other renderer caches already are (`lib/lru-cache`,
`chat/shiki-highlight-cache`): an entry ceiling and a total-character ceiling,
because one busy process can hold as much as twenty quiet ones. `backlog`
insertion order is the LRU clock, so a chunk write re-inserts to move its process
to the tail — a plain re-set keeps the original slot and would let the
oldest-first sweep evict a process that is still streaming.
A process whose terminal is MOUNTED is never evicted: that is on screen, not a
cache. Everything dropped is regenerable — the next `syncAgentTerminalSnapshot`
re-seeds the tab from the registry's rolling tail. The four maps are evicted
together because `lastSnapshots` is the delta fence for `backlog`; dropping one
without the other makes the next snapshot diff against a tail that is gone and
paint only the delta.
Each guarantee is pinned by a regression that fails when that guarantee alone is
reverted: no eviction, evicting a mounted process, losing the recency refresh,
and keeping a stale delta fence.
* fix(tools): inline agent tools get the shared argument coercion
Inline agent tools (todo_list, clarify, memory, session_search, and the
rest of INLINE_TOOL_EXECUTORS) never went through handle_function_call.
They did not get coerce_tool_args, so a JSON-encoded string for an array
parameter failed unless the tool parsed it by hand.
Every entry of INLINE_TOOL_EXECUTORS now runs coerce_tool_args first.
The sequential path and the concurrent invoke_tool path both read that
table, so one wrapper covers both.
todo_tool no longer parses a JSON string by hand. The todo_list schema
declares todos as an array, so coercion covers it. The existing test now
calls the dispatch table, not todo_tool directly.
* chore: retrigger CI (zero-job dispatch failure, auto-heal)
electron-builder resolves every piece of packaging artwork from the workspace
— the app icon, the icon.ico extraResource, the MSIX logo set before-build
stages into build/appx, and the exe identity stamp — so a flavored canary or
commit build copies its rendered icon set over apps/desktop/assets before
packaging.
Those files stopped being gitignored in 4b7229d612, which turned a write that
was invisible into 15 dirty paths. The source custody check then refuses the
checkout twice: inside the same build, at the check that used to sit after the
copy, and on every later build from the first check on a now permanently dirty
tree.
Hold the render at the workspace path for the packaging window only and
restore the admitted files afterwards, whether packaging finished or failed.
The final custody check now asserts the build handed the checkout back.
#127828 changed CARD_TOOL_NAMES in apps/desktop/src/lib/tool-render-class.ts
from `new Set([...])` to `[...] as const`. The pytest that pins that list
against the gateway's _TOOL_LIFECYCLE_UI_TOOLS located it with a regex that
only matched `new Set([`, so it has failed on main since with
"CARD_TOOL_NAMES not found in tool-render-class.ts". The gateway set still
covers every desktop card name; only the test's parser was stale.
The PR merged green because it touched only apps/, which skips the Python
lane, so the one test that reads that file never ran on it.
- The parser accepts both declaration forms and fails on an empty list
instead of passing a vacuous subset check.
- tool-render-class.ts joins _PY_RELEVANT_CONTRACT_FILES so an edit to it
runs the Python lane, with a classifier case for the entry.
saved_toolset_resolver built the hermes-<platform> bundle set eagerly, so
every migrate_config run (hermes update, config migrate, doctor) and every
config check paid a full bundled+user manifest scan even with no saved
platform_toolsets. The scan is now cached and lazy, like the plugin lookup.
The stale-toolset test stubs the plugin lookups so host-installed plugins
cannot change its verdict.
Follow-up to the config check diagnostics from #127392.
- Saved platform_toolsets are validated by one resolver,
toolset_validation.saved_toolset_resolver, shared with
`hermes config migrate`. The two commands previously disagreed: migrate
flagged MCP server names and plugin-platform bundles (even suggesting
'hermes-teams' for 'hermes-teams'), while config check accepted them.
- A configured MCP server is known even when disabled (enabled: false);
it is inactive, not a typo.
- known_plugin_toolsets is no longer treated as proof that a toolset
exists. It records what a platform was offered at save time, so it hid
toolsets of uninstalled plugins, the stale case this check is for.
- Plugin-provided names are consulted only for names nothing else
explains, so a clean config no longer runs plugin discovery.
- A malformed mcp_servers (a list) no longer crashes the command.
- The top-level `toolsets` check is dropped: the CLI resolves its
toolsets from --toolsets or platform_toolsets, and reads the top-level
key only for the legacy kanban opt-in.
- Disabled platforms are read with the runtime's plugins.disabled reader,
so a non-list value (which the runtime ignores) is no longer reported
as disabling a platform. Required credentials come from
_platform_manifest_env_entries, which now takes optional=False.
- Tests use a synthetic platform manifest instead of the real Telegram
one, and assert config check and migrate report the same names.
The tool-group slot folded consecutive manage_connections calls into one
connection card only when guided onboarding was enabled, while MCP targets
were folded for every user. Connector calls now fold the same way for
everyone, and the onboarding check is gone.
stable-release.yml called desktop-bundled-release.yml, docker.yml and
termux-verify.yml without `secrets: inherit`, so every job inside them that
declares `environment: release-signing` (or container-publish) attached its
deployment and read the environment's `vars`, but resolved `secrets.*` to the
empty string. The signed-candidate legs died at "Archive every pinned input"
with `missing env CLOUDFLARE_R2_ACCESS_KEY_ID`; the docker publish legs and
the termux packaging checks would have failed the same way.
A called workflow receives no secrets by default, and a job-level
`environment:` inside it is required but not sufficient: a caller job that
`uses:` a reusable workflow cannot declare `environment:` at all, so
`secrets: inherit` is the only mechanism that can carry them
(actions/runner#1490).
`secrets: inherit` is added only where the callee reads a protected
environment's secrets. ci.yaml, nix, pm-bundle, windows-venv-e2e and the
install-e2e chain reference none, and ci.yaml must stay secret-free by its own
header (it runs PR-controlled code).
* refactor(desktop): route tool cards through a table keyed on the card-tool names
The name-keyed if ladder in ChainToolFallback becomes TOOL_CARDS, typed
Record<CardToolName, FC>. CardToolName comes from the same list isCardTool
reads, so a card added to one list and not the other fails the build.
Behaviour is unchanged.
* refactor(desktop): dispatch desktop bridge events through a handler table
The six-branch event.type ladder becomes DESKTOP_BRIDGE_HANDLERS, one row
per event with its body and comments unchanged. Behaviour is unchanged.
* chore: retrigger CI (zero-job dispatch failure, auto-heal)
* refactor(desktop): split clarify-tool.tsx into a clarify/ folder
Pure moves, no behaviour change. Parsing, the question-card core
(shell, choice rows, question block), the delivery watchdog, and each
pending/settled card get their own file so the core can be reused.
* feat(clarify): one questions[] shape with per-question status and one outcome
The clarify tool now takes only questions=[{question, choices?, multi_select?}].
A wrong shape is a tool error that names the right one, so a model that
sends the old top-level question/choices corrects itself on the next call.
Every result has the same shape on every surface: each response carries
status (answered, skipped, unanswered) with user_response null unless
answered, and the result carries one outcome (submitted, cancelled,
timed_out, undelivered) plus an optional surface-written notice. The
timeout and cancel sentences that used to pose as the user's answer are
gone, and the compressor reads status instead of matching their prefixes.
The callback contract is callback(questions) -> {answers, outcome, notice?}
(None = skipped, missing qid = unanswered). tui_gateway settles a batch the
same way for the last lock, a cancel, an interrupt and the deadline, and
clarify.lock keeps a null answer as a skip. A multi-select answer that is
not a JSON array counts as one typed ("Other") answer. The tool-row preview
reads the first question.
* feat(clarify): every surface asks through the one question card
CLI: the batch panel is the only panel; Enter on an empty field skips a
question, Ctrl+C cancels and keeps the locked answers, the deadline returns
timed_out. -q and -z return undelivered with a no-user notice.
Ink TUI: the single-question prompt is gone; the card supports multi-select
(Space or a digit toggles, Enter locks, typed Other text joins the picks),
an empty submit skips, Esc cancels, and "Batch" names are dropped.
Desktop: the single-question card is deleted and its keyboard handling moved
into the one card (arrows, letters and digits, auto-advance, Enter picks then
confirms). Confirm enables at one answer; blank questions lock null; Skip and
a composer message cancel; the settled card shows No answer for unanswered
questions. The bots room card follows the same contract.
Messaging: one card per question with a "Reply skip to skip" line in every
locale; timeouts and delivery failures map to timed_out and undelivered.
* fix(clarify): cancelled for a released messaging card, labels win over the skip word
A prose reply to a messaging choice card, /new and session end released
the wait with "", which read as timed_out. They now resolve with a
CANCELLED marker and the tool gets outcome cancelled.
A typed reply that matches a choice label ("Skip") now resolves to that
choice; the skip word applies only when no choice matches.
The bots room card sends the picked labels as they are; the tool already
strips the recommendation label. Unused OUTCOMES and a new docstring and
comment are removed.
* fix(tui): re-editing a multi-select answer keeps its picks
The Ink card stored a multi-select answer as display text ("A, B"), so
revisiting the question put the whole string into Other and re-locked it
as one item. The answers map now keeps the raw JSON array, the card
restores the picks on revisit, and only the display lines format it.
Comments that earlier edits reworded are restored to their original
words, minus the phrases the change made wrong. The compute-host clarify
lock accepts None for a skipped question.
* test(clarify): align three checks with the one-answer confirm and the regenerated keys
The desktop Cmd+Enter test now expects a send with one answer (the blank
question locks null), the compressor test expects the single-answer summary
the code gives, and locales/_keys.desktop.json is regenerated for the
removed and added clarify strings.
* test(tui): the one-question card header is singular
hermes debug share, gateway /debug, the dashboard share and the desktop
diagnostics upload sent credential query parameters (?token=, ?api_key=,
?access_token=) and user:pass@ userinfo from logged URLs unchanged, to
the paste service or to Nous storage. Log lines keep these values
because log-time redaction leaves web URLs alone, and Firecrawl, for
example, logs the full URL it scrapes at INFO.
The cause is hermes_cli/debug.py:_redact_log_text, the one redactor on
every share path. It called redact_sensitive_text(force=True) without
redact_url_credentials=True, so the strict URL pass that compaction,
memory context, cron errors and the kanban notifier already use never
ran on the upload copy.
Pass redact_url_credentials=True there. Parameter names, hosts and
paths stay readable; values of credential-named parameters and URL
userinfo become ***.
(cherry picked from commit f522171b836bfd842354f5a34c8c2bdd46b39467)
Review follow-ups on the live strict-identity tests:
- keep the holder as the psutil.Process captured at spawn, so a PID
recycled before class teardown is refused instead of killed;
- suppress psutil.Error (AccessDenied on an exiting Windows process),
not only NoSuchProcess, so one failed kill cannot leave the rest of
the tree alive and wedge communicate();
- bound the handshake at 20s (a normal start takes ~1s);
- garble the lock in binary mode like the restore does, and say why
overwriting without truncating is safe;
- drop spawns_gateway_lookalike from the non-gateway holder test (its
argv carries no `gateway run`) and note that the class holder spawns
before the per-test guard is armed.
The live lock-holder tests could hang or leak:
- the handshake readline had no deadline and stderr went to DEVNULL,
so a child stuck on import blocked the worker with no diagnostics;
- the holder slept 120s, outliving a killed pytest while holding
gateway.lock;
- `assert acquire_gateway_runtime_lock()` vanishes under -O;
- kill() hit only the Windows venv launcher, never the self-reported
interpreter, and pipes were never closed (ResourceWarning per test);
- the discovery test relied on list_profiles() resolving to the temp
HERMES_HOME and compared unresolved paths (/var vs /private/var on
macOS, 8.3 names on Windows).
The holder now blocks on stdin, reports failure via exit + captured
stderr, and teardown kills the launcher tree plus the reported PID and
reaps via communicate(). The four class tests share one class-scoped
holder with per-test restore of gateway.pid / gateway.lock (5 spawns ->
2), and discovery pins the profile roots like the named-pipe live test.
Also read gateway.pid with utf-8-sig (windows-footguns lane).
hermes update on Windows maps running gateways to profiles through
get_running_pid_identity_strict, which raised "active gateway lock has no
PID metadata" whenever the lock was held but gateway.pid was missing. The
whole update aborted before touching anything.
That state is reachable on Windows: status cleanup unlinks gateway.pid and
gateway.lock together, and the lock unlink fails silently while the live
gateway holds the file open, so only gateway.pid disappears (#96582).
The gateway writes the same identity record into the lock when it acquires
it. The strict probe now takes the PID from the lock record, requires a
PID file that exists to agree with it, and runs every existing liveness,
start-time and command-line check against what is present. A garbled lock,
a disagreeing PID file, or a lock holder that is not a gateway still aborts.
(cherry picked from commit 1d6bfbbf94605fcc4c84cb52636887e1e8ae36c1)
A supervised start that also relaunches onto the store interpreter printed the
notice in both processes; it now prints only after the relaunch decision.
The _supervised_child docstring claimed the neighbouring truthy set, which it
does not match, and prepare_launch said a supervised child "never" retries the
tail although stale dependencies still do; both reworded. The supervised test
also pins a clean stdout and a single notice.