Commit Graph

2 Commits

Author SHA1 Message Date
briandevans c005546cbc test(cli): skip the permission-preservation cases on Windows
The four new mode-preservation guards assert POSIX permission bits, which
Windows does not model (os.chmod only toggles the read-only flag there).
Guard them the way the suite already guards POSIX-specific semantics so the
tests stay meaningful on Linux/macOS without failing for Windows contributors.
The symlink cases stay unguarded, matching the existing symlink tests in
tests/hermes_cli/.
2026-08-06 05:00:17 +05:30
briandevans 67827dd99e fix(cli): route the remaining destructive user-file rewrites through atomic writes
`utils.atomic_write_text`'s docstring states the invariant: it exists "so that
every destructive file rewrite in the codebase shares one implementation."
Four full-file rewrites of *existing user-authored files* still bypass it and
use a bare truncating `open(path, "w")` / `Path.write_text()`, which truncates
the target before the new content is produced. A crash, SIGINT, or ENOSPC
mid-write therefore leaves the file empty or half-written.

In all four cases the read half degrades silently to a default rather than
erroring, so the damage is invisible and the next write cements it:

* `xai_retirement.apply_migration()` rewrites the user's config.yaml. Merged
  commit beaa1a08e added a readability guard here and noted the writer "lives
  outside the atomic_yaml_write path, so the chokepoint didn't cover it"; this
  closes the durability half it left open. `--no-backup` is a documented flag,
  so on that path the truncated file is the only copy that exists, and the
  loader returns early on `doc is None` — the next run reports nothing to
  migrate rather than surfacing the damage.
* `uninstall.remove_path_from_shell_configs()` rewrites the user's shell rc
  (~/.bashrc, ~/.zshrc, ...). Hermes does not own these files and this function
  takes no backup; the enclosing `except Exception` downgrades a partial write
  to a warning, so the next login just starts a bare shell.
* `web_routers.profiles.update_profile_soul()` replaces SOUL.md from the
  dashboard editor. The paired GET reports an unreadable file as
  `{"content": "", "exists": False}`, so an interrupted save presents as "your
  persona was never set" and the editor's next Save persists the empty document.
* `profile_distribution.write_manifest()` rewrites distribution.yaml on every
  install/update. `read_manifest` treats an unparseable manifest as "not a
  distribution", silently dropping update tracking and env_requires.

The xAI migration keeps its ruamel round-trip dumper (comments, key order and
quoting must survive) and now serializes to a string before handing the bytes
to the shared writer. `write_manifest` moves to `atomic_yaml_write`, whose
SafeDumper output the manifest already round-trips through, retiring the local
`_dump_yaml` helper.

`atomic_write_text` recreates the target from a 0600 temp file, so each of its
call sites re-applies the file's previous permission bits: `_secure_file`
deliberately leaves config.yaml alone under managed (NixOS 0640) and container
installs, shell rc files are normally 0644, and profile SOUL.md is created 0644
and never secured. `atomic_yaml_write` already preserves mode and owner itself.
Routing through `atomic_replace` also keeps a symlinked config.yaml or ~/.zshrc
(dotfiles repo, managed deployment) pointing at the real file.

Tests: one regression test per site fails on clean main (the interrupted write
completes there and destroys the file) and passes here; the remaining cases are
behaviour guards covering symlink survival, permission preservation, comment
round-tripping, and the existing happy paths.
2026-08-06 05:00:17 +05:30