Compare commits

..
Author SHA1 Message Date
teknium1 ea718a5d69 fix(windows): verify rebuilt Hermes.exe integrity before shipping it as an update (#69179)
The desktop self-update chain (Desktop -> hermes-setup --update ->
hermes update -> hermes desktop --build-only -> relaunch) rebuilds
Hermes.exe on the user's machine and declared success on bare file
EXISTENCE. A truncated PE (corrupt cached Electron zip / interrupted
extraction or rcedit rewrite / full disk) or a wrong-architecture
unpacked tree therefore shipped as the 'updated' app, which Windows
refuses to load with 'This app can't run on your computer'
(此应用无法在你的电脑上运行) — and the previous working build had
already been wiped by before-pack.mjs, leaving nothing to fall back to.

Fix, in three parts:

- hermes_cli/main.py: post-build integrity gate on Windows
  (_ensure_desktop_exe_launchable). Parses the PE header of the freshly
  built Hermes.exe — MZ/PE magic, section-table completeness vs file
  size (catches truncation), and COFF machine vs the host arch (catches
  arm64/x64 mixups). On failure it purges the (likely corrupt) cached
  Electron zip, invalidates the content-hash build stamp so the
  updater's retry-once genuinely re-downloads and rebuilds, restores
  the previous build from the .bak tree when one exists (keeping the
  corrupt tree as .corrupt for diagnostics), tells the user the update
  was aborted and their old version kept, and exits nonzero.
  _desktop_packaged_executable also now prefers a host-loadable PE over
  pure newest-mtime when multiple win-*-unpacked trees coexist.

- apps/desktop/scripts/before-pack.mjs: on win32, the previous unpacked
  tree is preserved as <appOutDir>.bak (only when it holds the product
  exe — partial/corrupt trees still get the plain wipe) instead of
  being destroyed, providing the rollback material for the gate above.
  Non-Windows behavior is unchanged.

- Behavior-contract tests: tests/hermes_cli/test_desktop_exe_integrity.py
  (23 tests — synthetic PE fixtures for truncation/non-PE/arch-mismatch,
  rollback semantics, and the build-only exit contract) and 6 new vitest
  cases in before-pack.test.mjs for the .bak preservation rules.

Progresses #69179
2026-07-24 18:11:30 -07:00
brooklyn! a979ca2a67 Merge pull request #71109 from NousResearch/bb/desktop-display-metadata
CI / Detect affected areas (push) Waiting to run
CI / Python tests (push) Blocked by required conditions
CI / Python lints (push) Blocked by required conditions
CI / JS & TS checks (push) Blocked by required conditions
CI / Desktop E2E (push) Blocked by required conditions
CI / Docs Site (push) Blocked by required conditions
CI / Deny unrelated histories (push) Blocked by required conditions
CI / Check contributors (push) Blocked by required conditions
CI / Check uv.lock (push) Blocked by required conditions
CI / package-lock.json diff (push) Blocked by required conditions
CI / Lint Docker scripts (push) Blocked by required conditions
CI / Build&Test Docker image (push) Blocked by required conditions
CI / Supply-chain scan (push) Blocked by required conditions
CI / Review label gate (push) Blocked by required conditions
CI / OSV scan (push) Waiting to run
CI / CI review comment (live) (push) Blocked by required conditions
CI / All required checks pass (push) Blocked by required conditions
CI / CI timing report (push) Blocked by required conditions
Docker Build, Test, and Publish / build (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest) (push) Waiting to run
Docker Build, Test, and Publish / build (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Build, Test, and Publish / publish (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest) (push) Blocked by required conditions
Docker Build, Test, and Publish / publish (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
Docker Build, Test, and Publish / merge (push) Blocked by required conditions
fix(desktop): session resume fails on undecoded display_metadata
2026-07-24 20:07:48 -05:00
brooklyn! 476f009ff6 Merge pull request #71104 from NousResearch/bb/desktop-boot-readiness
fix(desktop): boot readiness probes a lightweight /api/health
2026-07-24 20:02:17 -05:00
0c40f00ad1 fix(desktop): tolerate unparsed display_metadata from an older backend
The desktop and the Hermes backend it talks to version independently — a
remote VM running an older build still serves display_metadata as JSON text.
Indexing into that string with `in` threw and failed the whole resume, so
narrow the type to admit a string and parse it before reading task_count.
Falling back to the generic label keeps a delegation event renderable even
when the metadata is unusable.

Co-authored-by: xxxigm <xxxigm@users.noreply.github.com>
Co-authored-by: Studio729 <Studio729@users.noreply.github.com>
2026-07-24 19:53:32 -05:00
19dc35cf57 fix(state): stop double-encoding display_metadata on write
export_session() reads through get_messages(), so before the read fix an
already-serialized string went straight back into _insert_message_rows() and
got re-dumped — an export/import round trip permanently corrupted the row.
Guard the three write paths the same way tool_calls already is: parse a
string argument before storing it, and drop metadata that isn't an object
rather than persisting something no reader can use.

Co-authored-by: xxxigm <xxxigm@users.noreply.github.com>
Co-authored-by: aml1973 <aml1973@users.noreply.github.com>
2026-07-24 19:53:25 -05:00
3399bf28a5 fix(state): decode display_metadata at every message read path
get_messages(), get_messages_around() and get_anchored_view() returned the
raw display_metadata column instead of the dict every caller expects. The
desktop paints a resumed transcript from the REST prefetch, which reads
through get_messages(), so any session holding an async_delegation_complete
event failed resume with "Cannot use 'in' operator to search for
'task_count'" — on every such session, not just corrupted ones.

Route all four read paths through one shared codec that also unwraps rows
carrying a second JSON layer, so sessions already broken on disk recover on
read rather than needing a migration.

Co-authored-by: Studio729 <Studio729@users.noreply.github.com>
Co-authored-by: aml1973 <aml1973@users.noreply.github.com>
Co-authored-by: xxxigm <xxxigm@users.noreply.github.com>
2026-07-24 19:52:41 -05:00
23cb26c2e3 fix(desktop): probe /api/health for boot readiness, and survive a stalled loop
Desktop boot polls /api/status, so readiness waits on gateway config and a
cold plugin import tree. On Windows that regularly outlives the probe and
Desktop kills a backend that is already listening, respawns it, and re-pays
the same import cost — the reported crash loop.

Probe /api/health instead, falling back to /api/status only for the
missing-route shapes the fetch helpers emit (404, or HTML from the SPA), so
an older remote backend still connects. Timeouts and server errors keep
polling health rather than dropping to the heavyweight route.

A cheap route is not enough on its own. Warming the gateway import holds the
GIL, so the event loop can stall for tens of seconds and starve /api/health
too. At the default 15s socket timeout only three attempts fit in the 45s
budget; give each probe 5s so the loop keeps retrying across the stall.

Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Co-authored-by: DESXIE <78300229+DESXIE@users.noreply.github.com>
Co-authored-by: frohsinnllc <231045016+frohsinnllc@users.noreply.github.com>
2026-07-24 19:44:48 -05:00
izumi0uu ccab46ca43 fix(dashboard): add lightweight /api/health liveness endpoint
/api/status is the only public liveness route, and its handler loads the
gateway config, probes gateway health, and counts sessions before it can
answer. That work is wrong for a readiness probe: a caller that only needs
to know the process is up pays for a cold plugin import tree.

Add /api/health, which returns process liveness, version, and the auth-gate
shape and touches nothing else.
2026-07-24 19:43:44 -05:00
hermes-seaeye[bot]andgithub-actions[bot] bb4765d21c fmt(js): npm run fix on merge (#71099)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-25 00:35:03 +00:00
brooklyn! 2c1a38a3cc Merge pull request #71094 from NousResearch/bb/desktop-ui-consistency
refactor(desktop): UI-consistency follow-up for the Webhooks & Cron Blueprints panes
2026-07-24 19:27:26 -05:00
Brooklyn Nicholson 0d865ddbb1 refactor(desktop): webhooks create form uses shared Field; drop status pill
The create dialog used the settings-surface ListRow/ToggleRow inside a
modal, which read differently from every other form dialog, and the detail
header carried an enabled/disabled pill that rendered as a stray dash.
Switch the form to the shared Field primitive (+ Switch) and remove the
pill.
2026-07-24 19:16:45 -05:00
Brooklyn Nicholson ef6049c215 refactor(desktop): fold cron Blueprints into the New Job dialog
Blueprints lived behind a separate Jobs/Blueprints tab with its own card
gallery — a bespoke surface no other overlay uses. Remove the tab and make
blueprints a "Start from" dropdown at the top of the New Job dialog
(default "Custom" = the manual editor); picking one swaps the form for that
blueprint's typed slots. Also promote the detail-view "Trigger now" button
to a primary action and adopt the shared Field primitive.
2026-07-24 19:16:45 -05:00
Brooklyn Nicholson a3421aadda fix(desktop): unify overlay-pane padding and add primary PanelAction
Overlay panes each set their own top padding, so the Settings sidebar and
Panel headers sat at different heights than System/Agents and the close X
(the #67759 regression). Hoist the shared beside-the-X clearance into
OVERLAY_TOP_CLEARANCE, keep the taller pad only on OverlayMain (which sits
under the X), tighten OverlayMain's gutters, and drop the one-off Settings
override. Also give PanelAction a `primary` variant so a detail header can
promote its main action to a filled button.
2026-07-24 19:16:45 -05:00
Brooklyn Nicholson a8c7a5f70f refactor(desktop): add shared Field form-dialog primitive
Dialog forms each hand-rolled their own label+control+hint stack (or
borrowed the settings-surface ListRow), so gaps and hint styling drifted
between the profile, cron, and webhook dialogs. Add a single Field /
FieldHint primitive for label-over-control dialog fields and adopt it in
the create/rename profile dialogs as the first consumers.
2026-07-24 19:16:45 -05:00
teknium1 d372fda6f0 chore: contributor email mappings for the file-I/O salvage 2026-07-24 17:10:39 -07:00
teknium1 0a6fda01e3 fix: restore utf-8-sig BOM tolerance at .env readers the sweep normalized
The cherry-pick auto-resolution + AST sweep applied plain utf-8 at three
.env reader sites where the salvaged PRs (#62617, #62123) deliberately
use utf-8-sig — a Notepad BOM must not hide/duplicate the first key.
Restore the contract (tests pin it).
2026-07-24 17:10:39 -07:00
teknium1 75e0d52034 fix(windows): sweep remaining bare read_text/write_text sites + linter rule
AST-driven pass over every Path.read_text()/write_text() without an
explicit encoding= across non-test code: 71 sites in 34 files
(skills_hub, hermes_cli/main+profiles+service_manager+container_boot,
mem0/hindsight/honcho plugins, achievements dashboard, release/CI
scripts, productivity+comfyui skill helpers, agent/*). Verified zero
positional-encoding collisions before insertion; per-file compile()
check after.

Adds a check-windows-footguns rule flagging bare single-line
read_text/write_text (multi-line forms stay covered by the AST guard
test from #38985). Together with the salvaged contributor commits this
retires the ~169-site bare file-I/O class (#37423's long tail).
2026-07-24 17:10:39 -07:00
solyanviktor-star adecb0d1a9 fix(skills): read OOXML parts as bytes and form JSON as UTF-8 in office skill scripts
The bundled office skills (#68595) read user documents and agent-authored
payloads with the locale-default codec:

- docx/powerpoint validators/base.py opened OOXML part XML in text mode
  before handing it to lxml. On Windows (cp1251/GBK) the bytes decode to
  mojibake that lxml then parses, so validation runs against silently
  corrupted document text; on locales where the UTF-8 bytes don't decode
  the validator crashes with UnicodeDecodeError instead of validating.
  Opening as bytes lets lxml honor the encoding declared in the XML prolog.

- The pdf form scripts (fill_fillable_fields, fill_pdf_form_with_annotations,
  create_validation_image, check_bounding_boxes) read the fields JSON the
  agent authors — UTF-8 by construction — with the locale codec, so
  non-ASCII form values (any Cyrillic/CJK/accented input) either crash or
  get written into the user's PDF as mojibake. The json.dump writers use
  ensure_ascii=True and were already safe; only the readers needed pinning.

Adds a contract test asserting every document/payload reader is
locale-independent, plus a live regression test that runs
check_bounding_boxes.py on a non-ASCII fields.json under a forced
non-UTF-8 locale — it fails without the fix on both POSIX (C locale)
and Windows (cp1251 chokes on the 0x98 byte of U+2018).
2026-07-24 17:10:39 -07:00
solyanviktor-starandClaude Fable 5 607f647141 fix(cli): read .worktreeinclude and .gitignore as UTF-8 in worktree setup
_setup_worktree read both files with the locale default encoding. On a
cp1251/GBK Windows machine a UTF-8 include list either decodes to
mojibake paths (non-ASCII entries silently not copied) or raises
UnicodeDecodeError, which the enclosing handler logs at DEBUG and
swallows — no include is copied at all, so the worktree starts without
.env/keys and the agent breaks invisibly. A Notepad BOM likewise glues
to the first include entry on every platform, and to the first
.gitignore line, defeating the '.worktrees/' membership check and
appending a duplicate entry on each run.

Read both files with utf-8-sig + errors=replace, matching the canonical
.env readers in hermes_cli/config.py (utf-8-sig because Notepad adds a
BOM) and the UTF-8 append this same block already performs on
.gitignore.

Regression tests exercise the real cli._setup_worktree: the two BOM
tests fail without the fix on any platform, the non-ASCII include test
additionally reproduces the Windows locale failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:10:39 -07:00
PRATHAMESH75 6e30aa2a3c fix(skills): tolerate non-UTF-8 bytes in hub lock.json
_read_hub_installed_names() reads ~/.hermes/skills/.hub/lock.json with a
strict utf-8 decode. Hub skill descriptions can carry Windows-1252
typographic bytes (em-dash 0x97, smart quotes, bullets) as single high
bytes; read_text(encoding="utf-8") then raises UnicodeDecodeError, which
is a ValueError sibling not caught by the function's
except (OSError, json.JSONDecodeError). It escapes and 500s the whole
/api/skills endpoint, blanking the desktop Skills panel.

Decode with errors="replace" so the offending byte degrades to U+FFFD
and the structurally valid JSON — and every other skill — stays readable.

Fixes #68053
2026-07-24 17:10:39 -07:00
AlexFucuson9 de9d480413 fix: add UTF-8 encoding to read_text/write_text in tools/ and agent/
Path.read_text() and Path.write_text() without encoding= default to the
system locale (cp1252 on Windows), which corrupts non-ASCII JSON content.

Coverage-gap fix for files not addressed by prior encoding PRs:
- tools/skills_hub.py: 6 read_text + 8 write_text (cache, index, lock files)
- tools/skills_sync.py: 1 read_text (lock file)
- tools/xai_http.py: 1 read_text + 1 write_text (auth store, marker)
- agent/shell_hooks.py: 1 read_text (allowlist)
- gateway/status.py: 1 read_text (PID file)
- hermes_cli/banner.py: 1 read_text + 1 write_text (update cache)

All sites read/write JSON or short text. No behavioral change on Linux
(already UTF-8); fixes silent data corruption on Windows.
2026-07-24 17:10:39 -07:00
solyanviktor-star 40828997a5 fix(profile): read .env as utf-8-sig in the distribution-install preview
`_render_distribution_plan` reads the target profile's `.env` to decide
whether a required env var is already set (so it doesn't nag the user),
using `Path.read_text()` with no encoding. Two bugs:

1. `Path.read_text()` defaults to the system locale (cp1251/GBK on Windows),
   which raises `UnicodeDecodeError` on any non-ASCII byte. The surrounding
   `except OSError` does NOT catch that — `UnicodeDecodeError` is a
   `ValueError` — so a mis-encoded `.env` aborts the entire install preview.
2. Even on a UTF-8 locale, a Notepad-added BOM prefixes the first key
   (`KEY`), so the very first required env var is mis-reported as
   "needs setting" when it is actually present.

`.env` is written as UTF-8 everywhere in the codebase. Read it as
`utf-8-sig` (tolerates the BOM) and also catch `UnicodeDecodeError` so a
genuinely un-decodable file skips the pre-check instead of crashing.

Regression tests: a BOM-prefixed `.env` whose first key must still read as
"set", and an invalid-UTF-8 `.env` that must not abort the preview.
2026-07-24 17:10:39 -07:00
solyanviktor-starandClaude Fable 5 f1ea4a56c2 fix(memory): cover the remaining setup-time .env reads with utf-8-sig
Follow-up to review feedback:

- mem0 _prompt_api_key read .env with the locale default, so a Notepad
  BOM hid the first key from the masked current-value lookup; read it
  with utf-8-sig + errors=replace like the canonical readers in
  hermes_cli/config.py.
- hindsight _load_simple_env used plain utf-8; it also parses the Hermes
  .env during post_setup, where a BOM stuck to the first key. Switch to
  utf-8-sig + errors=replace.
- Add hindsight regressions: BOM key matching in _load_simple_env and in
  the cloud post_setup writer, plus non-ASCII round-trip preservation,
  and a mem0 regression for the BOM'd masked-key lookup. The BOM tests
  fail without the fix on any platform.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:10:39 -07:00
solyanviktor-starandClaude Fable 5 75afc47baa fix(memory): read/write .env as UTF-8 in mem0 and hindsight setup
The mem0 and hindsight memory-provider setup routines round-trip the
user's ~/.hermes/.env: they read existing lines, update the keys they
manage, and rewrite the whole file preserving every other line verbatim.
Both used env_path.read_text() / write_text() with no encoding.

read_text()/write_text() with no encoding fall back to the system locale
(cp1252/GBK on Windows), so on a non-UTF-8 host the preserved lines get
mangled or the call crashes on any non-ASCII value, and — because the
reader never strips a BOM — a Notepad-edited .env makes the first key
fail the in-place match and get duplicated instead of updated.

Match the canonical .env readers in hermes_cli/config.py: read with
encoding='utf-8-sig' (BOM-tolerant) and write with encoding='utf-8'.
mem0/_setup.py already pins utf-8 for mem0.json, so this just aligns the
.env path in the same file. Fixes both memory plugins in one class fix.

Adds regression tests: a BOM'd .env updates the first key in place
(locale-independent, fails without the fix) and non-ASCII existing lines
survive the round-trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:10:39 -07:00
jeay e02170f54a fix(hindsight): specify UTF-8 encoding for file I/O on Windows
On Windows with CJK locales (e.g. Chinese/GBK), pathlib.Path.read_text()
defaults to the system encoding instead of UTF-8, causing UnicodeDecodeError
when reading .env or .json config files that contain non-ASCII characters.

Explicitly pass encoding='utf-8' to all read_text() and write_text() calls
in the hindsight memory provider plugin.
2026-07-24 17:10:39 -07:00
AlexFucuson9 411a686de2 fix: add encoding="utf-8" to Path.write_text() calls (P1)
Path.write_text() without encoding defaults to system locale encoding.
On Windows (cp1252), this silently corrupts non-ASCII content written
to JSON files, config files, and cache files.

This is the write-side counterpart to the read_text() encoding fix
(PR #56115). PLW1514 only covers open() calls — Path methods are
unguarded by ruff.

39 instances across 16 files, all passing py_compile.

Files changed:
- agent/copilot_acp_client.py (1)
- tools/web_tools.py (1)
- tools/xai_http.py (1)
- tools/skills_hub.py (8)
- gateway/slash_commands.py (1)
- gateway/run.py (5)
- gateway/dead_targets.py (1)
- gateway/delivery.py (2)
- gateway/platforms/qqbot/adapter.py (1)
- hermes_cli/gateway.py (1)
- hermes_cli/banner.py (1)
- hermes_cli/service_manager.py (5)
- hermes_cli/container_boot.py (5)
- hermes_cli/uninstall.py (1)
- hermes_cli/main.py (2)
- hermes_cli/profiles.py (3)
2026-07-24 17:10:39 -07:00
kael-odin 44649e69f4 test(install): add UTF-8 regression guard for skills_sync child path
Addresses hermes-sweeper review on PR #54866: the installer runs
tools/skills_sync.py as a child python.exe whose PYTHONIOENCODING /
PYTHONUTF8 the scoped install.ps1 block sets, but there was no
regression test for this child-Python UTF-8 path. The existing
test_child_process_inherits_utf8_mode covers a different (bootstrap
entry-point) flow.

Add TestSkillsSyncUtf8Guard: three subprocess tests that import
skills_sync (triggering its import-time stdout/stderr reconfigure)
and assert the checkmark/up-arrow glyphs the script prints at
tools/skills_sync.py:596,675 emit valid UTF-8 and exit 0 even when
the child env is left unset or explicitly hostile (gbk). A third
test proves the guard is load-bearing by reproducing the crash
without it.

Also keep the new install.ps1 comment ASCII-only (the checkmark
spelled out as U+2713) per the file's PS 5.1 parser-compatibility
contract at scripts/install.ps1:79-80; the literal glyph in the
comment violated that contract.
2026-07-24 17:10:39 -07:00
kael-odin 3f1de1fc4a fix(install): emit UTF-8 from skills_sync on non-UTF-8 Windows locales
On Windows with a non-UTF-8 system locale (e.g. CP936/GBK on zh-CN),
Python defaults stdout/stderr to the active codepage. tools/skills_sync.py
prints glyphs such as checkmark (U+2713) and up-arrow (U+2191) that GBK
cannot encode, raising UnicodeEncodeError mid-run.

The installer (scripts/install.ps1) captures this script's stdout and the
Rust bootstrap parses it as UTF-8 expecting a JSON result frame. A GBK
byte stream (or the traceback it triggers) surfaces as:

  WARN stdout read error: stream did not contain valid UTF-8
  stage=config-templates state=Failed
  error=install.ps1 -Stage config-templates produced no JSON result frame
        (exit=Some(0))

i.e. the stage fails even though the script exits 0. install.ps1 already
sets [Console]::OutputEncoding = UTF8, but that does not propagate to the
python.exe child (Python reads PYTHONIOENCODING / locale, not the console
encoding).

Fix in two places for defense in depth:
- tools/skills_sync.py: reconfigure sys.stdout/stderr to UTF-8 at import so
  output is valid UTF-8 regardless of caller or active codepage.
- scripts/install.ps1: set PYTHONIOENCODING=utf-8 and PYTHONUTF8=1 (scoped
  to the call, restored afterwards) around the skills_sync.py invocation.
2026-07-24 17:10:39 -07:00
AlexFucuson9 cf35fd6de5 fix(core,cli,gateway,plugins): add encoding='utf-8' to read_text() calls
Path.read_text() without an explicit encoding uses the platform's
default encoding. On Windows this is typically cp1252 or mbcs, which
causes UnicodeDecodeError or silent data corruption when reading
UTF-8 content (JSON files, user text, config with non-ASCII chars).

This is the read-side companion to the write_text() encoding fix.
Fixed the most critical locations that read JSON data, user content,
and config files across 14 files with 31 call sites.

Pattern: .read_text() → .read_text(encoding='utf-8')
         json.loads(path.read_text()) → json.loads(path.read_text(encoding='utf-8'))
2026-07-24 17:10:39 -07:00
annguyenNous efaba061fb fix(cli): add explicit encoding to read_text/write_text calls
Path.read_text() and Path.write_text() without explicit encoding
default to the system locale encoding. On Windows this is typically
cp1252, which causes UnicodeDecodeError for UTF-8 content (JSON
configs, user data, service scripts).

Add encoding="utf-8" to all read_text() and write_text() calls
across 8 CLI files, matching the pattern established in PR #50534
(security_audit_startup.py) and ruff rule PLW1514.

Fixed files:
- main.py: 4 read_text calls
- auth.py: 3 read_text calls
- banner.py: 1 read_text + 1 write_text
- service_manager.py: 1 read_text + 4 write_text
- container_boot.py: 1 read_text + 4 write_text
- doctor.py: 3 read_text calls
- uninstall.py: 2 read_text calls
- gateway.py: 1 write_text call
2026-07-24 17:10:39 -07:00
CoreyNoDream 9f6b2a64e0 fix: decode config and state files as UTF-8 on non-UTF-8 locales
Several file-I/O call sites still use open() / Path.read_text() /
Path.write_text() without an explicit encoding, so they fall back to
the platform default. On Windows CN/JP/KR locales (GBK/CP932/CP949)
any non-ASCII byte in a config/state/user-content file raises
UnicodeDecodeError or UnicodeEncodeError and crashes the caller.

to the remaining hot paths:

- agent/copilot_acp_client.py:  fs/read_text_file and fs/write_text_file
                                (Copilot's read_file / write_file tools,
                                 directly reported in #18637 bug 2)
- agent/model_metadata.py:      context-length YAML cache load + two
                                save sites (context probing is on the
                                call path of every model invocation)
- agent/nous_rate_guard.py:     cross-session rate-limit JSON state
                                (read + atomic write via os.fdopen)
- cron/scheduler.py:            user config.yaml read in run_job
- gateway/delivery.py:          cron output writes for AI-generated
                                content, very likely non-ASCII

yaml.dump call sites also gain allow_unicode=True so the emitted
YAML preserves non-ASCII chars as-is instead of emitting \u escape
sequences.

Adds regression tests that monkeypatch builtins.open / Path.read_text
/ Path.write_text to simulate a GBK locale: each test raises
UnicodeDecodeError / UnicodeEncodeError unless the caller explicitly
passes encoding='utf-8'. Verified that the tests fail on main and
pass with this change, on Linux as well as on Windows.

Refs #18637
2026-07-24 17:10:39 -07:00
chancelu 049af61d64 fix: handle non-UTF-8 files in OpenClaw migration script 2026-07-24 17:10:39 -07:00
Tianworld 60dbce7c34 fix(doctor): UTF-8/latin-1 fallback when scanning .env
Prefer UTF-8 for ~/.hermes/.env provider scans, then latin-1 for cp1252/Notepad files. Add regression test for invalid UTF-8 bytes.
2026-07-24 17:10:39 -07:00
114 changed files with 2508 additions and 748 deletions
+1 -1
View File
@@ -1716,7 +1716,7 @@ def _read_nous_auth() -> Optional[dict]:
try:
if not _AUTH_JSON_PATH.is_file():
return None
data = json.loads(_AUTH_JSON_PATH.read_text())
data = json.loads(_AUTH_JSON_PATH.read_text(encoding="utf-8"))
if data.get("active_provider") != "nous":
return None
provider = data.get("providers", {}).get("nous", {})
+2 -2
View File
@@ -708,7 +708,7 @@ class CopilotACPClient:
if block_error:
raise PermissionError(block_error)
try:
content = path.read_text()
content = path.read_text(encoding="utf-8")
except FileNotFoundError:
content = ""
line = params.get("line")
@@ -736,7 +736,7 @@ class CopilotACPClient:
if denied:
raise PermissionError(denied)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(str(params.get("content") or ""))
path.write_text(str(params.get("content") or ""), encoding="utf-8")
response = {
"jsonrpc": "2.0",
"id": message_id,
+1 -1
View File
@@ -164,7 +164,7 @@ def _remove_env_source(provider: str, removed) -> RemovalResult:
if env_path.exists():
env_in_dotenv = any(
line.strip().startswith(f"{env_var}=")
for line in env_path.read_text(errors="replace").splitlines()
for line in env_path.read_text(errors="replace", encoding="utf-8").splitlines()
)
except OSError:
pass
+1 -1
View File
@@ -117,7 +117,7 @@ def record_nous_rate_limit(
# Atomic write: write to temp file + rename
fd, tmp_path = tempfile.mkstemp(dir=state_dir, suffix=".tmp")
try:
with os.fdopen(fd, "w") as f:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(state, f)
atomic_replace(tmp_path, path)
except Exception:
+1 -1
View File
@@ -632,7 +632,7 @@ def allowlist_path() -> Path:
def load_allowlist() -> Dict[str, Any]:
"""Return the parsed allowlist, or an empty skeleton if absent."""
try:
raw = json.loads(allowlist_path().read_text())
raw = json.loads(allowlist_path().read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {"approvals": []}
if not isinstance(raw, dict):
@@ -75,6 +75,7 @@ test('buildDesktopBackendEnv forces PYTHONUTF8 unless the user set it explicitly
platform: 'darwin',
pathModule: path.posix
})
assert.equal(defaulted.PYTHONUTF8, '1')
const optedOut = buildDesktopBackendEnv({
@@ -83,6 +84,7 @@ test('buildDesktopBackendEnv forces PYTHONUTF8 unless the user set it explicitly
platform: 'darwin',
pathModule: path.posix
})
assert.equal(optedOut.PYTHONUTF8, '0')
})
@@ -0,0 +1,136 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { DEFAULT_HEALTH_PROBE_TIMEOUT_MS, isMissingHealthEndpointError, waitForHermesReady } from './backend-health'
test('uses lightweight /api/health for current backends', async () => {
const calls: string[][] = []
await waitForHermesReady('http://127.0.0.1:9000/', {
token: 'secret-token',
fetchPublicJson: async url => {
calls.push(['public', url])
return { ok: true }
},
fetchJson: async url => {
calls.push(['token', url])
throw new Error('status should not be called')
},
sleep: async () => {},
timeoutMs: 100,
pollMs: 1
})
assert.deepEqual(calls, [['public', 'http://127.0.0.1:9000/api/health']])
})
test('falls back to /api/status only for old backends without /api/health', async () => {
const calls: string[][] = []
await waitForHermesReady('http://127.0.0.1:9000', {
token: 'secret-token',
fetchPublicJson: async url => {
calls.push(['public', url])
throw new Error('404: {"detail":"Not Found"}')
},
fetchJson: async (url, token) => {
calls.push(['token', url, token ?? ''])
return { version: 'old' }
},
sleep: async () => {},
timeoutMs: 100,
pollMs: 1
})
assert.deepEqual(calls, [
['public', 'http://127.0.0.1:9000/api/health'],
['token', 'http://127.0.0.1:9000/api/status', 'secret-token']
])
})
test('does not fall back to heavyweight /api/status for transient health failures', async () => {
const calls: string[][] = []
let currentTime = 0
await assert.rejects(
waitForHermesReady('http://127.0.0.1:9000', {
fetchPublicJson: async url => {
calls.push(['public', url])
throw new Error('Timed out connecting to Hermes backend after 15000ms')
},
fetchJson: async url => {
calls.push(['token', url])
},
sleep: async () => {},
now: () => {
currentTime += 20
return currentTime
},
timeoutMs: 50,
pollMs: 1
}),
/Timed out connecting/
)
assert.ok(calls.length > 0)
assert.ok(calls.every(call => call[0] === 'public' && call[1].endsWith('/api/health')))
})
test('probes health on a short timeout but leaves the legacy fallback its own', async () => {
const timeouts: (number | undefined)[] = []
await waitForHermesReady('http://127.0.0.1:9000', {
fetchPublicJson: async (_url, options) => {
timeouts.push(options?.timeoutMs)
throw new Error('404: {"detail":"Not Found"}')
},
fetchJson: async (_url, _token, options) => {
timeouts.push(options?.timeoutMs)
return { version: 'old' }
},
sleep: async () => {},
timeoutMs: 100,
pollMs: 1
})
assert.deepEqual(timeouts, [DEFAULT_HEALTH_PROBE_TIMEOUT_MS, undefined])
})
test('aborts as superseded when the bootstrap signal fires', async () => {
const controller = new AbortController()
controller.abort()
await assert.rejects(
waitForHermesReady('http://127.0.0.1:9000', {
signal: controller.signal,
fetchPublicJson: async () => {
throw new Error('should not probe after abort')
},
fetchJson: async () => {
throw new Error('should not probe after abort')
},
timeoutMs: 100,
pollMs: 1
}),
(error: any) => error.kind === 'superseded'
)
})
test('recognizes missing-route shapes only', () => {
assert.equal(isMissingHealthEndpointError(new Error('404: {"detail":"Not Found"}')), true)
assert.equal(
isMissingHealthEndpointError(
new Error('Expected JSON from /api/health but got HTML. The endpoint is likely missing on the Hermes backend.')
),
true
)
assert.equal(isMissingHealthEndpointError(new Error('Timed out connecting to Hermes backend after 15000ms')), false)
assert.equal(isMissingHealthEndpointError(new Error('500: boom')), false)
})
+95
View File
@@ -0,0 +1,95 @@
export const DEFAULT_BACKEND_READY_TIMEOUT_MS = 45_000
export const DEFAULT_BACKEND_READY_POLL_MS = 500
// A cold backend can stall its event loop for tens of seconds while Windows
// scans and byte-compiles the gateway import tree. At the default 15s socket
// timeout only three probes fit in the budget; a short one keeps retrying
// across the stall. Health only — the legacy /api/status fallback is genuinely
// slow to answer and keeps the caller's default timeout.
export const DEFAULT_HEALTH_PROBE_TIMEOUT_MS = 5_000
type FetchPublicJson = (url: string, options?: { timeoutMs?: number }) => Promise<unknown>
type FetchJson = (url: string, token?: string | null, options?: { timeoutMs?: number }) => Promise<unknown>
export interface HermesReadyOptions {
fetchPublicJson: FetchPublicJson
fetchJson: FetchJson
token?: string | null
signal?: AbortSignal
timeoutMs?: number
pollMs?: number
healthProbeTimeoutMs?: number
sleep?: (ms: number) => Promise<void>
now?: () => number
}
export function isMissingHealthEndpointError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error ?? '')
return /^404:/.test(message) || message.includes('endpoint is likely missing')
}
function supersededError() {
const error: any = new Error('SSH bootstrap was superseded by newer connection settings.')
error.kind = 'superseded'
return error
}
export async function waitForHermesReady(baseUrl: string, options: HermesReadyOptions): Promise<void> {
const timeoutMs = options.timeoutMs ?? DEFAULT_BACKEND_READY_TIMEOUT_MS
const pollMs = options.pollMs ?? DEFAULT_BACKEND_READY_POLL_MS
const healthProbeTimeoutMs = options.healthProbeTimeoutMs ?? DEFAULT_HEALTH_PROBE_TIMEOUT_MS
const now = options.now ?? Date.now
const signal = options.signal
const sleep =
options.sleep ??
(ms =>
new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, ms)
signal?.addEventListener(
'abort',
() => {
clearTimeout(timer)
reject(supersededError())
},
{ once: true }
)
}))
const base = baseUrl.replace(/\/+$/, '')
const deadline = now() + timeoutMs
let lastError: unknown = null
let useStatusFallback = false
while (now() < deadline) {
if (signal?.aborted) {
throw supersededError()
}
try {
if (useStatusFallback) {
await options.fetchJson(`${base}/api/status`, options.token)
} else {
await options.fetchPublicJson(`${base}/api/health`, { timeoutMs: healthProbeTimeoutMs })
}
return
} catch (error) {
lastError = error
// Only an explicitly missing route means the backend predates
// /api/health; timeouts and server errors keep polling health.
if (!useStatusFallback && isMissingHealthEndpointError(error)) {
useStatusFallback = true
continue
}
await sleep(pollMs)
}
}
const detail = lastError instanceof Error ? lastError.message : 'timeout'
throw new Error(`Hermes backend did not become ready: ${detail}`)
}
+16 -50
View File
@@ -34,6 +34,7 @@ import { stopBackendChild as stopBackendChildImpl } from './backend-child'
import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command'
import { createBackendConnectionState } from './backend-connection-state'
import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env'
import { waitForHermesReady } from './backend-health'
import { canImportHermesCli, shouldTrustHermesOverride, verifyHermesCli } from './backend-probes'
import { waitForDashboardPortAnnouncement } from './backend-ready'
import { shouldLatchBackendStartFailure } from './backend-start-failure'
@@ -2985,6 +2986,7 @@ function preflightStateDb(hermesHome, rememberLog) {
if (!fileExists(stateDbPath)) {
rememberLog('[updates] state.db pre-flight: not found (fresh install?)')
return
}
@@ -3015,23 +3017,19 @@ function preflightStateDb(hermesHome, rememberLog) {
// Emergency timestamped backup, separate from the Python-level snapshot.
const ts = new Date().toISOString().replace(/[:.]/g, '-')
const emergencyPath = path.join(
hermesHome,
`state.db.pre-update-emergency-${ts}.bak`
)
const emergencyPath = path.join(hermesHome, `state.db.pre-update-emergency-${ts}.bak`)
try {
fs.copyFileSync(stateDbPath, emergencyPath)
const emergStat = fs.statSync(emergencyPath)
rememberLog(
`[updates] emergency state.db backup: ${emergencyPath} ` +
`(${emergStat.size} bytes)`
)
rememberLog(`[updates] emergency state.db backup: ${emergencyPath} ` + `(${emergStat.size} bytes)`)
// Prune to the 2 most recent emergency backups.
try {
const homeDir = fs.readdirSync(hermesHome)
const backups = homeDir
.filter(
f =>
@@ -3053,19 +3051,13 @@ function preflightStateDb(hermesHome, rememberLog) {
void 0
}
} catch (copyErr) {
rememberLog(
`[updates] emergency state.db backup failed: ${copyErr.message}`
)
rememberLog(`[updates] emergency state.db backup failed: ${copyErr.message}`)
}
} else {
rememberLog(
`[updates] state.db too small (${stat.size} bytes) for a valid SQLite database`
)
rememberLog(`[updates] state.db too small (${stat.size} bytes) for a valid SQLite database`)
}
} catch (statErr) {
rememberLog(
`[updates] could not stat state.db before update: ${statErr.message}`
)
rememberLog(`[updates] could not stat state.db before update: ${statErr.message}`)
}
}
@@ -3083,6 +3075,7 @@ async function applyUpdatesPosixInApp(opts: any) {
if (!hermes) {
emitUpdateProgress({ stage: 'manual', message: 'hermes update', percent: null })
return { ok: true, manual: true, command: 'hermes update', hermesRoot: updateRoot }
}
@@ -4823,39 +4816,12 @@ function closePreviewWatchers() {
}
async function waitForHermes(baseUrl, token, signal?) {
const deadline = Date.now() + 45_000
let lastError = null
while (Date.now() < deadline) {
if (signal?.aborted) {
const error: any = new Error('SSH bootstrap was superseded by newer connection settings.')
error.kind = 'superseded'
throw error
}
try {
await fetchJson(`${baseUrl}/api/status`, token)
return
} catch (error) {
lastError = error
await new Promise((resolve, reject) => {
const timer = setTimeout(resolve, 500)
signal?.addEventListener(
'abort',
() => {
clearTimeout(timer)
const aborted: any = new Error('SSH bootstrap was superseded by newer connection settings.')
aborted.kind = 'superseded'
reject(aborted)
},
{ once: true }
)
})
}
}
throw new Error(`Hermes backend did not become ready: ${lastError?.message || 'timeout'}`)
return waitForHermesReady(baseUrl, {
token,
signal,
fetchPublicJson,
fetchJson
})
}
function getWindowButtonPosition() {
+45 -2
View File
@@ -57,7 +57,8 @@
* - electronPlatformName: 'win32' | 'darwin' | 'linux'
* - arch: Arch enum (0=ia32, 1=x64, 2=armv7l, 3=arm64, 4=universal)
*/
import { existsSync, rmSync } from 'node:fs'
import { existsSync, rmSync, renameSync } from 'node:fs'
import path from 'node:path'
import { Arch } from 'electron-builder'
import { stageNodePty } from './stage-native-deps.mjs'
@@ -75,10 +76,52 @@ export function cleanStaleAppOutDir(appOutDir) {
return true
}
/**
* Windows rollback material (#69179): before wiping the previous unpacked
* tree, preserve it as `<appOutDir>.bak` — but ONLY when it holds the product
* exe (i.e. it is a previously-working build, not the corrupted partial state
* cleanStaleAppOutDir exists to remove). If the fresh pack then produces a
* Hermes.exe that Windows can't load (truncated PE from a corrupt cached
* Electron zip, wrong arch), the updater's integrity gate in
* `hermes desktop --build-only` (hermes_cli/main.py
* `_ensure_desktop_exe_launchable`) restores this .bak instead of leaving the
* user with "This app can't run on your computer".
*
* Returns true when the tree was preserved (appOutDir no longer exists), false
* when there was nothing worth preserving (caller falls through to the wipe).
* A rename failure (AV holding a handle) also returns false — the wipe is the
* safe fallback and matches pre-#69179 behavior exactly.
*/
export function preserveRollbackBackup(appOutDir, productExeName = 'Hermes.exe') {
if (!appOutDir || typeof appOutDir !== 'string' || !existsSync(appOutDir)) {
return false
}
if (!existsSync(path.join(appOutDir, productExeName))) {
// Partial/corrupt tree (interrupted prior pack) — not rollback material.
return false
}
const backupDir = `${appOutDir}.bak`
try {
rmSync(backupDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
renameSync(appOutDir, backupDir)
return true
} catch {
return false
}
}
export default async function beforePack(context) {
const appOutDir = context && context.appOutDir
const platformName = context && context.electronPlatformName
try {
if (cleanStaleAppOutDir(appOutDir)) {
// Windows: keep the previous working build as rollback material for the
// post-build integrity gate (#69179) instead of destroying it. Falls
// through to the plain wipe when the old tree is partial/corrupt or the
// rename fails.
const productExe = `${(context && context.packager?.appInfo?.productFilename) || 'Hermes'}.exe`
if (platformName === 'win32' && preserveRollbackBackup(appOutDir, productExe)) {
console.log(`[before-pack] preserved previous unpacked dir for rollback: ${appOutDir}.bak`)
} else if (cleanStaleAppOutDir(appOutDir)) {
console.log(`[before-pack] removed stale unpacked dir before staging: ${appOutDir}`)
}
} catch (err) {
+104 -1
View File
@@ -4,7 +4,7 @@ import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import beforePack, { cleanStaleAppOutDir } from '../scripts/before-pack.mjs'
import beforePack, { cleanStaleAppOutDir, preserveRollbackBackup } from '../scripts/before-pack.mjs'
test('cleanStaleAppOutDir removes a populated unpacked directory', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
@@ -50,3 +50,106 @@ test('beforePack default export resolves even when cleanup throws', async () =>
// remove; the contract under test is that the hook never rejects.
await assert.doesNotReject(beforePack({ appOutDir: '', electronPlatformName: 'linux' }))
})
// ─── Windows rollback preservation (#69179) ────────────────────────────────
test('preserveRollbackBackup moves a working build to .bak', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
try {
const appOutDir = path.join(tempRoot, 'win-unpacked')
fs.mkdirSync(appOutDir, { recursive: true })
fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'MZ-old-build', 'utf8')
fs.writeFileSync(path.join(appOutDir, 'resources.pak'), 'x', 'utf8')
const preserved = preserveRollbackBackup(appOutDir, 'Hermes.exe')
assert.equal(preserved, true)
// Original slot vacated so electron-builder stages into a clean tree...
assert.equal(fs.existsSync(appOutDir), false)
// ...and the previous working build is intact under .bak for rollback.
assert.equal(
fs.readFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'utf8'),
'MZ-old-build'
)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('preserveRollbackBackup replaces a stale .bak from an older update', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
try {
const appOutDir = path.join(tempRoot, 'win-unpacked')
fs.mkdirSync(appOutDir, { recursive: true })
fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'current', 'utf8')
fs.mkdirSync(`${appOutDir}.bak`, { recursive: true })
fs.writeFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'two-updates-ago', 'utf8')
assert.equal(preserveRollbackBackup(appOutDir, 'Hermes.exe'), true)
assert.equal(fs.readFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'utf8'), 'current')
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('preserveRollbackBackup refuses a partial tree missing the product exe', () => {
// The corrupted partial state (interrupted prior pack) must NOT become
// rollback material — it is exactly what cleanStaleAppOutDir exists to wipe.
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
try {
const appOutDir = path.join(tempRoot, 'win-unpacked')
fs.mkdirSync(appOutDir, { recursive: true })
fs.writeFileSync(path.join(appOutDir, 'LICENSE.electron.txt'), 'x', 'utf8')
assert.equal(preserveRollbackBackup(appOutDir, 'Hermes.exe'), false)
// Tree untouched; the caller's wipe path handles it.
assert.equal(fs.existsSync(appOutDir), true)
assert.equal(fs.existsSync(`${appOutDir}.bak`), false)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('preserveRollbackBackup ignores missing or invalid input', () => {
assert.equal(preserveRollbackBackup(''), false)
assert.equal(preserveRollbackBackup(undefined), false)
assert.equal(preserveRollbackBackup(null), false)
assert.equal(preserveRollbackBackup(path.join(os.tmpdir(), 'does-not-exist-xyz')), false)
})
test('beforePack on win32 preserves the previous build instead of wiping it', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
try {
const appOutDir = path.join(tempRoot, 'win-unpacked')
fs.mkdirSync(appOutDir, { recursive: true })
fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'MZ-working', 'utf8')
// No packager info in the context → default 'Hermes.exe' product name.
// node-pty staging is skipped because arch is not a number here.
await beforePack({ appOutDir, electronPlatformName: 'win32' })
assert.equal(fs.existsSync(appOutDir), false)
assert.equal(
fs.readFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'utf8'),
'MZ-working'
)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('beforePack on linux keeps the plain wipe (no .bak)', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
try {
const appOutDir = path.join(tempRoot, 'linux-unpacked')
fs.mkdirSync(appOutDir, { recursive: true })
fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'x', 'utf8')
await beforePack({ appOutDir, electronPlatformName: 'linux' })
assert.equal(fs.existsSync(appOutDir), false)
assert.equal(fs.existsSync(`${appOutDir}.bak`), false)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
@@ -12,8 +12,7 @@ const mockComposerApi = { setText: vi.fn() }
vi.mock('@assistant-ui/react', () => ({
useAui: () => ({ composer: () => mockComposerApi }),
useAuiState: (selector: (state: { composer: { text: string } }) => unknown) =>
selector({ composer: { text: '' } }),
useAuiState: (selector: (state: { composer: { text: string } }) => unknown) => selector({ composer: { text: '' } }),
useComposerRuntime: () => ({
getState: () => ({ text: '' }),
subscribe: () => () => undefined
@@ -60,11 +59,7 @@ describe('useComposerDraft — attachment scope stays coherent with the committe
const snapshots: ComposerAttachment[][] = []
const { rerender } = render(
<ProbeHarness
activeQueueSessionKey="session-A"
onLayoutSnapshot={s => snapshots.push(s)}
sessionId="session-A"
/>
<ProbeHarness activeQueueSessionKey="session-A" onLayoutSnapshot={s => snapshots.push(s)} sessionId="session-A" />
)
// Mount loads session A's stashed attachment into the (module-level) main
@@ -98,18 +93,24 @@ describe('useComposerDraft — rehydrate diagnostic log stays redacted', () => {
it('logs counts/kinds/scope on restore but never the raw url, refText, or label', () => {
const secretUrl = 'https://secret.example.com/private-workspace-path'
const attachment: ComposerAttachment = {
id: 'url-secret',
kind: 'url',
label: 'do-not-leak-label',
refText: `@url:${secretUrl}`
}
stashSessionDraft('session-secret', '', [attachment])
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined)
render(
<ProbeHarness activeQueueSessionKey="session-secret" onLayoutSnapshot={() => undefined} sessionId="session-secret" />
<ProbeHarness
activeQueueSessionKey="session-secret"
onLayoutSnapshot={() => undefined}
sessionId="session-secret"
/>
)
const rehydrateCalls = debugSpy.mock.calls.filter(call => call[0] === '[composer-rehydrate]')
-73
View File
@@ -1,16 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { getAutomationBlueprints } from '@/hermes'
import type { AutomationBlueprint, AutomationBlueprintField } from '@/hermes'
import { useI18n } from '@/i18n'
import { selectableCardClass } from '@/lib/selectable-card'
import { cn } from '@/lib/utils'
import { PanelDetail, PanelEmpty, PanelPill } from '../overlays/panel'
// The blueprint catalog is shared with the dashboard, so its deliver slot
// defaults to "origin" (the chat/home-channel a dashboard or gateway job was
@@ -99,66 +89,3 @@ export function BlueprintSlotControl({
/>
)
}
// A clickable blueprint card — mirrors the app's other selectable cards
// (theme/pet/gateway/profile pickers) via selectableCardClass. Clicking opens
// the shared cron editor dialog pre-filled with this blueprint's slots; there's
// no inline expand form or divider.
function BlueprintCard({ blueprint, onSetUp }: { blueprint: AutomationBlueprint; onSetUp: () => void }) {
return (
<button
className={cn(selectableCardClass({ prominent: true }), 'w-full p-2 text-left')}
onClick={onSetUp}
type="button"
>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{blueprint.title}</p>
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">{blueprint.description}</p>
{blueprint.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{blueprint.tags.map(tag => (
<PanelPill key={tag}>{tag}</PanelPill>
))}
</div>
)}
</div>
</button>
)
}
// Automation Blueprints gallery — the desktop counterpart to the dashboard's
// blueprint tab. Each card opens the shared cron editor dialog pre-filled with
// the blueprint's typed slots; submitting POSTs to
// /api/cron/blueprints/instantiate, which fills the blueprint and creates the
// job via the same create_job path as a hand-written cron.
export function BlueprintsPanel({ onSetUp }: { onSetUp: (blueprint: AutomationBlueprint) => void }) {
const { t } = useI18n()
const c = t.cron
const blueprints = useQuery({
queryKey: ['cron-blueprints'],
queryFn: async () => (await getAutomationBlueprints()).blueprints
})
const cards = useMemo(() => blueprints.data ?? [], [blueprints.data])
if (blueprints.isLoading) {
return <PageLoader label={c.blueprints.loading} />
}
if (blueprints.isError) {
return <PanelEmpty description={c.blueprints.failedLoad} icon="warning" title={c.blueprints.failedLoad} />
}
if (cards.length === 0) {
return <PanelEmpty description={c.blueprints.emptyDesc} icon="lightbulb" title={c.blueprints.emptyTitle} />
}
return (
<PanelDetail>
{cards.map(blueprint => (
<BlueprintCard blueprint={blueprint} key={blueprint.key} onSetUp={() => onSetUp(blueprint)} />
))}
</PanelDetail>
)
}
+73 -102
View File
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Codicon, codiconIcon } from '@/components/ui/codicon'
import { Codicon } from '@/components/ui/codicon'
import {
Dialog,
DialogContent,
@@ -14,8 +14,8 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Field, FieldHint } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { SegmentedControl } from '@/components/ui/segmented-control'
import {
Select,
SelectContent,
@@ -32,6 +32,7 @@ import {
type CronDeliveryTarget,
type CronJob,
deleteCronJob,
getAutomationBlueprints,
getCronDeliveryTargets,
getCronJobRuns,
getCronJobs,
@@ -70,27 +71,20 @@ import {
} from '../overlays/panel'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import {
BlueprintSlotControl,
blueprintSlotHelp,
BlueprintsPanel,
cleanBlueprintFieldError,
initialBlueprintValues
} from './blueprints'
import { BlueprintSlotControl, blueprintSlotHelp, cleanBlueprintFieldError, initialBlueprintValues } from './blueprints'
import { cronEditorUpdates, jobIsScriptOnly, validateCronEditor } from './cron-job-model'
import { jobState, jobTitle, STATE_DOT } from './job-state'
const DEFAULT_DELIVER = 'local'
// Two surfaces share the cron panel: the live Jobs list and the Blueprints
// gallery (parameterized templates that instantiate a real job). The active tab
// is pure view state — it lives here, not in a store.
type CronTab = 'blueprints' | 'jobs'
// Radix <SelectItem> rejects empty-string values, so the "no override" row in
// the model picker carries this sentinel and is mapped back to '' on save.
const MODEL_DEFAULT_VALUE = '__default__'
// "Start from" default: the manual editor (blank cron). Any other value is a
// blueprint key. Blueprint keys never collide with this sentinel.
const CUSTOM_TEMPLATE = 'custom'
const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
{ expr: '0 9 * * *', value: 'daily' },
{ expr: '0 9 * * 1-5', value: 'weekdays' },
@@ -305,7 +299,6 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
const pendingScrollRef = useRef<null | string>(null)
const focusJobId = useStore($cronFocusJobId)
const [tab, setTab] = useState<CronTab>('jobs')
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
const [deleting, setDeleting] = useState(false)
@@ -455,12 +448,11 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
// Blueprint instantiation is a distinct backend path (fills typed slots, then
// creates the job) so it can't share the raw-cron onSave contract. Merge the
// created job into $cronJobs like every other create path.
async function handleBlueprintCreate(
blueprint: AutomationBlueprint,
values: Record<string, string>,
profile: string
) {
// created job into $cronJobs like every other create path. A blueprint writes a
// real per-profile job, and "all" is not a writable target — collapse it to
// 'default', matching the manual create path in handleEditorSave.
async function handleBlueprintCreate(blueprint: AutomationBlueprint, values: Record<string, string>) {
const profile = profileScope === ALL_PROFILES ? 'default' : profileScope
const job = await instantiateAutomationBlueprint({ blueprint: blueprint.key, values }, profile)
updateCronJobs(rows => {
@@ -472,42 +464,11 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
setEditor({ mode: 'closed' })
}
const tabToggle = (
<SegmentedControl
onChange={setTab}
options={[
{ icon: codiconIcon('watch'), id: 'jobs', label: c.tabs.jobs },
{ icon: codiconIcon('lightbulb'), id: 'blueprints', label: c.blueprints.tab }
]}
value={tab}
/>
)
return (
<Panel closeLabel={c.close} onClose={onClose}>
<PanelHeader
actions={tabToggle}
subtitle={tab === 'jobs' ? c.count(totalCount) : c.blueprints.subtitle}
title={c.title}
/>
<PanelHeader subtitle={c.count(totalCount)} title={c.title} />
{tab === 'blueprints' ? (
// A blueprint instantiates a real per-profile job, and "all" is not a
// writable target — collapse it to 'default', matching the create path
// in handleEditorSave. A user scoped to all profiles gets the job in
// 'default'. The gallery is a single scroll column, so it renders
// directly (BlueprintsPanel uses PanelDetail) rather than in PanelBody's
// master/detail row.
<BlueprintsPanel
onSetUp={blueprint =>
setEditor({
blueprint,
mode: 'blueprint',
profile: profileScope === ALL_PROFILES ? 'default' : profileScope
})
}
/>
) : loading && jobs.length === 0 ? (
{loading && jobs.length === 0 ? (
<PageLoader label={c.loading} />
) : totalCount === 0 ? (
<PanelEmpty
@@ -663,7 +624,7 @@ function CronJobDetail({
<PanelAction disabled={busy} icon={isPaused ? 'play' : 'debug-pause'} onClick={onPauseResume}>
{isPaused ? c.resumeTitle : c.pauseTitle}
</PanelAction>
<PanelAction disabled={busy} icon="zap" onClick={onTrigger}>
<PanelAction disabled={busy} icon="zap" onClick={onTrigger} primary>
{c.triggerNow}
</PanelAction>
</div>
@@ -847,7 +808,7 @@ function CronEditorDialog({
onSave
}: {
editor: EditorState
onBlueprintCreate: (blueprint: AutomationBlueprint, values: Record<string, string>, profile: string) => Promise<void>
onBlueprintCreate: (blueprint: AutomationBlueprint, values: Record<string, string>) => Promise<void>
onClose: () => void
onSave: (values: EditorValues) => Promise<void>
}) {
@@ -855,8 +816,6 @@ function CronEditorDialog({
const c = t.cron
const open = editor.mode !== 'closed'
const isEdit = editor.mode === 'edit'
const isBlueprint = editor.mode === 'blueprint'
const blueprint = isBlueprint ? editor.blueprint : null
const initial = isEdit ? editor.job : null
const scriptOnlyJob = initial ? jobIsScriptOnly(initial) : false
@@ -868,14 +827,33 @@ function CronEditorDialog({
// Per-job model override, encoded as `${providerSlug}:${model}` (split on the
// first ':' when saving). MODEL_DEFAULT_VALUE = follow the global default.
const [modelChoice, setModelChoice] = useState(MODEL_DEFAULT_VALUE)
// Blueprint mode fills typed slots (time/enum/weekdays/text) instead of the
// raw cron fields; the backend renders the prompt + schedule from them.
// Blueprint fills typed slots (time/enum/weekdays/text) instead of the raw
// cron fields; the backend renders the prompt + schedule from them.
const [slotValues, setSlotValues] = useState<Record<string, string>>({})
// Create mode can start from a ready-made blueprint instead of a blank cron.
// CUSTOM_TEMPLATE (default) = the manual editor; any other value is a
// blueprint key that swaps the form for that blueprint's typed slots.
const [templateChoice, setTemplateChoice] = useState(CUSTOM_TEMPLATE)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
// The blueprint catalog powers the create dialog's "Start from" dropdown; it's
// meaningless when editing an existing job, so skip the fetch there.
const blueprintsQuery = useQuery({
queryKey: ['cron-blueprints'],
queryFn: async () => (await getAutomationBlueprints()).blueprints,
enabled: open && !isEdit
})
const blueprintList = blueprintsQuery.data ?? []
const blueprint =
templateChoice === CUSTOM_TEMPLATE ? null : (blueprintList.find(item => item.key === templateChoice) ?? null)
const isBlueprint = blueprint !== null
// Same catalog the chat model picker uses: configured providers and their
// actually-available models only. Script-only + blueprint dialogs never pick a
// actually-available models only. Script-only + blueprint forms never pick a
// model here, so skip the fetch entirely for them.
const modelOptions = useQuery({
queryKey: ['model-options', 'global'],
@@ -903,10 +881,18 @@ function CronEditorDialog({
setSchedulePreset(initial ? scheduleOptionForExpr(jobScheduleExpr(initial)).value : 'daily')
setDeliver(initial ? jobDeliver(initial) : DEFAULT_DELIVER)
setModelChoice(initial && jobModel(initial) ? `${jobProvider(initial)}:${jobModel(initial)}` : MODEL_DEFAULT_VALUE)
setSlotValues(blueprint ? initialBlueprintValues(blueprint) : {})
setSlotValues({})
setTemplateChoice(CUSTOM_TEMPLATE)
setError(null)
setSaving(false)
}, [blueprint, initial, open])
}, [initial, open])
// Seed the typed slots with the blueprint's defaults whenever a blueprint is
// picked from "Start from" (and reset them when switching back to Custom).
useEffect(() => {
setSlotValues(blueprint ? initialBlueprintValues(blueprint) : {})
setError(null)
}, [blueprint])
const selectedScheduleOption =
SCHEDULE_OPTIONS.find(candidate => candidate.value === schedulePreset) ?? SCHEDULE_OPTIONS[0]
@@ -988,7 +974,7 @@ function CronEditorDialog({
async function handleBlueprintSubmit(event: React.FormEvent) {
event.preventDefault()
if (!isBlueprint) {
if (!blueprint) {
return
}
@@ -996,7 +982,7 @@ function CronEditorDialog({
setError(null)
try {
await onBlueprintCreate(editor.blueprint, slotValues, editor.profile)
await onBlueprintCreate(blueprint, slotValues)
} catch (err) {
// 422 carries the slot-level validation message; surface it inline.
setError(cleanBlueprintFieldError(err instanceof Error ? err.message : String(err)))
@@ -1009,12 +995,29 @@ function CronEditorDialog({
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{isBlueprint ? blueprint?.title : isEdit ? c.editTitle : c.createTitle}</DialogTitle>
<DialogDescription>
{isBlueprint ? blueprint?.description || c.blueprints.dialogDesc : isEdit ? c.editDesc : c.createDesc}
</DialogDescription>
<DialogTitle>{isEdit ? c.editTitle : c.createTitle}</DialogTitle>
<DialogDescription>{isEdit ? c.editDesc : c.createDesc}</DialogDescription>
</DialogHeader>
{!isEdit && blueprintList.length > 0 && (
<Field htmlFor="cron-template" label={c.blueprints.startFrom}>
<Select onValueChange={setTemplateChoice} value={templateChoice}>
<SelectTrigger className="h-9 rounded-md" id="cron-template">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={CUSTOM_TEMPLATE}>{c.blueprints.custom}</SelectItem>
{blueprintList.map(item => (
<SelectItem key={item.key} value={item.key}>
{item.title}
</SelectItem>
))}
</SelectContent>
</Select>
{blueprint?.description && <FieldHint>{blueprint.description}</FieldHint>}
</Field>
)}
{isBlueprint && blueprint ? (
<form className="grid gap-4" onSubmit={handleBlueprintSubmit}>
{blueprint.fields.map(field => {
@@ -1192,39 +1195,7 @@ function CronEditorDialog({
)
}
function Field({
children,
htmlFor,
label,
optional,
optionalLabel
}: {
children: React.ReactNode
htmlFor: string
label: string
optional?: boolean
optionalLabel?: string
}) {
return (
<div className="grid gap-1.5">
<label className="flex items-baseline gap-2 text-xs font-medium text-foreground" htmlFor={htmlFor}>
{label}
{optional && <span className="text-[0.65rem] font-normal text-muted-foreground">{optionalLabel}</span>}
</label>
{children}
</div>
)
}
function FieldHint({ children }: { children: React.ReactNode }) {
return <p className="text-[0.66rem] leading-4 text-muted-foreground">{children}</p>
}
type EditorState =
| { blueprint: AutomationBlueprint; mode: 'blueprint'; profile: string }
| { job: CronJob; mode: 'edit' }
| { mode: 'closed' }
| { mode: 'create' }
type EditorState = { job: CronJob; mode: 'edit' } | { mode: 'closed' } | { mode: 'create' }
interface EditorValues {
deliver: string
@@ -4,7 +4,9 @@ import { TabDropdown } from '@/components/ui/tab-dropdown'
import type { IconComponent } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { PAGE_INSET_X, PAGE_MAX_W } from '../layout-constants'
import { PAGE_MAX_W } from '../layout-constants'
import { OVERLAY_TOP_CLEARANCE } from './overlay-view'
// The wide rail and the narrow dropdown swap at exactly the width where
// OverlaySplitLayout drops to a single column, so the rail never stacks.
@@ -57,10 +59,12 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
return (
<aside
className={cn(
// pt clears the in-card close button (the OverlayView now insets the
// whole card below the OS titlebar); the bg fills from the card's top
// edge so there's no surface-colored gap above the sidebar.
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 pb-3 pt-[calc(var(--titlebar-height)/2+1rem)]',
// The left links sit beside (not under) the floating close button, so
// they ride up via the shorter shared OVERLAY_TOP_CLEARANCE (same line
// as a Panel header) instead of main's taller X-clearance. The bg still
// fills from the card's top edge, so there's no gap above the sidebar.
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 pb-3',
OVERLAY_TOP_CLEARANCE,
className
)}
>
@@ -73,11 +77,15 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
return (
<main
className={cn(
// Narrow: the OverlayNav dropdown bar already clears the titlebar, so
// drop the tall top pad to a normal gap below it.
'mx-auto flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-transparent pb-3 pt-[calc(var(--titlebar-height)/2+1rem)] max-[47.5rem]:pt-2',
// Main sits UNDER the floating close button (top-right), so it keeps the
// taller top pad to clear the X — unlike the sidebar / Panel header,
// which sit to its left and ride up via OVERLAY_TOP_CLEARANCE. All four
// paddings are 1/3 tighter than the raw values (×2/3): the wide/narrow
// top clearance, the bottom gutter, and the horizontal clamp gutter
// (inlined from PAGE_INSET_X so only overlay panes tighten, not the
// shared page gutter). Narrow top drops toward the OverlayNav bar.
'mx-auto flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-transparent pb-2 pt-[calc((var(--titlebar-height)/2+1rem)*2/3)] max-[47.5rem]:pt-[calc(0.5rem*2/3)] px-[clamp(0.8333rem,2.6667vw,2.6667rem)]',
PAGE_MAX_W,
PAGE_INSET_X,
className
)}
>
@@ -9,6 +9,14 @@ import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
// Shared top clearance for overlay content that sits *beside* the floating
// close button (which is absolute at `0.1875rem + titlebar/2`, -translate-y-1/2,
// so it costs no layout space): a Panel's header and the split layout's left
// sidebar links. They ride up next to the X on the same line across every
// overlay (settings, system, agents, cron, …) — change it here, not per-surface.
// Main content sits *under* the X (top-right) and keeps its own taller pad.
export const OVERLAY_TOP_CLEARANCE = 'pt-[calc(var(--titlebar-height)/2-0.4375rem)]'
interface OverlayViewProps {
children: ReactNode
onClose: () => void
+14 -12
View File
@@ -9,7 +9,7 @@ import { Tip } from '@/components/ui/tooltip'
import { translateNow } from '@/i18n'
import { cn } from '@/lib/utils'
import { OverlayView } from './overlay-view'
import { OVERLAY_TOP_CLEARANCE, OverlayView } from './overlay-view'
// Overlay "panel" primitive — the centered, capped card + framed chrome lifted
// straight from the trace / agents overlay so every non-settings overlay (cron,
@@ -47,13 +47,9 @@ export function Panel({
return (
<OverlayView
closeLabel={closeLabel}
// Top pad aligns the header title's center with the floating close button
// (which sits at 0.1875rem + titlebar/2, -translate-y-1/2). The X is
// absolute so it costs no layout space — the header rides up next to it.
contentClassName={cn(
'flex h-full min-h-0 flex-col px-4 pb-4 pt-[calc(var(--titlebar-height)/2-0.4375rem)] sm:px-5',
contentClassName
)}
// Header title rides up next to the floating close button — see
// OVERLAY_TOP_CLEARANCE, the shared clearance every overlay column uses.
contentClassName={cn('flex h-full min-h-0 flex-col px-4 pb-4 sm:px-5', OVERLAY_TOP_CLEARANCE, contentClassName)}
onClose={onClose}
rootClassName={cn('flex h-full w-full flex-col', className)}
>
@@ -373,25 +369,31 @@ export function PanelAddButton({
)
}
// Visible ghost action for a detail header (cron pause/resume/trigger, …).
// Visible action for a detail header (cron pause/resume/trigger, …). Ghost by
// default; `primary` promotes the header's main action to a filled button.
export function PanelAction({
children,
disabled,
icon,
onClick
onClick,
primary
}: {
children: ReactNode
disabled?: boolean
icon: string
onClick: () => void
primary?: boolean
}) {
return (
<Button
className="gap-1.5 text-muted-foreground hover:bg-(--ui-row-hover-background) hover:text-foreground"
className={cn(
'gap-1.5',
!primary && 'text-muted-foreground hover:bg-(--ui-row-hover-background) hover:text-foreground'
)}
disabled={disabled}
onClick={onClick}
size="sm"
variant="ghost"
variant={primary ? 'default' : 'ghost'}
>
<Codicon name={icon} size="0.875rem" />
{children}
@@ -10,13 +10,13 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Field, FieldHint } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { createProfile, updateProfileSoul } from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { ProfileInfo } from '@/types/hermes'
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
@@ -100,10 +100,7 @@ export function CreateProfileDialog({
</DialogHeader>
<form className="grid gap-4" onSubmit={handleSubmit}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-name">
{p.nameLabel}
</label>
<Field htmlFor="new-profile-name" label={p.nameLabel}>
<Input
aria-invalid={invalid}
autoFocus
@@ -112,15 +109,10 @@ export function CreateProfileDialog({
placeholder="my-profile"
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
{p.nameHint}
</p>
</div>
<FieldHint error={invalid}>{p.nameHint}</FieldHint>
</Field>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-clone-from">
{p.cloneFrom}
</label>
<Field htmlFor="new-profile-clone-from" label={p.cloneFrom}>
<Select
onValueChange={value => setCloneFrom(value === '__none__' ? null : value)}
value={cloneFrom ?? '__none__'}
@@ -137,13 +129,10 @@ export function CreateProfileDialog({
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{p.cloneFromDesc}</p>
</div>
<FieldHint>{p.cloneFromDesc}</FieldHint>
</Field>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-soul">
SOUL.md <span className="font-normal text-muted-foreground">- {p.soulOptional}</span>
</label>
<Field htmlFor="new-profile-soul" label="SOUL.md" optional optionalLabel={p.soulOptional}>
<Textarea
className="min-h-28 font-mono text-xs leading-5"
id="new-profile-soul"
@@ -151,7 +140,7 @@ export function CreateProfileDialog({
placeholder={p.soulPlaceholder(cloneFrom ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
value={soul}
/>
</div>
</Field>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
@@ -10,11 +10,11 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Field, FieldHint } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { renameProfile } from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { isValidProfileName } from './create-profile-dialog'
@@ -93,11 +93,8 @@ export function RenameProfileDialog({
</DialogDescription>
</DialogHeader>
<form className="grid gap-3" onSubmit={handleSubmit}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="rename-profile-name">
{p.newNameLabel}
</label>
<form className="grid gap-4" onSubmit={handleSubmit}>
<Field htmlFor="rename-profile-name" label={p.newNameLabel}>
<Input
aria-invalid={invalid}
autoFocus
@@ -105,10 +102,8 @@ export function RenameProfileDialog({
onChange={event => setName(event.target.value)}
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
{p.nameHint}
</p>
</div>
<FieldHint error={invalid}>{p.nameHint}</FieldHint>
</Field>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
@@ -77,6 +77,7 @@ export function useSessionStateCache({
selectedStoredSessionIdPropRef.current = selectedStoredSessionId
selectedStoredSessionIdRef.current = selectedStoredSessionId
}
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const runtimeIdByStoredSessionIdRef = useRef(new Map<string, string>())
const pendingViewStateRef = useRef<{ sessionId: string; state: ClientSessionState } | null>(null)
+1 -1
View File
@@ -288,7 +288,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
<OverlaySplitLayout>
<OverlayNav footer={navFooter} groups={navGroups} />
<OverlayMain className="px-0 pb-0 pt-[calc(var(--titlebar-height)+1rem)]">
<OverlayMain className="px-0 pb-0">
{activeView === 'config:appearance' ? (
<AppearanceSettings />
) : activeView === 'about' ? (
+79 -90
View File
@@ -15,8 +15,10 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
createWebhook,
@@ -50,7 +52,7 @@ import {
PanelRowMenu,
PanelSectionLabel
} from '../overlays/panel'
import { ListRow, ToggleRow } from '../settings/primitives'
import { ListRow } from '../settings/primitives'
const DELIVER_OPTIONS: readonly string[] = ['log', 'telegram', 'discord', 'slack', 'email', 'github_comment']
@@ -435,100 +437,90 @@ export function WebhooksView({ onClose }: WebhooksViewProps) {
</DialogFooter>
</div>
) : (
<div className="grid gap-1">
<div className="grid grid-cols-2 gap-4">
<ListRow
action={
<Input
autoFocus
id="webhook-name"
onChange={e => setName(e.target.value)}
placeholder={w.fieldNamePlaceholder}
value={name}
/>
}
title={<label htmlFor="webhook-name">{w.fieldName}</label>}
wide
/>
<ListRow
action={
<Input
id="webhook-description"
onChange={e => setDescription(e.target.value)}
placeholder={w.fieldDescriptionPlaceholder}
value={description}
/>
}
title={<label htmlFor="webhook-description">{w.fieldDescription}</label>}
wide
/>
</div>
<ListRow
action={
<Textarea
className="min-h-[80px]"
id="webhook-prompt"
onChange={e => setPrompt(e.target.value)}
placeholder={w.fieldPromptPlaceholder}
value={prompt}
<form
className="grid gap-4"
onSubmit={e => {
e.preventDefault()
void handleCreate()
}}
>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="webhook-name" label={w.fieldName}>
<Input
autoFocus
id="webhook-name"
onChange={e => setName(e.target.value)}
placeholder={w.fieldNamePlaceholder}
value={name}
/>
}
title={<label htmlFor="webhook-prompt">{w.fieldPrompt}</label>}
wide
/>
<div className="grid grid-cols-2 gap-4">
<ListRow
action={
<Input
id="webhook-events"
onChange={e => setEvents(e.target.value)}
placeholder={w.fieldEventsPlaceholder}
value={events}
/>
}
title={<label htmlFor="webhook-events">{w.fieldEvents}</label>}
wide
/>
<ListRow
action={
<Input
id="webhook-skills"
onChange={e => setSkills(e.target.value)}
placeholder={w.fieldSkillsPlaceholder}
value={skills}
/>
}
title={<label htmlFor="webhook-skills">{w.fieldSkills}</label>}
wide
/>
</Field>
<Field htmlFor="webhook-description" label={w.fieldDescription}>
<Input
id="webhook-description"
onChange={e => setDescription(e.target.value)}
placeholder={w.fieldDescriptionPlaceholder}
value={description}
/>
</Field>
</div>
<div className="grid grid-cols-2 items-start gap-4">
<ListRow
action={
<Select onValueChange={setDeliver} value={deliver}>
<SelectTrigger className="h-9 rounded-md" id="webhook-deliver">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVER_OPTIONS.map(opt => (
<SelectItem key={opt} value={opt}>
{w.deliverOptions[opt] ?? opt}
</SelectItem>
))}
</SelectContent>
</Select>
}
title={<label htmlFor="webhook-deliver">{w.fieldDeliver}</label>}
wide
<Field htmlFor="webhook-prompt" label={w.fieldPrompt}>
<Textarea
className="min-h-24"
id="webhook-prompt"
onChange={e => setPrompt(e.target.value)}
placeholder={w.fieldPromptPlaceholder}
value={prompt}
/>
<ToggleRow checked={deliverOnly} label={w.fieldDeliverOnly} onChange={setDeliverOnly} />
</Field>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="webhook-events" label={w.fieldEvents}>
<Input
id="webhook-events"
onChange={e => setEvents(e.target.value)}
placeholder={w.fieldEventsPlaceholder}
value={events}
/>
</Field>
<Field htmlFor="webhook-skills" label={w.fieldSkills}>
<Input
id="webhook-skills"
onChange={e => setSkills(e.target.value)}
placeholder={w.fieldSkillsPlaceholder}
value={skills}
/>
</Field>
</div>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="webhook-deliver" label={w.fieldDeliver}>
<Select onValueChange={setDeliver} value={deliver}>
<SelectTrigger className="h-9 rounded-md" id="webhook-deliver">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVER_OPTIONS.map(opt => (
<SelectItem key={opt} value={opt}>
{w.deliverOptions[opt] ?? opt}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field htmlFor="webhook-deliver-only" label={w.fieldDeliverOnly}>
<div className="flex h-9 items-center">
<Switch checked={deliverOnly} id="webhook-deliver-only" onCheckedChange={setDeliverOnly} />
</div>
</Field>
</div>
<DialogFooter>
<Button disabled={creating} onClick={() => void handleCreate()} size="sm">
<Button disabled={creating} size="sm" type="submit">
{creating ? w.creating : w.create}
</Button>
</DialogFooter>
</div>
</form>
)}
</DialogContent>
</Dialog>
@@ -565,9 +557,6 @@ function WebhookDetail({ sub }: { sub: WebhookRoute }) {
<header className="space-y-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="text-[0.95rem] font-semibold tracking-tight text-foreground">{sub.name}</h3>
<PanelPill tone={sub.enabled ? 'good' : 'muted'}>
{sub.enabled ? t.messaging.states.enabled : t.messaging.states.disabled}
</PanelPill>
{sub.deliver_only && <PanelPill tone="warn">{w.deliverOnly}</PanelPill>}
</div>
+41
View File
@@ -0,0 +1,41 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
// Shared form-field primitive for dialog forms: a label stacked above its
// control, with an optional inline "(optional)" tag. Pair with FieldHint for
// help text below the control. This is the single field language for every form
// dialog (cron, webhooks, profiles, …) — don't hand-roll label+control stacks
// or reach for the settings-surface ListRow inside a dialog. Stack Fields in a
// `grid gap-4` form; pair two across with `grid items-start gap-4 sm:grid-cols-2`.
export function Field({
children,
htmlFor,
label,
optional,
optionalLabel
}: {
children: ReactNode
htmlFor?: string
label: ReactNode
optional?: boolean
optionalLabel?: string
}) {
return (
<div className="grid gap-1.5">
<label className="flex items-baseline gap-2 text-xs font-medium text-foreground" htmlFor={htmlFor}>
{label}
{optional && optionalLabel && (
<span className="text-[0.65rem] font-normal text-muted-foreground">{optionalLabel}</span>
)}
</label>
{children}
</div>
)
}
export function FieldHint({ children, error }: { children: ReactNode; error?: boolean }) {
return (
<p className={cn('text-[0.66rem] leading-4', error ? 'text-destructive' : 'text-muted-foreground')}>{children}</p>
)
}
+2
View File
@@ -1688,6 +1688,8 @@ export const en: Translations = {
},
blueprints: {
tab: 'Blueprints',
startFrom: 'Start from',
custom: 'Custom',
subtitle: 'Ready-made automations',
dialogDesc: 'Fill in the details and schedule it.',
scheduleIt: 'Schedule it',
+2
View File
@@ -1560,6 +1560,8 @@ export const ja = defineLocale({
},
blueprints: {
tab: 'ブレーンプリント',
startFrom: '開始点',
custom: 'カスタム',
subtitle: 'すぐに使える自動化',
dialogDesc: '詳細を入力してスケジュールします。',
scheduleIt: 'スケジュールする',
+2
View File
@@ -1399,6 +1399,8 @@ export interface Translations {
}
blueprints: {
tab: string
startFrom: string
custom: string
subtitle: string
dialogDesc: string
scheduleIt: string
+2
View File
@@ -1509,6 +1509,8 @@ export const zhHant = defineLocale({
},
blueprints: {
tab: '藍圖',
startFrom: '從此開始',
custom: '自訂',
subtitle: '現成的自動化',
dialogDesc: '填寫詳細資訊並進行排程。',
scheduleIt: '安排工作',
+2
View File
@@ -1879,6 +1879,8 @@ export const zh: Translations = {
},
blueprints: {
tab: '蓝图',
startFrom: '从此开始',
custom: '自定义',
subtitle: '现成的自动化',
dialogDesc: '填写详细信息并进行排程。',
scheduleIt: '安排任务',
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { SessionMessage } from '@/types/hermes'
import type { ChatMessage, ChatMessagePart } from './chat-messages'
import {
appendAssistantTextPart,
@@ -191,6 +193,30 @@ describe('toChatMessages', () => {
'background agent work finished'
])
})
// A backend older than this app serves display_metadata as unparsed JSON
// text. Indexing into that string used to throw and fail the whole resume.
it.each([
['an object', { delegation_id: 'deleg_1', task_count: 2 }, '2 background agents finished'],
['JSON text', JSON.stringify({ delegation_id: 'deleg_1', task_count: 1 }), '1 background agent finished'],
['unparseable text', '{not-json', 'background agent work finished'],
['text that is not an object', '"deleg_1"', 'background agent work finished'],
['a missing task count', { delegation_id: 'deleg_1' }, 'background agent work finished']
])('labels a delegation event given %s', (_case, displayMetadata, expected) => {
const read = () =>
toChatMessages([
{
role: 'user',
content: 'opaque delegation context payload',
display_kind: 'async_delegation_complete',
display_metadata: displayMetadata as SessionMessage['display_metadata'],
timestamp: 1
}
])
expect(read).not.toThrow()
expect(chatMessageText(read()[0])).toBe(expected)
})
})
describe('renderMediaTags', () => {
+23 -4
View File
@@ -311,16 +311,35 @@ function transcriptContent(displayKind: SessionMessage['display_kind'], content:
return displayKind === 'hidden' ? null : content
}
// A remote backend older than this app serves display_metadata as raw JSON text,
// and `in` throws on a primitive — which used to fail the whole session resume.
function timelineTaskCount(metadata: SessionMessage['display_metadata']): number | undefined {
let parsed: unknown = metadata
if (typeof parsed === 'string') {
try {
parsed = JSON.parse(parsed)
} catch {
return undefined
}
}
if (!parsed || typeof parsed !== 'object') {
return undefined
}
const count = (parsed as { task_count?: unknown }).task_count
return typeof count === 'number' ? count : undefined
}
function timelineDisplayContent(message: SessionMessage, content: string): string {
if (message.display_kind === 'model_switch') {
return 'model changed'
}
if (message.display_kind === 'async_delegation_complete') {
const count =
message.display_metadata && 'task_count' in message.display_metadata
? message.display_metadata.task_count
: undefined
const count = timelineTaskCount(message.display_metadata)
return count === undefined
? 'background agent work finished'
+5 -1
View File
@@ -494,7 +494,11 @@ export interface SessionMessage {
reasoning_content?: null | string
reasoning_details?: unknown
display_kind?: 'async_delegation_complete' | 'hidden' | 'model_switch' | string
display_metadata?: TimelineDisplayMetadata
/**
* A backend older than this app can still serve this as unparsed JSON text,
* so readers must narrow before indexing into it.
*/
display_metadata?: string | TimelineDisplayMetadata
role: 'assistant' | 'system' | 'tool' | 'user'
text?: unknown
timestamp?: number
+17 -2
View File
@@ -1557,7 +1557,15 @@ def _setup_worktree(repo_root: str = None, sync_base: bool = True) -> Optional[D
gitignore = Path(repo_root) / ".gitignore"
_ignore_entry = ".worktrees/"
try:
existing = gitignore.read_text() if gitignore.exists() else ""
# utf-8-sig: git files are UTF-8 and Notepad prepends a BOM, which
# would glue to the first line and defeat the membership check below
# (duplicating the entry); the locale default also breaks non-ASCII
# patterns on Windows. The append below already writes UTF-8.
existing = (
gitignore.read_text(encoding="utf-8-sig", errors="replace")
if gitignore.exists()
else ""
)
if _ignore_entry not in existing.splitlines():
with open(gitignore, "a", encoding="utf-8") as f:
if existing and not existing.endswith("\n"):
@@ -1607,7 +1615,14 @@ def _setup_worktree(repo_root: str = None, sync_base: bool = True) -> Optional[D
try:
repo_root_resolved = Path(repo_root).resolve()
wt_path_resolved = wt_path.resolve()
for line in include_file.read_text().splitlines():
# utf-8-sig, not the locale default: on a cp1251/GBK Windows
# machine a UTF-8 include list either decodes to mojibake paths
# (entries silently not copied) or raises UnicodeDecodeError,
# which the enclosing handler swallows at DEBUG — no include is
# copied at all. A Notepad BOM likewise glued to the first entry.
for line in include_file.read_text(
encoding="utf-8-sig", errors="replace"
).splitlines():
entry = line.strip()
if not entry or entry.startswith("#"):
continue
+1
View File
@@ -0,0 +1 @@
kael-odin
@@ -0,0 +1 @@
chancelu
+1
View File
@@ -0,0 +1 @@
jeeaay
+3 -3
View File
@@ -1123,7 +1123,7 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]:
return {"version": AUTH_STORE_VERSION, "providers": {}}
try:
raw = json.loads(auth_file.read_text())
raw = json.loads(auth_file.read_text(encoding="utf-8"))
except Exception as exc:
corrupt_path = auth_file.with_suffix(".json.corrupt")
try:
@@ -3826,7 +3826,7 @@ def _import_codex_cli_tokens() -> Optional[Dict[str, str]]:
if not auth_path.is_file():
return None
try:
payload = json.loads(auth_path.read_text())
payload = json.loads(auth_path.read_text(encoding="utf-8"))
tokens = payload.get("tokens")
if not isinstance(tokens, dict):
return None
@@ -5261,7 +5261,7 @@ def _read_shared_nous_state() -> Optional[Dict[str, Any]]:
if not path.is_file():
return None
try:
payload = json.loads(path.read_text())
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
logger.debug("Shared Nous auth store at %s is unreadable: %s", path, exc)
return None
+3 -2
View File
@@ -292,7 +292,7 @@ def check_for_updates() -> Optional[int]:
now = time.time()
try:
if cache_file.exists():
cached = json.loads(cache_file.read_text())
cached = json.loads(cache_file.read_text(encoding="utf-8"))
if (
now - cached.get("ts", 0) < _UPDATE_CHECK_CACHE_SECONDS
and cached.get("rev") == embedded_rev
@@ -321,7 +321,8 @@ def check_for_updates() -> Optional[int]:
try:
cache_file.write_text(
json.dumps({"ts": now, "behind": behind, "rev": embedded_rev, "ver": VERSION})
json.dumps({"ts": now, "behind": behind, "rev": embedded_rev, "ver": VERSION}),
encoding="utf-8",
)
except Exception:
pass
+6 -6
View File
@@ -236,7 +236,7 @@ def _maybe_migrate_legacy_gateway_run_state(
"desired_state": "running",
"timestamp": int(time.time()),
"migrated_from": "legacy-container-cmd",
}) + "\n")
}) + "\n", encoding="utf-8")
return "running"
@@ -382,7 +382,7 @@ def _read_desired_state(profile_dir: Path) -> str | None:
if not state_file.exists():
return None
try:
data = json.loads(state_file.read_text())
data = json.loads(state_file.read_text(encoding="utf-8"))
desired_state = data.get("desired_state")
if desired_state is not None:
return desired_state
@@ -450,7 +450,7 @@ def _register_service(scandir: Path, profile: str, *, start: bool) -> None:
tmp_dir.mkdir(parents=True)
try:
(tmp_dir / "type").write_text("longrun\n")
(tmp_dir / "type").write_text("longrun\n", encoding="utf-8")
# Reuse the manager's run-script rendering — single source of
# truth so register_profile_gateway and reconcile_profile_gateways
@@ -458,18 +458,18 @@ def _register_service(scandir: Path, profile: str, *, start: bool) -> None:
# per-profile env can set it via the profile's config.yaml
# (which the gateway itself loads).
run = tmp_dir / "run"
run.write_text(S6ServiceManager._render_run_script(profile, extra_env={}))
run.write_text(S6ServiceManager._render_run_script(profile, extra_env={}), encoding="utf-8")
run.chmod(0o755)
finish = tmp_dir / "finish"
finish.write_text(S6ServiceManager._render_finish_script())
finish.write_text(S6ServiceManager._render_finish_script(), encoding="utf-8")
finish.chmod(0o755)
# Persistent log rotation (OQ8-C).
log_subdir = tmp_dir / "log"
log_subdir.mkdir()
log_run = log_subdir / "run"
log_run.write_text(S6ServiceManager._render_log_run(profile))
log_run.write_text(S6ServiceManager._render_log_run(profile), encoding="utf-8")
log_run.chmod(0o755)
# The presence of a `down` file tells s6-supervise to NOT
@@ -31,6 +31,11 @@ the SPA should bootstrap it after login instead.
from __future__ import annotations
PUBLIC_API_PATHS: frozenset[str] = frozenset({
# Minimal process liveness probe for desktop/backend boot handshakes. It
# intentionally avoids gateway config, platform discovery, MCP setup, and
# host-local detail so readiness checks cannot spend their budget inside
# cold plugin imports.
"/api/health",
# Liveness probe target. Returns version, gateway state, active
# session count, and the dashboard auth-gate shape. No bodies, no
# session content, no secrets. Documented as the portal's wildcard
+9 -7
View File
@@ -887,11 +887,13 @@ def run_doctor(args):
if env_path.exists():
check_ok(f"{_DHH}/.env file exists")
# Check for common issues. Pin encoding to UTF-8 because .env files are
# written as UTF-8 everywhere in the codebase, while Path.read_text()
# defaults to the system locale — which crashes on non-UTF-8 Windows
# locales (e.g. GBK) as soon as the file contains any non-ASCII byte.
content = env_path.read_text(encoding="utf-8")
# Prefer UTF-8 (.env is written as UTF-8 elsewhere). Fall back to
# latin-1 for Windows Notepad/cp1252 files that are not valid UTF-8 —
# matches hermes_cli.env_loader._load_dotenv_with_fallback.
try:
content = env_path.read_text(encoding="utf-8")
except UnicodeDecodeError:
content = env_path.read_text(encoding="latin-1")
if _has_provider_env_config(content):
check_ok("API key or custom endpoint configured")
else:
@@ -2441,7 +2443,7 @@ def run_doctor(args):
if lock_file.exists():
try:
import json
lock_data = json.loads(lock_file.read_text())
lock_data = json.loads(lock_file.read_text(encoding="utf-8"))
count = len(lock_data.get("installed", {}))
check_ok(f"Lock file OK ({count} hub-installed skill(s))")
except Exception:
@@ -2607,7 +2609,7 @@ def run_doctor(args):
if not wrapper.is_file():
continue
try:
content = wrapper.read_text()
content = wrapper.read_text(encoding="utf-8")
if "hermes -p" in content:
_m = _re.search(r"hermes -p (\S+)", content)
if _m and not profile_exists(_m.group(1)):
+1 -1
View File
@@ -4251,7 +4251,7 @@ def launchd_install(force: bool = False):
if _refuse_temp_home_service_write(new_plist, "launchd plist"):
return
print(f"Installing launchd service to: {plist_path}")
plist_path.write_text(new_plist)
plist_path.write_text(new_plist, encoding="utf-8")
try:
_launchctl_bootstrap(
+250 -9
View File
@@ -639,7 +639,7 @@ def _apply_profile_override() -> None:
active_path = get_default_hermes_root() / "active_profile"
if active_path.exists():
name = active_path.read_text().strip()
name = active_path.read_text(encoding="utf-8").strip()
if name and name != "default":
profile_name = name
consume = 0 # don't strip anything from argv
@@ -1021,7 +1021,7 @@ def _has_any_provider_configured() -> bool:
try:
import json
auth = json.loads(auth_file.read_text())
auth = json.loads(auth_file.read_text(encoding="utf-8"))
active = auth.get("active_provider")
if active:
status = get_auth_status(active)
@@ -4806,7 +4806,7 @@ def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0)
"id": str(_uuid.uuid4()),
}
tmp = prompt_path.with_suffix(".tmp")
tmp.write_text(_json.dumps(payload))
tmp.write_text(_json.dumps(payload), encoding="utf-8")
tmp.replace(prompt_path)
# Poll for response
@@ -4814,7 +4814,7 @@ def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0)
while _time.monotonic() < deadline:
if response_path.exists():
try:
answer = response_path.read_text().strip()
answer = response_path.read_text(encoding="utf-8").strip()
response_path.unlink(missing_ok=True)
prompt_path.unlink(missing_ok=True)
return answer if answer else default
@@ -5507,9 +5507,227 @@ def _desktop_packaged_executable(desktop_dir: Path) -> Optional[Path]:
existing = [p for p in candidates if p.exists()]
if not existing:
return None
if sys.platform == "win32" and len(existing) > 1:
# Multiple unpacked trees can coexist (e.g. a stale win-arm64-unpacked
# left behind by a cross-arch experiment next to the real win-unpacked).
# Picking purely by mtime can then hand a wrong-architecture Hermes.exe
# to the launcher, which Windows rejects with "This app can't run on
# your computer" (#69179). Prefer candidates whose PE machine field
# matches the host; fall back to mtime when none can be parsed.
expected = _expected_windows_pe_machines()
matching = [p for p in existing if _pe_machine_or_none(p) in expected]
if matching:
existing = matching
return max(existing, key=lambda p: p.stat().st_mtime)
# ─── Desktop exe integrity gate (#69179) ────────────────────────────────────
#
# The desktop self-update chain (Desktop → hermes-setup --update →
# `hermes update` → `hermes desktop --build-only` → relaunch) rebuilds
# Hermes.exe on the end user's machine and used to verify only that the file
# EXISTS before declaring success. A corrupt cached Electron zip whose
# extraction produced a truncated electron.exe, an interrupted rcedit resource
# rewrite, a disk-full pack, or a wrong-arch unpacked tree therefore shipped a
# broken binary that Windows refuses to load ("This app can't run on your
# computer" / 此应用无法在你的电脑上运行). These helpers parse the PE header —
# no signature infrastructure required — so a structurally broken or
# wrong-architecture Hermes.exe is caught BEFORE the updater replaces the
# working app, and the previous build can be restored from the .bak tree that
# apps/desktop/scripts/before-pack.mjs now preserves.
_PE_MACHINE_I386 = 0x014C
_PE_MACHINE_AMD64 = 0x8664
_PE_MACHINE_ARM64 = 0xAA64
_PE_MACHINE_NAMES = {
_PE_MACHINE_I386: "x86 (32-bit)",
_PE_MACHINE_AMD64: "x64 (AMD64)",
_PE_MACHINE_ARM64: "ARM64",
}
def _expected_windows_pe_machines() -> set:
"""PE machine values the current Windows host can natively load.
AMD64 hosts run x64 and (via WOW64) x86. ARM64 hosts run ARM64 and
(Windows 11 emulation) x64. 32-bit x86 hosts run only x86. Unknown
machines return the permissive full set so the integrity gate can never
brick launch on exotic hosts.
"""
import platform as _platform
machine = (_platform.machine() or "").upper()
if machine in ("AMD64", "X86_64", "X64"):
return {_PE_MACHINE_AMD64, _PE_MACHINE_I386}
if machine in ("ARM64", "AARCH64"):
return {_PE_MACHINE_ARM64, _PE_MACHINE_AMD64}
if machine in ("X86", "I386", "I486", "I586", "I686"):
return {_PE_MACHINE_I386}
return {_PE_MACHINE_AMD64, _PE_MACHINE_ARM64, _PE_MACHINE_I386}
def _parse_pe_machine(path: Path) -> int:
"""Parse ``path`` as a PE executable and return its COFF machine field.
Raises ``ValueError`` with a human-readable reason when the file is not a
structurally complete PE: missing MZ/PE magic (an HTML error page or JSON
body saved as .exe), header truncation, or raw section data extending past
the end of the file (the truncated-download / interrupted-extraction
shape). Purely a header walk — cheap even on a 200 MB Electron exe.
"""
import struct
try:
file_size = path.stat().st_size
except OSError as exc:
raise ValueError(f"unreadable: {exc}")
if file_size < 512:
raise ValueError(
f"file is only {file_size} bytes — far too small to be a Windows executable"
)
with path.open("rb") as fh:
head = fh.read(64)
if len(head) < 64 or head[:2] != b"MZ":
raise ValueError(
"missing MZ header — not a Windows executable "
"(a truncated or non-binary file saved as .exe?)"
)
e_lfanew = struct.unpack_from("<I", head, 0x3C)[0]
if e_lfanew <= 0 or e_lfanew + 24 > file_size:
raise ValueError("corrupt DOS header: PE header offset points past end of file")
fh.seek(e_lfanew)
pe_head = fh.read(24)
if len(pe_head) < 24 or pe_head[:4] != b"PE\x00\x00":
raise ValueError("missing PE signature — corrupt executable header")
machine, n_sections = struct.unpack_from("<HH", pe_head, 4)
size_of_optional = struct.unpack_from("<H", pe_head, 20)[0]
fh.seek(e_lfanew + 24 + size_of_optional)
max_section_end = 0
for _ in range(n_sections):
section = fh.read(40)
if len(section) < 40:
raise ValueError("truncated PE section table")
size_of_raw, pointer_to_raw = struct.unpack_from("<II", section, 16)
max_section_end = max(max_section_end, pointer_to_raw + size_of_raw)
if file_size < max_section_end:
raise ValueError(
f"truncated executable: file is {file_size} bytes but its PE "
f"sections extend to {max_section_end} bytes"
)
return machine
def _pe_machine_or_none(path: Path) -> Optional[int]:
try:
return _parse_pe_machine(path)
except ValueError:
return None
def _desktop_exe_integrity_error(path: Path) -> Optional[str]:
"""Return a human-readable reason ``path`` cannot run on this Windows host,
or ``None`` when the exe parses as a complete PE of a loadable architecture.
"""
try:
machine = _parse_pe_machine(path)
except ValueError as exc:
return str(exc)
expected = _expected_windows_pe_machines()
if machine not in expected:
import platform as _platform
got = _PE_MACHINE_NAMES.get(machine, f"unknown machine 0x{machine:04X}")
return (
f"architecture mismatch: built a {got} executable but this is a "
f"{_platform.machine()} Windows host"
)
return None
def _desktop_backup_unpacked_dir(packaged_executable: Path) -> Path:
"""The rollback tree before-pack.mjs preserves: ``<unpacked-dir>.bak``."""
unpacked = packaged_executable.parent
return unpacked.parent / (unpacked.name + ".bak")
def _rollback_desktop_from_backup(packaged_executable: Path) -> Optional[Path]:
"""Restore the previous unpacked desktop app from its ``.bak`` tree.
Returns the restored executable path, or ``None`` when no usable backup
exists (missing, or its exe fails the same integrity probe). The corrupt
tree is kept alongside as ``<unpacked-dir>.corrupt`` for diagnostics.
Best-effort: never raises.
"""
unpacked = packaged_executable.parent
backup_dir = _desktop_backup_unpacked_dir(packaged_executable)
backup_exe = backup_dir / packaged_executable.name
if not backup_exe.exists():
return None
if _desktop_exe_integrity_error(backup_exe) is not None:
return None
corrupt_dir = unpacked.parent / (unpacked.name + ".corrupt")
try:
shutil.rmtree(corrupt_dir, ignore_errors=True)
try:
unpacked.rename(corrupt_dir)
except OSError:
shutil.rmtree(unpacked, ignore_errors=True)
backup_dir.rename(unpacked)
except OSError:
return None
restored = unpacked / packaged_executable.name
return restored if restored.exists() else None
def _ensure_desktop_exe_launchable(
desktop_dir: Path, packaged_executable: Optional[Path]
) -> tuple:
"""Windows post-build integrity gate for the self-update rebuild (#69179).
Returns ``(verified_exe_or_None, rolled_back)``:
- exe passed the probe → ``(exe, False)``
- exe corrupt/wrong-arch, previous build restored → ``(old_exe, True)``
- exe corrupt and nothing restorable → ``(None, False)``
On any integrity failure the corrupt cached Electron zip is purged and the
desktop build stamp invalidated, so the updater's retry-once rebuild pulls
a fresh, SHASUM-verified Electron download instead of re-staging the same
corrupt bytes. No-op off Windows and when there is no executable to check.
"""
if packaged_executable is None or sys.platform != "win32":
return packaged_executable, False
error = _desktop_exe_integrity_error(packaged_executable)
if error is None:
return packaged_executable, False
print(f"✗ The built Hermes.exe failed its integrity check: {error}")
print(f" at: {packaged_executable}")
# Self-heal setup for the retry: drop the (likely corrupt) cached Electron
# zip and the content stamp so the next rebuild is a genuine re-download +
# re-stage rather than a replay of the same broken extraction.
_purge_electron_build_cache(desktop_dir)
try:
_desktop_stamp_path().unlink()
except OSError:
pass
restored = _rollback_desktop_from_backup(packaged_executable)
if restored is not None:
print(" ↩ Update aborted — restored the previous working Hermes.exe from backup.")
print(" Your existing version was kept and still works. Run `hermes desktop`")
print(" (or the in-app update) again to retry with a fresh Electron download.")
return restored, True
print(" ✗ No usable backup was found to restore.")
print(" Run `hermes desktop --force-build` to rebuild, or re-run the Hermes")
print(" installer to repair the install.")
return None, False
def _electron_download_cache_dirs() -> list[Path]:
"""Return the per-user Electron download cache directories for this OS.
@@ -6151,6 +6369,22 @@ def cmd_gui(args: argparse.Namespace):
# damaged"). No-op on non-macOS and on real-identity builds.
_desktop_macos_relaunchable_fixup(desktop_dir)
# Windows integrity gate (#69179): never declare the rebuild a
# success on a Hermes.exe Windows cannot load (truncated PE from
# a corrupt cached Electron zip, wrong-arch tree, interrupted
# rcedit rewrite). Roll back to the .bak tree preserved by
# before-pack.mjs when possible, then fail loudly so the
# updater's retry-once rebuilds from a fresh Electron download
# instead of silently shipping the broken exe.
verified_executable, rolled_back = _ensure_desktop_exe_launchable(
desktop_dir, packaged_executable
)
if packaged_executable is not None and (
rolled_back or verified_executable is None
):
sys.exit(1)
packaged_executable = verified_executable
# Build succeeded — write the stamp so next run can skip
_write_desktop_build_stamp(PROJECT_ROOT, source_mode=source_mode)
@@ -9959,7 +10193,7 @@ def _ensure_fhs_path_guard() -> None:
if not cfg.is_file():
continue
try:
existing = cfg.read_text(errors="replace")
existing = cfg.read_text(errors="replace", encoding="utf-8")
except OSError:
continue
# Idempotency: skip if any uncommented PATH= line already references
@@ -11905,7 +12139,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
if gateway_mode:
_exit_code_path = get_hermes_home() / ".update_exit_code"
try:
_exit_code_path.write_text("0")
_exit_code_path.write_text("0", encoding="utf-8")
except OSError:
pass
@@ -12535,7 +12769,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
if gateway_mode:
_exit_code_path = get_hermes_home() / ".update_exit_code"
try:
_exit_code_path.write_text("1")
_exit_code_path.write_text("1", encoding="utf-8")
except OSError:
pass
_warn_incomplete_gateway_fleet_restart(failed_or_stale_units)
@@ -13357,7 +13591,11 @@ def _render_distribution_plan(plan) -> None:
env_path = plan.target_dir / ".env"
if env_path.is_file():
try:
for raw in env_path.read_text().splitlines():
# .env is written as UTF-8 everywhere in the codebase,
# but a Notepad-edited file can carry a BOM — read as
# utf-8-sig so the first key isn't hidden behind
# U+FEFF (#62617).
for raw in env_path.read_text(encoding="utf-8-sig").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
@@ -13365,7 +13603,10 @@ def _render_distribution_plan(plan) -> None:
if key == er.name:
already = True
break
except OSError:
except (OSError, UnicodeDecodeError):
# UnicodeDecodeError is a ValueError, not an OSError, so
# the old guard let a mis-encoded .env abort the whole
# install preview. Skip the pre-check instead.
pass
status = "✓ set" if already else ("needs setting" if er.required else "—")
line = f" • {er.name} ({tag}, {status})"
+1 -1
View File
@@ -1601,7 +1601,7 @@ class PluginManager:
init_file = plugin_dir / "__init__.py"
if init_file.exists():
try:
source_text = init_file.read_text(errors="replace")[:8192]
source_text = init_file.read_text(errors="replace", encoding="utf-8")[:8192]
if (
"register_memory_provider" in source_text
or "MemoryProvider" in source_text
+7 -7
View File
@@ -414,7 +414,7 @@ def check_alias_collision(name: str) -> Optional[str]:
expected = wrapper_dir / (f"{canon}.bat" if is_windows else canon)
if existing_path == str(expected):
try:
content = expected.read_text()
content = expected.read_text(encoding="utf-8")
if "hermes -p" in content:
return None # it's our wrapper, safe to overwrite
except Exception:
@@ -458,7 +458,7 @@ def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[P
if is_windows:
wrapper_path = wrapper_dir / f"{canon}.bat"
try:
wrapper_path.write_text(f"@echo off\r\nhermes -p {profile} %*\r\n")
wrapper_path.write_text(f"@echo off\r\nhermes -p {profile} %*\r\n", encoding="utf-8")
return wrapper_path
except OSError as e:
print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
@@ -467,7 +467,7 @@ def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[P
wrapper_path = wrapper_dir / canon
try:
hermes_exe = shutil.which("hermes") or "hermes"
wrapper_path.write_text(f'#!/bin/sh\nexec {shlex.quote(hermes_exe)} -p {profile} "$@"\n')
wrapper_path.write_text(f'#!/bin/sh\nexec {shlex.quote(hermes_exe)} -p {profile} "$@"\n', encoding="utf-8")
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
return wrapper_path
except OSError as e:
@@ -496,7 +496,7 @@ def remove_wrapper_script(name: str) -> bool:
if wrapper_path.exists():
try:
# Verify it's our wrapper before removing
content = wrapper_path.read_text()
content = wrapper_path.read_text(encoding="utf-8")
if "hermes -p" in content:
wrapper_path.unlink()
return True
@@ -1755,7 +1755,7 @@ def _stop_gateway_process(profile_dir: Path) -> None:
return
try:
raw = pid_file.read_text().strip()
raw = pid_file.read_text(encoding="utf-8").strip()
data = json.loads(raw) if raw.startswith("{") else {"pid": int(raw)}
pid = int(data["pid"])
# Route through terminate_pid so Windows uses the appropriate
@@ -1796,7 +1796,7 @@ def get_active_profile() -> str:
"""
path = _get_active_profile_path()
try:
name = path.read_text().strip()
name = path.read_text(encoding="utf-8").strip()
if not name:
return "default"
return name
@@ -1825,7 +1825,7 @@ def set_active_profile(name: str) -> None:
else:
# Atomic write
tmp = path.with_suffix(".tmp")
tmp.write_text(canon + "\n")
tmp.write_text(canon + "\n", encoding="utf-8")
tmp.replace(path)
+6 -6
View File
@@ -376,7 +376,7 @@ def _write_gateway_desired_state(name: str, desired_state: str) -> None:
if not profile_dir.exists():
return
try:
data = json.loads(state_file.read_text()) if state_file.exists() else {}
data = json.loads(state_file.read_text(encoding="utf-8")) if state_file.exists() else {}
if not isinstance(data, dict):
data = {}
except (OSError, json.JSONDecodeError):
@@ -384,7 +384,7 @@ def _write_gateway_desired_state(name: str, desired_state: str) -> None:
data["desired_state"] = desired_state
data["updated_at"] = int(time.time())
tmp = state_file.with_suffix(state_file.suffix + ".tmp")
tmp.write_text(json.dumps(data, separators=(",", ":")) + "\n")
tmp.write_text(json.dumps(data, separators=(",", ":")) + "\n", encoding="utf-8")
tmp.replace(state_file)
except OSError:
return
@@ -987,22 +987,22 @@ class S6ServiceManager:
tmp_dir.mkdir(parents=True)
try:
(tmp_dir / "type").write_text("longrun\n")
(tmp_dir / "type").write_text("longrun\n", encoding="utf-8")
run_script = self._render_run_script(profile, extra_env or {})
run_path = tmp_dir / "run"
run_path.write_text(run_script)
run_path.write_text(run_script, encoding="utf-8")
run_path.chmod(0o755)
finish_path = tmp_dir / "finish"
finish_path.write_text(self._render_finish_script())
finish_path.write_text(self._render_finish_script(), encoding="utf-8")
finish_path.chmod(0o755)
# Persistent log rotation (OQ8-C).
log_subdir = tmp_dir / "log"
log_subdir.mkdir()
log_run = log_subdir / "run"
log_run.write_text(self._render_log_run(profile))
log_run.write_text(self._render_log_run(profile), encoding="utf-8")
log_run.chmod(0o755)
# Pre-create the supervise/ skeleton with hermes ownership
+3 -3
View File
@@ -57,7 +57,7 @@ def remove_path_from_shell_configs():
for config_path in configs:
try:
content = config_path.read_text()
content = config_path.read_text(encoding="utf-8")
original_content = content
# Remove lines containing hermes-agent or hermes PATH entries
@@ -87,7 +87,7 @@ def remove_path_from_shell_configs():
new_content = new_content.replace('\n\n\n', '\n\n')
if new_content != original_content:
config_path.write_text(new_content)
config_path.write_text(new_content, encoding="utf-8")
removed_from.append(config_path)
except Exception as e:
@@ -108,7 +108,7 @@ def remove_wrapper_script():
if wrapper.exists():
try:
# Check if it's our wrapper (contains hermes_cli reference)
content = wrapper.read_text()
content = wrapper.read_text(encoding="utf-8")
if 'hermes_cli' in content or 'hermes-agent' in content:
wrapper.unlink()
removed.append(wrapper)
+10
View File
@@ -3036,6 +3036,16 @@ async def get_ssh_ownership(request: Request):
return {"ok": True, "sshOwnerNonce": _SSH_OWNER_NONCE, "protocolVersion": 1}
@app.get("/api/health")
async def get_health():
"""Lightweight process liveness for desktop/backend readiness probes."""
return {
"ok": True,
"version": __version__,
"auth_required": bool(getattr(app.state, "auth_required", False)),
}
@app.get("/api/status")
async def get_status(profile: Optional[str] = None):
status_scope = None
+1 -1
View File
@@ -78,7 +78,7 @@ def _warn_profile_fallback_once() -> None:
try:
fallback_home = _get_platform_default_hermes_home()
active_path = fallback_home / "active_profile"
active = active_path.read_text().strip() if active_path.exists() else ""
active = active_path.read_text(encoding="utf-8").strip() if active_path.exists() else ""
except (UnicodeDecodeError, OSError):
active = ""
if active and active != "default":
+64 -7
View File
@@ -5983,6 +5983,58 @@ class SessionDB:
return content
return content
@staticmethod
def _encode_display_metadata(display_metadata: Any) -> Optional[str]:
"""Serialize ``display_metadata`` for its TEXT column without double-encoding.
Import/replace paths can hand us an already-serialized JSON string (the
same hazard ``tool_calls`` guards against above). ``json.dumps`` on that
string would store a quoted JSON string, and the single ``json.loads``
on read then yields a ``str`` instead of a dict.
"""
if not display_metadata:
return None
if isinstance(display_metadata, str):
try:
parsed = json.loads(display_metadata)
except (json.JSONDecodeError, TypeError):
logger.warning("Ignoring non-JSON display metadata on write")
return None
if not isinstance(parsed, dict):
logger.warning("Ignoring non-object display metadata on write")
return None
return json.dumps(parsed)
if isinstance(display_metadata, dict):
return json.dumps(display_metadata)
logger.warning(
"Ignoring unexpected display metadata type on write: %s",
type(display_metadata).__name__,
)
return None
@staticmethod
def _decode_display_metadata(raw: Any) -> Optional[Dict[str, Any]]:
"""Decode a ``display_metadata`` column into the dict every reader expects.
Every message read path must go through this. Returning the raw TEXT
instead reaches the desktop as a string, where ``'task_count' in meta``
throws and fails the whole resume. Rows written before the encode guard
landed are double-encoded, so unwrap a second layer when we find one.
"""
if raw is None:
return None
try:
meta = json.loads(raw) if isinstance(raw, str) else raw
if isinstance(meta, str):
meta = json.loads(meta)
except (json.JSONDecodeError, TypeError):
logger.warning("Ignoring invalid display metadata on message row")
return None
if not isinstance(meta, dict):
logger.warning("Ignoring non-object display metadata on message row")
return None
return meta
def append_message(
self,
session_id: str,
@@ -6029,7 +6081,7 @@ class SessionDB:
"""
# Display metadata is presentation-only and never changes the model
# context role/content replayed to providers.
display_metadata_json = json.dumps(display_metadata) if display_metadata else None
display_metadata_json = self._encode_display_metadata(display_metadata)
# Serialize structured fields to JSON before entering the write txn
reasoning_details_json = (
json.dumps(reasoning_details)
@@ -6168,7 +6220,7 @@ class SessionDB:
"UPDATE messages SET display_kind = ?, display_metadata = ? WHERE id = ?",
(
_scrub_surrogates(display_kind),
json.dumps(display_metadata) if display_metadata else None,
self._encode_display_metadata(display_metadata),
row[0],
),
)
@@ -6262,7 +6314,7 @@ class SessionDB:
1,
_scrub_surrogates(api_content) if isinstance(api_content, str) else None,
_scrub_surrogates(msg.get("display_kind")) if isinstance(msg.get("display_kind"), str) else None,
json.dumps(msg["display_metadata"]) if msg.get("display_metadata") else None,
self._encode_display_metadata(msg.get("display_metadata")),
),
)
inserted += 1
@@ -6476,6 +6528,8 @@ class SessionDB:
except (json.JSONDecodeError, TypeError):
logger.warning("Failed to deserialize tool_calls in get_messages, falling back to []")
msg["tool_calls"] = []
if msg.get("display_metadata") is not None:
msg["display_metadata"] = self._decode_display_metadata(msg["display_metadata"])
result.append(msg)
return result
@@ -6545,6 +6599,8 @@ class SessionDB:
"Failed to deserialize tool_calls in get_messages_around, falling back to []"
)
msg["tool_calls"] = []
if msg.get("display_metadata") is not None:
msg["display_metadata"] = self._decode_display_metadata(msg["display_metadata"])
result.append(msg)
# before_rows includes the anchor itself; subtract 1 for the count of
@@ -6667,6 +6723,8 @@ class SessionDB:
"Failed to deserialize tool_calls in get_anchored_view, falling back to []"
)
msg["tool_calls"] = []
if msg.get("display_metadata") is not None:
msg["display_metadata"] = self._decode_display_metadata(msg["display_metadata"])
return msg
return {
@@ -6865,10 +6923,9 @@ class SessionDB:
if row["display_kind"]:
msg["display_kind"] = row["display_kind"]
if row["display_metadata"]:
try:
msg["display_metadata"] = json.loads(row["display_metadata"])
except (TypeError, json.JSONDecodeError):
logger.warning("Ignoring invalid display metadata on message row")
decoded = self._decode_display_metadata(row["display_metadata"])
if decoded is not None:
msg["display_metadata"] = decoded
if row["timestamp"]:
msg["timestamp"] = row["timestamp"]
if row["tool_call_id"]:
@@ -68,7 +68,7 @@ ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"
def load_template(name: str) -> str:
return (ASSETS_DIR / name).read_text()
return (ASSETS_DIR / name).read_text(encoding="utf-8")
PROFILE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
@@ -471,7 +471,7 @@ def main():
help="Write TEAM.md alongside (default: skipped)")
args = ap.parse_args()
plan = json.loads(Path(args.plan_json).read_text())
plan = json.loads(Path(args.plan_json).read_text(encoding="utf-8"))
errors = validate_plan(plan)
if errors:
print("Plan validation failed:", file=sys.stderr)
@@ -483,15 +483,15 @@ def main():
team = render_team_md(plan)
setup = render_setup_sh(plan, brief, team)
Path(args.out).write_text(setup)
Path(args.out).write_text(setup, encoding="utf-8")
os.chmod(args.out, 0o755)
print(f"Wrote {args.out}")
if args.brief_out:
Path(args.brief_out).write_text(brief)
Path(args.brief_out).write_text(brief, encoding="utf-8")
print(f"Wrote {args.brief_out}")
if args.team_out:
Path(args.team_out).write_text(team)
Path(args.team_out).write_text(team, encoding="utf-8")
print(f"Wrote {args.team_out}")
@@ -312,7 +312,7 @@ def sha256_file(path: Path) -> str:
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
return path.read_text(encoding="utf-8", errors="replace")
def normalize_text(text: str) -> str:
@@ -349,7 +349,12 @@ def resolve_secret_input(value: Any, env: Optional[Dict[str, str]] = None) -> Op
def load_yaml_file(path: Path) -> Dict[str, Any]:
if yaml is None or not path.exists():
return {}
data = yaml.safe_load(path.read_text(encoding="utf-8"))
try:
# errors="replace" means read_text() cannot raise UnicodeDecodeError;
# OSError covers races like the file disappearing or being unreadable.
data = yaml.safe_load(path.read_text(encoding="utf-8", errors="replace"))
except (yaml.YAMLError, OSError):
return {}
return data if isinstance(data, dict) else {}
@@ -367,7 +372,13 @@ def parse_env_file(path: Path) -> Dict[str, str]:
if not path.exists():
return {}
data: Dict[str, str] = {}
for raw_line in path.read_text(encoding="utf-8").splitlines():
try:
# errors="replace" means read_text() cannot raise UnicodeDecodeError;
# OSError covers races like the file disappearing or being unreadable.
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return {}
for raw_line in lines:
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
@@ -1208,10 +1219,13 @@ class Migrator:
return
try:
data = json.loads(source.read_text(encoding="utf-8"))
data = json.loads(source.read_text(encoding="utf-8", errors="replace"))
except json.JSONDecodeError as exc:
self.record("command-allowlist", source, destination, "error", f"Invalid JSON: {exc}")
return
except OSError as exc:
self.record("command-allowlist", source, destination, "error", f"Could not read file: {exc}")
return
patterns: List[str] = []
agents = data.get("agents", {})
@@ -1262,9 +1276,11 @@ class Migrator:
config_path = self.source_root / name
if config_path.exists():
try:
data = json.loads(config_path.read_text(encoding="utf-8"))
# errors="replace" means read_text() cannot raise
# UnicodeDecodeError; OSError covers unreadable/vanished files.
data = json.loads(config_path.read_text(encoding="utf-8", errors="replace"))
return data if isinstance(data, dict) else {}
except json.JSONDecodeError:
except (json.JSONDecodeError, OSError):
continue
return {}
@@ -1343,9 +1359,11 @@ class Migrator:
allowlist_path = self.source_root / "credentials" / "telegram-default-allowFrom.json"
if allowlist_path.exists():
try:
allow_data = json.loads(allowlist_path.read_text(encoding="utf-8"))
allow_data = json.loads(allowlist_path.read_text(encoding="utf-8", errors="replace"))
except json.JSONDecodeError:
self.record("messaging-settings", allowlist_path, self.target_root / ".env", "error", "Invalid JSON in Telegram allowlist file")
except OSError as exc:
self.record("messaging-settings", allowlist_path, self.target_root / ".env", "error", f"Could not read Telegram allowlist file: {exc}")
else:
allow_from = allow_data.get("allowFrom", [])
if isinstance(allow_from, list):
@@ -1617,7 +1635,7 @@ class Migrator:
auth_profiles_path = self.source_root / "agents" / "main" / "agent" / "auth-profiles.json"
if auth_profiles_path.exists():
try:
profiles = json.loads(auth_profiles_path.read_text(encoding="utf-8"))
profiles = json.loads(auth_profiles_path.read_text(encoding="utf-8", errors="replace"))
if isinstance(profiles, dict):
# auth-profiles.json wraps profiles in a "profiles" key
profile_entries = profiles.get("profiles", profiles) if isinstance(profiles.get("profiles"), dict) else profiles
@@ -1901,7 +1919,12 @@ class Migrator:
all_incoming: List[str] = []
for md_file in md_files:
entries = extract_markdown_entries(read_text(md_file))
try:
# read_text() uses errors="replace" so it cannot raise
# UnicodeDecodeError; OSError covers unreadable/vanished files.
entries = extract_markdown_entries(read_text(md_file))
except OSError:
continue
all_incoming.extend(entries)
if not all_incoming:
@@ -141,7 +141,7 @@ def build_findings(
# 3. Timing-based findings.
if timing_path and Path(timing_path).exists():
timing = json.loads(Path(timing_path).read_text())
timing = json.loads(Path(timing_path).read_text(encoding="utf-8"))
for r in timing.get("results", []):
if not r.get("significant"):
continue
@@ -190,7 +190,7 @@ def build_findings(
},
"findings": findings,
}
Path(out_path).write_text(json.dumps(payload, indent=2))
Path(out_path).write_text(json.dumps(payload, indent=2), encoding="utf-8")
return payload
@@ -198,7 +198,7 @@ def analyze(
"results": results,
}
Path(out_path).write_text(json.dumps(payload, indent=2))
Path(out_path).write_text(json.dumps(payload, indent=2), encoding="utf-8")
return payload
+1 -1
View File
@@ -86,7 +86,7 @@ def _is_cron_provider_dir(path: Path) -> bool:
if not init_file.exists():
return False
try:
source = init_file.read_text(errors="replace")[:8192]
source = init_file.read_text(errors="replace", encoding="utf-8")[:8192]
return "register_cron_scheduler" in source or "CronScheduler" in source
except Exception:
return False
+3 -3
View File
@@ -110,12 +110,12 @@ def load_tracked() -> List[Dict[str, Any]]:
return []
try:
return json.loads(tf.read_text())
return json.loads(tf.read_text(encoding="utf-8"))
except (json.JSONDecodeError, ValueError):
bak = tf.with_suffix(".json.bak")
if bak.exists():
try:
data = json.loads(bak.read_text())
data = json.loads(bak.read_text(encoding="utf-8"))
_log("WARN: tracked.json corrupted — restored from .bak")
return data
except Exception:
@@ -129,7 +129,7 @@ def save_tracked(tracked: List[Dict[str, Any]]) -> None:
tf = get_tracked_file()
tf.parent.mkdir(parents=True, exist_ok=True)
tmp = tf.with_suffix(".json.tmp")
tmp.write_text(json.dumps(tracked, indent=2))
tmp.write_text(json.dumps(tracked, indent=2), encoding="utf-8")
if tf.exists():
shutil.copy2(tf, tf.with_suffix(".json.bak"))
tmp.replace(tf)
@@ -262,7 +262,7 @@ class RealtimeSpeaker:
if not self.queue_path.exists():
return []
out: list[dict] = []
for line in self.queue_path.read_text().splitlines():
for line in self.queue_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
@@ -281,10 +281,10 @@ class RealtimeSpeaker:
if not remaining:
# Keep the file but empty — consumers may be watching for
# new writes via mtime, and delete-then-recreate is a race.
self.queue_path.write_text("")
self.queue_path.write_text("", encoding="utf-8")
return
self.queue_path.write_text(
"\n".join(json.dumps(e) for e in remaining) + "\n"
"\n".join(json.dumps(e) for e in remaining) + "\n", encoding="utf-8"
)
def _append_processed(self, entry: dict, result: dict) -> None:
@@ -159,7 +159,7 @@ def load_state() -> Dict[str, Any]:
if not path.exists():
return {"unlocks": {}}
try:
return json.loads(path.read_text())
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {"unlocks": {}}
@@ -167,7 +167,7 @@ def load_state() -> Dict[str, Any]:
def save_state(state: Dict[str, Any]) -> None:
path = state_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(state, indent=2, sort_keys=True))
path.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")
def _json_safe(value: Any) -> Any:
@@ -185,7 +185,7 @@ def load_snapshot() -> Optional[Dict[str, Any]]:
if not path.exists():
return None
try:
data = json.loads(path.read_text())
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict):
return data
except Exception:
@@ -196,7 +196,7 @@ def load_snapshot() -> Optional[Dict[str, Any]]:
def save_snapshot(data: Dict[str, Any]) -> None:
path = snapshot_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(_json_safe(data), indent=2, sort_keys=True))
path.write_text(json.dumps(_json_safe(data), indent=2, sort_keys=True), encoding="utf-8")
def load_checkpoint() -> Dict[str, Any]:
@@ -204,7 +204,7 @@ def load_checkpoint() -> Dict[str, Any]:
if not path.exists():
return {"schema_version": 1, "generated_at": 0, "sessions": {}}
try:
data = json.loads(path.read_text())
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict):
data.setdefault("schema_version", 1)
data.setdefault("generated_at", 0)
@@ -219,7 +219,7 @@ def load_checkpoint() -> Dict[str, Any]:
def save_checkpoint(data: Dict[str, Any]) -> None:
path = checkpoint_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(_json_safe(data), indent=2, sort_keys=True))
path.write_text(json.dumps(_json_safe(data), indent=2, sort_keys=True), encoding="utf-8")
def session_fingerprint(meta: Dict[str, Any]) -> Dict[str, Any]:
+1 -1
View File
@@ -81,7 +81,7 @@ def _is_memory_provider_dir(path: Path) -> bool:
if not init_file.exists():
return False
try:
source = init_file.read_text(errors="replace")[:8192]
source = init_file.read_text(errors="replace", encoding="utf-8")[:8192]
return "register_memory_provider" in source or "MemoryProvider" in source
except Exception:
return False
+11 -5
View File
@@ -496,7 +496,9 @@ def _load_simple_env(path) -> dict[str, str]:
return {}
values: dict[str, str] = {}
for line in path.read_text(encoding="utf-8").splitlines():
# utf-8-sig, not plain utf-8: this is also used on the Hermes .env during
# post_setup, and a Notepad BOM would otherwise stick to the first key.
for line in path.read_text(encoding="utf-8-sig", errors="replace").splitlines():
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
@@ -746,7 +748,7 @@ class HindsightMemoryProvider(MemoryProvider):
existing = {}
if config_path.exists():
try:
existing = json.loads(config_path.read_text())
existing = json.loads(config_path.read_text(encoding="utf-8"))
except Exception:
pass
existing.update(values)
@@ -895,7 +897,8 @@ class HindsightMemoryProvider(MemoryProvider):
env_path = Path(hermes_home) / ".env"
existing_llm_key = ""
if env_path.exists():
for line in env_path.read_text().splitlines():
# utf-8-sig: a Notepad BOM must not hide the first key.
for line in env_path.read_text(encoding="utf-8-sig").splitlines():
if line.startswith("HINDSIGHT_LLM_API_KEY="):
existing_llm_key = line.split("=", 1)[1]
break
@@ -925,7 +928,10 @@ class HindsightMemoryProvider(MemoryProvider):
env_path.parent.mkdir(parents=True, exist_ok=True)
existing_lines = []
if env_path.exists():
existing_lines = env_path.read_text().splitlines()
# utf-8-sig: a Notepad BOM would glue U+FEFF onto the first
# key, defeating the in-place update below and appending a
# duplicate line instead.
existing_lines = env_path.read_text(encoding="utf-8-sig").splitlines()
updated_keys = set()
new_lines = []
for line in existing_lines:
@@ -938,7 +944,7 @@ class HindsightMemoryProvider(MemoryProvider):
for k, v in env_writes.items():
if k not in updated_keys:
new_lines.append(f"{k}={v}")
env_path.write_text("\n".join(new_lines) + "\n")
env_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
if mode == "local_embedded":
materialized_config = dict(provider_config)
+1 -1
View File
@@ -317,7 +317,7 @@ class HonchoMemoryProvider(MemoryProvider):
existing = {}
if config_path.exists():
try:
existing = json.loads(config_path.read_text())
existing = json.loads(config_path.read_text(encoding="utf-8"))
except Exception:
pass
existing.update(values)
+1 -1
View File
@@ -240,7 +240,7 @@ class Mem0MemoryProvider(MemoryProvider):
existing = {}
if config_path.exists():
try:
existing = json.loads(config_path.read_text())
existing = json.loads(config_path.read_text(encoding="utf-8"))
except Exception:
pass
existing.update(values)
+18 -6
View File
@@ -193,7 +193,14 @@ def _write_env(env_path: Path, env_writes: dict[str, str]) -> None:
env_path.parent.mkdir(parents=True, exist_ok=True)
existing_lines: list[str] = []
if env_path.exists():
existing_lines = env_path.read_text().splitlines()
# Read as UTF-8 (BOM-tolerant), matching the canonical .env readers in
# hermes_cli/config.py. read_text() with no encoding falls back to the
# system locale (cp1252/GBK on Windows): it mangles or crashes on
# non-ASCII values while copying existing lines through, and a BOM'd
# first line would fail the key match and get duplicated.
existing_lines = env_path.read_text(
encoding="utf-8-sig"
).splitlines()
updated_keys: set[str] = set()
new_lines: list[str] = []
@@ -208,7 +215,7 @@ def _write_env(env_path: Path, env_writes: dict[str, str]) -> None:
if k not in updated_keys:
new_lines.append(f"{k}={v}")
env_path.write_text("\n".join(new_lines) + "\n")
env_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
def _save_mem0_json(hermes_home: str, data: dict) -> None:
@@ -221,7 +228,7 @@ def _save_mem0_json(hermes_home: str, data: dict) -> None:
except Exception:
pass
existing.update(data)
config_path.write_text(json.dumps(existing, indent=2) + "\n")
config_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
def _setup_platform(hermes_home: str, config: dict, flags: dict[str, str]) -> None:
@@ -241,7 +248,7 @@ def _setup_platform(hermes_home: str, config: dict, flags: dict[str, str]) -> No
config_path = Path(hermes_home) / "mem0.json"
if config_path.exists():
try:
existing_config = json.loads(config_path.read_text())
existing_config = json.loads(config_path.read_text(encoding="utf-8"))
except Exception:
pass
@@ -362,7 +369,7 @@ def _setup_selfhosted(hermes_home: str, config: dict, flags: dict[str, str]) ->
config_path = Path(hermes_home) / "mem0.json"
if config_path.exists():
try:
existing_config = json.loads(config_path.read_text())
existing_config = json.loads(config_path.read_text(encoding="utf-8"))
except Exception:
pass
@@ -493,7 +500,12 @@ def _prompt_api_key(label: str, env_var: str, hermes_home: str) -> str:
if not existing:
env_path = Path(hermes_home) / ".env"
if env_path.exists():
for line in env_path.read_text().splitlines():
# BOM-tolerant read matching the canonical .env readers in
# hermes_cli/config.py; a Notepad BOM on the first line would
# otherwise defeat the startswith() key match below.
for line in env_path.read_text(
encoding="utf-8-sig", errors="replace"
).splitlines():
if line.startswith(f"{env_var}="):
existing = line.split("=", 1)[1].strip()
break
+28
View File
@@ -373,6 +373,34 @@ FOOTGUNS: list[Footgun] = [
and _is_likely_subprocess_call(line)
),
),
Footgun(
name="bare Path.read_text()/write_text() without encoding=",
# Match ``.read_text(`` / ``.write_text(`` when the same line does
# not pass ``encoding=``. Multi-line calls where encoding= sits on
# a later line are handled by the post_filter's lookahead-free
# heuristic accepting a small false-negative rate — the AST guard
# test in tests/gateway/test_gateway_utf8_encoding.py catches the
# gateway/adapters exactly, and this rule catches the common
# single-line form everywhere else.
pattern=re.compile(r"\.(read_text|write_text)\s*\("),
message=(
"Path.read_text()/write_text() without encoding= uses "
"locale.getpreferredencoding() — cp936/cp1252 on Windows — "
"so UTF-8 content (config JSON, session state, skills) "
"crashes with UnicodeDecodeError or writes mojibake. "
"See issue #37423 and the #71014 / read_text campaign."
),
fix='path.read_text(encoding="utf-8") / path.write_text(data, encoding="utf-8")',
post_filter=lambda m, line: (
"encoding=" not in line
and "encoding =" not in line
and not _looks_like_string_literal(line, m)
# Skip calls that continue onto the next line — the closing
# paren isn't on this line, so encoding= may follow. AST-level
# enforcement for those lives in the gateway guard test.
and line.rstrip().endswith(")")
),
),
]
+2 -2
View File
@@ -181,7 +181,7 @@ def main() -> int:
if any(skip.rstrip("/") in parts for skip in SKIP_DIRS):
continue
content = py_file.read_text()
content = py_file.read_text(encoding="utf-8")
violations = find_subprocess_calls(content, rel)
all_violations.extend(violations)
@@ -205,7 +205,7 @@ def main() -> int:
continue
try:
content = py_file.read_text()
content = py_file.read_text(encoding="utf-8")
except Exception:
continue
violations = find_subprocess_calls(content, rel)
+1 -1
View File
@@ -419,7 +419,7 @@ def main() -> int:
pending_jobs=pending,
)
args.output.write_text(body)
args.output.write_text(body, encoding="utf-8")
print(f"Wrote {len(body)} chars to {args.output}")
return 0
+17 -1
View File
@@ -2435,7 +2435,23 @@ You are Hermes Agent, an intelligent AI assistant created by Nous Research. You
$pythonExe = "$InstallDir\venv\Scripts\python.exe"
if (Test-Path $pythonExe) {
try {
& $pythonExe "$InstallDir\tools\skills_sync.py" 2>$null
# Force the child python.exe to emit UTF-8 on its stdout/stderr.
# On non-UTF-8 Windows locales (CP936/GBK zh-CN) Python defaults
# its stream encoding to the active codepage and crashes on glyphs
# like the checkmark (U+2713) that the codepage can't encode; the
# resulting non-UTF-8 bytes break this script's JSON result frame on
# stdout and abort the config-templates stage. Scope to this call
# only. (Comment kept ASCII per this file's PS 5.1 contract above.)
$prevPythonioencoding = $env:PYTHONIOENCODING
$prevPythonutf8 = $env:PYTHONUTF8
$env:PYTHONIOENCODING = "utf-8"
$env:PYTHONUTF8 = "1"
try {
& $pythonExe "$InstallDir\tools\skills_sync.py" 2>$null
} finally {
$env:PYTHONIOENCODING = $prevPythonioencoding
$env:PYTHONUTF8 = $prevPythonutf8
}
Write-Success "Skills synced to $HermesHome\skills"
} catch {
# Fallback: simple directory copy
+2 -2
View File
@@ -30,7 +30,7 @@ def _load_json(path: Path | None) -> list[dict]:
if path is None or not path.exists() or path.stat().st_size == 0:
return []
try:
data = json.loads(path.read_text())
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
print(f"warning: could not parse {path}: {exc}", file=sys.stderr)
return []
@@ -197,7 +197,7 @@ def main() -> int:
summary = "\n".join(buf)
if args.output:
args.output.write_text(summary)
args.output.write_text(summary, encoding="utf-8")
else:
print(summary)
return 0
+1 -1
View File
@@ -516,7 +516,7 @@ def main() -> int:
if not path.exists():
print(f"\n⚠ no baseline at {path} — run with --save {args.compare} first")
else:
before = json.loads(path.read_text())
before = json.loads(path.read_text(encoding="utf-8"))
print(f"\n═══ A/B diff vs /tmp/perf-{args.compare}.json ═══")
print(format_diff(before, metrics))
+5 -5
View File
@@ -2138,7 +2138,7 @@ def next_available_tag(base_tag: str) -> tuple[str, str]:
def get_current_version():
"""Read current semver from __init__.py."""
content = VERSION_FILE.read_text()
content = VERSION_FILE.read_text(encoding="utf-8")
match = re.search(r'__version__\s*=\s*"([^"]+)"', content)
return match.group(1) if match else "0.0.0"
@@ -2168,7 +2168,7 @@ def bump_version(current: str, part: str) -> str:
def update_version_files(semver: str, calver_date: str):
"""Update version strings in source files."""
# Update __init__.py
content = VERSION_FILE.read_text()
content = VERSION_FILE.read_text(encoding="utf-8")
content = re.sub(
r'__version__\s*=\s*"[^"]+"',
f'__version__ = "{semver}"',
@@ -2179,17 +2179,17 @@ def update_version_files(semver: str, calver_date: str):
f'__release_date__ = "{calver_date}"',
content,
)
VERSION_FILE.write_text(content)
VERSION_FILE.write_text(content, encoding="utf-8")
# Update pyproject.toml
pyproject = PYPROJECT_FILE.read_text()
pyproject = PYPROJECT_FILE.read_text(encoding="utf-8")
pyproject = re.sub(
r'^version\s*=\s*"[^"]+"',
f'version = "{semver}"',
pyproject,
flags=re.MULTILINE,
)
PYPROJECT_FILE.write_text(pyproject)
PYPROJECT_FILE.write_text(pyproject, encoding="utf-8")
# Keep the desktop Electron app's package.json version in lockstep with the
# Python package version. The desktop About panel reads the live Hermes
+2 -2
View File
@@ -533,7 +533,7 @@ def _load_durations(repo_root: Path) -> dict[str, float]:
if not path.is_file():
return {}
try:
return json.loads(path.read_text())
return json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as e:
print("[ERROR] Failed to load json durations file! {e}")
return {}
@@ -555,7 +555,7 @@ def _save_durations(
key = _format_file(f, repo_root)
data[key] = round(t, 3)
path = repo_root / _DURATIONS_FILE
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def _compute_lpt_slices(
@@ -158,7 +158,7 @@ def main(argv: list[str] | None = None) -> int:
sources: dict[str, str] = {}
if args.models_from_file:
try:
sources = json.loads(Path(args.models_from_file).read_text())
sources = json.loads(Path(args.models_from_file).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
log(f"Could not read --models-from-file: {e}")
@@ -303,7 +303,7 @@ def main(argv: list[str] | None = None) -> int:
out = json.dumps(schema, indent=2, default=str)
if args.output:
Path(args.output).write_text(out)
Path(args.output).write_text(out, encoding="utf-8")
print(f"Schema written to {args.output}", file=sys.stderr)
else:
print(out)
@@ -620,7 +620,7 @@ def main(argv: list[str] | None = None) -> int:
args_str = args.args
if args_str.startswith("@"):
try:
args_str = Path(args_str[1:]).read_text()
args_str = Path(args_str[1:]).read_text(encoding="utf-8")
except OSError as e:
emit_json({"error": f"Cannot read args file: {e}"})
return 1
+3 -3
View File
@@ -22,17 +22,17 @@ sys.path.insert(0, str(SCRIPTS))
@pytest.fixture
def sd15_workflow() -> dict:
return json.loads((WORKFLOWS / "sd15_txt2img.json").read_text())
return json.loads((WORKFLOWS / "sd15_txt2img.json").read_text(encoding="utf-8"))
@pytest.fixture
def flux_workflow() -> dict:
return json.loads((WORKFLOWS / "flux_dev_txt2img.json").read_text())
return json.loads((WORKFLOWS / "flux_dev_txt2img.json").read_text(encoding="utf-8"))
@pytest.fixture
def video_workflow() -> dict:
return json.loads((WORKFLOWS / "wan_video_t2v.json").read_text())
return json.loads((WORKFLOWS / "wan_video_t2v.json").read_text(encoding="utf-8"))
@pytest.fixture
+1 -1
View File
@@ -436,7 +436,7 @@ class TestVideoWorkflow:
def test_animatediff_workflow(self, workflows_dir):
import json
wf = json.loads((workflows_dir / "animatediff_video.json").read_text())
wf = json.loads((workflows_dir / "animatediff_video.json").read_text(encoding="utf-8"))
assert looks_like_video_workflow(wf) is True
def test_wan_workflow(self, video_workflow):
@@ -16,20 +16,20 @@ from run_workflow import (
class TestParseInputImageArg:
def test_with_name(self, tmp_path):
f = tmp_path / "x.png"
f.write_text("x")
f.write_text("x", encoding="utf-8")
n, p = parse_input_image_arg(f"image={f}")
assert n == "image"
assert p == f
def test_without_name_defaults(self, tmp_path):
f = tmp_path / "x.png"
f.write_text("x")
f.write_text("x", encoding="utf-8")
n, p = parse_input_image_arg(str(f))
assert n == "image"
def test_custom_name(self, tmp_path):
f = tmp_path / "x.png"
f.write_text("x")
f.write_text("x", encoding="utf-8")
n, p = parse_input_image_arg(f"mask_image={f}")
assert n == "mask_image"
@@ -92,7 +92,7 @@ def _setup_libreoffice_macro() -> bool:
macro_dir = Path(MACRO_DIR)
macro_file = macro_dir / "Module1.xba"
if macro_file.exists() and "AcceptAllTrackedChanges" in macro_file.read_text():
if macro_file.exists() and "AcceptAllTrackedChanges" in macro_file.read_text(encoding="utf-8"):
return True
if not macro_dir.exists():
@@ -111,7 +111,7 @@ def _setup_libreoffice_macro() -> bool:
macro_dir.mkdir(parents=True, exist_ok=True)
try:
macro_file.write_text(ACCEPT_CHANGES_MACRO)
macro_file.write_text(ACCEPT_CHANGES_MACRO, encoding="utf-8")
return True
except Exception as e:
logger.warning(f"Failed to setup LibreOffice macro: {e}")
@@ -64,7 +64,7 @@ def _ensure_shim() -> Path:
return _SHIM_SO
src = Path(tempfile.gettempdir()) / "lo_socket_shim.c"
src.write_text(_SHIM_SOURCE)
src.write_text(_SHIM_SOURCE, encoding="utf-8")
subprocess.run(
["gcc", "-shared", "-fPIC", "-o", str(_SHIM_SO), str(src), "-ldl"],
check=True,
@@ -783,7 +783,7 @@ class BaseSchemaValidator:
try:
schema = _load_schema(str(schema_path))
with open(xml_file, "r") as f:
with open(xml_file, "rb") as f:
xml_doc = lxml.etree.parse(f)
xml_doc, _ = self._remove_template_tags_from_text_nodes(xml_doc)
@@ -70,7 +70,7 @@ def _ensure_authenticated():
def _stored_token_scopes() -> list[str]:
try:
data = json.loads(TOKEN_PATH.read_text())
data = json.loads(TOKEN_PATH.read_text(encoding="utf-8"))
except Exception:
return list(SCOPES)
scopes = data.get("scopes")
@@ -192,7 +192,7 @@ def get_credentials():
json.dumps(
_normalize_authorized_user_payload(json.loads(creds.to_json())),
indent=2,
)
), encoding="utf-8"
)
if not creds.valid:
print("Token is invalid. Re-run setup.", file=sys.stderr)
@@ -69,7 +69,7 @@ def refresh_token(token_data: dict) -> dict:
).isoformat()
get_token_path().write_text(
json.dumps(_normalize_authorized_user_payload(token_data), indent=2)
json.dumps(_normalize_authorized_user_payload(token_data), indent=2), encoding="utf-8"
)
return token_data
@@ -81,7 +81,7 @@ def get_valid_token() -> str:
print("ERROR: No Google token found. Run setup.py --auth-url first.", file=sys.stderr)
sys.exit(1)
token_data = json.loads(token_path.read_text())
token_data = json.loads(token_path.read_text(encoding="utf-8"))
expiry = token_data.get("expiry", "")
if expiry:
@@ -71,7 +71,7 @@ def _normalize_authorized_user_payload(payload: dict) -> dict:
def _load_token_payload(path: Path = TOKEN_PATH) -> dict:
try:
return json.loads(path.read_text())
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
@@ -220,7 +220,7 @@ def check_auth(quiet: bool = False):
json.dumps(
_normalize_authorized_user_payload(json.loads(creds.to_json())),
indent=2,
)
), encoding="utf-8"
)
missing_scopes = _missing_scopes_from_payload(_load_token_payload(TOKEN_PATH))
if missing_scopes:
@@ -260,7 +260,7 @@ def store_client_secret(path: str):
sys.exit(1)
try:
data = json.loads(src.read_text())
data = json.loads(src.read_text(encoding="utf-8"))
except json.JSONDecodeError:
print("ERROR: File is not valid JSON.")
sys.exit(1)
@@ -270,7 +270,7 @@ def store_client_secret(path: str):
print("Download the correct file from: https://console.cloud.google.com/apis/credentials")
sys.exit(1)
CLIENT_SECRET_PATH.write_text(json.dumps(data, indent=2))
CLIENT_SECRET_PATH.write_text(json.dumps(data, indent=2), encoding="utf-8")
print(f"OK: Client secret saved to {CLIENT_SECRET_PATH}")
@@ -284,7 +284,7 @@ def _save_pending_auth(*, state: str, code_verifier: str):
"redirect_uri": REDIRECT_URI,
},
indent=2,
)
), encoding="utf-8"
)
@@ -295,7 +295,7 @@ def _load_pending_auth() -> dict:
sys.exit(1)
try:
data = json.loads(PENDING_AUTH_PATH.read_text())
data = json.loads(PENDING_AUTH_PATH.read_text(encoding="utf-8"))
except Exception as e:
print(f"ERROR: Could not read pending OAuth session: {e}")
print("Run --auth-url again to start a fresh OAuth session.")
@@ -410,7 +410,7 @@ def exchange_auth_code(code: str):
print(f"WARNING: Token missing some Google Workspace scopes: {', '.join(missing_scopes)}")
print("Some services may not be available.")
TOKEN_PATH.write_text(json.dumps(token_payload, indent=2))
TOKEN_PATH.write_text(json.dumps(token_payload, indent=2), encoding="utf-8")
PENDING_AUTH_PATH.unlink(missing_ok=True)
print(f"OK: Authenticated. Token saved to {TOKEN_PATH}")
print(f"Profile-scoped token location: {display_hermes_home()}/google_token.json")
@@ -59,7 +59,7 @@ if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: check_bounding_boxes.py [fields.json]")
sys.exit(1)
with open(sys.argv[1]) as f:
with open(sys.argv[1], encoding="utf-8") as f:
messages = get_bounding_box_messages(f)
for msg in messages:
print(msg)
@@ -7,7 +7,7 @@ from PIL import Image, ImageDraw
def create_validation_image(page_number, fields_json_path, input_path, output_path):
with open(fields_json_path, 'r') as f:
with open(fields_json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
img = Image.open(input_path)
@@ -9,7 +9,7 @@ from extract_form_field_info import get_field_info
def fill_pdf_fields(input_pdf_path: str, fields_json_path: str, output_pdf_path: str):
with open(fields_json_path) as f:
with open(fields_json_path, encoding="utf-8") as f:
fields = json.load(f)
fields_by_page = {}
for field in fields:
@@ -32,7 +32,7 @@ def transform_from_pdf_coords(bbox, pdf_height):
def fill_pdf_form(input_pdf_path, fields_json_path, output_pdf_path):
with open(fields_json_path, "r") as f:
with open(fields_json_path, "r", encoding="utf-8") as f:
fields_data = json.load(f)
reader = PdfReader(input_pdf_path)
@@ -64,7 +64,7 @@ def _ensure_shim() -> Path:
return _SHIM_SO
src = Path(tempfile.gettempdir()) / "lo_socket_shim.c"
src.write_text(_SHIM_SOURCE)
src.write_text(_SHIM_SOURCE, encoding="utf-8")
subprocess.run(
["gcc", "-shared", "-fPIC", "-o", str(_SHIM_SO), str(src), "-ldl"],
check=True,
@@ -783,7 +783,7 @@ class BaseSchemaValidator:
try:
schema = _load_schema(str(schema_path))
with open(xml_file, "r") as f:
with open(xml_file, "rb") as f:
xml_doc = lxml.etree.parse(f)
xml_doc, _ = self._remove_template_tags_from_text_nodes(xml_doc)
@@ -64,7 +64,7 @@ def _ensure_shim() -> Path:
return _SHIM_SO
src = Path(tempfile.gettempdir()) / "lo_socket_shim.c"
src.write_text(_SHIM_SOURCE)
src.write_text(_SHIM_SOURCE, encoding="utf-8")
subprocess.run(
["gcc", "-shared", "-fPIC", "-o", str(_SHIM_SO), str(src), "-ldl"],
check=True,
+1 -1
View File
@@ -71,7 +71,7 @@ def setup_libreoffice_macro(profile_dir: Path, timeout=30):
return None, "LibreOffice did not create a usable profile; formulas were NOT recalculated"
try:
(macro_dir / MACRO_FILENAME).write_text(RECALCULATE_MACRO)
(macro_dir / MACRO_FILENAME).write_text(RECALCULATE_MACRO, encoding="utf-8")
except OSError as e:
return None, f"Could not install the recalculation macro: {e}"
+78
View File
@@ -203,6 +203,84 @@ class CopilotACPClientSafetyTests(unittest.TestCase):
self.assertNotIn("abc123def456", content)
self.assertIn("OPENAI_API_KEY=", content)
def test_fs_read_text_file_decodes_as_utf8_under_non_utf8_locale(self) -> None:
"""Regression for #18637 (bug 2): fs/read_text_file used
``path.read_text()`` with no explicit encoding, so on Windows
GBK/CP932/CP949 locales the Copilot read_file tool crashed on any
source file with non-ASCII content (e.g. a CJK comment, an em dash,
or UTF-8 BOM)."""
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
target = root / "note.md"
target.write_text("# 中文标题\nem dash — here\n", encoding="utf-8")
original_read_text = Path.read_text
def strict_read_text(self, encoding=None, errors=None, **kwargs):
if self == target and encoding != "utf-8":
raise UnicodeDecodeError(
"gbk", b"\x94", 0, 1, "illegal multibyte sequence"
)
return original_read_text(
self, encoding=encoding, errors=errors, **kwargs
)
with patch.object(Path, "read_text", strict_read_text):
response = self._dispatch(
{
"jsonrpc": "2.0",
"id": 10,
"method": "fs/read_text_file",
"params": {"path": str(target)},
},
cwd=str(root),
)
self.assertNotIn("error", response)
content = ((response.get("result") or {}).get("content") or "")
self.assertIn("中文标题", content)
self.assertIn("em dash —", content)
def test_fs_write_text_file_encodes_as_utf8(self) -> None:
"""Regression for #18637 (bug 2): fs/write_text_file used
``path.write_text()`` with no explicit encoding, so on non-UTF-8
locales the Copilot write tool could not emit code/config files
containing any char outside the platform codec."""
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
target = root / "out.md"
payload = "# 中文标题\nem dash — here\n"
original_write_text = Path.write_text
def strict_write_text(
self, data, encoding=None, errors=None, **kwargs
):
if self == target and encoding != "utf-8":
raise UnicodeEncodeError(
"gbk", data, 0, 1, "illegal multibyte sequence"
)
return original_write_text(
self, data, encoding=encoding, errors=errors, **kwargs
)
with patch.object(Path, "write_text", strict_write_text):
response = self._dispatch(
{
"jsonrpc": "2.0",
"id": 11,
"method": "fs/write_text_file",
"params": {
"path": str(target),
"content": payload,
},
},
cwd=str(root),
)
self.assertNotIn("error", response)
self.assertEqual(target.read_text(encoding="utf-8"), payload)
def test_write_text_file_reuses_write_denylist(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
home = Path(tmpdir) / "home"
+45
View File
@@ -389,3 +389,48 @@ class TestIsGenuineNousRateLimit:
assert is_genuine_nous_rate_limit(
headers=None, last_known_state=None
) is False
class TestRateGuardStateEncoding:
"""Regression for #18637: the cross-session rate-limit state file was
opened without ``encoding="utf-8"`` on both the atomic write (os.fdopen)
and the read. On Windows Chinese locales the platform default decoder
raises on UTF-8 bytes written by a peer process, silently losing the
rate-limit guard and re-enabling the retry amplification the module was
built to prevent.
"""
def test_read_uses_utf8_under_non_utf8_locale(self, rate_guard_env, monkeypatch):
import builtins
from agent.nous_rate_guard import nous_rate_limit_remaining, _state_path
path = _state_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
# State JSON written with a UTF-8 provider label. Python's json
# module itself escapes non-ASCII by default, but a peer writer
# (or human) using ensure_ascii=False produces raw UTF-8 bytes,
# which is what this guards against.
with open(path, "w", encoding="utf-8") as f:
f.write(
'{"reset_at": %d, "provider": "中文", "recorded_at": 0}'
% (int(time.time()) + 3600)
)
real_open = builtins.open
def guarded_open(file, mode="r", *args, **kwargs):
try:
is_target = str(file) == str(path)
except Exception:
is_target = False
if is_target and "b" not in mode and kwargs.get("encoding") != "utf-8":
raise UnicodeDecodeError(
"gbk", b"\x94", 0, 1, "illegal multibyte sequence"
)
return real_open(file, mode, *args, **kwargs)
monkeypatch.setattr(builtins, "open", guarded_open)
remaining = nous_rate_limit_remaining()
assert remaining is not None and remaining > 0
+61
View File
@@ -128,3 +128,64 @@ class TestWorktreeIncludeSecurity:
assert (linked_dir / "lib" / "marker.txt").read_text() == "venv marker"
finally:
_force_remove_worktree(info)
class TestWorktreeIncludeEncoding:
"""The include list and .gitignore are UTF-8 files; reading them with the
locale default breaks Windows (cp1251/GBK mojibake or UnicodeDecodeError,
swallowed at DEBUG so no include is copied), and a Notepad BOM glues to
the first line on every platform."""
def test_bom_in_worktreeinclude_does_not_hide_first_entry(self, git_repo):
import cli as cli_mod
(git_repo / ".env").write_text("SECRET=***\n")
# Notepad-style UTF-8 with BOM; the BOM must not become part of the
# first entry's path.
(git_repo / ".worktreeinclude").write_bytes(".env\n".encode("utf-8"))
info = None
try:
info = cli_mod._setup_worktree(str(git_repo))
assert info is not None
assert (Path(info["path"]) / ".env").exists()
finally:
_force_remove_worktree(info)
def test_non_ascii_worktreeinclude_entry_copied(self, git_repo):
import cli as cli_mod
secret = git_repo / "секреты.env"
secret.write_text("SECRET=***\n", encoding="utf-8")
(git_repo / ".worktreeinclude").write_bytes(
"# ключи агента\nсекреты.env\n".encode("utf-8")
)
info = None
try:
info = cli_mod._setup_worktree(str(git_repo))
assert info is not None
assert (Path(info["path"]) / "секреты.env").exists()
finally:
_force_remove_worktree(info)
def test_bom_in_gitignore_does_not_duplicate_worktrees_entry(self, git_repo):
import cli as cli_mod
(git_repo / ".gitignore").write_bytes(".worktrees/\n".encode("utf-8"))
info = None
try:
info = cli_mod._setup_worktree(str(git_repo))
assert info is not None
lines = (
(git_repo / ".gitignore")
.read_text(encoding="utf-8-sig")
.splitlines()
)
assert lines.count(".worktrees/") == 1, (
"BOM glued to the first line must not defeat the membership "
f"check and duplicate the entry: {lines!r}"
)
finally:
_force_remove_worktree(info)
@@ -78,6 +78,7 @@ def test_gated_status_is_public(gated_app):
@pytest.mark.parametrize("path", [
"/api/health",
"/api/config/defaults",
"/api/config/schema",
"/api/model/info",
@@ -67,6 +67,22 @@ def test_status_reports_auth_required_in_gated_mode(gated_client):
assert body["auth_providers"] == ["stub"]
def test_health_reports_liveness_without_loading_gateway_config(gated_client, monkeypatch):
def _boom():
raise AssertionError("health must not load gateway config")
monkeypatch.setattr("gateway.config.load_gateway_config", _boom)
r = gated_client.get("/api/health")
assert r.status_code == 200
body = r.json()
assert body == {
"ok": True,
"version": web_server.__version__,
"auth_required": True,
}
def test_status_reports_auth_disabled_in_loopback_mode(loopback_client):
r = loopback_client.get("/api/status")
assert r.status_code == 200
@@ -0,0 +1,376 @@
"""Behavior tests for the Windows desktop-exe integrity gate (#69179).
The desktop self-update chain (Desktop → hermes-setup --update →
``hermes update`` → ``hermes desktop --build-only`` → relaunch) rebuilds
Hermes.exe on the end user's machine. Before this gate, "build succeeded" was
just "the file exists", so a truncated PE (corrupt cached Electron zip), a
non-PE file, or a wrong-architecture tree shipped as the new app — Windows
then refuses to launch it with "This app can't run on your computer"
(此应用无法在你的电脑上运行) and the user has no working install left.
These tests exercise the behavior contract only: synthetic PE files go in,
verdicts/rollbacks come out.
"""
from __future__ import annotations
import argparse
import struct
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
from hermes_cli import main as cli_main
PE_AMD64 = 0x8664
PE_ARM64 = 0xAA64
PE_I386 = 0x014C
def make_pe(path: Path, machine: int = PE_AMD64, *, truncate_to: int | None = None) -> Path:
"""Write a minimal, structurally-complete PE file with one section.
Layout: DOS header (e_lfanew=0x80) → PE signature + COFF header at 0x80 →
one 40-byte section entry whose raw data spans [0x200, 0x400). Total file
size 0x400 unless ``truncate_to`` cuts it short (the corrupt-download
shape).
"""
buf = bytearray(0x400)
buf[0:2] = b"MZ"
struct.pack_into("<I", buf, 0x3C, 0x80)
buf[0x80:0x84] = b"PE\x00\x00"
# COFF: machine, nsections=1, timestamp, symtab ptr, nsyms, opt-hdr size=0, chars
struct.pack_into("<HHIIIHH", buf, 0x84, machine, 1, 0, 0, 0, 0, 0x0002)
# Section table entry at 0x80 + 24 = 0x98; SizeOfRawData @+16, PointerToRawData @+20
section_off = 0x98
struct.pack_into("<II", buf, section_off + 16, 0x200, 0x200)
data = bytes(buf if truncate_to is None else buf[:truncate_to])
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return path
# ─── _parse_pe_machine ──────────────────────────────────────────────────────
def test_parse_pe_machine_reads_machine_field(tmp_path):
exe = make_pe(tmp_path / "Hermes.exe", PE_AMD64)
assert cli_main._parse_pe_machine(exe) == PE_AMD64
exe_arm = make_pe(tmp_path / "arm.exe", PE_ARM64)
assert cli_main._parse_pe_machine(exe_arm) == PE_ARM64
def test_parse_pe_machine_rejects_non_pe_file(tmp_path):
"""An HTML error page saved as .exe — the classic corrupted-download body."""
fake = tmp_path / "Hermes.exe"
fake.write_bytes(b"<html><body>404 Not Found</body></html>" + b" " * 600)
with pytest.raises(ValueError, match="MZ"):
cli_main._parse_pe_machine(fake)
def test_parse_pe_machine_rejects_tiny_file(tmp_path):
stub = tmp_path / "Hermes.exe"
stub.write_bytes(b"MZ")
with pytest.raises(ValueError, match="too small"):
cli_main._parse_pe_machine(stub)
def test_parse_pe_machine_rejects_truncated_sections(tmp_path):
"""File cut mid-download: headers parse but section data extends past EOF."""
exe = make_pe(tmp_path / "Hermes.exe", PE_AMD64, truncate_to=0x300)
with pytest.raises(ValueError, match="truncated"):
cli_main._parse_pe_machine(exe)
def test_parse_pe_machine_rejects_bad_pe_signature(tmp_path):
exe = make_pe(tmp_path / "Hermes.exe", PE_AMD64)
data = bytearray(exe.read_bytes())
data[0x80:0x84] = b"XX\x00\x00"
exe.write_bytes(bytes(data))
with pytest.raises(ValueError, match="PE signature"):
cli_main._parse_pe_machine(exe)
# ─── _expected_windows_pe_machines ──────────────────────────────────────────
@pytest.mark.parametrize(
("host", "loadable", "not_loadable"),
[
("AMD64", {PE_AMD64, PE_I386}, {PE_ARM64}),
("ARM64", {PE_ARM64, PE_AMD64}, {PE_I386}),
("x86", {PE_I386}, {PE_AMD64, PE_ARM64}),
],
)
def test_expected_machines_per_host(host, loadable, not_loadable):
with patch("platform.machine", return_value=host):
expected = cli_main._expected_windows_pe_machines()
assert loadable <= expected
assert not (not_loadable & expected)
def test_expected_machines_unknown_host_is_permissive():
"""The gate must never brick launch on hosts we don't recognize."""
with patch("platform.machine", return_value="RISCV64"):
expected = cli_main._expected_windows_pe_machines()
assert {PE_AMD64, PE_ARM64, PE_I386} <= expected
# ─── _desktop_exe_integrity_error ───────────────────────────────────────────
def test_integrity_error_none_for_matching_arch(tmp_path):
exe = make_pe(tmp_path / "Hermes.exe", PE_AMD64)
with patch("platform.machine", return_value="AMD64"):
assert cli_main._desktop_exe_integrity_error(exe) is None
def test_integrity_error_reports_arch_mismatch(tmp_path):
"""ARM64 exe on the reporter's 'Windows 10 AMD64' host — the wrong-arch
flavor of 'This app can't run on your computer'."""
exe = make_pe(tmp_path / "Hermes.exe", PE_ARM64)
with patch("platform.machine", return_value="AMD64"):
error = cli_main._desktop_exe_integrity_error(exe)
assert error is not None and "architecture mismatch" in error
assert "ARM64" in error
def test_integrity_error_reports_corruption_reason(tmp_path):
exe = make_pe(tmp_path / "Hermes.exe", PE_AMD64, truncate_to=0x300)
error = cli_main._desktop_exe_integrity_error(exe)
assert error is not None and "truncated" in error
# ─── _desktop_packaged_executable arch preference (win32) ───────────────────
def test_packaged_executable_prefers_host_arch_over_mtime(tmp_path, monkeypatch):
"""A newer wrong-arch tree must not shadow the loadable one (#69179)."""
monkeypatch.setattr(cli_main.sys, "platform", "win32")
desktop_dir = tmp_path / "apps" / "desktop"
good = make_pe(desktop_dir / "release" / "win-unpacked" / "Hermes.exe", PE_AMD64)
bad = make_pe(desktop_dir / "release" / "win-arm64-unpacked" / "Hermes.exe", PE_ARM64)
# Make the wrong-arch tree the newest, which the pure-mtime pick would take.
import os
os.utime(bad, (bad.stat().st_atime + 1000, bad.stat().st_mtime + 1000))
with patch("platform.machine", return_value="AMD64"):
assert cli_main._desktop_packaged_executable(desktop_dir) == good
def test_packaged_executable_falls_back_to_mtime_when_unparseable(tmp_path, monkeypatch):
"""Non-PE stubs (dev trees, tests) keep the historical newest-wins pick."""
monkeypatch.setattr(cli_main.sys, "platform", "win32")
desktop_dir = tmp_path / "apps" / "desktop"
a = desktop_dir / "release" / "win-unpacked" / "Hermes.exe"
b = desktop_dir / "release" / "win-arm64-unpacked" / "Hermes.exe"
for p in (a, b):
p.parent.mkdir(parents=True)
p.write_text("", encoding="utf-8")
import os
os.utime(b, (b.stat().st_atime + 1000, b.stat().st_mtime + 1000))
with patch("platform.machine", return_value="AMD64"):
assert cli_main._desktop_packaged_executable(desktop_dir) == b
# ─── rollback ───────────────────────────────────────────────────────────────
def _win_tree(tmp_path: Path) -> tuple[Path, Path]:
desktop_dir = tmp_path / "apps" / "desktop"
exe = desktop_dir / "release" / "win-unpacked" / "Hermes.exe"
return desktop_dir, exe
def test_rollback_restores_backup_and_keeps_corrupt_copy(tmp_path):
desktop_dir, exe = _win_tree(tmp_path)
make_pe(exe, PE_AMD64, truncate_to=0x300) # corrupt new build
backup_exe = desktop_dir / "release" / "win-unpacked.bak" / "Hermes.exe"
make_pe(backup_exe, PE_AMD64) # valid old build
with patch("platform.machine", return_value="AMD64"):
restored = cli_main._rollback_desktop_from_backup(exe)
assert restored == exe
# The restored exe is the old, valid build.
assert cli_main._parse_pe_machine(exe) == PE_AMD64
assert exe.stat().st_size == 0x400
# Corrupt tree preserved for diagnostics; backup consumed.
assert (desktop_dir / "release" / "win-unpacked.corrupt" / "Hermes.exe").exists()
assert not backup_exe.exists()
def test_rollback_returns_none_without_backup(tmp_path):
_, exe = _win_tree(tmp_path)
make_pe(exe, PE_AMD64, truncate_to=0x300)
assert cli_main._rollback_desktop_from_backup(exe) is None
# The corrupt tree is left in place (nothing to restore over it).
assert exe.exists()
def test_rollback_refuses_corrupt_backup(tmp_path):
"""Never 'restore' a backup that would also fail to launch."""
desktop_dir, exe = _win_tree(tmp_path)
make_pe(exe, PE_AMD64, truncate_to=0x300)
backup_exe = desktop_dir / "release" / "win-unpacked.bak" / "Hermes.exe"
make_pe(backup_exe, PE_AMD64, truncate_to=0x280) # backup also corrupt
assert cli_main._rollback_desktop_from_backup(exe) is None
assert backup_exe.exists() # untouched
# ─── _ensure_desktop_exe_launchable (the gate) ──────────────────────────────
def test_gate_passes_valid_exe(tmp_path, monkeypatch):
monkeypatch.setattr(cli_main.sys, "platform", "win32")
desktop_dir, exe = _win_tree(tmp_path)
make_pe(exe, PE_AMD64)
with patch("platform.machine", return_value="AMD64"):
verified, rolled_back = cli_main._ensure_desktop_exe_launchable(desktop_dir, exe)
assert verified == exe
assert rolled_back is False
def test_gate_noop_off_windows(tmp_path, monkeypatch):
monkeypatch.setattr(cli_main.sys, "platform", "linux")
desktop_dir, exe = _win_tree(tmp_path)
exe.parent.mkdir(parents=True)
exe.write_text("not a pe at all", encoding="utf-8")
verified, rolled_back = cli_main._ensure_desktop_exe_launchable(desktop_dir, exe)
assert verified == exe
assert rolled_back is False
def test_gate_rolls_back_corrupt_exe_and_purges_cache(tmp_path, monkeypatch, capsys):
monkeypatch.setattr(cli_main.sys, "platform", "win32")
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
desktop_dir, exe = _win_tree(tmp_path)
make_pe(exe, PE_AMD64, truncate_to=0x300)
make_pe(desktop_dir / "release" / "win-unpacked.bak" / "Hermes.exe", PE_AMD64)
stamp = tmp_path / "home" / "desktop-build-stamp.json"
stamp.parent.mkdir(parents=True, exist_ok=True)
stamp.write_text("{}", encoding="utf-8")
with patch("platform.machine", return_value="AMD64"), \
patch("hermes_cli.main._purge_electron_build_cache", return_value=[]) as mock_purge, \
patch("hermes_cli.main._desktop_stamp_path", return_value=stamp):
verified, rolled_back = cli_main._ensure_desktop_exe_launchable(desktop_dir, exe)
assert rolled_back is True
assert verified == exe
assert cli_main._parse_pe_machine(exe) == PE_AMD64 # restored old build
mock_purge.assert_called_once()
assert not stamp.exists() # stamp invalidated so retry genuinely rebuilds
out = capsys.readouterr().out
assert "integrity check" in out
assert "Update aborted" in out
assert "existing version was kept" in out
def test_gate_fails_clearly_without_backup(tmp_path, monkeypatch, capsys):
monkeypatch.setattr(cli_main.sys, "platform", "win32")
desktop_dir, exe = _win_tree(tmp_path)
fake = exe
fake.parent.mkdir(parents=True)
fake.write_bytes(b"<html>proxy error</html>" + b" " * 600)
with patch("hermes_cli.main._purge_electron_build_cache", return_value=[]), \
patch("hermes_cli.main._desktop_stamp_path", return_value=tmp_path / "stamp.json"):
verified, rolled_back = cli_main._ensure_desktop_exe_launchable(desktop_dir, exe)
assert verified is None
assert rolled_back is False
out = capsys.readouterr().out
assert "integrity check" in out
assert "No usable backup" in out
# ─── end-to-end: `hermes desktop --build-only` exits nonzero on corrupt exe ─
def _ns(**kw):
defaults = dict(
skip_build=False,
build_only=True,
force_build=False,
source=False,
fake_boot=False,
ignore_existing=False,
hermes_root=None,
cwd=None,
)
defaults.update(kw)
return argparse.Namespace(**defaults)
def test_build_only_fails_when_pack_produces_corrupt_exe(tmp_path, monkeypatch, capsys):
"""The updater chain's contract: a rebuild whose Hermes.exe cannot launch
must exit nonzero (so hermes-setup's retry-once kicks in) and must restore
the previous working build instead of leaving the corrupt one."""
root = tmp_path / "hermes-agent"
desktop_dir = root / "apps" / "desktop"
desktop_dir.mkdir(parents=True)
(desktop_dir / "package.json").write_text("{}", encoding="utf-8")
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
monkeypatch.setattr(cli_main.sys, "platform", "win32")
exe = desktop_dir / "release" / "win-unpacked" / "Hermes.exe"
make_pe(exe, PE_AMD64, truncate_to=0x300) # what the failed pack produced
make_pe(desktop_dir / "release" / "win-unpacked.bak" / "Hermes.exe", PE_AMD64)
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0)
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
patch("hermes_cli.main._stop_desktop_processes_locking_build", return_value=[]), \
patch("hermes_cli.main._purge_electron_build_cache", return_value=[]), \
patch("hermes_cli.main._desktop_stamp_path", return_value=tmp_path / "stamp.json"), \
patch("hermes_cli.main._write_desktop_build_stamp") as mock_stamp, \
patch("platform.machine", return_value="AMD64"), \
patch("hermes_cli.main.subprocess.run", return_value=pack_ok), \
pytest.raises(SystemExit) as exc:
cli_main.cmd_gui(_ns())
assert exc.value.code == 1
# The previous working exe was restored...
assert cli_main._parse_pe_machine(exe) == PE_AMD64
# ...and the poisoned build was never stamped as good.
mock_stamp.assert_not_called()
out = capsys.readouterr().out
assert "integrity check" in out
def test_build_only_succeeds_with_valid_exe(tmp_path, monkeypatch, capsys):
root = tmp_path / "hermes-agent"
desktop_dir = root / "apps" / "desktop"
desktop_dir.mkdir(parents=True)
(desktop_dir / "package.json").write_text("{}", encoding="utf-8")
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
monkeypatch.setattr(cli_main.sys, "platform", "win32")
make_pe(desktop_dir / "release" / "win-unpacked" / "Hermes.exe", PE_AMD64)
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0)
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
patch("hermes_cli.main._stop_desktop_processes_locking_build", return_value=[]), \
patch("hermes_cli.main._write_desktop_build_stamp") as mock_stamp, \
patch("platform.machine", return_value="AMD64"), \
patch("hermes_cli.main.subprocess.run", return_value=pack_ok):
cli_main.cmd_gui(_ns())
mock_stamp.assert_called_once()
assert "Desktop packaged app ready" in capsys.readouterr().out
+24
View File
@@ -126,6 +126,30 @@ class TestDoctorEnvFileEncoding:
doctor_mod.run_doctor(Namespace(fix=False))
def test_doctor_reads_invalid_utf8_env_via_latin1_fallback(
self, monkeypatch, tmp_path
):
"""cp1252/latin-1 .env with ASCII provider hints must not abort doctor."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
env_path = hermes_home / ".env"
# 0xff is invalid UTF-8; latin-1 decodes it. Keep an ASCII provider key
# so the scan still reports a configured endpoint/key.
env_path.write_bytes(b"OPENAI_API_KEY=sk-test\xff\n")
monkeypatch.setattr(doctor_mod, "HERMES_HOME", hermes_home)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: (_ for _ in ()).throw(SystemExit(0)),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
with pytest.raises(SystemExit):
doctor_mod.run_doctor(Namespace(fix=False))
class TestDoctorToolAvailabilityOverrides:
def test_marks_honcho_available_when_configured(self, monkeypatch):
monkeypatch.setattr(doctor, "_honcho_is_configured_for_doctor", lambda: True)
@@ -0,0 +1,72 @@
"""Regression: the distribution-install preview must read .env as UTF-8.
`_render_distribution_plan` inspects the target profile's `.env` to decide
whether a required env var is already set, so it doesn't nag the user. It
used `Path.read_text()` with no encoding, which:
1. defaults to the system locale (cp1251/GBK on Windows) and raises
`UnicodeDecodeError` on any non-ASCII byte — and the surrounding
`except OSError` does NOT catch that (it's a `ValueError`), so a
mis-encoded .env aborts the whole install preview; and
2. even on a UTF-8 locale, a Notepad-added BOM prefixes the first key,
so the very first env var is mis-detected as "needs setting".
The fix reads `utf-8-sig` and also catches `UnicodeDecodeError`.
"""
from types import SimpleNamespace
import pytest
from hermes_cli.main import _render_distribution_plan
def _make_plan(target_dir, env_requires):
manifest = SimpleNamespace(
name="demo",
version="1.0.0",
description="",
author="",
hermes_requires="",
env_requires=env_requires,
)
return SimpleNamespace(
manifest=manifest,
provenance="local",
target_dir=target_dir,
existing=False,
has_cron=False,
)
def test_bom_prefixed_env_first_key_is_detected_as_set(tmp_path, monkeypatch, capsys):
"""A required key on the first line of a BOM-prefixed .env reads as set."""
monkeypatch.delenv("FOO_TOKEN", raising=False)
profile = tmp_path / "profile"
profile.mkdir()
# utf-8-sig write == UTF-8 with a leading BOM, exactly what Notepad emits.
(profile / ".env").write_text("FOO_TOKEN=abc\nBAR=1\n", encoding="utf-8-sig")
plan = _make_plan(profile, [SimpleNamespace(name="FOO_TOKEN", required=True, description="")])
_render_distribution_plan(plan)
out = capsys.readouterr().out
foo_line = next(ln for ln in out.splitlines() if "FOO_TOKEN" in ln)
assert "✓ set" in foo_line, f"BOM must not hide the first key: {foo_line!r}"
def test_non_utf8_env_does_not_abort_the_preview(tmp_path, monkeypatch, capsys):
"""A .env with invalid UTF-8 bytes must not crash the install preview."""
monkeypatch.delenv("FOO_TOKEN", raising=False)
profile = tmp_path / "profile"
profile.mkdir()
# \xff is an invalid UTF-8 start byte -> UnicodeDecodeError on read.
(profile / ".env").write_bytes(b"FOO_TOKEN=\xff\xfe bad\n")
plan = _make_plan(profile, [SimpleNamespace(name="FOO_TOKEN", required=True, description="")])
# Must return normally (old `except OSError` let UnicodeDecodeError escape).
_render_distribution_plan(plan)
out = capsys.readouterr().out
assert any("FOO_TOKEN" in ln for ln in out.splitlines())
@@ -22,6 +22,7 @@ from plugins.memory.hindsight import (
REFLECT_SCHEMA,
RETAIN_SCHEMA,
_load_config,
_load_simple_env,
_build_embedded_profile_env,
_normalize_observation_scopes,
_normalize_retain_tags,
@@ -1819,3 +1820,62 @@ def test_save_config_sets_owner_only_permissions(tmp_path):
assert config_file.exists()
mode = stat.S_IMODE(config_file.stat().st_mode)
assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}"
class TestLoadSimpleEnv:
def test_bom_first_key_is_recognized(self, tmp_path):
"""A Notepad-edited .env carries a BOM; the first key must still parse
instead of becoming '\ufeffHINDSIGHT_LLM_API_KEY'."""
env_path = tmp_path / ".env"
env_path.write_bytes("HINDSIGHT_LLM_API_KEY=sk-test\n".encode("utf-8"))
values = _load_simple_env(env_path)
assert values.get("HINDSIGHT_LLM_API_KEY") == "sk-test"
def test_non_ascii_values_read_intact(self, tmp_path):
env_path = tmp_path / ".env"
env_path.write_bytes("PROXY_NOTE=café-zürich-完了\n".encode("utf-8"))
values = _load_simple_env(env_path)
assert values["PROXY_NOTE"] == "café-zürich-完了"
class TestPostSetupEnvEncoding:
def _run_cloud_post_setup(self, tmp_path, monkeypatch):
"""Drive post_setup through the cloud path with piped stdin."""
import io
import shutil as shutil_mod
monkeypatch.setattr("hermes_cli.memory_setup._curses_select",
lambda *a, **kw: 0) # cloud mode
monkeypatch.setattr("hermes_cli.config.save_config", lambda c: None)
monkeypatch.setattr(shutil_mod, "which", lambda *_: None) # skip uv install
# First line: API key prompt (readline). Second line: API URL (input).
monkeypatch.setattr(sys, "stdin", io.StringIO("sk-new\n\n"))
provider = HindsightMemoryProvider()
provider.post_setup(str(tmp_path), {"memory": {}})
def test_bom_first_key_updated_in_place(self, tmp_path, monkeypatch):
"""The setup writer reads the existing .env BOM-tolerantly, so a
BOM'd first key is matched and rewritten, not duplicated."""
env_path = tmp_path / ".env"
env_path.write_bytes("HINDSIGHT_API_KEY=old\n".encode("utf-8"))
self._run_cloud_post_setup(tmp_path, monkeypatch)
content = env_path.read_text(encoding="utf-8")
assert content.count("HINDSIGHT_API_KEY=") == 1
assert "HINDSIGHT_API_KEY=sk-new" in content
assert "old" not in content
assert "" not in content
def test_non_ascii_lines_survive_round_trip(self, tmp_path, monkeypatch):
"""Unrelated non-ASCII .env content must be copied through as UTF-8
(the locale codec would crash or mangle it on Windows)."""
env_path = tmp_path / ".env"
env_path.write_bytes("PROXY_NOTE=café-zürich-完了\n".encode("utf-8"))
self._run_cloud_post_setup(tmp_path, monkeypatch)
content = env_path.read_text(encoding="utf-8")
assert "PROXY_NOTE=café-zürich-完了" in content
assert "HINDSIGHT_API_KEY=sk-new" in content
+43
View File
@@ -11,6 +11,7 @@ from plugins.memory.mem0._setup import (
parse_flags,
build_oss_config,
_write_env,
_prompt_api_key,
post_setup,
_check_qdrant_path,
_check_ollama,
@@ -181,6 +182,48 @@ class TestWriteEnv:
assert "OTHER=keep" in content
assert "old" not in content
def test_preserves_non_ascii_existing_lines(self, tmp_path):
"""Existing non-ASCII .env content must survive the read-modify-write
as UTF-8 (the locale codec would crash/mangle it on Windows)."""
env_path = tmp_path / ".env"
env_path.write_bytes("PROXY_NOTE=café-zürich-完了\n".encode("utf-8"))
_write_env(env_path, {"OPENAI_API_KEY": "sk-test"})
content = env_path.read_text(encoding="utf-8")
assert "PROXY_NOTE=café-zürich-完了" in content
assert "OPENAI_API_KEY=sk-test" in content
def test_updates_first_key_with_bom(self, tmp_path):
"""A Notepad-edited .env carries a BOM; the first key must still be
matched/updated in place, not duplicated."""
env_path = tmp_path / ".env"
env_path.write_bytes("OPENAI_API_KEY=old\n".encode("utf-8"))
_write_env(env_path, {"OPENAI_API_KEY": "new"})
content = env_path.read_text(encoding="utf-8")
assert content.count("OPENAI_API_KEY=") == 1
assert "OPENAI_API_KEY=new" in content
class TestPromptApiKey:
def test_existing_key_found_behind_bom(self, tmp_path, monkeypatch):
"""The masked-current-value lookup must see a key on the BOM'd first
line of a Notepad-edited .env instead of prompting from scratch."""
env_path = tmp_path / ".env"
env_path.write_bytes("OPENAI_API_KEY=sk-existing\n".encode("utf-8"))
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
prompts: list[str] = []
def _fake_getpass(prompt):
prompts.append(prompt)
return ""
monkeypatch.setattr("plugins.memory.mem0._setup.getpass.getpass", _fake_getpass)
_prompt_api_key("OpenAI", "OPENAI_API_KEY", str(tmp_path))
assert len(prompts) == 1
assert "current: ...ting" in prompts[0]
class TestPostSetup:

Some files were not shown because too many files have changed in this diff Show More