Files
hermes-agent/hermes_cli/venv_sync.py
ethernet 284dbaf537 fix(pm): isolate bootstrap dependencies and unify YAML on ruamel
Activation reaches plugin discovery before the application dependencies
exist. Give PM its own locked Python project and runtime so it can install
or repair the application without importing that dependency tree.

Keep PM outside the application workspace. A shared uv workspace resolves
the application graph and cannot provide this isolation. Route mutations
through an isolated worker and preserve transaction callbacks, cancellation,
custom package registrations, and correlated receipts.

Use the same runtime builder for source installs and packaged payloads.
Keep offline wheelhouse support in that builder. Nix builds the independent
PM lock as a separate derivation. Refuse lazy-disabled bootstrap before
installing tools or dependencies.

Move first-party YAML readers and writers to ruamel. Keep the application
lock's transitive PyYAML requirements for third-party packages.

Verification:
- Focused canonical Python suite: 177 passed, 1 host-gated skip.
- Electron backend probes: 12 passed. Electron typecheck passed.
- Both uv locks, scoped lint, Bash syntax, and whitespace checks passed.
- Cold activation, corrupt-app repair, offline staging, and relocation ran.
- Built and exercised the Nix PM runtime and standalone YAML merge script.

Six broader caller test files retain the same 24 failing test IDs as an
archive of HEAD. The existing real-home guard blocks those tests before
they can exercise the affected paths. No full-suite pass is claimed.
Native Windows signing and full Bionic package execution remain unverified.
2026-09-11 12:23:51 -04:00

266 lines
9.0 KiB
Python

"""Sync the install's Python environment to its tree — and nothing else.
One of the two separately-invocable, stdlib-only-at-import halves of
"make this install runnable":
* ``pm`` — pinned tools and the venv ledger (facts.json), shape-universal.
* THIS module — the pre-venv entry point, whose meaning depends on the
install shape:
- **checkout** (git clone + venv): make the venv match
``pyproject.toml``/``uv.lock``. For the tree this module runs FROM,
the work is delegated to :func:`pm.sync_venv` — pm owns the extras
ledger and records the result in facts.json, so update and fresh
clone converge on one authority. For a foreign ``--project-root``
(installer bootstrapping a clone it has not exec'd into yet), uv is
driven directly with pm's pinned binary and sanitized env.
- **sealed** (desktop bundle, nix, docker): the interpreter tree is a
build artifact — syncing it is not possible and not meaningful.
Exit 0 with ``{"state": "sealed"}``, cleanly.
Stdlib-only at import is a hard contract: this runs on freshly-cloned
trees where the venv does not exist yet, and after tree swaps where the
venv is not trustworthy — exactly the moments a third-party import
would explode. ``pm`` is first-party code and is imported at sync time,
not when this module is imported.
PM decides whether this checkout needs a sync from its recorded inputs
and selected environment. A foreign bootstrap root retains its separate
lockfile-digest stamp until execution moves into that checkout.
Invocation:
python -m hermes_cli.venv_sync # sync if stale
python -m hermes_cli.venv_sync --check # report, change nothing
python -m hermes_cli.venv_sync --json # machine-readable
Exit 0: current/synced/sealed. Exit 1: a sync was needed and failed.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
import sys
from pathlib import Path
from hermes_cli.steward import UPDATE_MECHANISMS
STAMP_NAME = "venv-sync.json"
STAMP_SCHEMA = 1
def _project_root() -> Path:
return Path(__file__).resolve().parents[1]
def _runtime_dir(project_root: Path) -> Path:
"""Where per-install runtime state lives for ``project_root``.
Mirrors pm.paths.store_root()'s ladder (HERMES_RUNTIME_DIR, then the
install stamp's runtimeDir) but stays stdlib-local and falls back to
``<root>/.hermes-runtime`` instead of the machine-global tool store:
the venv-sync stamp is per-install, and two checkouts sharing the
default store must not share a currency claim.
"""
env = os.environ.get("HERMES_RUNTIME_DIR")
if env:
return Path(env)
try:
data = json.loads(
(project_root / "install-stamp.json").read_text(encoding="utf-8-sig")
)
stamped = data.get("runtimeDir") if isinstance(data, dict) else None
if stamped:
return Path(stamped)
except (OSError, ValueError):
pass
return project_root / ".hermes-runtime"
def _stamp_path(project_root: Path) -> Path:
return _runtime_dir(project_root) / "cache" / STAMP_NAME
def _lock_digest(project_root: Path) -> str | None:
"""Hash the manifest and lock inputs for a foreign-root bootstrap."""
h = hashlib.sha256()
found = False
for name in ("uv.lock", "pyproject.toml"):
try:
h.update((project_root / name).read_bytes())
found = True
except OSError:
h.update(b"-")
return h.hexdigest() if found else None
def _is_sealed(project_root: Path) -> bool:
"""A sealed tree ships its interpreter; only checkouts own a venv.
The stamp file is the authority (hermes_cli.steward reads the same
file; restated here to keep the bare import stdlib-and-local). A
tree with BOTH a stamp and .git is a dev tree — treat as checkout.
A stamp without a valid ``updateMechanism`` is a build-lane bug and
must not be silently read as "not sealed" (that is exactly the
misclassification that made sealed trees look updatable) — same
guard as hermes_cli.version_info._stamp_version_info.
"""
if (project_root / ".git").exists():
return False
try:
data = json.loads(
(project_root / "install-stamp.json").read_text(encoding="utf-8-sig")
)
except (OSError, ValueError):
return False
if not (isinstance(data, dict) and bool(data)):
return False
if data.get("updateMechanism") not in UPDATE_MECHANISMS:
raise RuntimeError(
f"install-stamp.json at {project_root} is missing a valid "
f"'updateMechanism' (one of {', '.join(UPDATE_MECHANISMS)}). The "
"build lane that wrote this stamp must pass --update-mechanism to "
"scripts/write_install_stamp.py."
)
return True
def _managed_uv() -> tuple:
"""(uv binary path, sanitized env) from pm, or (None, None).
``pm.ensure.uv`` IS the resolution: facts from the store the pins
point at, env sanitized against interpreter hijack (#83914). pm is
stdlib-only first-party code, so the lazy import costs nothing and
keeps this module's bare-import surface stdlib-pure.
"""
try:
from pm.client import uv as pm_uv
uv_bin, env = pm_uv(realize=True)
except Exception:
return None, None
return uv_bin, env
def read_stamp(project_root: Path) -> dict:
try:
data = json.loads(_stamp_path(project_root).read_text(encoding="utf-8-sig"))
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
def write_stamp(project_root: Path, digest: str) -> None:
path = _stamp_path(project_root)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(
json.dumps(
{
"schemaVersion": STAMP_SCHEMA,
"lockDigest": digest,
"python": sys.version.split()[0],
}
),
encoding="utf-8",
)
os.replace(tmp, path)
def _sync_foreign(root: Path) -> dict:
"""Bootstrap a foreign checkout with PM's pinned uv and environment."""
uv, env = _managed_uv()
if uv is None:
return {
"ok": False,
"detail": "managed uv not provisioned (run `hermes pm install`)",
}
env = dict(env)
env["UV_PROJECT_ENVIRONMENT"] = str(root / "venv")
# A stale VIRTUAL_ENV from the calling shell would win over the
# project environment and sync the WRONG venv.
env.pop("VIRTUAL_ENV", None)
cmd = [str(uv), "sync"]
if (root / "uv.lock").is_file():
cmd.append("--frozen")
proc = subprocess.run(cmd, cwd=str(root), env=env)
if proc.returncode != 0:
return {"ok": False, "detail": f"uv sync exited {proc.returncode}"}
return {"ok": True, "detail": None}
def sync(project_root: Path | None = None, *, check: bool = False) -> dict:
"""Report or sync dependencies. A malformed install stamp is a build error."""
root = Path(project_root) if project_root else _project_root()
if _is_sealed(root):
return {"state": "sealed", "ok": True}
try:
from pm import paths as pm_paths
if Path(pm_paths.repo_root()).resolve() == root.resolve():
from pm import sync_venv
from pm.ensure import venv_is_current
if venv_is_current():
return {"state": "current", "ok": True}
if check:
return {"state": "would-sync", "ok": True}
sync_venv(explicit=True)
return {"state": "synced", "ok": True}
except Exception as exc:
return {"state": "failed", "ok": False, "detail": str(exc)}
digest = _lock_digest(root)
if digest is None:
return {
"state": "failed",
"ok": False,
"detail": f"no pyproject.toml or uv.lock under {root}",
}
if read_stamp(root).get("lockDigest") == digest:
return {"state": "current", "ok": True}
if check:
return {"state": "would-sync", "ok": True}
result = _sync_foreign(root)
if not result["ok"]:
# No stamp write: the next run must try again, not skip.
return {"state": "failed", "ok": False, "detail": result["detail"]}
write_stamp(root, digest)
return {"state": "synced", "ok": True}
def main(argv: list | None = None) -> int:
parser = argparse.ArgumentParser(prog="hermes_cli.venv_sync")
parser.add_argument("--project-root", default=None)
parser.add_argument(
"--check", action="store_true", help="report; change nothing"
)
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv)
result = sync(
Path(args.project_root) if args.project_root else None, check=args.check
)
if args.json:
print(json.dumps(result))
else:
detail = f" ({result['detail']})" if result.get("detail") else ""
print(f"venv sync: {result['state']}{detail}")
return 0 if result["ok"] else 1
if __name__ == "__main__":
raise SystemExit(main())