diff --git a/.github/workflows/canary-release.yml b/.github/workflows/canary-release.yml index 4f97afef2f..85ea1f7e81 100644 --- a/.github/workflows/canary-release.yml +++ b/.github/workflows/canary-release.yml @@ -85,4 +85,4 @@ jobs: CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }} CLOUDFLARE_R2_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }} CLOUDFLARE_R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }} - run: uv run --no-project --with PyYAML==6.0.3 python -m scripts.releases.r2 prune-canaries --keep-days 14 + run: uv run --no-project --with ruamel.yaml==0.18.17 python -m scripts.releases.r2 prune-canaries --keep-days 14 diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index d024b7772d..fa016a9b3f 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -72,10 +72,10 @@ jobs: toolchain: all node-cache-dependency-path: website/package-lock.json - - name: Install PyYAML for skill extraction + - name: Install ruamel.yaml for skill extraction uses: ./.github/actions/retry with: - command: uv pip install --python "$HERMES_PYTHON" pyyaml==6.0.2 httpx==0.28.1 + command: uv pip install --python "$HERMES_PYTHON" ruamel.yaml==0.18.17 httpx==0.28.1 - name: Prepare skills index (unified multi-source catalog) env: diff --git a/.github/workflows/desktop-bundled-release.yml b/.github/workflows/desktop-bundled-release.yml index 219db1243a..68be4377ae 100644 --- a/.github/workflows/desktop-bundled-release.yml +++ b/.github/workflows/desktop-bundled-release.yml @@ -59,7 +59,7 @@ name: Desktop Bundled Release # account id + an R2 API token (S3-compatible) with read/write on the # release bucket; CLOUDFLARE_R2_BUCKET and CLOUDFLARE_R2_PUBLIC_URL are # non-secret vars. scripts/releases/r2.py derives the S3 endpoint from -# the account id. Upload/list operations need only Python; feed operations use PyYAML. +# the account id. Upload/list operations need only Python; feed operations use ruamel.yaml. # # Windows Store submission (publish-win32-store): MSStore CLI via # microsoft/microsoft-store-apppublisher. Credentials live in the @@ -1242,7 +1242,7 @@ jobs: # (feed-side); the pointer upload is the last write of the run. shell: bash run: | - uv run --no-project --with PyYAML==6.0.3 python -m scripts.releases.r2 finalize --tag "$HERMES_PAYLOAD_TAG" --dir staged + uv run --no-project --with ruamel.yaml==0.18.17 python -m scripts.releases.r2 finalize --tag "$HERMES_PAYLOAD_TAG" --dir staged termux-deb: name: Build + publish the termux .deb (aarch64) @@ -1816,7 +1816,7 @@ jobs: - uses: ./.github/actions/setup-pm with: cache-python: false - - run: uv run --no-project --with PyYAML==6.0.3 python -m scripts.bundles.release_artifacts promote --root verified + - run: uv run --no-project --with ruamel.yaml==0.18.17 python -m scripts.bundles.release_artifacts promote --root verified - run: python scripts/render-builds-table.py --tag "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" stable-phase-result: diff --git a/.github/workflows/docs-site-checks.yml b/.github/workflows/docs-site-checks.yml index 1e7ced3ce2..ca44f47ab2 100644 --- a/.github/workflows/docs-site-checks.yml +++ b/.github/workflows/docs-site-checks.yml @@ -28,7 +28,7 @@ jobs: - name: Install ascii-guard uses: ./.github/actions/retry with: - command: uv pip install --python "$HERMES_PYTHON" ascii-guard==2.3.0 pyyaml==6.0.3 + command: uv pip install --python "$HERMES_PYTHON" ascii-guard==2.3.0 ruamel.yaml==0.18.17 - name: Extract skill metadata for dashboard run: python3 website/scripts/extract-skills.py diff --git a/.github/workflows/plugin-catalog-ci.yml b/.github/workflows/plugin-catalog-ci.yml index 84efbc0761..204574d801 100644 --- a/.github/workflows/plugin-catalog-ci.yml +++ b/.github/workflows/plugin-catalog-ci.yml @@ -29,10 +29,10 @@ jobs: with: python-version: "3.11" - - name: Install PyYAML + - name: Install ruamel.yaml uses: ./.github/actions/retry with: - command: pip install pyyaml==6.0.2 + command: pip install ruamel.yaml==0.18.17 - name: Validate catalog files (structural) run: | @@ -93,7 +93,7 @@ jobs: import shlex import sys - import yaml + import hermes_yaml as yaml with open(sys.argv[1], encoding="utf-8") as fh: data = yaml.safe_load(fh) or {} diff --git a/.github/workflows/pm-toolchain.yml b/.github/workflows/pm-toolchain.yml index 346bdd26a8..4c07715877 100644 --- a/.github/workflows/pm-toolchain.yml +++ b/.github/workflows/pm-toolchain.yml @@ -52,8 +52,8 @@ jobs: cache-suffix: smoke-consumers-${{ github.run_id }}-${{ github.run_attempt }} - name: Install the docs tools into the command environment run: | - uv pip install --python "$HERMES_PYTHON" ascii-guard==2.3.0 pyyaml==6.0.3 httpx==0.28.1 - python3 -c 'import yaml,httpx; print(yaml.__version__, httpx.__version__)' + uv pip install --python "$HERMES_PYTHON" ascii-guard==2.3.0 ruamel.yaml==0.18.17 httpx==0.28.1 + python3 -c 'import ruamel.yaml,httpx; print(ruamel.yaml.__version__, httpx.__version__)' - name: Install the locked desktop and test workspaces run: npm ci --workspace apps/desktop --workspace tests-js --include-workspace-root --include=dev --ignore-scripts --no-audit --no-fund - name: Exercise the actual after-pack relocation hook diff --git a/.github/workflows/skills-index.yml b/.github/workflows/skills-index.yml index 119e195b6d..60c980206b 100644 --- a/.github/workflows/skills-index.yml +++ b/.github/workflows/skills-index.yml @@ -39,7 +39,7 @@ jobs: - name: Install dependencies uses: ./.github/actions/retry with: - command: uv pip install --python "$HERMES_PYTHON" httpx==0.28.1 pyyaml==6.0.2 + command: uv pip install --python "$HERMES_PYTHON" httpx==0.28.1 ruamel.yaml==0.18.17 - name: Build skills index env: diff --git a/Dockerfile b/Dockerfile index e2a8eb77b7..1199106dae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -207,8 +207,9 @@ COPY pm/ pm/ COPY hermes_constants.py hermes_constants.py # PM imports the shared stdlib runtime path and locking owners before deps exist. COPY hermes_cli/__init__.py hermes_cli/runtime_paths.py hermes_cli/runtime_state.py hermes_cli/ +COPY scripts/bundles/payload.py scripts/bundles/payload.py RUN set -eu; \ - python3 -m pm.cli install uv chromium; \ + python3 -c 'from pm.ensure import ensure; [ensure(name, explicit=True) for name in ("uv", "chromium")]'; \ ln -sf /opt/hermes/tools/uv-*/uv /usr/local/bin/uv; \ python3 -c 'from pathlib import Path; from pm.lock import Facts; from pm.registry import get_package; from pm.store import current_target; root = Path("/opt/hermes/tools"); fact = Facts(root / "facts.json").get("python"); binary = get_package("python").binary(root / fact["entry"], current_target()); Path("/usr/local/bin/python3").symlink_to(binary)'; \ uv --version; \ @@ -218,6 +219,10 @@ RUN set -eu; \ mkdir -p /etc/hermes; \ printf '%s' "$browser_bin" > /etc/hermes/agent-browser-executable-path +# PM is resident too: never borrow application libraries or create its worker +# environment under /root (unreachable to the runtime UID). +RUN python3 -c 'from pathlib import Path; from pm.runtime_stage import stage_runtime; from scripts.bundles.payload import seal_pm_runtime; root = Path("/opt/hermes"); python = Path("/usr/local/bin/python3").resolve(); stage_runtime(Path("/usr/local/bin/uv"), python, root / "pm-runtime", project=root / "pm"); seal_pm_runtime(root, python)' + # Raw uv commands must use PM's staged interpreter, not download another # under /root where the unprivileged runtime user cannot traverse it. ENV UV_PYTHON=/usr/local/bin/python3 @@ -393,6 +398,7 @@ RUN set -eu; \ printf '{"schemaVersion":2,"commit":"0000000000000000000000000000000000000000","distribution":"docker","source":"fallback","updateMechanism":"external"}\n' \ > /opt/hermes/install-stamp.json; \ fi; \ + python3 -c 'import json; from pathlib import Path; path = Path("/opt/hermes/install-stamp.json"); stamp = json.loads(path.read_text()); stamp["pmRuntime"] = "/opt/hermes/pm-runtime"; path.write_text(json.dumps(stamp) + "\n")'; \ mkdir -p /etc/hermes; \ python3 -c 'import json, pathlib, tomllib; project = tomllib.loads(pathlib.Path("/opt/hermes/pyproject.toml").read_text(encoding="utf-8"))["project"]; stamp = json.loads(pathlib.Path("/opt/hermes/install-stamp.json").read_text(encoding="utf-8")); commit = stamp.get("commit"); revision = commit if commit and set(commit) != {"0"} else None; marker = pathlib.Path("/etc/hermes/image-provenance.json"); marker.write_text(json.dumps({"schema": 1, "deployment_kind": "image", "manager": "docker", "image": "nousresearch/hermes-agent", "version": project["version"], "revision": revision}, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8"); marker.chmod(0o444)' diff --git a/agent/curator.py b/agent/curator.py index c078794698..9cd0dd8f1c 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -532,7 +532,7 @@ def _parse_structured_summary(llm_final: str) -> Dict[str, List[Dict[str, str]]] data = None if match: try: - import yaml # type: ignore + import hermes_yaml as yaml data = yaml.safe_load(match.group(1)) except Exception: pass diff --git a/agent/i18n.py b/agent/i18n.py index 66cc1f2e85..b6cec724e2 100644 --- a/agent/i18n.py +++ b/agent/i18n.py @@ -100,7 +100,7 @@ def _load_catalog(lang: str) -> dict[str, str]: logger.debug("i18n catalog missing for %s at %s", lang, path) return _cache_catalog(lang, flat) try: - import yaml + import hermes_yaml as yaml with path.open("r", encoding="utf-8-sig") as f: _flatten_into(yaml.safe_load(f) or {}, "", flat) except Exception as exc: diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 1e20bb5c13..2a293f583f 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -17,7 +17,7 @@ from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple from urllib.parse import urlparse -import yaml +import hermes_yaml as yaml from agent import model_metadata_http diff --git a/agent/onboarding.py b/agent/onboarding.py index cb290cecef..13a96c10e6 100644 --- a/agent/onboarding.py +++ b/agent/onboarding.py @@ -153,10 +153,10 @@ def is_seen(config: Mapping[str, Any], flag: str) -> bool: def mark_seen(config_path: Path, flag: str) -> bool: """Persist ``onboarding.seen. = True`` atomically; False on any error (best-effort).""" try: - import yaml + import hermes_yaml as yaml from hermes_cli.config import atomic_config_write except Exception as e: # pragma: no cover — dependency issue - logger.debug("onboarding: failed to import yaml/utils: %s", e) + logger.debug("onboarding: failed to import hermes_yaml/utils: %s", e) return False try: cfg: dict = {} diff --git a/agent/proxy_sources/iron_proxy.py b/agent/proxy_sources/iron_proxy.py index 4d19919af8..19d86d606b 100644 --- a/agent/proxy_sources/iron_proxy.py +++ b/agent/proxy_sources/iron_proxy.py @@ -411,10 +411,10 @@ def ensure_management_token(*, force: bool = False) -> str: def _yaml(): - """PyYAML module or None (it is a Hermes dep, but never a hard requirement here).""" + """Shared YAML helpers or None (not a hard requirement for proxy discovery).""" try: - import yaml + import hermes_yaml as yaml return yaml except ImportError: return None @@ -434,7 +434,7 @@ def _parse_listen(listen) -> Optional[Tuple[str, int]]: def _config_listen(section: str, *keys: str, config_path: Optional[Path] = None) -> Optional[Tuple[str, int]]: - """``(host, port)`` from the first truthy ``proxy.yaml[section][key]``, or None (also when file/PyYAML is missing).""" + """``(host, port)`` from the first truthy ``proxy.yaml[section][key]``, or None (also when file/ruamel.yaml is missing).""" yaml, data = _yaml(), {} if yaml is not None: with suppress(OSError, yaml.YAMLError): @@ -607,7 +607,7 @@ def _write_state_file_atomic(state: Path, name: str, dump) -> Path: def write_proxy_config(config: Dict) -> Path: """Serialize the config dict to ``/proxy/proxy.yaml`` (safe_dump, no Python tags).""" if (yaml := _yaml()) is None: - raise RuntimeError("PyYAML is required to write the iron-proxy config but is not installed.") + raise RuntimeError("ruamel.yaml is required to write the iron-proxy config but is not installed.") return _write_state_file_atomic(_proxy_state_dir(), "proxy.yaml", lambda f: yaml.safe_dump(config, f, default_flow_style=False, sort_keys=False)) diff --git a/agent/skill_bundles.py b/agent/skill_bundles.py index facbfe704a..2fc0290798 100644 --- a/agent/skill_bundles.py +++ b/agent/skill_bundles.py @@ -13,7 +13,7 @@ import os from pathlib import Path from typing import Any, Dict, List, Optional, Tuple -import yaml +import hermes_yaml as yaml from hermes_constants import get_hermes_home from agent.skill_commands import command_snapshot, diff_command_snapshots, resolve_slash_key, slugify_skill_name as _slugify diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 42fabfefa6..fe783cd52a 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -115,12 +115,11 @@ _yaml_load_fn = None def yaml_load(content: str): - """Parse YAML with lazy import and CSafeLoader preference.""" + """Parse YAML with the shared safe loader, imported lazily.""" global _yaml_load_fn if _yaml_load_fn is None: - import functools - import yaml - _yaml_load_fn = functools.partial(yaml.load, Loader=getattr(yaml, "CSafeLoader", None) or yaml.SafeLoader) + from hermes_yaml import safe_load + _yaml_load_fn = safe_load return _yaml_load_fn(content) diff --git a/apps/desktop/electron/backend-probes-runtime.test.ts b/apps/desktop/electron/backend-probes-runtime.test.ts index 33119b7135..6b215ea797 100644 --- a/apps/desktop/electron/backend-probes-runtime.test.ts +++ b/apps/desktop/electron/backend-probes-runtime.test.ts @@ -8,8 +8,8 @@ import { test } from 'vitest' import { canImportHermesCli } from './backend-probes' -const REPO = path.resolve(import.meta.dirname, '../../..') -const PYTHON = process.env.HERMES_PYTHON || process.env.UV_PYTHON || (process.platform === 'win32' ? 'python' : 'python3') +const REPO: string = path.resolve(import.meta.dirname, '../../..') +const PYTHON: string = process.env.HERMES_PYTHON || process.env.UV_PYTHON || (process.platform === 'win32' ? 'python' : 'python3') interface RuntimeFixture { python: string @@ -17,11 +17,11 @@ interface RuntimeFixture { dependencies: string } -test('the real bootstrap supplies selected dependencies and rejects foreign-path rescue', () => { - const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-probe-runtime-')) - const home = path.join(temp, 'home') +test('the real bootstrap supplies ruamel-only dependencies and rejects foreign-path rescue', (): void => { + const temp: string = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-probe-runtime-')) + const home: string = path.join(temp, 'home') - const env = { + const env: NodeJS.ProcessEnv = { ...process.env, HERMES_HOME: home, HERMES_RUNTIME_DIR: path.join(temp, 'tools'), @@ -33,7 +33,7 @@ test('the real bootstrap supplies selected dependencies and rejects foreign-path PYTHONDONTWRITEBYTECODE: '1' } - const setup = ` + const setup: string = ` import json, os, re, shutil, subprocess, sys, tomllib, venv from pathlib import Path root, temp = map(Path, sys.argv[1:]) @@ -44,7 +44,7 @@ venv.EnvBuilder(with_pip=False).create(seed) dependencies = temp / 'dependencies' manifest = tomllib.loads((root / 'pyproject.toml').read_text(encoding='utf-8')) specs = [spec for spec in manifest['project']['dependencies'] - if re.split(r'[<>=;\\[]', spec, maxsplit=1)[0].lower() in ('pyyaml', 'python-dotenv')] + if re.split(r'[<>=;\\[]', spec, maxsplit=1)[0].lower() in ('ruamel.yaml', 'python-dotenv')] assert len(specs) == 2, specs subprocess.run([shutil.which('uv'), 'pip', 'install', '--python', sys.executable, '--target', str(dependencies), '--no-deps', *specs], @@ -61,7 +61,7 @@ print(json.dumps({'python': str(python), 'site': str(site), 'dependencies': str( ` try { - const fixture = JSON.parse(execFileSync(PYTHON, ['-I', '-c', setup, REPO, temp], { + const fixture: RuntimeFixture = JSON.parse(execFileSync(PYTHON, ['-I', '-c', setup, REPO, temp], { cwd: temp, env, encoding: 'utf8', timeout: 90_000, windowsHide: true })) as RuntimeFixture @@ -72,7 +72,7 @@ print(json.dumps({'python': str(python), 'site': str(site), 'dependencies': str( }), true, 'Python home overrides must be scrubbed before the interpreter starts') fs.unlinkSync(path.join(fixture.site, 'selected-dependencies.pth')) - const foreign = path.join(temp, 'foreign-packages') + const foreign: string = path.join(temp, 'foreign-packages') fs.symlinkSync(fixture.dependencies, foreign, process.platform === 'win32' ? 'junction' : 'dir') assert.equal(canImportHermesCli(fixture.python, { cwd: REPO, diff --git a/apps/desktop/electron/backend-probes.ts b/apps/desktop/electron/backend-probes.ts index 23cf7cb639..8fd3f58d1e 100644 --- a/apps/desktop/electron/backend-probes.ts +++ b/apps/desktop/electron/backend-probes.ts @@ -88,10 +88,10 @@ function canImportHermesCli(pythonPath: string, opts: { env?: NodeJS.ProcessEnv; } try { - const env = { ...process.env, ...opts.env } + const env: NodeJS.ProcessEnv = { ...process.env, ...opts.env } // Bootstrap selects the committed generation before any dependency import. - execProbeSync(pythonPath, ['-c', 'import hermes_bootstrap; import yaml; import dotenv; import hermes_cli.config'], { + execProbeSync(pythonPath, ['-c', 'import hermes_bootstrap; import hermes_yaml; import dotenv; import hermes_cli.config'], { cwd: opts.cwd, env: { ...env, ...buildDesktopBackendEnv({ currentEnv: env }) }, stdio: 'ignore', diff --git a/evals/browser_use/single_run.py b/evals/browser_use/single_run.py index 6015656fb1..bbe116f3cf 100644 --- a/evals/browser_use/single_run.py +++ b/evals/browser_use/single_run.py @@ -56,7 +56,8 @@ cfg = { "browser": browser_cfg, "display": {"quiet": True}, } -import yaml +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) +import hermes_yaml as yaml with open(os.path.join(hh, "config.yaml"), "w", encoding="utf-8") as f: yaml.safe_dump(cfg, f) diff --git a/evals/desktop_bug_campaign/hooks_live.py b/evals/desktop_bug_campaign/hooks_live.py index 74638d7325..0b722bafaa 100644 --- a/evals/desktop_bug_campaign/hooks_live.py +++ b/evals/desktop_bug_campaign/hooks_live.py @@ -10,7 +10,8 @@ import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -import yaml +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +import hermes_yaml as yaml from websockets.sync.client import connect p = argparse.ArgumentParser() diff --git a/evals/desktop_bug_campaign/linux_launcher_probe.py b/evals/desktop_bug_campaign/linux_launcher_probe.py index afc8e9f7b7..792d6a267f 100644 --- a/evals/desktop_bug_campaign/linux_launcher_probe.py +++ b/evals/desktop_bug_campaign/linux_launcher_probe.py @@ -48,8 +48,8 @@ def main(): ) rows: dict = {"source_sha": args.source_sha or subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip(), "root": str(root), "lexical_python": str(python), "base_python": str(python.resolve())} - rows["venv_import"] = run([str(python), "-I", "-c", "import yaml,hermes_cli.main; print(yaml.__version__)"], env, "/") - rows["base_import_negative"] = run([str(python.resolve()), "-I", "-c", "import yaml,hermes_cli.main"], env, "/") + rows["venv_import"] = run([str(python), "-I", "-c", "import ruamel.yaml,hermes_cli.main; print(ruamel.yaml.__version__)"], env, "/") + rows["base_import_negative"] = run([str(python.resolve()), "-I", "-c", "import ruamel.yaml,hermes_cli.main"], env, "/") rows["install"] = run([str(python), "-c", install], env, "/") assert rows["install"]["returncode"] == 0, rows["install"] installed = json.loads(rows["install"]["stdout"].splitlines()[-1]) diff --git a/evals/desktop_bug_campaign/persistence_live.py b/evals/desktop_bug_campaign/persistence_live.py index 037f09b72f..42e46024b4 100644 --- a/evals/desktop_bug_campaign/persistence_live.py +++ b/evals/desktop_bug_campaign/persistence_live.py @@ -59,7 +59,8 @@ def main(): model = ThreadingHTTPServer(('127.0.0.1', args.port + 1), Model) threading.Thread(target=model.serve_forever, daemon=True).start() config = {'model': {'default': 'persistence-fixture', 'provider': 'custom', 'base_url': f'http://127.0.0.1:{args.port+1}/v1'}, 'agent': {'max_turns': 1}, 'compression': {'enabled': False}, 'toolsets': [], 'platform_toolsets': {'gui': [], 'cli': []}, 'memory': {'memory_enabled': False, 'user_profile_enabled': False}} - import yaml + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + import hermes_yaml as yaml for p in (home, profile): (p / 'config.yaml').write_text(yaml.safe_dump(config)) (p / '.env').write_text('OPENAI_API_KEY=local-fixture\n') diff --git a/evals/gateway/session_time_persistence_controls.py b/evals/gateway/session_time_persistence_controls.py index 46652674d3..e70099d449 100644 --- a/evals/gateway/session_time_persistence_controls.py +++ b/evals/gateway/session_time_persistence_controls.py @@ -19,7 +19,7 @@ for key in list(os.environ): os.environ.pop(key, None) os.environ.update(HOME=str(home), HERMES_HOME=str(home / ".hermes"), SESSION_IDLE_MINUTES="1", SESSION_RESET_HOUR="0") sys.path.insert(0, str(repo)) -import yaml +import hermes_yaml as yaml from gateway.config import load_gateway_config, GatewayConfig, Platform from gateway.session import SessionStore, SessionSource from gateway.run import GatewayRunner diff --git a/evals/postmortem/live_ab/nested_delegate_deadline.py b/evals/postmortem/live_ab/nested_delegate_deadline.py index f9629799ef..1816cf48fe 100644 --- a/evals/postmortem/live_ab/nested_delegate_deadline.py +++ b/evals/postmortem/live_ab/nested_delegate_deadline.py @@ -9,7 +9,8 @@ sys.path.insert(0, root) # Temp HERMES_HOME with the real auth + a config that shortens the generic sequential deadline to 40 s, so the # run takes ~1.5 min instead of 8. The fix exempts delegate_task from this deadline entirely, so the shortened # value is exactly what main will hit. -import shutil, tempfile, yaml +import shutil, tempfile +import hermes_yaml as yaml home = tempfile.mkdtemp(prefix="dl_home_"); os.environ["HERMES_HOME"] = home real_home = os.environ.get("HERMES_HOME_SOURCE", os.path.expanduser("~/.hermes")) # credentials are copied from here into a temp home shutil.copy(f"{real_home}/auth.json", f"{home}/auth.json") diff --git a/evals/postmortem/review_probes/context_cap_probe.py b/evals/postmortem/review_probes/context_cap_probe.py index 57b3300f6a..144adc65ac 100644 --- a/evals/postmortem/review_probes/context_cap_probe.py +++ b/evals/postmortem/review_probes/context_cap_probe.py @@ -16,7 +16,7 @@ for k in list(os.environ): home = tempfile.mkdtemp(prefix='cap-review-') os.environ['HERMES_HOME'] = home os.environ['HERMES_DISABLE_REDACTION'] = 'true' -import yaml +import hermes_yaml as yaml cfg = {'model': {'default': 'anthropic/claude-fable-5.1', 'provider':'openai-compat', 'base_url':'http://127.0.0.1:1/v1', 'context_length':1000000}, 'compression':{'threshold':0.85}, 'delegation': {}} if len(sys.argv)>3: cfg['delegation']['compression_threshold_tokens'] = json.loads(sys.argv[3]) diff --git a/evals/provider_fallback/probe_104360.py b/evals/provider_fallback/probe_104360.py index 58f6f663a2..26338d8115 100644 --- a/evals/provider_fallback/probe_104360.py +++ b/evals/provider_fallback/probe_104360.py @@ -83,7 +83,7 @@ class Handler(BaseHTTPRequestHandler): server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) threading.Thread(target=server.serve_forever, daemon=True).start() url = f"http://127.0.0.1:{server.server_port}/serving-endpoints" -import yaml +import hermes_yaml as yaml config = { "model": {"provider": "fixture-provider", "default": "model-a"}, diff --git a/evals/provider_wire/issue_104359.py b/evals/provider_wire/issue_104359.py index 1f023ab3f7..d628b88520 100644 --- a/evals/provider_wire/issue_104359.py +++ b/evals/provider_wire/issue_104359.py @@ -121,7 +121,7 @@ config = { }, "prompt_caching": {"enabled": False}, } -import yaml +import hermes_yaml as yaml Path(os.environ["HERMES_HOME"], "config.yaml").write_text(yaml.safe_dump(config), encoding="utf-8") from hermes_state import SessionDB diff --git a/gateway/config_loader.py b/gateway/config_loader.py index fd276eb431..4a19a3567e 100644 --- a/gateway/config_loader.py +++ b/gateway/config_loader.py @@ -344,7 +344,7 @@ def read_yaml_layers(home: Path) -> dict: (``gateway.relay.relay_explicitly_disabled``) reads through here so it cannot disagree with ``load_gateway_config()`` on which files count. """ - import yaml + import hermes_yaml as yaml config_yaml_path = home / "config.yaml" yaml_cfg: dict = {} diff --git a/gateway/hooks.py b/gateway/hooks.py index 15c546b379..027dcf9023 100644 --- a/gateway/hooks.py +++ b/gateway/hooks.py @@ -15,7 +15,7 @@ import sys from pathlib import Path from typing import Any, Callable, Dict, List, Optional -import yaml +import hermes_yaml as yaml from hermes_cli.config import get_hermes_home diff --git a/gateway/profile_routing.py b/gateway/profile_routing.py index ae19d6a808..95b505140e 100644 --- a/gateway/profile_routing.py +++ b/gateway/profile_routing.py @@ -90,7 +90,7 @@ class ProfileRoute: def _coerce_route_id(value: Any) -> Optional[str]: """Normalize a route discriminator to str for strict equality matching. - PyYAML loads unquoted numeric IDs as ``int`` while ``SessionSource`` fields are ``str``. Only + YAML loads unquoted numeric IDs as ``int`` while ``SessionSource`` fields are ``str``. Only ``int`` (not ``bool``) is coerced; floats stringify to something (``"123.0"``) that can never match, so they get a load-time warning instead. diff --git a/gateway/readiness.py b/gateway/readiness.py index aa4e84405d..7b05bc1029 100644 --- a/gateway/readiness.py +++ b/gateway/readiness.py @@ -8,7 +8,7 @@ from contextlib import closing from pathlib import Path from typing import Any -import yaml +import hermes_yaml as yaml from hermes_constants import get_hermes_home diff --git a/gateway/run.py b/gateway/run.py index 9df5b884da..7e70a8dbf9 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2789,7 +2789,7 @@ def _load_gateway_config(config_path: "Path | None" = None) -> dict: if not used_canonical: try: if config_path.exists(): - import yaml + import hermes_yaml as yaml with open(config_path, 'r', encoding='utf-8-sig') as f: raw = yaml.safe_load(f) or {} except Exception: @@ -5416,7 +5416,7 @@ def main(): config = None if args.config: - import yaml + import hermes_yaml as yaml with open(args.config, encoding="utf-8-sig") as f: config = GatewayConfig.from_dict(yaml.safe_load(f) or {}) diff --git a/gateway/status_phrases.py b/gateway/status_phrases.py index 659827b116..02cfe4b275 100644 --- a/gateway/status_phrases.py +++ b/gateway/status_phrases.py @@ -13,7 +13,7 @@ from collections.abc import Mapping, MutableSequence from pathlib import Path from typing import Any -import yaml +import hermes_yaml as yaml from hermes_constants import get_hermes_home diff --git a/hermes_cli/agent_import.py b/hermes_cli/agent_import.py index 14a63a2267..6a49f5ac74 100644 --- a/hermes_cli/agent_import.py +++ b/hermes_cli/agent_import.py @@ -17,7 +17,7 @@ import tomllib from pathlib import Path from typing import Any, Dict, List, Optional, Sequence, Tuple -import yaml +import hermes_yaml as yaml from utils import atomic_write_text, atomic_yaml_write diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index 9f8a77abe4..4eaadb7797 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -1696,7 +1696,7 @@ def _read_raw_yaml_dict(path: Path) -> Optional[Dict[str, Any]]: if not path.is_file(): return None try: - import yaml + import hermes_yaml as yaml with open(path, "r", encoding="utf-8-sig") as f: data = yaml.safe_load(f) diff --git a/hermes_cli/cli_info_mixin.py b/hermes_cli/cli_info_mixin.py index 693b6e1afa..f7dc079e4c 100644 --- a/hermes_cli/cli_info_mixin.py +++ b/hermes_cli/cli_info_mixin.py @@ -799,7 +799,7 @@ class CLIInfoMixin: cache** (the next message re-sends the full input prefix, expensive on long-context / high-reasoning models). See #1474. """ - import yaml as _yaml + import hermes_yaml as _yaml now = time.monotonic() if now - self._last_config_check < CONFIG_WATCH_INTERVAL: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 85b51c0078..86f656108f 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -21,7 +21,7 @@ from decimal import Decimal, InvalidOperation from pathlib import Path from typing import Dict, Any, Optional, List, Tuple, Set -import yaml +import hermes_yaml as yaml from hermes_cli.cli_output import line_input from hermes_cli.colors import Colors, color diff --git a/hermes_cli/doctor_platform.py b/hermes_cli/doctor_platform.py index 1402934f41..fa26301234 100644 --- a/hermes_cli/doctor_platform.py +++ b/hermes_cli/doctor_platform.py @@ -428,7 +428,7 @@ def _check_certificates(should_fix: bool, f: Finding) -> None: # (import name, display name, optional) _PACKAGES = ( ("openai", "OpenAI SDK", False), ("rich", "Rich (terminal UI)", False), ("dotenv", "python-dotenv", False), - ("yaml", "PyYAML", False), ("httpx", "HTTPX", False), + ("ruamel.yaml", "ruamel.yaml", False), ("httpx", "HTTPX", False), ("croniter", "Croniter (cron expressions)", True), ("telegram", "python-telegram-bot", True), ("discord", "discord.py", True), ) diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py index 5e1b110ee5..a3d4e86ef3 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py @@ -554,7 +554,7 @@ def _load_secrets_config(home_path: Path) -> dict: except Exception: pass try: - import yaml # type: ignore + import hermes_yaml as yaml except ImportError: return {} try: diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index 2d4f80047f..e94e05d62d 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -44,7 +44,7 @@ def load_picker_context() -> ConfigContext: cfg = load_config() model_cfg = cfg.get("model", {}) if isinstance(model_cfg, dict): - # PyYAML parses unquoted scalars as int (`provider: 2070`); keep strings so picker/options + # YAML parses unquoted scalars as int (`provider: 2070`); keep strings so picker/options # paths never call `.strip()` on an int. current_model = str(model_cfg.get("default", model_cfg.get("name", "")) or "") current_provider = coerce_provider_id(model_cfg.get("provider", "")) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 739d1c52d8..a3549c213e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -215,12 +215,10 @@ def _config_default_interface_early() -> str: else: cfg_path = os.path.join(os.path.expanduser("~"), ".hermes", "config.yaml") if os.path.exists(cfg_path): - import yaml as _yaml_iface + import hermes_yaml as _yaml_iface with open(cfg_path, encoding="utf-8-sig") as _f: - raw = _yaml_iface.load( - _f, Loader=getattr(_yaml_iface, "CSafeLoader", None) or _yaml_iface.SafeLoader - ) or {} + raw = _yaml_iface.safe_load(_f) or {} disp = raw.get("display", {}) if isinstance(disp, dict): iface = disp.get("interface") diff --git a/hermes_cli/managed_scope.py b/hermes_cli/managed_scope.py index 34375473cf..8398c67cfb 100644 --- a/hermes_cli/managed_scope.py +++ b/hermes_cli/managed_scope.py @@ -15,7 +15,7 @@ import threading from pathlib import Path from typing import Dict, Optional -import yaml +import hermes_yaml as yaml logger = logging.getLogger(__name__) diff --git a/hermes_cli/mcp_catalog.py b/hermes_cli/mcp_catalog.py index 247a7200b7..305771b90f 100644 --- a/hermes_cli/mcp_catalog.py +++ b/hermes_cli/mcp_catalog.py @@ -13,7 +13,7 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Optional -import yaml +import hermes_yaml as yaml from hermes_constants import get_hermes_home, get_optional_mcps_dir from hermes_cli._subprocess_compat import noninteractive_git_env diff --git a/hermes_cli/model_switch_providers.py b/hermes_cli/model_switch_providers.py index f8d5ee641d..3c2245e6dc 100644 --- a/hermes_cli/model_switch_providers.py +++ b/hermes_cli/model_switch_providers.py @@ -1103,7 +1103,7 @@ def list_authenticated_providers( except Exception: pass - # PyYAML parses unquoted numeric names (`provider: 2070`) as int. + # YAML parses unquoted numeric names (`provider: 2070`) as int. # seen_slugs: set = set() # lowercase-normalized to catch case variants (#9545) current_provider = coerce_provider_id(current_provider) current_base_url = str(current_base_url or "").strip() diff --git a/hermes_cli/plugin_catalog.py b/hermes_cli/plugin_catalog.py index dea2f32ac2..41f41fbeb1 100644 --- a/hermes_cli/plugin_catalog.py +++ b/hermes_cli/plugin_catalog.py @@ -22,7 +22,7 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Optional -import yaml +import hermes_yaml as yaml logger = logging.getLogger(__name__) diff --git a/hermes_cli/plugin_packs.py b/hermes_cli/plugin_packs.py index 7430e4a96b..83e366fd0f 100644 --- a/hermes_cli/plugin_packs.py +++ b/hermes_cli/plugin_packs.py @@ -100,7 +100,7 @@ def validate_config_seed(plugin_id: str, seed: Any) -> dict[str, Any]: def parse_pack(text: str, *, source: str = "") -> PluginPack: """Parse and validate a pack YAML document.""" - import yaml + import hermes_yaml as yaml try: raw = yaml.safe_load(text) except yaml.YAMLError as exc: @@ -419,7 +419,7 @@ def _sanitized_entry_config(plugin_id: str) -> dict[str, Any]: def export_pack(*, enabled_only: bool = False, pack_name: str = "my-hermes-pack") -> tuple[str, List[str]]: """Build pack YAML from the current install; returns ``(yaml_text, warnings)``. Plugins with unknown Git provenance (no install metadata) become warnings + YAML comments, never entries.""" - import yaml + import hermes_yaml as yaml from hermes_cli.plugins_cmd import _get_enabled_set, _plugins_dir, _read_install_metadata metadata = _read_install_metadata() enabled = _get_enabled_set() diff --git a/hermes_cli/plugin_validate.py b/hermes_cli/plugin_validate.py index 3555df2e77..e34daae90d 100644 --- a/hermes_cli/plugin_validate.py +++ b/hermes_cli/plugin_validate.py @@ -432,7 +432,7 @@ def validate_plugin_dir(plugin_dir: Path) -> ValidationReport: ) return report - import yaml + import hermes_yaml as yaml try: manifest = yaml.safe_load( diff --git a/hermes_cli/plugins_admission.py b/hermes_cli/plugins_admission.py index c1151499d3..7cdd20484b 100644 --- a/hermes_cli/plugins_admission.py +++ b/hermes_cli/plugins_admission.py @@ -90,7 +90,7 @@ def admit_plugin_set_change( …) or when the config write itself fails. The active environment and the previous config bytes are kept EXACTLY — no rollback re-resolve. """ - from pm.ensure import sync_venv + from pm.client import sync_venv extra_dirs = tuple(extra_dirs) try: diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 01ee11387d..400c1b09ec 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -271,7 +271,7 @@ def _has_portable_manifest(plugin_dir: Path) -> bool: def _load_yaml_manifest(manifest_file: Path): """``yaml.safe_load`` of *manifest_file* (``{}`` when empty); raises on any read/parse error.""" - import yaml + import hermes_yaml as yaml with open(manifest_file, encoding="utf-8-sig") as f: return yaml.safe_load(f) or {} diff --git a/hermes_cli/plugins_manifest.py b/hermes_cli/plugins_manifest.py index 184adfb75e..487d3551df 100644 --- a/hermes_cli/plugins_manifest.py +++ b/hermes_cli/plugins_manifest.py @@ -19,7 +19,7 @@ from utils import fast_safe_load from hermes_cli.plugin_capabilities import parse_declared_capabilities as _parse_declared_capabilities try: - import yaml + import hermes_yaml as yaml except ImportError: # pragma: no cover – yaml is optional at import time yaml = None # type: ignore[assignment] @@ -462,7 +462,7 @@ def parse_manifest_file( """Parse one ``plugin.yaml`` into a :class:`PluginManifest`; ``None`` (warned) on failure.""" try: if yaml is None: - logger.warning("PyYAML not installed – cannot load %s", manifest_file) + logger.warning("ruamel.yaml not installed – cannot load %s", manifest_file) return None data = fast_safe_load(manifest_file.read_text(encoding="utf-8-sig")) or {} name = data.get("name", plugin_dir.name) diff --git a/hermes_cli/plugins_transaction.py b/hermes_cli/plugins_transaction.py index cacdc563b0..7a70ddac16 100644 --- a/hermes_cli/plugins_transaction.py +++ b/hermes_cli/plugins_transaction.py @@ -86,7 +86,7 @@ def publish_plugin(staged: Path, target: Path, old_metadata: dict, new_metadata: from hermes_cli import plugins_cmd from hermes_cli.runtime_state import recover_publication, runtime_lock from pm import paths - from pm.ensure import sync_venv + from pm.client import sync_venv from pm.workspace import _is_member_candidate, enabled_plugin_dirs, member_sources project = paths.repo_root() diff --git a/hermes_cli/plugins_updates.py b/hermes_cli/plugins_updates.py index aeebb570aa..9bdfe84277 100644 --- a/hermes_cli/plugins_updates.py +++ b/hermes_cli/plugins_updates.py @@ -70,7 +70,7 @@ class CheckResult: def _read_manifest_field(plugin_dir: Path, key: str) -> Optional[str]: """One field from the installed plugin.yaml (claims, not provenance).""" - import yaml + import hermes_yaml as yaml manifest = plugin_dir / "plugin.yaml" if not manifest.is_file(): @@ -232,7 +232,7 @@ def check_provenanced( def parse_feed_yml(text: str) -> dict: """The electron-updater-derived feed shape: version, released, min_hermes, artifacts{git,bundle,bundle_sha256}, notes_url.""" - import yaml + import hermes_yaml as yaml try: data = yaml.safe_load(text) diff --git a/hermes_cli/profile_distribution.py b/hermes_cli/profile_distribution.py index bb9799d161..78e7654187 100644 --- a/hermes_cli/profile_distribution.py +++ b/hermes_cli/profile_distribution.py @@ -17,7 +17,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Tuple -import yaml +import hermes_yaml as yaml from hermes_cli._subprocess_compat import noninteractive_git_env from hermes_cli.archive_safe import normalize_archive_parts diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 04f91106a5..a74121f0e5 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -523,7 +523,7 @@ def _load_yaml_dict(path: Path) -> Optional[dict]: if not path.is_file(): return None try: - import yaml + import hermes_yaml as yaml data = yaml.safe_load(path.read_text(encoding="utf-8-sig")) or {} except Exception: return None @@ -563,7 +563,7 @@ def _seed_model_config(profile_dir: Path) -> None: if config_path.exists(): return with contextlib.suppress(Exception): # creation must not fail over this; `hermes model` sets it later - import yaml + import hermes_yaml as yaml from hermes_constants import get_hermes_home from hermes_cli.config import read_user_config_raw source = get_hermes_home() / "config.yaml" diff --git a/hermes_cli/runtime_repair.py b/hermes_cli/runtime_repair.py index 1ac3081358..c095fd542f 100644 --- a/hermes_cli/runtime_repair.py +++ b/hermes_cli/runtime_repair.py @@ -1018,7 +1018,7 @@ def repair_vulnerable_runtime( if not (root / "pyproject.toml").is_file() or not live_python.is_file(): return RuntimeRepairResult("not-applicable") - from pm.ensure import uv as pm_uv + from pm.client import uv as pm_uv uv_bin, _uv_env = pm_uv() if not uv_bin: diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index 0306cb99c7..a42ca6ee57 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -1106,7 +1106,7 @@ def do_tap(action: str, repo: str = "", console: Optional[Console] = None) -> No def _read_frontmatter(skill_md: str) -> dict: """YAML frontmatter of a SKILL.md body ({} when absent/invalid).""" - import yaml + import hermes_yaml as yaml match = re.search(r'\n---\s*\n', skill_md[3:]) if skill_md.startswith("---") else None try: return (yaml.safe_load(skill_md[3:match.start() + 3]) or {}) if match else {} diff --git a/hermes_cli/skin_cmd.py b/hermes_cli/skin_cmd.py index f7eb2abe3e..6f098a66e2 100644 --- a/hermes_cli/skin_cmd.py +++ b/hermes_cli/skin_cmd.py @@ -34,7 +34,7 @@ def _use(name: str) -> None: def _skin_set(key: str, value: str, skin: str | None) -> int: - import yaml + import hermes_yaml as yaml if not _HEX_RE.match(value): print(f"✗ {value!r} is not a #rrggbb hex color", file=sys.stderr) return 1 diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index f93cd951d7..5150c69eeb 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -351,7 +351,7 @@ def _skins_dir() -> Path: def _load_skin_from_yaml(path: Path) -> Optional[Dict[str, Any]]: """Load a skin definition from a YAML file; None on any failure.""" try: - import yaml + import hermes_yaml as yaml with open(path, "r", encoding="utf-8-sig") as f: data = yaml.safe_load(f) if isinstance(data, dict) and "name" in data: diff --git a/hermes_cli/tools_config_cua.py b/hermes_cli/tools_config_cua.py index f7f28e3a5e..9ec6f6a807 100644 --- a/hermes_cli/tools_config_cua.py +++ b/hermes_cli/tools_config_cua.py @@ -155,7 +155,7 @@ def _pip_install(args: List[str], *, timeout: int = 300, capture_output: bool = install_flags = _post_setup_no_window_flags(streams_to_console=not capture_output) # Resolve uv and its target environment through PM, not ambient PATH. - from pm.ensure import uv as pm_uv + from pm.client import uv as pm_uv from pm.package import InstallError try: diff --git a/hermes_cli/update_cmd_deps.py b/hermes_cli/update_cmd_deps.py index 42089fb1f2..b1d67966a1 100644 --- a/hermes_cli/update_cmd_deps.py +++ b/hermes_cli/update_cmd_deps.py @@ -176,7 +176,7 @@ def _refresh_active_lazy_features(features: list[str] | None = None) -> bool: the locked versions of everything enabled. Never raises. """ try: - from pm.ensure import sync_venv + from pm.client import sync_venv sync_venv(features, explicit=True) return True diff --git a/hermes_cli/venv_sync.py b/hermes_cli/venv_sync.py index dc742f6097..5b119fee16 100644 --- a/hermes_cli/venv_sync.py +++ b/hermes_cli/venv_sync.py @@ -139,7 +139,7 @@ def _managed_uv() -> tuple: keeps this module's bare-import surface stdlib-pure. """ try: - from pm.ensure import uv as pm_uv + from pm.client import uv as pm_uv uv_bin, env = pm_uv(realize=True) except Exception: diff --git a/hermes_cli/web_routers/analytics.py b/hermes_cli/web_routers/analytics.py index 81f82576f4..4906d039dc 100644 --- a/hermes_cli/web_routers/analytics.py +++ b/hermes_cli/web_routers/analytics.py @@ -8,7 +8,7 @@ import asyncio import time from typing import Any, Dict, List, Optional -import yaml +import hermes_yaml as yaml from fastapi import APIRouter, HTTPException, Query from hermes_cli.config import get_config_path, read_raw_config diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 1375ac8cc7..e7e7c84a9e 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1494,7 +1494,7 @@ import shutil # noqa: F401,E402 import stat # noqa: F401,E402 import tempfile # noqa: F401,E402 from datetime import timezone # noqa: F401,E402 -import yaml # noqa: F401,E402 +import hermes_yaml as yaml # noqa: F401,E402 import zipfile # noqa: F401,E402 diff --git a/hermes_cli/web_server_dashboard.py b/hermes_cli/web_server_dashboard.py index 23d3627d2f..cfd7d61571 100644 --- a/hermes_cli/web_server_dashboard.py +++ b/hermes_cli/web_server_dashboard.py @@ -8,7 +8,7 @@ import os import sys import threading import time -import yaml +import hermes_yaml as yaml from fastapi import FastAPI, Request from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles diff --git a/hermes_cli/web_server_memory.py b/hermes_cli/web_server_memory.py index 9cd4afaf45..7f5ec6213b 100644 --- a/hermes_cli/web_server_memory.py +++ b/hermes_cli/web_server_memory.py @@ -7,7 +7,7 @@ import os import re import shlex import subprocess -import yaml +import hermes_yaml as yaml from fastapi import HTTPException from pathlib import Path from typing import Any, Dict, List, Optional diff --git a/hermes_time.py b/hermes_time.py index 26bcea13de..96b0a00459 100644 --- a/hermes_time.py +++ b/hermes_time.py @@ -43,7 +43,7 @@ def _resolve_timezone_name() -> str: from hermes_cli.config import read_raw_config cfg = read_raw_config() or {} except Exception: - import yaml + import hermes_yaml as yaml config_path = get_config_path() cfg = (yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}) if config_path.exists() else {} if cfg: diff --git a/hermes_wisdom/agent_led/share_flow.py b/hermes_wisdom/agent_led/share_flow.py index bb3b058027..0d8bca5aa0 100644 --- a/hermes_wisdom/agent_led/share_flow.py +++ b/hermes_wisdom/agent_led/share_flow.py @@ -372,7 +372,7 @@ class ShareFlow: def normalize_generated_package(package: SharePackage) -> SharePackage: """Materialize the generated metadata as part of the reviewable package.""" - import yaml + import hermes_yaml as yaml from .setup_document import SETUP_PATH, render_setup_document files = [] diff --git a/hermes_yaml.py b/hermes_yaml.py new file mode 100644 index 0000000000..291728aef2 --- /dev/null +++ b/hermes_yaml.py @@ -0,0 +1,77 @@ +"""Shared YAML 1.1 policy for config, manifests, and frontmatter. + +Ruamel's native schema includes bare y/n booleans and rejects duplicate keys. +Every operation owns its parser/emitter; instances must not be shared by threads. +""" + +from io import StringIO +from typing import Any, IO, overload + +from ruamel.yaml import YAML +from ruamel.yaml.error import YAMLError as YAMLError +from ruamel.yaml.resolver import VersionedResolver + + +class _Yaml11Resolver(VersionedResolver): + # Quote strings like "off" without adding a %YAML directive to every config/snippet. + @property + def processing_version(self) -> tuple[int, int]: + return (1, 1) + + +def safe_load(stream: str | bytes | IO[str] | IO[bytes]) -> Any: + """Read standard YAML data; existing configs use YAML 1.1 booleans.""" + yaml = YAML(typ="safe") + yaml.version = (1, 1) + return yaml.load(stream) + + +@overload +def safe_dump( + data: Any, stream: None = None, *, default_flow_style: bool = False, + sort_keys: bool = True, allow_unicode: bool = True, width: int = 80, +) -> str: ... + + +@overload +def safe_dump( + data: Any, stream: IO[str], *, default_flow_style: bool = False, + sort_keys: bool = True, allow_unicode: bool = True, width: int = 80, +) -> None: ... + + +def safe_dump( + data: Any, + stream: IO[str] | None = None, + *, + default_flow_style: bool = False, + sort_keys: bool = True, + allow_unicode: bool = True, + width: int = 80, +) -> str | None: + """Write standard YAML data with readable Unicode and indented block lists.""" + # The C emitter ignores sequence offsets and escapes astral Unicode. + yaml = YAML(typ="safe", pure=True) + yaml.Resolver = _Yaml11Resolver + yaml.default_flow_style = default_flow_style + yaml.allow_unicode = allow_unicode + yaml.width = width + yaml.sort_base_mapping_type_on_output = sort_keys + yaml.indent(mapping=2, sequence=4, offset=2) + if stream is not None: + yaml.dump(data, stream) + return None + output = StringIO() + yaml.dump(data, output) + return output.getvalue() + + +def roundtrip_yaml() -> YAML: + """Create a fresh comment/quote-preserving editor for user-authored YAML.""" + yaml = YAML(typ="rt") + yaml.Resolver = _Yaml11Resolver + yaml.preserve_quotes = True + yaml.allow_unicode = True + yaml.default_flow_style = False + yaml.indent(mapping=2, sequence=4, offset=2) + return yaml diff --git a/nix/configMergeScript.nix b/nix/configMergeScript.nix index bea2d61611..5f6a741c07 100644 --- a/nix/configMergeScript.nix +++ b/nix/configMergeScript.nix @@ -4,9 +4,16 @@ # Nix keys override; user-added keys (skills, streaming, etc.) are preserved. { pkgs }: pkgs.writeScript "hermes-config-merge" '' - #!${pkgs.python3.withPackages (ps: [ ps.pyyaml ])}/bin/python3 - import json, yaml, sys + #!${pkgs.python3.withPackages (ps: [ ps.ruamel-yaml ])}/bin/python3 + import json, sys from pathlib import Path + from ruamel.yaml import YAML + + yaml = YAML(typ="safe", pure=True) + # Existing configs use YAML 1.1 booleans such as yes and off. + yaml.version = (1, 1) + yaml.default_flow_style = False + yaml.sort_base_mapping_type_on_output = False nix_json, config_path = sys.argv[1], Path(sys.argv[2]) @@ -16,7 +23,7 @@ pkgs.writeScript "hermes-config-merge" '' existing = {} if config_path.exists(): with open(config_path) as f: - existing = yaml.safe_load(f) or {} + existing = yaml.load(f) or {} def deep_merge(base, override): result = dict(base) @@ -29,5 +36,5 @@ pkgs.writeScript "hermes-config-merge" '' merged = deep_merge(existing, nix) with open(config_path, "w") as f: - yaml.dump(merged, f, default_flow_style=False, sort_keys=False) + yaml.dump(merged, f) '' diff --git a/nix/hermes-agent.nix b/nix/hermes-agent.nix index a406d56733..032af00240 100644 --- a/nix/hermes-agent.nix +++ b/nix/hermes-agent.nix @@ -8,6 +8,7 @@ lib, stdenv, makeWrapper, + writeText, callPackage, electron, ripgrep, @@ -72,7 +73,7 @@ let version; # CLI and Electron consume the same provenance and update owner. - installStampFile = builtins.toFile "hermes-install-stamp.json" (builtins.toJSON { + installStampFile = writeText "hermes-install-stamp.json" (builtins.toJSON { schemaVersion = 2; commit = rev; commitDate = lastModified; @@ -83,6 +84,7 @@ let distance = stampDistance; source = "nix"; distribution = "nix"; + pmRuntime = toString pmRuntime; updateMechanism = "external"; payload = "bootstrap"; tag = null; @@ -98,6 +100,10 @@ let hermesVenv = (mkHermesVenv extraDependencyGroups).venv; + pmRuntime = callPackage ./pm-runtime.nix { + inherit uv2nix pyproject-nix pyproject-build-systems; + }; + generatedIcons = callPackage ./icons.nix { inherit (mkHermesVenv [ ]) iconBuildVenv; }; @@ -293,6 +299,8 @@ stdenv.mkDerivation (finalAttrs: { hermesWeb hermesNpmLib hermesVenv + installStampFile + pmRuntime python ; diff --git a/nix/pm-runtime.nix b/nix/pm-runtime.nix new file mode 100644 index 0000000000..362ba1c0e7 --- /dev/null +++ b/nix/pm-runtime.nix @@ -0,0 +1,28 @@ +# PM's dependency graph must remain independent of the application closure. +{ + lib, + callPackage, + uv2nix, + pyproject-nix, + pyproject-build-systems, +}: +let + python = (callPackage ./pythonLock.nix { }).interpreter; + workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ../pm; }; + pythonSet = (callPackage pyproject-nix.build.packages { inherit python; }).overrideScope ( + lib.composeManyExtensions [ + pyproject-build-systems.overlays.default + (workspace.mkPyprojectOverlay { sourcePreference = "wheel"; }) + ] + ); + environment = pythonSet.mkVirtualEnv "hermes-pm-runtime" workspace.deps.default; +in +# Nix owns this environment; no runtime download or uv resolution is needed. +environment.overrideAttrs (old: { + postInstall = (old.postInstall or "") + '' + printf '%s\n' '${builtins.toJSON { + python = "${python}/bin/python3"; + sitePackages = python.sitePackages; + }}' > "$out/pm-runtime.json" + ''; +}) diff --git a/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl b/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl index c6a95848c6..dcf375b529 100644 --- a/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl +++ b/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl @@ -66,7 +66,7 @@ configure_profile() { local toolsets_json="$2" # JSON array string, e.g. '["kanban","terminal","file"]' local skills_json="$3" # JSON array string, e.g. '["ascii-video"]' python3 - "$profile" "$toolsets_json" "$skills_json" "$WORKSPACE" <<'PY' -"""Patch a Hermes profile config.yaml using PyYAML so we don't depend on the +"""Patch a Hermes profile config.yaml using ruamel.yaml so we don't depend on the exact default-config string format. Validates the patch took effect and exits non-zero if anything's off.""" import json @@ -74,11 +74,16 @@ import os import sys try: - import yaml + from ruamel.yaml import YAML except ImportError: - print("ERROR: PyYAML required. pip install pyyaml", file=sys.stderr) + print("ERROR: ruamel.yaml required. pip install ruamel.yaml==0.18.17", file=sys.stderr) sys.exit(1) +yaml = YAML(typ="safe", pure=True) +yaml.version = (1, 1) +yaml.default_flow_style = False +yaml.sort_base_mapping_type_on_output = False + profile, toolsets_json, skills_json, workspace = sys.argv[1:5] toolsets = json.loads(toolsets_json) skills = json.loads(skills_json) @@ -89,7 +94,7 @@ if not os.path.exists(p): sys.exit(1) with open(p) as f: - cfg = yaml.safe_load(f) or {} + cfg = yaml.load(f) or {} # Apply our changes — only the keys we actually want to set. cfg["toolsets"] = toolsets @@ -102,11 +107,11 @@ cfg["skills"]["always_load"] = skills # don't need to mutate cfg["terminal"]["cwd"] either. with open(p, "w") as f: - yaml.safe_dump(cfg, f, sort_keys=False) + yaml.dump(cfg, f) # Validate with open(p) as f: - after = yaml.safe_load(f) + after = yaml.load(f) errors = [] if after.get("toolsets") != toolsets: errors.append(f"toolsets mismatch: {after.get('toolsets')!r}") diff --git a/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md b/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md index 0a85164e07..822b4e67da 100644 --- a/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md +++ b/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md @@ -13,7 +13,7 @@ JSON. > are adapted from alt-glitch's original multi-agent video pipeline: > [NousResearch/kanban-video-pipeline](https://github.com/NousResearch/kanban-video-pipeline). > This skill generalizes those patterns across video styles and replaces the -> string-replacement config patcher with a PyYAML-based one. +> string-replacement config patcher with a ruamel.yaml-based one. ## Project workspace structure @@ -94,7 +94,7 @@ modify `terminal.cwd` — the kanban dispatcher overrides cwd per-task via `--workspace dir:`, so the profile's cwd is irrelevant to the kanban work and changing it could break the user's interactive use of the profile. -Use **PyYAML**, not string replacement, so the patch is robust against +Use **ruamel.yaml**, not string replacement, so the patch is robust against default-config schema drift: ```bash @@ -103,21 +103,26 @@ configure_profile() { local toolsets_json="$2" # JSON array, e.g. '["kanban","terminal","file"]' local skills_json="$3" # JSON array, e.g. '["ascii-video"]' python3 - "$profile" "$toolsets_json" "$skills_json" <<'PY' -import json, os, sys, yaml +import json, os, sys +from ruamel.yaml import YAML +yaml = YAML(typ="safe", pure=True) +yaml.version = (1, 1) +yaml.default_flow_style = False +yaml.sort_base_mapping_type_on_output = False profile, ts_json, sk_json = sys.argv[1:4] p = os.path.expanduser(f"~/.hermes/profiles/{profile}/config.yaml") with open(p) as f: - cfg = yaml.safe_load(f) or {} + cfg = yaml.load(f) or {} cfg["toolsets"] = json.loads(ts_json) cfg.setdefault("skills", {})["always_load"] = json.loads(sk_json) with open(p, "w") as f: - yaml.safe_dump(cfg, f, sort_keys=False) + yaml.dump(cfg, f) PY } ``` -PyYAML must be installed in the user's Python (it ships with most Hermes -installs). If absent: `pip install pyyaml`. +ruamel.yaml must be installed in the user's Python (it ships with Hermes). +If absent: `pip install ruamel.yaml==0.18.17`. The setup script should also **validate** the patch by re-reading the file and comparing — see `assets/setup.sh.tmpl` for the validation pattern. diff --git a/optional-skills/creative/touchdesigner-mcp/scripts/setup.sh b/optional-skills/creative/touchdesigner-mcp/scripts/setup.sh index 15dc662c1c..9f96e05185 100644 --- a/optional-skills/creative/touchdesigner-mcp/scripts/setup.sh +++ b/optional-skills/creative/touchdesigner-mcp/scripts/setup.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # setup.sh — Automated setup for twozero MCP plugin for TouchDesigner # Idempotent: safe to run multiple times. +# Config editing requires ruamel.yaml in python3: pip install ruamel.yaml==0.18.17 set -euo pipefail GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' @@ -52,11 +53,16 @@ elif grep -q 'twozero_td' "$HERMES_CFG" 2>/dev/null; then else echo -e " ${WARN} Adding twozero_td MCP entry to Hermes config..." python3 -c " -import yaml, sys, copy +from ruamel.yaml import YAML + +yaml = YAML(typ='safe', pure=True) +yaml.version = (1, 1) +yaml.default_flow_style = False +yaml.sort_base_mapping_type_on_output = False cfg_path = '$HERMES_CFG' with open(cfg_path, 'r') as f: - cfg = yaml.safe_load(f) or {} + cfg = yaml.load(f) or {} if 'mcp_servers' not in cfg: cfg['mcp_servers'] = {} @@ -68,9 +74,9 @@ if 'twozero_td' not in cfg['mcp_servers']: 'connect_timeout': 60 } with open(cfg_path, 'w') as f: - yaml.dump(cfg, f, default_flow_style=False, sort_keys=False) + yaml.dump(cfg, f) " 2>/dev/null && echo -e " ${OK} twozero_td MCP entry added to config" \ - || { echo -e " ${FAIL} Could not update config (is PyYAML installed?)"; \ + || { echo -e " ${FAIL} Could not update config (is ruamel.yaml installed?)"; \ manual_steps+=("Add twozero_td MCP entry to ${HERMES_CFG} manually"); } manual_steps+=("Restart Hermes session to pick up config change") fi diff --git a/optional-skills/migration/openclaw-migration/SKILL.md b/optional-skills/migration/openclaw-migration/SKILL.md index 500a49a50b..d9e69ac339 100644 --- a/optional-skills/migration/openclaw-migration/SKILL.md +++ b/optional-skills/migration/openclaw-migration/SKILL.md @@ -226,6 +226,9 @@ The helper script still supports category-level `--include` / `--exclude`, but t ## Commands +Run the helper with Hermes' Python environment, which includes `ruamel.yaml`. +For a standalone Python environment, install `ruamel.yaml==0.18.17` first. + Dry run with full discovery: ```bash diff --git a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py index bad94b186b..2c900c09aa 100644 --- a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py +++ b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py @@ -22,7 +22,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Sequence, Tuple try: - import yaml + from ruamel import yaml except Exception: # pragma: no cover - handled at runtime yaml = None @@ -371,7 +371,7 @@ def load_yaml_file(path: Path) -> Dict[str, Any]: :class:`ConfigReadError` so the caller refuses and leaves the file byte-identical. - ``yaml is None`` (PyYAML not installed) still yields ``{}``: nothing can be + ``yaml is None`` (ruamel.yaml not installed) still yields ``{}``: nothing can be written in that state either, since :func:`dump_yaml_file` raises. """ if yaml is None or not path.exists(): @@ -386,7 +386,9 @@ def load_yaml_file(path: Path) -> Dict[str, Any]: f"({exc}). Fix the file permissions or move it aside first." ) from exc try: - data = yaml.safe_load(raw) + reader = yaml.YAML(typ="safe") + reader.version = (1, 1) # Match Hermes' existing config scalar semantics. + data = reader.load(raw) except yaml.YAMLError as exc: raise ConfigReadError( f"Refusing to overwrite {path}: the existing file is not valid YAML " @@ -421,7 +423,7 @@ def dump_yaml_file(path: Path, data: Dict[str, Any]) -> None: ``~/.hermes/config.yaml`` into a dotfiles repo or profile package. """ if yaml is None: - raise RuntimeError("PyYAML is required to update Hermes config.yaml") + raise RuntimeError("ruamel.yaml is required to update Hermes config.yaml") ensure_parent(path) target = os.path.realpath(str(path)) if os.path.islink(str(path)) else str(path) fd, tmp_path = tempfile.mkstemp( @@ -429,7 +431,13 @@ def dump_yaml_file(path: Path, data: Dict[str, Any]) -> None: ) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: - handle.write(yaml.safe_dump(data, sort_keys=False, allow_unicode=False)) + # The C emitter leaves YAML 1.1 boolean-like strings unquoted. + writer = yaml.YAML(typ="safe", pure=True) + writer.version = (1, 1) + writer.default_flow_style = False + writer.sort_base_mapping_type_on_output = False + writer.allow_unicode = False + writer.dump(data, handle) handle.flush() os.fsync(handle.fileno()) try: @@ -1341,7 +1349,7 @@ class Migrator: self.record("command-allowlist", None, destination, "skipped", "No OpenClaw exec approvals file found") return if yaml is None: - self.record("command-allowlist", source, destination, "error", "PyYAML is not available") + self.record("command-allowlist", source, destination, "error", "ruamel.yaml is not available") return try: @@ -1839,7 +1847,7 @@ class Migrator: break if yaml is None: - self.record("model-config", source_path, destination, "error", "PyYAML is not available") + self.record("model-config", source_path, destination, "error", "ruamel.yaml is not available") return hermes_config = load_yaml_file(destination) @@ -1874,7 +1882,7 @@ class Migrator: return if yaml is None: - self.record("tts-config", source_path, destination, "error", "PyYAML is not available") + self.record("tts-config", source_path, destination, "error", "ruamel.yaml is not available") return tts_data: Dict[str, Any] = {} diff --git a/optional-skills/productivity/telephony/scripts/telephony.py b/optional-skills/productivity/telephony/scripts/telephony.py index 291fd8629a..c445ec6b84 100644 --- a/optional-skills/productivity/telephony/scripts/telephony.py +++ b/optional-skills/productivity/telephony/scripts/telephony.py @@ -11,7 +11,8 @@ Capabilities: - Make outbound AI voice calls via Bland.ai or Vapi This file intentionally uses Python stdlib HTTP clients so the skill can run in a -minimal environment with no extra pip installs. +minimal environment with no extra pip installs. Reading Hermes config.yaml is +optional and requires ruamel.yaml (standalone install: ruamel.yaml==0.18.17). """ from __future__ import annotations @@ -89,12 +90,14 @@ def _load_root_config() -> dict[str, Any]: if not path.exists(): return {} try: - import yaml # optional dependency; Hermes already ships PyYAML + from ruamel.yaml import YAML # optional dependency; Hermes ships ruamel.yaml except Exception: return {} try: + reader = YAML(typ="safe") + reader.version = (1, 1) with path.open("r", encoding="utf-8") as handle: - data = yaml.safe_load(handle) or {} + data = reader.load(handle) or {} return data if isinstance(data, dict) else {} except Exception: return {} diff --git a/optional-skills/research/research-paper-writing/references/experiment-patterns.md b/optional-skills/research/research-paper-writing/references/experiment-patterns.md index f9fb243fe5..0bda821e6d 100644 --- a/optional-skills/research/research-paper-writing/references/experiment-patterns.md +++ b/optional-skills/research/research-paper-writing/references/experiment-patterns.md @@ -82,7 +82,7 @@ def save_pass_artifacts(output_dir, pass_num, artifacts): **3. Configuration Management** -Use YAML configs for reproducibility: +Use YAML configs for reproducibility (install `ruamel.yaml==0.18.17` in the experiment environment): ```yaml # config.yaml @@ -96,10 +96,10 @@ convergence_k: 2 ``` ```python -import yaml +from ruamel.yaml import YAML with open("config.yaml") as f: - config = yaml.safe_load(f) + config = YAML(typ="safe").load(f) ``` **4. Separation of Concerns** diff --git a/optional-skills/security/godmode/SKILL.md b/optional-skills/security/godmode/SKILL.md index e7d221465b..c2fffbb41c 100644 --- a/optional-skills/security/godmode/SKILL.md +++ b/optional-skills/security/godmode/SKILL.md @@ -55,6 +55,9 @@ See `scripts/godmode_race.py` for the implementation. ## Step 0: Auto-Jailbreak (Recommended) +The helper requires `ruamel.yaml` (included with Hermes). In a standalone +Python environment, install `ruamel.yaml==0.18.17` before loading it. + The fastest path — auto-detect the model, test strategies, and lock in the winner: ```python diff --git a/optional-skills/security/godmode/scripts/auto_jailbreak.py b/optional-skills/security/godmode/scripts/auto_jailbreak.py index 5c7055a99b..bb0a96affc 100644 --- a/optional-skills/security/godmode/scripts/auto_jailbreak.py +++ b/optional-skills/security/godmode/scripts/auto_jailbreak.py @@ -18,9 +18,16 @@ Usage in execute_code: import os import json import time -import yaml +from ruamel.yaml import YAML from pathlib import Path +yaml = YAML(typ="safe", pure=True) +yaml.version = (1, 1) +yaml.default_flow_style = False +yaml.allow_unicode = True +yaml.width = 120 +yaml.sort_base_mapping_type_on_output = False + try: from openai import OpenAI except ImportError: @@ -325,7 +332,7 @@ def _get_current_model() -> tuple: return None, None try: with open(CONFIG_PATH) as f: - cfg = yaml.safe_load(f) or {} + cfg = yaml.load(f) or {} model_cfg = cfg.get("model", {}) if isinstance(model_cfg, str): return model_cfg, "https://openrouter.ai/api/v1" @@ -386,7 +393,7 @@ def _write_config(system_prompt: str = None, prefill_file: str = None): if CONFIG_PATH.exists(): try: with open(CONFIG_PATH) as f: - cfg = yaml.safe_load(f) or {} + cfg = yaml.load(f) or {} except Exception: cfg = {} @@ -401,8 +408,7 @@ def _write_config(system_prompt: str = None, prefill_file: str = None): cfg["agent"].pop("prefill_messages_file", None) with open(CONFIG_PATH, "w") as f: - yaml.dump(cfg, f, default_flow_style=False, allow_unicode=True, - width=120, sort_keys=False) + yaml.dump(cfg, f) return str(CONFIG_PATH) @@ -718,14 +724,13 @@ def undo_jailbreak(verbose=True): if CONFIG_PATH.exists(): try: with open(CONFIG_PATH) as f: - cfg = yaml.safe_load(f) or {} + cfg = yaml.load(f) or {} if "agent" in cfg: cfg["agent"].pop("system_prompt", None) cfg["agent"].pop("prefill_messages_file", None) cfg.pop("prefill_messages_file", None) with open(CONFIG_PATH, "w") as f: - yaml.dump(cfg, f, default_flow_style=False, allow_unicode=True, - width=120, sort_keys=False) + yaml.dump(cfg, f) if verbose: print(f"[UNDO] Cleared system_prompt and prefill_messages_file from {CONFIG_PATH}") except Exception as e: diff --git a/plugins/memory/hindsight/embedded_runtime.py b/plugins/memory/hindsight/embedded_runtime.py index 2808cdec32..0d7c162bce 100644 --- a/plugins/memory/hindsight/embedded_runtime.py +++ b/plugins/memory/hindsight/embedded_runtime.py @@ -128,7 +128,7 @@ def _generation_current(generation: Path) -> bool: def _uv_bridge(venv: Path) -> tuple[str, dict[str, str]]: """The sanctioned pm bridge: pinned uv binary + sanitized env for *venv*.""" - from pm.ensure import uv as pm_uv + from pm.client import uv as pm_uv uv_bin, env = pm_uv(venv=venv) if not uv_bin: diff --git a/plugins/memory/holographic/__init__.py b/plugins/memory/holographic/__init__.py index 8ff1d0ce9a..62d11cc899 100644 --- a/plugins/memory/holographic/__init__.py +++ b/plugins/memory/holographic/__init__.py @@ -115,12 +115,12 @@ class HolographicMemoryProvider(MemoryProvider): """Write config to config.yaml under plugins.hermes-memory-store.""" config_path = Path(hermes_home) / "config.yaml" try: - import yaml + import hermes_yaml as yaml from hermes_cli.config import read_user_config_raw # raw read: merged defaults must not be persisted existing = read_user_config_raw(config_path) existing.setdefault("plugins", {})["hermes-memory-store"] = values with open(config_path, "w", encoding="utf-8") as f: - yaml.dump(existing, f, default_flow_style=False) + yaml.safe_dump(existing, f, default_flow_style=False) except Exception: pass diff --git a/plugins/plugin_loader.py b/plugins/plugin_loader.py index 279b277c9d..a68e6fa339 100644 --- a/plugins/plugin_loader.py +++ b/plugins/plugin_loader.py @@ -46,7 +46,7 @@ def iter_plugin_dirs(root: Path) -> List[Path]: def read_plugin_description(plugin_dir: Path) -> str: """Return ``description`` from ``plugin.yaml`` (empty string if absent/unreadable).""" try: - import yaml + import hermes_yaml as yaml with open(plugin_dir / "plugin.yaml", encoding="utf-8-sig") as f: meta = yaml.safe_load(f) or {} return meta.get("description", "") diff --git a/pm/__init__.py b/pm/__init__.py index d05aca8660..76630203f3 100644 --- a/pm/__init__.py +++ b/pm/__init__.py @@ -16,14 +16,12 @@ from pm.ensure import ( adopt, check, enabled_extras, - ensure, env_for, is_installed, installed_package, lazy_installs_allowed, - sync_venv, - uv, ) +from pm.client import ensure, sync_venv, uv from pm.extras import available, ensure_import from pm.lock import Facts, Lockfile from pm.package import InstallError, Package, Runner, compose_env diff --git a/pm/cli.py b/pm/cli.py index 08da8a3654..68bcc79acc 100644 --- a/pm/cli.py +++ b/pm/cli.py @@ -502,7 +502,15 @@ def main(argv=None) -> int: p.set_defaults(func=cmd_update) args = parser.parse_args(argv) - return args.func(args) + from pm.runtime import is_runtime, run_cli + + try: + if not is_runtime(): + return run_cli(list(sys.argv[1:] if argv is None else argv)) + return args.func(args) + except InstallError as exc: + print(f"✗ {exc}", file=sys.stderr) + return 1 if __name__ == "__main__": diff --git a/pm/client.py b/pm/client.py new file mode 100644 index 0000000000..07ff403ac2 --- /dev/null +++ b/pm/client.py @@ -0,0 +1,224 @@ +"""Synchronous PM mutations in an isolated interpreter, never the app's imports.""" +from __future__ import annotations + +from collections.abc import Mapping +import json +import os +from pathlib import Path +import subprocess +import threading +import uuid + +from pm import paths +from pm.package import InstallError, Runner, StatePackage +from pm.runtime import is_runtime, runtime_command, runtime_environment + + +def _members(value): + if value is None: + return None + if isinstance(value, Mapping): + return {"sources": [[str(Path(key).absolute()), str(Path(source).absolute())] + for key, source in value.items()]} + return {"paths": [str(Path(path).absolute()) for path in value]} + + +def _missing_or_refuse(name): + from pm.ensure import _refuse_lazy, is_installed, lazy_installs_allowed + from pm.registry import walk + + missing = [package.name for package in walk([name]) if not is_installed(package.name)] + if missing and not lazy_installs_allowed(): + raise _refuse_lazy(name, ", ".join(missing)) + return missing + + +def _request(operation, arguments, *, callbacks=None, pause_event=None): + from pm import receipt + from pm.ensure import lazy_installs_allowed + from pm.registry import get_package, package_definitions + + request_id = uuid.uuid4().hex + update_id = receipt._ambient_update_id() + callbacks = callbacks or {} + names = ([arguments["name"]] if operation in ("ensure", "stage_only") else + {"uv": ["uv"], "sync_venv": ["venv"]}.get(operation, [])) + message = { + "id": request_id, "operation": operation, "arguments": arguments, + "update_id": update_id, + "callbacks": list(callbacks), + "packages": package_definitions(names), + "context": {"repo": str(paths.repo_root()), "lockfile": str(paths.lockfile_path())}, + } + worker = Path(__file__).with_name("worker.py").resolve() + environment = runtime_environment() + state_sync = operation == "sync_venv" or ( + operation == "ensure" and isinstance(get_package(arguments["name"]), StatePackage)) + if (state_sync and not arguments.get("explicit") and not arguments.get("repair") + and not lazy_installs_allowed()): + # A ready PM still decides no-op/refusal under its install lock. A cold + # PM is itself a missing prerequisite, not permission to bootstrap tools. + try: + command = runtime_command(worker, bootstrap=False) + except InstallError as exc: + token = receipt.begin("sync") + try: + receipt.record_refusal("lazy-install", str(exc)) + receipt.record_step("dependency-sync", False, f"{type(exc).__name__}: {exc}") + finally: + receipt.finalize("failed", 1, token=token) + raise + environment["HERMES_DISABLE_LAZY_INSTALLS"] = "1" + else: + command = runtime_command(worker) + callback_error = None + stopped = threading.Event() + write_lock = threading.Lock() + monitor = None + with subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + text=True, encoding="utf-8", env=environment) as process: + assert process.stdin is not None and process.stdout is not None + writer = process.stdin + + def send(data): + with write_lock: + cancelled = pause_event is not None and pause_event.is_set() + writer.write(json.dumps({"id": request_id, "cancel": cancelled, **data}) + "\n") + writer.flush() + + def watch_pause(): + assert pause_event is not None + while not stopped.wait(0.05): + if pause_event.is_set(): + try: + send({"type": "cancel"}) + except (OSError, ValueError): + return # The final response may already be on its way. + return + + try: + send(message) + if pause_event is not None: + monitor = threading.Thread(target=watch_pause, daemon=True) + monitor.start() + while True: + line = process.stdout.readline() + if not line: + raise InstallError("pm", "worker exited without a result", "check the worker diagnostics on stderr") + response = json.loads(line) + if response["id"] != request_id: + raise InstallError("pm", "worker returned a different request id") + if response["type"] == "result": + break + name = response["callback"] + try: + value = callbacks[name](*response.get("args", [])) + if name not in ("plugin_dirs", "before_publish"): + value = None + send({"type": "callback_result", "call": response["call"], "result": value}) + except BaseException as exc: + if callback_error is None: + callback_error = exc + send({"type": "callback_result", "call": response["call"], + "error": f"{type(exc).__name__}: {exc}"}) + stopped.set() + if monitor is not None: + monitor.join() + writer.close() + if process.wait(timeout=5): + raise InstallError("pm", "worker exited unsuccessfully") + receipt.accept_worker_receipt(response.get("receipt"), update_id) + if callback_error is not None: + raise callback_error + if "error" in response: + error = response["error"] + if "package" in error: + from pm.workspace import ResolutionConflict + kind = ResolutionConflict if error["type"] == "ResolutionConflict" else InstallError + raise kind(error["package"], error["cause"], error["remedy"]) + if error["type"] == "DownloadPaused": + from pm.downloader import DownloadPaused + raise DownloadPaused(error["message"]) + kind = {"ValueError": ValueError, "TypeError": TypeError, + "KeyError": KeyError, "OSError": OSError}.get(error["type"], RuntimeError) + raise kind(error["message"]) + return response["result"] + finally: + stopped.set() + if monitor is not None: + monitor.join() + if process.poll() is None: + process.terminate() + process.wait(timeout=5) + + +def ensure(name, *, base_env=None, explicit=False, progress=None, pause_event=None, download_progress=None) -> Runner: + from pm.ensure import env_for + from pm.registry import get_package + + if is_runtime(): + from pm.ensure import ensure as direct + return direct(name, base_env=base_env, explicit=explicit, progress=progress, + pause_event=pause_event, download_progress=download_progress) + if not explicit and not isinstance(get_package(name), StatePackage): + if not _missing_or_refuse(name): + return Runner(name, env_for(name, base_env=base_env)) + if pause_event is not None and pause_event.is_set(): + from pm.downloader import DownloadPaused + raise DownloadPaused("install paused") + callbacks = {} + if progress is not None: + callbacks["progress"] = progress + if download_progress is not None: + callbacks["download_progress"] = lambda done, total, ranges: download_progress( + done, total, {key: [tuple(row) for row in rows] for key, rows in ranges.items()}) + _request("ensure", {"name": name, "explicit": explicit}, callbacks=callbacks, pause_event=pause_event) + return Runner(name, env_for(name, base_env=base_env)) + + +def sync_venv(extras=None, *, explicit=False, plugin_dirs=None, before_publish=None, repair=False) -> None: + if is_runtime(): + from pm.ensure import sync_venv as direct + return direct(extras, explicit=explicit, plugin_dirs=plugin_dirs, + before_publish=before_publish, repair=repair) + callbacks = {} + if callable(plugin_dirs): + callbacks["plugin_dirs"] = lambda: _members(plugin_dirs()) + members = None + else: + members = _members(plugin_dirs) + if before_publish is not None: + def publish(): + publication = before_publish() + if publication is not None: + callbacks["undo"] = publication + if hasattr(publication, "finish"): + callbacks["finish"] = publication.finish + return {"undo": publication is not None, "finish": hasattr(publication, "finish")} + callbacks["before_publish"] = publish + _request("sync_venv", {"extras": extras, "explicit": explicit, "repair": repair, + "plugin_dirs": members}, callbacks=callbacks) + + +def stage_only(name, target, *, progress=None) -> Path: + if is_runtime(): + from pm.ensure import stage_only as direct + return direct(name, target, progress=progress) + callbacks = {"progress": progress} if progress is not None else {} + return Path(_request("stage_only", {"name": name, "target": target}, callbacks=callbacks)) + + +def uv(command="uv", *, venv=None, realize=True, explicit=False, base_env=None): + if command not in ("uv", "uvx"): + raise ValueError(f"unknown uv executable: {command}") + if not realize or is_runtime(): + from pm.ensure import uv as direct + return direct(command, venv=venv, realize=realize, explicit=explicit, base_env=base_env) + if not explicit: + _missing_or_refuse("uv") + binary, environment = _request("uv", { + "command": command, "venv": str(venv) if venv is not None else None, + "realize": realize, "explicit": explicit, + "base_env": dict(os.environ if base_env is None else base_env), + }) + return binary, environment diff --git a/pm/extras.py b/pm/extras.py index c86d11a084..ae69950f0a 100644 --- a/pm/extras.py +++ b/pm/extras.py @@ -164,7 +164,7 @@ def ensure_import(extra: str) -> None: f"extra {extra!r} is not supported on this platform " f"(gate: {marker!r}); the adapter degrades without it", ) - from pm.ensure import sync_venv + from pm.client import sync_venv sync_venv([extra]) # Activation is a process-boot operation. Never mix a newly resolved diff --git a/pm/launch.py b/pm/launch.py new file mode 100644 index 0000000000..256fc0a887 --- /dev/null +++ b/pm/launch.py @@ -0,0 +1,10 @@ +"""CLI entry after the isolated interpreter has been selected.""" +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from pm.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pm/plugins_state.py b/pm/plugins_state.py index 8344d1c7b2..fffca081ca 100644 --- a/pm/plugins_state.py +++ b/pm/plugins_state.py @@ -37,7 +37,7 @@ def _read_home_config(home: Path) -> Optional[dict[str, Any]]: # Missing YAML support is a broken runtime, not an empty plugin selection. import utils - from yaml import YAMLError + from ruamel.yaml.error import YAMLError try: config = utils.fast_safe_load(text) diff --git a/pm/pyproject.toml b/pm/pyproject.toml new file mode 100644 index 0000000000..d7f83bd724 --- /dev/null +++ b/pm/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "hermes-pm-runtime" +version = "0.0.0" +requires-python = ">=3.14,<3.15" +dependencies = [ + "packaging==26.0", + "tomli-w==1.2.0", + "ruamel.yaml==0.18.17", +] + +[tool.uv] +package = false + +# Deliberately independent: repairing the app cannot require resolving it. +[tool.uv.workspace] +members = [] diff --git a/pm/receipt.py b/pm/receipt.py index 2ad08eabc3..1a96f2bb55 100644 --- a/pm/receipt.py +++ b/pm/receipt.py @@ -37,6 +37,7 @@ sync never displaces the outer update's entry. from __future__ import annotations import contextvars +from contextlib import contextmanager import copy import json import os @@ -65,11 +66,49 @@ _completed_by_update: contextvars.ContextVar[Optional[dict[str, dict[str, Any]]] ) +_worker_update: contextvars.ContextVar[tuple[Optional[str]] | None] = contextvars.ContextVar( + "pm_worker_update", default=None +) +_last_completed: contextvars.ContextVar[Optional[dict[str, Any]]] = contextvars.ContextVar( + "pm_last_completed", default=None +) + + +@contextmanager +def worker_context(update_id: Optional[str]): + """Carry correlation across the worker seam without consulting disk state.""" + token = _worker_update.set((update_id,)) + completed = _last_completed.set(None) + try: + yield + finally: + _last_completed.reset(completed) + _worker_update.reset(token) + + +def last_completed() -> Optional[dict[str, Any]]: + return copy.deepcopy(_last_completed.get()) + + +def accept_worker_receipt(data: Optional[dict[str, Any]], update_id: Optional[str]) -> None: + if data is None: + return + if data.get("update_id") != update_id: + raise ValueError("PM worker receipt correlation mismatch") + if update_id: + completed = dict(_completed_by_update.get() or {}) + completed[update_id] = copy.deepcopy(data) + _completed_by_update.set(completed) + + def _ambient_update_id() -> Optional[str]: """The update correlation id in force in this context, or None. Lazy import: hermes_cli.update_receipt imports pm.receipt at embed time, so this direction must stay function-scoped. Never raises.""" + worker = _worker_update.get() + if worker is not None: + return worker[0] try: from hermes_cli.update_receipt import current_correlation_id @@ -209,6 +248,7 @@ def finalize( current["outcome"] = outcome current["exit_code"] = exit_code current["finished_at"] = _utc_now_iso() + _last_completed.set(copy.deepcopy(current)) # Correlation: file this completion under its update id (copy-on-write # — a deep-copied entry in a freshly copied map, never a shared dict). update_id = current.get("update_id") diff --git a/pm/recovery.py b/pm/recovery.py index 110cfd81db..a4dfac4ad1 100644 --- a/pm/recovery.py +++ b/pm/recovery.py @@ -10,7 +10,7 @@ from pm.package import InstallError STARTUP_IMPORTS = ( - ("PyYAML", "yaml", "SafeDumper"), + ("ruamel.yaml", "ruamel.yaml", "YAML"), ("python-dotenv", "dotenv", "load_dotenv"), ("click", "click", "Command"), ("certifi", "certifi", "contents"), @@ -49,7 +49,7 @@ def validate_environment(python: Path, *, env: dict, cwd: Path) -> None: def repair_dependencies(project_root: Path) -> None: """Restore this installation's recorded set; never repair a foreign tree.""" - from pm.ensure import sync_venv + from pm.client import sync_venv from pm.paths import repo_root if Path(project_root).resolve() != repo_root().resolve(): diff --git a/pm/registry.py b/pm/registry.py index b75270b7ac..ae93e6e195 100644 --- a/pm/registry.py +++ b/pm/registry.py @@ -2,7 +2,14 @@ from __future__ import annotations -from pm.package import Package +import importlib +import importlib.util +from pathlib import Path +import sys +from types import ModuleType +from typing import Any + +from pm.package import InstallError, Package _packages: dict[str, Package] = {} @@ -25,6 +32,94 @@ def all_packages() -> list[str]: return sorted(_packages) +def package_definitions(names: list[str] | None = None) -> list[dict[str, Any]]: + """Declarations for a fresh worker; built-ins already load with pm. + + Only import identities and file locations cross the wire, never instances, + source bodies, or the application's import path. ``names`` selects a closure. + """ + definitions = [] + for package in walk(names) if names is not None else list(_packages.values()): + cls = type(package) + if cls.__module__ == "pm.packages": + continue + definition = {"name": package.name, "module": cls.__module__, "qualname": cls.__qualname__} + module = sys.modules.get(cls.__module__) + resolved = module + for part in cls.__qualname__.split("."): + resolved = getattr(resolved, part, None) + if cls.__module__ == "__main__" or "" in cls.__qualname__ or resolved is not cls: + raise InstallError( + package.name, f"package definition {cls.__module__}.{cls.__qualname__} is not importable", + "define and register a module-level Package subclass in an importable file", + ) + source = getattr(module, "__file__", None) + if source: + definition["path"] = str(Path(source).resolve()) + namespaces = {} + parent = cls.__module__.rpartition(".")[0] + while parent: + loaded = sys.modules.get(parent) + if loaded is not None and not getattr(loaded, "__file__", None) and hasattr(loaded, "__path__"): + namespaces[parent] = [str(Path(path).resolve()) for path in loaded.__path__] + parent = parent.rpartition(".")[0] + if namespaces: + definition["namespaces"] = namespaces + definitions.append(definition) + return definitions + + +def load_package_definitions(definitions: list[dict[str, Any]]) -> None: + """Restore explicit registrations after built-ins, without plugin discovery.""" + restored = {} + for definition in definitions: + module_name = definition["module"] + try: + # Directory plugins use synthetic package parents. Their explicit + # search paths preserve relative imports without exposing app deps. + for name, locations in definition.get("namespaces", {}).items(): + if name not in sys.modules: + namespace = ModuleType(name) + namespace.__path__ = locations + sys.modules[name] = namespace + try: + module = importlib.import_module(module_name) + except ModuleNotFoundError as exc: + # Missing dependencies inside the definition are not evidence + # that its module is absent; never execute such a module twice. + if not exc.name or not (module_name == exc.name or module_name.startswith(exc.name + ".")): + raise + spec = importlib.util.spec_from_file_location(module_name, definition["path"]) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load {module_name}") + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + except BaseException: + sys.modules.pop(module_name, None) + raise + source = definition.get("path") + if source and Path(getattr(module, "__file__", "") or "").resolve() != Path(source).resolve(): + raise ImportError(f"{module_name} resolved to a different source") + cls = module + for part in definition["qualname"].split("."): + cls = getattr(cls, part) + if not isinstance(cls, type) or not issubclass(cls, Package): + raise TypeError("definition is not a Package subclass") + instance = cls() + if instance.name != definition["name"]: + raise ValueError(f"definition now registers {instance.name!r}") + restored[instance.name] = instance + except Exception as exc: + raise InstallError( + definition["name"], f"cannot load package definition {module_name}.{definition['qualname']}: {exc}", + "keep the package definition and its dependencies importable in the isolated PM runtime", + ) from exc + # Import-time decorators must not override the caller's final selection. + _packages.update(restored) + + def walk(names: list[str]) -> list[Package]: """Deps-first topological order over the requested packages.""" seen: dict[str, Package] = {} diff --git a/pm/runtime.py b/pm/runtime.py new file mode 100644 index 0000000000..55f24dbbbe --- /dev/null +++ b/pm/runtime.py @@ -0,0 +1,188 @@ +"""The dependency manager's runtime, independent of the application graph. + +Only the bootstrap below runs in the caller's interpreter. It never imports +application dependencies or adds the manager's dependencies to that process. +""" +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import uuid + +from pm.package import InstallError + + +def runtime_environment() -> dict[str, str]: + """Do not let an activated application or a uv caller select PM's imports.""" + from hermes_constants import get_hermes_home + from pm.paths import store_root + + env = {key: value for key, value in os.environ.items() + if not key.startswith("PYTHON") and not key.startswith("UV_") + and key != "VIRTUAL_ENV"} + env["HERMES_HOME"] = str(get_hermes_home()) + env["HERMES_RUNTIME_DIR"] = str(store_root()) + env["UV_PYTHON_DOWNLOADS"] = "never" + return env + + +def _python(environment: Path) -> Path: + return environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + + +def _inputs(project: Path, python: Path) -> str: + digest = hashlib.sha256() + for name in ("pyproject.toml", "uv.lock"): + digest.update((project / name).read_bytes()) + digest.update(b"\0") + # A different interpreter must not reuse a venv pointing at the old one. + digest.update(str(python.absolute()).encode()) + return digest.hexdigest() + + +def is_runtime() -> bool: + if (Path(sys.prefix) / "pm-runtime.json").is_file(): + return True + resident = _resident_runtime() + return resident is not None and str(resident[1]) in sys.path + + +def _resident_runtime() -> tuple[Path, Path] | None: + from pm.paths import repo_root + + project = repo_root() + payload = project.parent if (project.parent / "manifest.json").is_file() else None + if payload is not None: + runtime = payload / "pm-runtime" + else: + install_root = Path(os.environ.get("HERMES_INSTALL_ROOT") or project) + stamp_path = install_root / "install-stamp.json" + try: + stamp = json.loads(stamp_path.read_text(encoding="utf-8-sig")) + except FileNotFoundError: + return None + except (OSError, ValueError) as exc: + raise InstallError("pm-runtime", "invalid package install stamp", "reinstall this application") from exc + if stamp.get("distribution") not in ("nix", "docker"): + return None + value = stamp.get("pmRuntime") + if not isinstance(value, str) or not Path(value).is_absolute(): + raise InstallError("pm-runtime", "packaged PM runtime is missing", "reinstall this application") + runtime = Path(value) + try: + marker = json.loads((runtime / "pm-runtime.json").read_text(encoding="utf-8")) + python = (runtime / marker["python"]).resolve() + site = (runtime / marker["sitePackages"]).resolve() + except (OSError, ValueError, KeyError, TypeError) as exc: + raise InstallError("pm-runtime", "packaged PM runtime is missing or invalid", "reinstall this application") from exc + # Native payloads must remain self-contained. Nix's independent derivation + # instead refers to its immutable interpreter/dependencies in /nix/store. + if (not python.is_file() or not site.is_dir() + or (payload is not None and (not python.is_relative_to(payload) or not site.is_relative_to(runtime)))): + raise InstallError("pm-runtime", "packaged PM paths are missing or escape the payload", "reinstall this application") + return python, site + + +def _validate(python: Path, env: dict[str, str]) -> str: + try: + checked = subprocess.run( + [str(python), "-I", "-B", "-c", + "import packaging, tomli_w; from ruamel.yaml import YAML"], + env=env, capture_output=True, text=True, timeout=30, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return str(exc) + return checked.stderr.strip() or f"exit {checked.returncode}" if checked.returncode else "" + + +def prepare_runtime(uv: Path, python: Path, root: Path, *, offline: bool = False, + project: Path | None = None, bootstrap: bool = True) -> Path: + """Publish a locked PM environment without resolving the application. + + Generations are immutable after publication. Failed preparation leaves the + previous generation intact, including when an old worker is still running. + """ + from hermes_cli.runtime_state import _lock + from pm.lock import _write + from pm.runtime_stage import stage_runtime + + project = project or Path(__file__).resolve().parent + identity = _inputs(project, python) + env = runtime_environment() + root.mkdir(parents=True, exist_ok=True) + with (root / ".prepare.lock").open("a+b") as lock: + _lock(lock.fileno(), wait=True) + selected = root / "selected.json" + try: + fact = json.loads(selected.read_text(encoding="utf-8")) + except FileNotFoundError: + fact = {} + if fact.get("inputs") == identity: + environment = root / fact["generation"] + if (environment / "pm-runtime.json").is_file() and not _validate(_python(environment), env): + return _python(environment) + if not bootstrap: + raise InstallError("pm-runtime", "not installed or outdated and lazy installs are disabled", + "run `hermes pm install` to prepare the independent PM runtime") + generation = Path("generations") / uuid.uuid4().hex + environment = root / generation + try: + print("Preparing the isolated PM runtime…", file=sys.stderr, flush=True) + executable = stage_runtime(uv, python, environment, project=project, offline=offline) + _write(environment / "pm-runtime.json", {"inputs": identity}) + _write(selected, {"inputs": identity, "generation": generation.as_posix()}) + return executable + except BaseException: + shutil.rmtree(environment, ignore_errors=True) + raise + + + +def runtime_python(*, bootstrap: bool = True) -> Path: + """Resolve PM without selecting, repairing, or importing the app environment.""" + if is_runtime(): + return Path(sys.executable) + from hermes_cli.runtime_paths import install_state_dir + from pm.ensure import uv as managed_uv + from pm.paths import repo_root + + project = repo_root() + resident = _resident_runtime() + if resident is not None: + return resident[0] + uv, env = managed_uv(realize=False) + if uv is None: + if not bootstrap: + raise InstallError("pm-runtime", "not installed and lazy installs are disabled", + "run `hermes pm install` to prepare the independent PM runtime") + # This closure is deliberately stdlib-only: uv + Python, never Venv. + uv, env = managed_uv(explicit=True) + if uv is None: + raise InstallError("pm-runtime", "pinned uv and Python are unavailable") + return prepare_runtime(Path(uv), Path(env["UV_PYTHON"]), install_state_dir(project) / "pm-runtime", + bootstrap=bootstrap) + + +def runtime_command(script: Path, args: tuple[str, ...] | list[str] = (), *, bootstrap: bool = True) -> list[str]: + """One launch contract for mutable venvs and resident signed payloads.""" + resident = _resident_runtime() + if resident is None: + python = runtime_python() if bootstrap else runtime_python(bootstrap=False) + return [str(python), "-I", "-B", str(script), *args] + python, site = resident + launcher = ( + "import runpy,sys; sys.path.insert(0,sys.argv.pop(1)); " + "script=sys.argv.pop(1); sys.argv[0]=script; runpy.run_path(script,run_name='__main__')" + ) + return [str(python), "-I", "-S", "-B", "-c", launcher, str(site), str(script), *args] + + +def run_cli(argv: list[str]) -> int: + result = subprocess.run(runtime_command(Path(__file__).with_name("launch.py"), argv), + env=runtime_environment()) + return result.returncode diff --git a/pm/runtime_stage.py b/pm/runtime_stage.py new file mode 100644 index 0000000000..ab2b3aa761 --- /dev/null +++ b/pm/runtime_stage.py @@ -0,0 +1,71 @@ +"""One locked dependency builder for PM workers and packaged runtimes.""" +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + +from pm.package import InstallError + + +def stage_runtime(uv: Path, python: Path, destination: Path, *, + project: Path | None = None, offline: bool = False, + wheelhouse: Path | None = None) -> Path: + """Build at the final path; the caller owns publication and its marker. + + The scratch project prevents uv from discovering the application's workspace. + No project install, application extra, or application lock enters this graph. + """ + from pm.packages import uv_cache_dir + from pm.runtime import runtime_environment + + project = project or Path(__file__).resolve().parent + destination = destination.absolute() + executable = destination / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + env = runtime_environment() + env["UV_CACHE_DIR"] = str(uv_cache_dir()) + env["UV_PROJECT_ENVIRONMENT"] = str(destination) + env["UV_PYTHON"] = str(python) + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="pm-project-", dir=destination.parent) as temp: + snapshot = Path(temp) + for name in ("pyproject.toml", "uv.lock"): + shutil.copyfile(project / name, snapshot / name) + commands = [ + [str(uv), "venv", "--relocatable", "--python", str(python), str(destination)], + [str(uv), "sync", "--project", str(snapshot), "--locked", + "--no-default-groups", "--no-install-project", "--python", str(python)], + ] + if wheelhouse is not None: + # Locally rebuilt wheels are verified by the caller's wheelhouse + # manifest, not the upstream wheel hashes in the PM lock. + offline = True + requirements = snapshot / "requirements.txt" + commands[1:] = [ + [str(uv), "export", "--project", str(snapshot), "--frozen", + "--python", str(python), "--no-default-groups", "--no-emit-project", + "--no-hashes", "--output-file", str(requirements)], + [str(uv), "pip", "install", "--python", str(executable), + "--no-index", "--only-binary", ":all:", + "--find-links", str(wheelhouse.absolute()), "-r", str(requirements)], + [str(uv), "pip", "check", "--python", str(executable)], + ] + for command in commands: + # Neither the caller's uv.toml nor user-wide settings may choose + # PM's indexes, required uv version, or environment policy. + command.append("--no-config") + if offline: + command.append("--offline") + result = subprocess.run(command, cwd=snapshot, env=env, stdout=sys.stderr, stderr=sys.stderr, timeout=600) + if result.returncode: + raise InstallError("pm-runtime", f"{command[1]} exited {result.returncode}") + checked = subprocess.run( + [str(executable), "-I", "-B", "-c", "import packaging, tomli_w; from ruamel.yaml import YAML"], + env=env, capture_output=True, text=True, timeout=30, + ) + if checked.returncode: + raise InstallError("pm-runtime", f"dependency validation failed: {checked.stderr.strip()}") + return executable diff --git a/pm/uv.lock b/pm/uv.lock new file mode 100644 index 0000000000..2837f5fbbb --- /dev/null +++ b/pm/uv.lock @@ -0,0 +1,68 @@ +version = 1 +revision = 3 +requires-python = "==3.14.*" + +[[package]] +name = "hermes-pm-runtime" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "packaging" }, + { name = "ruamel-yaml" }, + { name = "tomli-w" }, +] + +[package.metadata] +requires-dist = [ + { name = "packaging", specifier = "==26.0" }, + { name = "ruamel-yaml", specifier = "==0.18.17" }, + { name = "tomli-w", specifier = "==1.2.0" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "ruamel-yaml" +version = "0.18.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ruamel-yaml-clib", marker = "platform_python_implementation == 'CPython'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/2b/7a1f1ebcd6b3f14febdc003e658778d81e76b40df2267904ee6b13f0c5c6/ruamel_yaml-0.18.17.tar.gz", hash = "sha256:9091cd6e2d93a3a4b157ddb8fabf348c3de7f1fb1381346d985b6b247dcd8d3c", size = 149602, upload-time = "2025-12-17T20:02:55.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/fe/b6045c782f1fd1ae317d2a6ca1884857ce5c20f59befe6ab25a8603c43a7/ruamel_yaml-0.18.17-py3-none-any.whl", hash = "sha256:9c8ba9eb3e793efdf924b60d521820869d5bf0cb9c6f1b82d82de8295e290b9d", size = 121594, upload-time = "2025-12-17T20:02:07.657Z" }, +] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/97/60fda20e2fb54b83a61ae14648b0817c8f5d84a3821e40bfbdae1437026a/ruamel_yaml_clib-0.2.15.tar.gz", hash = "sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600", size = 225794, upload-time = "2025-11-16T16:12:59.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/bd/ab8459c8bb759c14a146990bf07f632c1cbec0910d4853feeee4be2ab8bb/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:753faf20b3a5906faf1fc50e4ddb8c074cb9b251e00b14c18b28492f933ac8ef", size = 147248, upload-time = "2025-11-16T16:13:42.872Z" }, + { url = "https://files.pythonhosted.org/packages/69/f2/c4cec0a30f1955510fde498aac451d2e52b24afdbcb00204d3a951b772c3/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:480894aee0b29752560a9de46c0e5f84a82602f2bc5c6cde8db9a345319acfdf", size = 133764, upload-time = "2025-11-16T16:13:43.932Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/2480d062281385a2ea4f7cc9476712446e0c548cd74090bff92b4b49e898/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d3b58ab2454b4747442ac76fab66739c72b1e2bb9bd173d7694b9f9dbc9c000", size = 730537, upload-time = "2025-11-16T20:22:52.918Z" }, + { url = "https://files.pythonhosted.org/packages/75/08/e365ee305367559f57ba6179d836ecc3d31c7d3fdff2a40ebf6c32823a1f/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bfd309b316228acecfa30670c3887dcedf9b7a44ea39e2101e75d2654522acd4", size = 746944, upload-time = "2025-11-16T16:13:45.338Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/8b56b08db91e569d0a4fbfa3e492ed2026081bdd7e892f63ba1c88a2f548/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2812ff359ec1f30129b62372e5f22a52936fac13d5d21e70373dbca5d64bb97c", size = 778249, upload-time = "2025-11-16T16:13:46.871Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1d/70dbda370bd0e1a92942754c873bd28f513da6198127d1736fa98bb2a16f/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7e74ea87307303ba91073b63e67f2c667e93f05a8c63079ee5b7a5c8d0d7b043", size = 737140, upload-time = "2025-11-16T16:13:48.349Z" }, + { url = "https://files.pythonhosted.org/packages/5b/87/822d95874216922e1120afb9d3fafa795a18fdd0c444f5c4c382f6dac761/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:713cd68af9dfbe0bb588e144a61aad8dcc00ef92a82d2e87183ca662d242f524", size = 741070, upload-time = "2025-11-16T20:22:54.151Z" }, + { url = "https://files.pythonhosted.org/packages/b9/17/4e01a602693b572149f92c983c1f25bd608df02c3f5cf50fd1f94e124a59/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:542d77b72786a35563f97069b9379ce762944e67055bea293480f7734b2c7e5e", size = 765882, upload-time = "2025-11-16T16:13:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/9f/17/7999399081d39ebb79e807314de6b611e1d1374458924eb2a489c01fc5ad/ruamel_yaml_clib-0.2.15-cp314-cp314-win32.whl", hash = "sha256:424ead8cef3939d690c4b5c85ef5b52155a231ff8b252961b6516ed7cf05f6aa", size = 102567, upload-time = "2025-11-16T16:13:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/67/be582a7370fdc9e6846c5be4888a530dcadd055eef5b932e0e85c33c7d73/ruamel_yaml_clib-0.2.15-cp314-cp314-win_amd64.whl", hash = "sha256:ac9b8d5fa4bb7fd2917ab5027f60d4234345fd366fe39aa711d5dca090aa1467", size = 122847, upload-time = "2025-11-16T16:13:51.807Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] diff --git a/pm/worker.py b/pm/worker.py new file mode 100644 index 0000000000..5bd51dd328 --- /dev/null +++ b/pm/worker.py @@ -0,0 +1,138 @@ +"""One stdlib JSON-line PM request per isolated process.""" +from __future__ import annotations + +import importlib +import json +import os +from pathlib import Path +import queue +import sys +import threading + + +def _members(value): + if value is None: + return None + if "sources" in value: + return {Path(identity): Path(source) for identity, source in value["sources"]} + return [Path(path) for path in value["paths"]] + + +def _read_controls(messages, pause): + # Raw reads avoid a daemon thread holding sys.stdin's buffered lock at exit. + pending = b"" + request_id = None + try: + while block := os.read(0, 65536): + pending += block + while b"\n" in pending: + line, pending = pending.split(b"\n", 1) + message = json.loads(line) + if request_id is None: + request_id = message["id"] + if message["id"] != request_id: + raise ValueError("unexpected PM control request id") + if message.get("cancel"): + pause.set() + if message.get("type") != "cancel": + messages.put(message) + except (OSError, ValueError, KeyError) as exc: + messages.put(exc) + finally: + pause.set() + messages.put(None) + + +def main(): + # Capture the protocol FD before redirecting even native/subprocess stdout. + wire = os.fdopen(os.dup(sys.stdout.fileno()), "w", encoding="utf-8", buffering=1) + os.dup2(sys.stderr.fileno(), sys.stdout.fileno()) + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + messages = queue.Queue() + pause = threading.Event() + threading.Thread(target=_read_controls, args=(messages, pause), daemon=True).start() + + def receive(): + message = messages.get() + if message is None: + raise RuntimeError("PM client disconnected") + if isinstance(message, Exception): + raise message + return message + + request = receive() + from pm import paths, receipt + from pm.package import InstallError + from pm.registry import load_package_definitions + context = request["context"] + paths.repo_root = lambda: Path(context["repo"]) + paths.lockfile_path = lambda: Path(context["lockfile"]) + engine = importlib.import_module("pm.ensure") + call = 0 + callback_lock = threading.Lock() + + def send(data): + wire.write(json.dumps({"id": request["id"], **data}) + "\n") + + def callback(name, *args): + with callback_lock: + return exchange(name, args) + + def exchange(name, args): + nonlocal call + call += 1 + send({"type": "callback", "callback": name, "call": call, "args": args}) + reply = receive() + if reply["id"] != request["id"] or reply["call"] != call: + raise RuntimeError("unexpected PM callback response") + if "error" in reply: + raise RuntimeError(reply["error"]) + return reply["result"] + + def sync_venv(**arguments): + if "plugin_dirs" in request["callbacks"]: + arguments["plugin_dirs"] = lambda: _members(callback("plugin_dirs")) + else: + arguments["plugin_dirs"] = _members(arguments["plugin_dirs"]) + if "before_publish" in request["callbacks"]: + def publish(): + hooks = callback("before_publish") + if not any(hooks.values()): + return None + def undo(): + if hooks["undo"]: + callback("undo") + if hooks["finish"]: + undo.finish = lambda: callback("finish") + return undo + arguments["before_publish"] = publish + return engine.sync_venv(**arguments) + + with receipt.worker_context(request.get("update_id")): + try: + load_package_definitions(request.get("packages", [])) + operations = {"ensure": engine.ensure, "sync_venv": sync_venv, + "stage_only": engine.stage_only, "uv": engine.uv} + arguments = request["arguments"] + if request["operation"] == "ensure": + arguments["pause_event"] = pause + for name in ("progress", "download_progress"): + if name in request["callbacks"]: + arguments[name] = lambda *args, name=name: callback(name, *args) + result = operations[request["operation"]](**arguments) + if request["operation"] == "ensure": + result = None # Runner is reconstructed from the caller's base env. + if isinstance(result, Path): + result = str(result) + response = {"result": result} + except BaseException as exc: + error = {"type": type(exc).__name__, "message": str(exc)} + if isinstance(exc, InstallError): + error.update(package=exc.package, cause=exc.cause, remedy=exc.remedy) + response = {"error": error} + response["receipt"] = receipt.last_completed() + send({"type": "result", **response}) + + +if __name__ == "__main__": + main() diff --git a/providers/__init__.py b/providers/__init__.py index 59d1c75de8..68c98a622d 100644 --- a/providers/__init__.py +++ b/providers/__init__.py @@ -134,7 +134,7 @@ def _declares_model_provider_kind(plugin_dir: Path) -> bool: Only that kind is imported from the flat install directory — every other plugin there belongs to ``PluginManager``, which owns its lifecycle and - consent flow. Parsed with PyYAML when available, falling back to a line + consent flow. Parsed with ruamel.yaml when available, falling back to a line scan so provider discovery never hard-depends on it. """ for filename in ("plugin.yaml", "plugin.yml"): @@ -146,7 +146,7 @@ def _declares_model_provider_kind(plugin_dir: Path) -> bool: except Exception: return False try: - import yaml + import hermes_yaml as yaml data = yaml.safe_load(text) if isinstance(data, dict): diff --git a/pyproject.toml b/pyproject.toml index 167e4dccc0..e64e5cb002 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,6 @@ dependencies = [ "httpx[socks]==0.28.1", "rich==14.3.3", "tenacity==9.1.4", - "pyyaml==6.0.3", "tomli-w==1.2.0", # Preserve relative dependency paths in staged plugin metadata. "ruamel.yaml==0.18.17", "requests==2.33.0", # CVE-2026-25645 @@ -674,7 +673,7 @@ plugins = ["**/plugin.yaml", "**/plugin.yml"] # pm/lock.json is the runtime pin table (uv/python/tool versions + sha256s). # Without it a sealed wheel installs the pm package but has no pins to # realize — the pm store would be empty. -pm = ["lock.json", "artifact-mirror.json"] +pm = ["lock.json", "artifact-mirror.json", "pyproject.toml", "uv.lock"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/scripts/bundles/native.py b/scripts/bundles/native.py index a6b781156a..0053f40727 100644 --- a/scripts/bundles/native.py +++ b/scripts/bundles/native.py @@ -61,6 +61,18 @@ def stage_uv_cache(source: Path, destination: Path) -> None: wheel.unlink() +def stage_pm_runtime(root: Path, uv: Path, python: Path, repo: Path, *, offline: bool = False) -> None: + """Publish the same PM dependency graph as source installs, ready offline.""" + from pm.runtime_stage import stage_runtime + from scripts.bundles.payload import seal_pm_runtime + + destination = root / "pm-runtime" + if destination.exists(): + shutil.rmtree(destination) + stage_runtime(uv, python, destination, project=repo / "pm", offline=offline) + seal_pm_runtime(root, python) + + def stage_native(args) -> int: previous = os.environ.get("HERMES_RUNTIME_DIR") try: @@ -134,6 +146,9 @@ def _stage_native(args) -> int: _store().entry(python_fact["entry"]), current_target() ) + stage_pm_runtime(out, Path(uv_bin), python_bin, repo_dir) + print("✓ pm-runtime (independent locked dependencies)", flush=True) + # Build + sync INSIDE the staged repo: the editable project install # must point at the payload's own tree, not this checkout. venv_dir = out / "venv" diff --git a/scripts/bundles/payload.py b/scripts/bundles/payload.py index fc35129f8c..1f0812aa22 100644 --- a/scripts/bundles/payload.py +++ b/scripts/bundles/payload.py @@ -84,10 +84,48 @@ def plant_surfaces(repo: Path, source: Path, *, dashboard: bool = True) -> None: shutil.copytree(web, repo / "hermes_cli/web_dist") +def seal_pm_runtime(root: Path, python: Path) -> dict: + """Record a resident PM runtime without Windows' CWD-bound redirector. + + Sealed workers execute the base interpreter with -I -S and add only the + recorded site directory. Its paths remain valid after the payload moves. + """ + root, python = root.resolve(), python.resolve() + if not python.is_relative_to(root) or not python.is_file(): + raise ValueError(f"PM interpreter must belong to the payload: {python}") + runtime = root / "pm-runtime" + sites = list(runtime.glob("lib/python*/site-packages")) + list(runtime.glob("Lib/site-packages")) + if len(sites) != 1: + raise ValueError(f"PM dependency directory missing or ambiguous: {runtime}") + marker = { + "python": Path(os.path.relpath(python, runtime)).as_posix(), + "sitePackages": sites[0].relative_to(runtime).as_posix(), + } + cfg = runtime / "pyvenv.cfg" + lines = cfg.read_text(encoding="utf-8").splitlines() + lines = [line for line in lines if line.partition("=")[0].strip() not in + {"home", "executable", "base-executable", "base-prefix", "base-exec-prefix", "command"}] + lines.insert(0, f"home = {os.path.relpath(python.parent, runtime)}") + cfg.write_text("\n".join(lines) + "\n", encoding="utf-8") + # A sealed runtime is not activated, and copied Windows redirectors cannot + # follow its relative home from arbitrary working directories. + bindir = runtime / ("Scripts" if os.name == "nt" else "bin") + for entry in bindir.iterdir(): + if os.name == "nt" or not entry.is_symlink(): + if entry.is_file(): + entry.unlink() + _relativize_bin_links(root, runtime / "bin") + (runtime / "pm-runtime.json").write_text(json.dumps(marker, indent=2) + "\n", encoding="utf-8") + return marker + + def relativize_links(root: Path) -> int: """Only dependency-venv links move; framework links belong to codesign.""" root = root.resolve() - directory = root / "venv/bin" + return sum(_relativize_bin_links(root, root / name / "bin") for name in ("venv", "pm-runtime")) + + +def _relativize_bin_links(root: Path, directory: Path) -> int: count = 0 if not directory.is_dir(): return count diff --git a/scripts/ci/setup_toolchain.py b/scripts/ci/setup_toolchain.py index 34f1b4cd40..d6f71a1191 100644 --- a/scripts/ci/setup_toolchain.py +++ b/scripts/ci/setup_toolchain.py @@ -173,6 +173,17 @@ def dependencies(args) -> None: import subprocess import tomllib + from pm.runtime import is_runtime, runtime_command, runtime_environment + + if not is_runtime(): + subprocess.run( + runtime_command(Path(__file__).resolve(), + ["dependencies", "--home", str(args.home.resolve()), + "--toolchain", args.toolchain, "--extras", json.dumps(args.extras)]), + env=runtime_environment(), check=True, + ) + return + from hermes_cli.runtime_paths import selected_venv from pm.ensure import sync_venv, uv from pm.paths import repo_root diff --git a/scripts/ci/verify_toolchain.py b/scripts/ci/verify_toolchain.py index 9b0d77081b..3055ebfa37 100644 --- a/scripts/ci/verify_toolchain.py +++ b/scripts/ci/verify_toolchain.py @@ -54,9 +54,9 @@ def main() -> None: if Path(sys.prefix).resolve() != Path(selected["environment"]).resolve(): raise RuntimeError("PATH Python did not select the PM dependency environment") import pytest - import yaml + import ruamel.yaml - rows["dependencies"] = {"pytest": pytest.__version__, "pyyaml": yaml.__version__} + rows["dependencies"] = {"pytest": pytest.__version__, "ruamel.yaml": ruamel.yaml.__version__} code = "import sys,pytest; print(sys.prefix); print(pytest.__version__)" probe = subprocess.check_output([shutil.which("python3"), "-c", code], text=True, encoding="utf-8", timeout=60) if pytest.__version__ not in probe: diff --git a/scripts/discord-voice-doctor.py b/scripts/discord-voice-doctor.py index dd552a87b7..dcfad1f0a6 100755 --- a/scripts/discord-voice-doctor.py +++ b/scripts/discord-voice-doctor.py @@ -241,7 +241,7 @@ def check_config(groq_key, eleven_key): config_path = HERMES_HOME / "config.yaml" if config_path.exists(): try: - import yaml + import hermes_yaml as yaml with open(config_path, encoding="utf-8-sig") as f: cfg = yaml.safe_load(f) or {} diff --git a/scripts/e2e_shared_metrics_staging.py b/scripts/e2e_shared_metrics_staging.py index 9fe836e85d..f4aa9281e0 100644 --- a/scripts/e2e_shared_metrics_staging.py +++ b/scripts/e2e_shared_metrics_staging.py @@ -47,7 +47,7 @@ def main() -> int: from hermes_cli.observability.shared_metrics_sender import SharedMetricsSender # Resolve through the real config path so this exercises what a user gets. - import yaml + import hermes_yaml as yaml resolved = resolve_send_config( yaml.safe_load((scratch / "config.yaml").read_text(encoding="utf-8-sig")) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 82d8ae8dd0..8335515181 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -530,6 +530,8 @@ function Stage-Venv { # tool store — all hash-verified against pm/lock.json + uv.lock. install.ps1 # no longer runs `uv sync` directly; pm is the single install authority # (the run_locked_uv_sync contract moved into pm/packages.py::uv_env). +# This tool-only bootstrap runs before PM's own dependencies exist. pm.cli +# prepares and enters its independently locked runtime before installing apps. function Get-BootstrapPython { $uv = Get-Uv $lock = Get-Content (Join-Path $InstallDir "pm\lock.json") -Raw | ConvertFrom-Json diff --git a/scripts/install.sh b/scripts/install.sh index bfa88626ad..618f01f4d3 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -324,7 +324,8 @@ stage_venv() { (cd "$INSTALL_DIR" && "$UV_CMD" venv --allow-existing venv) || fail "uv venv failed" } -# Resolve the bootstrap interpreter without assuming a checkout-local venv. +# Tool-only bootstrap: acquire uv and Python before PM's own dependencies exist. +# The application dependency graph is never installed in this interpreter. bootstrap_python() { ensure_uv local _py @@ -337,7 +338,8 @@ bootstrap_python() { boot_py="${boot_py%$'\r'}" } -# uv exits before PM can replace its tool entry. +# uv exits before PM can replace its tool entry. pm.cli then prepares and +# enters its independently locked runtime before mutating application deps. bootstrap_pm() { local boot_py bootstrap_python diff --git a/scripts/iso-certify.py b/scripts/iso-certify.py index c01f009c6b..acc8fea962 100755 --- a/scripts/iso-certify.py +++ b/scripts/iso-certify.py @@ -62,6 +62,7 @@ except Exception as exc: # pragma: no cover - dependency guard raise REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) _READY_RE = re.compile(r"HERMES_(?:DASHBOARD|BACKEND)_READY port=(\d+)") _STALL_LOG_RE = re.compile(r"event loop stalled|ws write slow \(loop stalled") @@ -120,7 +121,7 @@ def seed_scratch_home(home: Path, *, isolation: str, heartbeat_secs: int, respaw "memory": {"enabled": False}, } # config.yaml is the canonical config; write it directly. - import yaml # provided by the runtime venv + import hermes_yaml as yaml (home / "config.yaml").write_text(yaml.safe_dump(cfg, sort_keys=True), encoding="utf-8") # A stub .env so credential resolution doesn't spelunk the real home. diff --git a/scripts/lib/wisdom-demo-env.sh b/scripts/lib/wisdom-demo-env.sh index 6124708df1..7afb5bdadb 100644 --- a/scripts/lib/wisdom-demo-env.sh +++ b/scripts/lib/wisdom-demo-env.sh @@ -19,7 +19,7 @@ wisdom_demo_repo_root() { } wisdom_demo_python_is_ready() { - "$1" -c 'import dotenv, pydantic, requests, yaml' >/dev/null 2>&1 + "$1" -c 'import dotenv, pydantic, requests, ruamel.yaml' >/dev/null 2>&1 } wisdom_demo_pick_python() { diff --git a/scripts/releases/darwin.py b/scripts/releases/darwin.py index d27b543395..8507a1b216 100644 --- a/scripts/releases/darwin.py +++ b/scripts/releases/darwin.py @@ -28,7 +28,7 @@ def _darwin_feed(channel: str, light: bool = False) -> dict[str, Any]: # --------------------------------------------------------------------------- def parse_mac_feed(text: str) -> dict[str, Any]: - import yaml # lazy: PyYAML loads only on the feed path + import hermes_yaml as yaml # lazy: YAML support loads only on the feed path feed = yaml.safe_load(text) if ( @@ -81,7 +81,7 @@ def mac_feed_references(text: str) -> list[str]: def merge_mac_feeds(legs: dict[str, str], tag: str, light: bool = False) -> dict[str, Any]: """Validate both native legs, merge them, and rewrite artifact URLs into the immutable per-release tag namespace.""" - import yaml # lazy + import hermes_yaml as yaml # lazy version = tag[1:] if isinstance(tag, str) and tag.startswith("v") else "" if not is_valid_version(version) or not _TAG_PATTERN.match(tag): diff --git a/scripts/termux/build_deb.sh b/scripts/termux/build_deb.sh index 86c036edb9..fdbef23a5c 100644 --- a/scripts/termux/build_deb.sh +++ b/scripts/termux/build_deb.sh @@ -18,7 +18,7 @@ # # Staged payload layout: python/ and node/ are pm-staged termux .deb # trees ($PREFIX-shaped: data/data/com.termux/files/usr/...). The -# installed layout is $PREFIX/lib/hermes-agent/{python,node,app,venv,bin} with +# installed layout is $PREFIX/lib/hermes-agent/{tools,app,venv,pm-runtime,bin} with # exactly one leak: $PREFIX/bin/hermes -> lib/hermes-agent/bin/hermes. set -Eeuo pipefail @@ -114,8 +114,9 @@ OUT_ABS="$(mkdir -p "$OUT" && cd "$OUT" && pwd)" # The container sees the payload at its real PREFIX path; the venv is built # staged trees so the shipped venv's absolute shebangs point at the REAL # $PREFIX path they will occupy on-device ($PREFIX is contractual). -log "Creating venv with the bundled CPython (inside the container)" +log "Creating application and PM environments with the bundled CPython (inside the container)" if [ -d "$PAYLOAD_ABS/venv" ]; then rm -rf "$PAYLOAD_ABS/venv"; fi +if [ -d "$PAYLOAD_ABS/pm-runtime" ]; then rm -rf "$PAYLOAD_ABS/pm-runtime"; fi # The venv's dep list: the resolved graph with markers intact (the installer # evaluates them on bionic) and documented android build misses skipped -- # uv pip check tolerates the app importing without them (its relay exporter @@ -131,8 +132,8 @@ write_reqs_file(Path(sys.argv[2]), Path(sys.argv[3])) PYREQS # The bind mount is runner-owned: the container (any uid) can only write # into a dir the HOST pre-created with open perms (same as the wheelhouse). -mkdir -p "$PAYLOAD_ABS/venv" -chmod 0777 "$PAYLOAD_ABS/venv" +mkdir -p "$PAYLOAD_ABS/venv" "$PAYLOAD_ABS/pm-runtime" +chmod 0777 "$PAYLOAD_ABS/venv" "$PAYLOAD_ABS/pm-runtime" # Mount the payload at its REAL on-device path: the venv records # absolute paths (interpreter symlink, pyvenv.cfg) that must be correct # on-device from birth -- a /payload alias would bake container paths in. @@ -145,6 +146,8 @@ docker run --rm --platform linux/arm64 \ -v "$PAYLOAD_ABS/wheelhouse:/data/data/com.termux/files/usr/lib/hermes-agent/wheelhouse" \ -v "$PAYLOAD_ABS/.work:/data/data/com.termux/files/usr/lib/hermes-agent/.work" \ -v "$PAYLOAD_ABS/venv:/data/data/com.termux/files/usr/lib/hermes-agent/venv" \ + -v "$PAYLOAD_ABS/pm-runtime:/data/data/com.termux/files/usr/lib/hermes-agent/pm-runtime" \ + -v "$PAYLOAD_ABS/app:/data/data/com.termux/files/usr/lib/hermes-agent/app:ro" \ "$IMAGE" bash -c ' set -euo pipefail export PREFIX=/data/data/com.termux/files/usr @@ -174,6 +177,20 @@ docker run --rm --platform linux/arm64 \ --offline --no-index --only-binary :all: --find-links "$PREFIX/lib/hermes-agent/wheelhouse" \ -r "$PREFIX/lib/hermes-agent/.work/resolved-reqs.txt" "$UV" pip check --python "$PREFIX/lib/hermes-agent/venv/bin/python" + + # PM uses the shared locked builder with the verified bionic wheelhouse. + ROOT="$PREFIX/lib/hermes-agent" + "$PY" -I -B -c " +import sys +from pathlib import Path +sys.path.insert(0, sys.argv[1]) +from pm.runtime_stage import stage_runtime +from scripts.bundles.payload import seal_pm_runtime +root, python, uv = map(Path, sys.argv[2:]) +stage_runtime(uv, python, root / \"pm-runtime\", project=root / \"app/pm\", + wheelhouse=root / \"wheelhouse\", offline=True) +seal_pm_runtime(root, python) +" "$ROOT/app" "$ROOT" "$PY" "$UV" ' || fail "venv assembly failed inside the container (offline wheelhouse install)" # The install-method stamp (code-scoped, next to hermes_cli/): the deb IS @@ -212,7 +229,7 @@ mkdir -p "$STAGE/DEBIAN" "$DEST/tools" for tool in python node uv npm ffmpeg ripgrep; do cp -a "$PAYLOAD_ABS/$tool" "$DEST/tools/" done -cp -a "$PAYLOAD_ABS/runtime-libs" "$PAYLOAD_ABS/app" "$PAYLOAD_ABS/venv" "$PAYLOAD_ABS/bin" "$DEST/" +cp -a "$PAYLOAD_ABS/runtime-libs" "$PAYLOAD_ABS/app" "$PAYLOAD_ABS/venv" "$PAYLOAD_ABS/pm-runtime" "$PAYLOAD_ABS/bin" "$DEST/" python3 "$HERE/payload_facts.py" "$DEST" "$PAYLOAD_ABS/.work/build_set.txt" python3 "$HERE/launchers.py" --payload "$DEST" --control "$STAGE/DEBIAN" @@ -243,7 +260,8 @@ docker run --rm --platform linux/arm64 \ -v "$DEB:/tmp/pkg.deb:ro" \ -v "$HERE/check_deb.sh:/tmp/check.sh:ro" \ -v "$HERE/validate_installed.py:/tmp/validate_installed.py:ro" \ - "termux/termux-docker@$DIGEST" bash /tmp/check.sh \ + "termux/termux-docker@$DIGEST" bash -c \ + 'source /tmp/check.sh; "$root/venv/bin/python" -m pm.cli status' \ || fail "container validation failed" log "Built $DEB (validated)" diff --git a/scripts/termux/termux_pkg_build.sh b/scripts/termux/termux_pkg_build.sh index bc9db1e578..1ea297841a 100755 --- a/scripts/termux/termux_pkg_build.sh +++ b/scripts/termux/termux_pkg_build.sh @@ -29,7 +29,8 @@ TARGET="linux-arm64-bionic" # Stage (idempotent: an already-published verifying entry is a no-op) and # print the entry path -- pm's own resolvers, no layout guessing here. # Resolve a real interpreter: some hosts alias python3 to a Store stub -# (Windows App Execution Aliases). pm runs on any stdlib python >= 3.11. +# (Windows App Execution Aliases). The stdlib client bootstraps PM's separate +# locked runtime; staging never imports PM's dependencies into this process. PM_PY="$(command -v python3 || true)" if [ -n "$PM_PY" ] && "$PM_PY" -c "import sys; assert sys.version_info >= (3, 11)" 2>/dev/null; then : @@ -40,7 +41,7 @@ fi ENTRY_DIR="$("$PM_PY" - "$PKG" "$TARGET" <<'PYEOF' import sys sys.path.insert(0, ".") -from pm.ensure import stage_only +from pm.client import stage_only # stage_only already returns the published entry path -- no re-derivation. print(stage_only(sys.argv[1], sys.argv[2])) PYEOF diff --git a/scripts/tool_search_livetest.py b/scripts/tool_search_livetest.py index 8cd775dddf..99b8e417bb 100644 --- a/scripts/tool_search_livetest.py +++ b/scripts/tool_search_livetest.py @@ -300,7 +300,7 @@ def setup_isolated_home(enabled: bool, listing: str = "off", def _yaml_dump(obj: Any) -> str: try: - import yaml + import hermes_yaml as yaml return yaml.safe_dump(obj, sort_keys=False) except ImportError: return json.dumps(obj, indent=2) diff --git a/scripts/validate_plugin_catalog.py b/scripts/validate_plugin_catalog.py index eb0078c7c0..d7fda110aa 100644 --- a/scripts/validate_plugin_catalog.py +++ b/scripts/validate_plugin_catalog.py @@ -3,7 +3,7 @@ Validates ``plugin-catalog/*.yaml`` catalog entries and ``plugin-catalog/removed.yaml`` against the catalog contract schema, using -only stdlib + PyYAML so the admission CI (and third-party repos) can run it +only stdlib + ruamel.yaml so the admission CI (and third-party repos) can run it WITHOUT installing hermes-agent. NOTE: this script intentionally duplicates the schema rules instead of @@ -31,10 +31,10 @@ import sys from pathlib import Path try: - import yaml + from ruamel.yaml import YAML, YAMLError except ImportError: # pragma: no cover - dependency guidance only print( - "ERROR: PyYAML is required (pip install pyyaml)", + "ERROR: ruamel.yaml is required (pip install ruamel.yaml==0.18.17)", file=sys.stderr, ) sys.exit(2) @@ -190,11 +190,13 @@ def validate_removed(data: object) -> tuple[list[str], list[str]]: def validate_file(path: Path) -> tuple[list[str], list[str]]: """Validate one YAML file (dispatching on filename). Returns (errors, warnings).""" try: + reader = YAML(typ="safe") + reader.version = (1, 1) with open(path, encoding="utf-8") as fh: - data = yaml.safe_load(fh) + data = reader.load(fh) except OSError as exc: return [f"cannot read file: {exc}"], [] - except yaml.YAMLError as exc: + except YAMLError as exc: return [f"invalid YAML: {exc}"], [] if path.name == "removed.yaml": diff --git a/skills/autonomous-ai-agents/hermes-agent/references/windows-quirks.md b/skills/autonomous-ai-agents/hermes-agent/references/windows-quirks.md index fbd3711b3e..56a40d329c 100644 --- a/skills/autonomous-ai-agents/hermes-agent/references/windows-quirks.md +++ b/skills/autonomous-ai-agents/hermes-agent/references/windows-quirks.md @@ -38,7 +38,7 @@ uses pytest-xdist; the canonical runner does per-file subprocess isolation, which the POSIX-only wrapper handles): ```bash -"/c/Program Files/Python311/python" -m pip install --user pytest pyyaml +"/c/Program Files/Python311/python" -m pip install --user pytest ruamel.yaml==0.18.17 export PYTHONPATH="$(pwd)" "/c/Program Files/Python311/python" -m pytest tests/foo/test_bar.py -v --tb=short ``` diff --git a/skills/software-development/hermes-agent-skill-authoring/SKILL.md b/skills/software-development/hermes-agent-skill-authoring/SKILL.md index 35dbb1c186..d46823b3c2 100644 --- a/skills/software-development/hermes-agent-skill-authoring/SKILL.md +++ b/skills/software-development/hermes-agent-skill-authoring/SKILL.md @@ -160,11 +160,12 @@ A skill exists to make the agent's process more predictable — the agent reliab 3. **Draft** with `write_file` to `skills///SKILL.md` (or `optional-skills/...`). 4. **Validate locally**: ```python - import yaml, re, pathlib + import re, pathlib + from ruamel.yaml import YAML content = pathlib.Path("skills///SKILL.md").read_text() assert content.startswith("---") m = re.search(r'\n---\s*\n', content[3:]) - fm = yaml.safe_load(content[3:m.start()+3]) + fm = YAML(typ="safe").load(content[3:m.start()+3]) assert "name" in fm and "description" in fm assert len(fm["description"]) <= 60, f"description {len(fm['description'])} chars — hardline is 60" assert fm["description"].endswith(".") diff --git a/tests/agent/test_actual_auxiliary_routing.py b/tests/agent/test_actual_auxiliary_routing.py index dfa2c9544d..12d6a16b24 100644 --- a/tests/agent/test_actual_auxiliary_routing.py +++ b/tests/agent/test_actual_auxiliary_routing.py @@ -8,7 +8,7 @@ import threading import time import pytest -import yaml +import hermes_yaml as yaml @pytest.fixture diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index ecd99c70a3..73acab527e 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -275,7 +275,7 @@ class TestMoaAggregatorSharedResolution: @staticmethod def _write_moa_config(tmp_path, monkeypatch, default_preset="opus-gpt"): - import yaml + import hermes_yaml as yaml home = tmp_path / ".hermes" home.mkdir(exist_ok=True) @@ -316,7 +316,7 @@ class TestMoaAggregatorSharedResolution: def test_real_config_explicit_task_provider_moa(self, tmp_path, monkeypatch): """auxiliary..provider: moa in a REAL config.yaml resolves to the aggregator through the genuine load_config()/resolve_moa_preset() path.""" - import yaml + import hermes_yaml as yaml home = self._write_moa_config(tmp_path, monkeypatch) cfg = yaml.safe_load((home / "config.yaml").read_text()) diff --git a/tests/agent/test_auxiliary_client_base_url_host_validation_52608.py b/tests/agent/test_auxiliary_client_base_url_host_validation_52608.py index a55d01a07d..164697e5b2 100644 --- a/tests/agent/test_auxiliary_client_base_url_host_validation_52608.py +++ b/tests/agent/test_auxiliary_client_base_url_host_validation_52608.py @@ -23,7 +23,7 @@ class TestTryAnthropicBaseUrlHostValidation: def test_openrouter_base_url_does_not_leak_into_auxiliary(self, tmp_path, monkeypatch): """cfg.model.base_url=https://openrouter.ai/api/v1 must NOT override aux base_url.""" - import yaml + import hermes_yaml as yaml from agent.auxiliary_client import _try_anthropic monkeypatch.setenv("HERMES_HOME", str(tmp_path)) (tmp_path / "config.yaml").write_text(yaml.safe_dump({ @@ -58,7 +58,7 @@ class TestTryAnthropicBaseUrlHostValidation: def test_anthropic_default_host_is_preserved(self, tmp_path, monkeypatch): """The common case (operator sets model.base_url to api.anthropic.com) must still apply.""" - import yaml + import hermes_yaml as yaml from agent.auxiliary_client import _try_anthropic monkeypatch.setenv("HERMES_HOME", str(tmp_path)) (tmp_path / "config.yaml").write_text(yaml.safe_dump({ @@ -90,7 +90,7 @@ class TestTryAnthropicBaseUrlHostValidation: def test_openai_base_url_does_not_leak(self, tmp_path, monkeypatch): """Generic non-Anthropic host must not be applied as auxiliary base_url.""" - import yaml + import hermes_yaml as yaml from agent.auxiliary_client import _try_anthropic monkeypatch.setenv("HERMES_HOME", str(tmp_path)) (tmp_path / "config.yaml").write_text(yaml.safe_dump({ @@ -125,7 +125,7 @@ class TestTryAnthropicBaseUrlHostValidation: def test_empty_base_url_falls_back_to_default(self, tmp_path, monkeypatch): """Empty model.base_url must not crash and must fall back to default.""" - import yaml + import hermes_yaml as yaml from agent.auxiliary_client import _try_anthropic monkeypatch.setenv("HERMES_HOME", str(tmp_path)) (tmp_path / "config.yaml").write_text(yaml.safe_dump({ @@ -159,7 +159,7 @@ class TestTryAnthropicBaseUrlHostValidation: """A gateway exposing the Messages protocol under a ``/anthropic`` suffix must be honored — the same convention the primary path already trusts — so auxiliary/fallback calls hit the configured endpoint, not the default.""" - import yaml + import hermes_yaml as yaml from agent.auxiliary_client import _try_anthropic monkeypatch.setenv("HERMES_HOME", str(tmp_path)) (tmp_path / "config.yaml").write_text(yaml.safe_dump({ @@ -204,7 +204,7 @@ class TestTryAnthropicBaseUrlHostValidation: def test_anthropic_host_with_path_is_preserved(self, tmp_path, monkeypatch): """api.anthropic.com with a path suffix must still pass the host check.""" - import yaml + import hermes_yaml as yaml from agent.auxiliary_client import _try_anthropic monkeypatch.setenv("HERMES_HOME", str(tmp_path)) (tmp_path / "config.yaml").write_text(yaml.safe_dump({ diff --git a/tests/agent/test_auxiliary_main_first.py b/tests/agent/test_auxiliary_main_first.py index 47923aedff..7068c015d0 100644 --- a/tests/agent/test_auxiliary_main_first.py +++ b/tests/agent/test_auxiliary_main_first.py @@ -94,7 +94,7 @@ class TestResolveAutoMainFirst: acting model). The virtual moa://local base_url + placeholder key must be dropped so the aggregator resolves via its own provider credentials. """ - import yaml + import hermes_yaml as yaml home = tmp_path / ".hermes" home.mkdir() diff --git a/tests/agent/test_auxiliary_named_custom_providers.py b/tests/agent/test_auxiliary_named_custom_providers.py index 40336b720c..dd46315b65 100644 --- a/tests/agent/test_auxiliary_named_custom_providers.py +++ b/tests/agent/test_auxiliary_named_custom_providers.py @@ -18,9 +18,9 @@ def _isolate(tmp_path, monkeypatch): def _write_config(tmp_path, config_dict): """Write a config.yaml to the test HERMES_HOME.""" - import yaml + import hermes_yaml as yaml config_path = tmp_path / ".hermes" / "config.yaml" - config_path.write_text(yaml.dump(config_dict)) + config_path.write_text(yaml.safe_dump(config_dict)) class TestNormalizeVisionProvider: diff --git a/tests/agent/test_auxiliary_user_default_headers.py b/tests/agent/test_auxiliary_user_default_headers.py index 2e9fafdb16..5d0aa4a1b1 100644 --- a/tests/agent/test_auxiliary_user_default_headers.py +++ b/tests/agent/test_auxiliary_user_default_headers.py @@ -24,8 +24,8 @@ def _isolate(tmp_path, monkeypatch): def _write_config(tmp_path, config_dict): - import yaml - (tmp_path / ".hermes" / "config.yaml").write_text(yaml.dump(config_dict)) + import hermes_yaml as yaml + (tmp_path / ".hermes" / "config.yaml").write_text(yaml.safe_dump(config_dict)) class TestApplyUserDefaultHeadersHelper: diff --git a/tests/agent/test_builtin_memory_disabled_surface.py b/tests/agent/test_builtin_memory_disabled_surface.py index 7d6c00a697..869031008f 100644 --- a/tests/agent/test_builtin_memory_disabled_surface.py +++ b/tests/agent/test_builtin_memory_disabled_surface.py @@ -16,7 +16,7 @@ import json from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml from model_tools import get_tool_definitions diff --git a/tests/agent/test_codex_usage_attribution.py b/tests/agent/test_codex_usage_attribution.py index 08d9e02aca..22473e71fd 100644 --- a/tests/agent/test_codex_usage_attribution.py +++ b/tests/agent/test_codex_usage_attribution.py @@ -10,7 +10,7 @@ from types import SimpleNamespace import httpx import pytest -import yaml +import hermes_yaml as yaml from hermes_cli import __version__ from hermes_constants import reset_hermes_home_override, set_hermes_home_override diff --git a/tests/agent/test_credential_pool.py b/tests/agent/test_credential_pool.py index 4213bb611f..eb66725656 100644 --- a/tests/agent/test_credential_pool.py +++ b/tests/agent/test_credential_pool.py @@ -1291,8 +1291,8 @@ def test_custom_endpoint_pool_seeds_from_config(tmp_path, monkeypatch): # Write config.yaml with a custom_providers entry config_path = tmp_path / "hermes" / "config.yaml" - import yaml - config_path.write_text(yaml.dump({ + import hermes_yaml as yaml + config_path.write_text(yaml.safe_dump({ "custom_providers": [ { "name": "Together.ai", @@ -1317,9 +1317,9 @@ def test_custom_endpoint_pool_seeds_from_model_config(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) _write_auth_store(tmp_path, {"version": 1}) - import yaml + import hermes_yaml as yaml config_path = tmp_path / "hermes" / "config.yaml" - config_path.write_text(yaml.dump({ + config_path.write_text(yaml.safe_dump({ "custom_providers": [ { "name": "Together.ai", diff --git a/tests/agent/test_i18n.py b/tests/agent/test_i18n.py index 57ed20300f..8906d46dcb 100644 --- a/tests/agent/test_i18n.py +++ b/tests/agent/test_i18n.py @@ -5,7 +5,7 @@ from __future__ import annotations from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml from agent import i18n diff --git a/tests/agent/test_image_gen_registry.py b/tests/agent/test_image_gen_registry.py index c8eb14991f..a64b8079ef 100644 --- a/tests/agent/test_image_gen_registry.py +++ b/tests/agent/test_image_gen_registry.py @@ -58,7 +58,7 @@ class TestGetActiveProvider: def test_explicit_config_wins(self, tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml monkeypatch.setenv("HERMES_HOME", str(tmp_path)) (tmp_path / "config.yaml").write_text( diff --git a/tests/agent/test_model_metadata.py b/tests/agent/test_model_metadata.py index 531cff0068..8229c7e8a6 100644 --- a/tests/agent/test_model_metadata.py +++ b/tests/agent/test_model_metadata.py @@ -14,7 +14,7 @@ import time from contextlib import contextmanager import pytest -import yaml +import hermes_yaml as yaml from unittest.mock import patch, MagicMock from agent.model_metadata import ( @@ -500,8 +500,8 @@ class TestCodexOAuthContextLength: base_url = "https://chatgpt.com/backend-api/codex" stale_key = f"gpt-5.5@{base_url}" other_key = "other-model@https://api.openai.com/v1/" - import yaml as _yaml - cache_file.write_text(_yaml.dump({"context_lengths": { + import hermes_yaml as _yaml + cache_file.write_text(_yaml.safe_dump({"context_lengths": { stale_key: stale_context, other_key: 128_000, }})) @@ -966,7 +966,7 @@ class TestNousPortalContextResolution: base_url = "https://inference-api.nousresearch.com/v1" stale_key = f"qwen3.6-plus@{base_url}" other_key = "other-model@https://api.openai.com/v1" - cache_file.write_text(yaml.dump({"context_lengths": { + cache_file.write_text(yaml.safe_dump({"context_lengths": { stale_key: 1_000_000, # pre-fix OR-derived value other_key: 128_000, # unrelated, must survive }})) diff --git a/tests/agent/test_onboarding.py b/tests/agent/test_onboarding.py index 514ada7f1e..c2f5f3fada 100644 --- a/tests/agent/test_onboarding.py +++ b/tests/agent/test_onboarding.py @@ -2,7 +2,7 @@ from __future__ import annotations -import yaml +import hermes_yaml as yaml from agent.onboarding import ( BUSY_INPUT_FLAG, diff --git a/tests/agent/test_probe_cache_followups.py b/tests/agent/test_probe_cache_followups.py index f108c0de4c..57e9a938e4 100644 --- a/tests/agent/test_probe_cache_followups.py +++ b/tests/agent/test_probe_cache_followups.py @@ -278,12 +278,12 @@ class TestContextCacheKeyNormalization: def test_invalidate_clears_both_key_shapes(self, tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml from agent import model_metadata path = tmp_path / "context_lengths.yaml" monkeypatch.setattr(model_metadata, "_get_context_cache_path", lambda: path) - path.write_text(yaml.dump({"context_lengths": { + path.write_text(yaml.safe_dump({"context_lengths": { "m1@http://host/v1": 128_000, "m1@http://host/v1/": 64_000, }})) diff --git a/tests/agent/test_skill_session_platform_gate.py b/tests/agent/test_skill_session_platform_gate.py index 9672e8f95b..b17b6f0ee0 100644 --- a/tests/agent/test_skill_session_platform_gate.py +++ b/tests/agent/test_skill_session_platform_gate.py @@ -39,7 +39,7 @@ class TestSessionPlatformGate: def test_teams_meeting_pipeline_carries_the_gate(self): from pathlib import Path - import re, yaml + import re, hermes_yaml as yaml p = Path(__file__).resolve().parents[2] / "skills" / "productivity" / "teams-meeting-pipeline" / "SKILL.md" content = p.read_text(encoding="utf-8") diff --git a/tests/agent/test_video_gen_registry.py b/tests/agent/test_video_gen_registry.py index f8e63c9d85..a776a3c575 100644 --- a/tests/agent/test_video_gen_registry.py +++ b/tests/agent/test_video_gen_registry.py @@ -74,7 +74,7 @@ class TestGetActiveProvider: def test_unknown_explicit_config_fails_closed(self, tmp_path, monkeypatch): """A typo must not silently route a paid request to another backend.""" - import yaml + import hermes_yaml as yaml monkeypatch.setenv("HERMES_HOME", str(tmp_path)) (tmp_path / "config.yaml").write_text( diff --git a/tests/ci/test_classify_changes.py b/tests/ci/test_classify_changes.py index 2217febd07..7a6a89b297 100644 --- a/tests/ci/test_classify_changes.py +++ b/tests/ci/test_classify_changes.py @@ -302,7 +302,7 @@ _REPO = Path(__file__).resolve().parents[2] def _yaml(rel: str) -> dict: - yaml = pytest.importorskip("yaml") + yaml = pytest.importorskip("hermes_yaml") return yaml.safe_load((_REPO / rel).read_text(encoding="utf-8")) diff --git a/tests/ci/test_desktop_release_tag_admission.py b/tests/ci/test_desktop_release_tag_admission.py index 9aec958937..cb6e7c319b 100644 --- a/tests/ci/test_desktop_release_tag_admission.py +++ b/tests/ci/test_desktop_release_tag_admission.py @@ -27,7 +27,7 @@ _SIGNING_ENV = "release-signing" def _workflow() -> dict: - yaml = pytest.importorskip("yaml") + yaml = pytest.importorskip("hermes_yaml") return yaml.safe_load(_WORKFLOW.read_text(encoding="utf-8")) diff --git a/tests/ci/test_live_comment.py b/tests/ci/test_live_comment.py index b4c1329eb0..f4540e0544 100644 --- a/tests/ci/test_live_comment.py +++ b/tests/ci/test_live_comment.py @@ -94,7 +94,7 @@ def test_workflow_watch_list_names_a_workflow_that_exists(): A name that matches nothing makes the poller silently drop that run from the comment, which no unit test on its own would notice. """ - yaml = pytest.importorskip("yaml") + yaml = pytest.importorskip("hermes_yaml") root = Path(__file__).resolve().parents[2] caller = yaml.safe_load( (root / ".github/workflows/ci-review-comment.yml").read_text(encoding="utf-8") @@ -123,7 +123,7 @@ def test_poller_never_watches_its_own_workflow(): itself would make the loop wait for itself and only ever exit on timeout. """ - yaml = pytest.importorskip("yaml") + yaml = pytest.importorskip("hermes_yaml") root = Path(__file__).resolve().parents[2] doc = yaml.safe_load( (root / ".github/workflows/ci-review-comment.yml").read_text(encoding="utf-8") diff --git a/tests/ci/test_stable_release_graph.py b/tests/ci/test_stable_release_graph.py index 6741370ebc..ff6ff91f04 100644 --- a/tests/ci/test_stable_release_graph.py +++ b/tests/ci/test_stable_release_graph.py @@ -1,13 +1,13 @@ """The release workflow's dependency graph enforces publication ordering.""" from pathlib import Path -import yaml +from ruamel.yaml import YAML ROOT = Path(__file__).resolve().parents[2] def workflow(name): - return yaml.load((ROOT / ".github/workflows" / name).read_text(encoding="utf-8"), Loader=yaml.BaseLoader) + return YAML(typ="base").load((ROOT / ".github/workflows" / name).read_text(encoding="utf-8")) def ancestors(jobs, name): diff --git a/tests/ci/test_termux_input_archive.py b/tests/ci/test_termux_input_archive.py index e9be5d6019..beb99b8ff2 100644 --- a/tests/ci/test_termux_input_archive.py +++ b/tests/ci/test_termux_input_archive.py @@ -2,14 +2,14 @@ from pathlib import Path import shlex -import yaml +from ruamel.yaml import YAML ROOT = Path(__file__).resolve().parents[2] R2_ENV = {"CLOUDFLARE_R2_ACCOUNT_ID", "CLOUDFLARE_R2_ACCESS_KEY_ID", "CLOUDFLARE_R2_SECRET_ACCESS_KEY", "CLOUDFLARE_R2_BUCKET"} def load(name): - return yaml.load((ROOT / ".github/workflows" / name).read_text(encoding="utf-8"), Loader=yaml.BaseLoader) + return YAML(typ="base").load((ROOT / ".github/workflows" / name).read_text(encoding="utf-8")) def test_termux_and_desktop_stagers_consume_archive_seeds(): @@ -54,7 +54,7 @@ def test_archive_gate_uses_bootstrap_python_and_trusted_exact_revision(): for name in ("build-win32", "build-darwin", "termux-deb"): assert "archive-inputs" in release[name]["needs"] - action = yaml.load((ROOT / ".github/actions/setup-pm/action.yml").read_text(encoding="utf-8"), Loader=yaml.BaseLoader) + action = YAML(typ="base").load((ROOT / ".github/actions/setup-pm/action.yml").read_text(encoding="utf-8")) assert action["inputs"]["archive-inputs"]["default"] == "false" steps = action["runs"]["steps"] archive_index, archive = next((i, s) for i, s in enumerate(steps) if "setup_toolchain.py\" archive-inputs" in s.get("run", "")) diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index e53e7fd74f..b5838532b2 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -492,7 +492,7 @@ class TestRootLevelProviderOverride: def test_model_provider_wins_over_root_provider(self, tmp_path, monkeypatch): """model.provider takes priority — root-level provider is only a fallback.""" - import yaml + import hermes_yaml as yaml hermes_home = tmp_path / ".hermes" hermes_home.mkdir() @@ -515,7 +515,7 @@ class TestRootLevelProviderOverride: def test_root_provider_used_as_fallback_when_model_provider_missing(self, tmp_path, monkeypatch): """Legacy root-level provider still populates model.provider in the CLI loader.""" - import yaml + import hermes_yaml as yaml hermes_home = tmp_path / ".hermes" hermes_home.mkdir() @@ -538,7 +538,7 @@ class TestRootLevelProviderOverride: def test_root_base_url_used_as_fallback_when_model_base_url_missing(self, tmp_path, monkeypatch): """Legacy root-level base_url still populates model.base_url in the CLI loader.""" - import yaml + import hermes_yaml as yaml hermes_home = tmp_path / ".hermes" hermes_home.mkdir() @@ -560,7 +560,7 @@ class TestRootLevelProviderOverride: def test_terminal_vercel_runtime_bridged_to_env(self, tmp_path, monkeypatch): """Classic CLI must expose terminal.vercel_runtime to terminal_tool.py.""" - import yaml + import hermes_yaml as yaml hermes_home = tmp_path / ".hermes" hermes_home.mkdir() diff --git a/tests/cli/test_cli_mcp_config_watch.py b/tests/cli/test_cli_mcp_config_watch.py index acfc7a3473..29dae5c768 100644 --- a/tests/cli/test_cli_mcp_config_watch.py +++ b/tests/cli/test_cli_mcp_config_watch.py @@ -35,11 +35,11 @@ class TestMCPConfigWatch: def test_new_mcp_server_triggers_reload(self, tmp_path): """Adding a new MCP server to config triggers auto-reload.""" - import yaml + import hermes_yaml as yaml obj, cfg_file = _make_cli(tmp_path, mcp_servers={}) # Simulate user adding a new MCP server to config.yaml - cfg_file.write_text(yaml.dump({"mcp_servers": {"github": {"url": "https://mcp.github.com"}}})) + cfg_file.write_text(yaml.safe_dump({"mcp_servers": {"github": {"url": "https://mcp.github.com"}}})) obj._config_mtime = 0.0 # force stale mtime with patch("hermes_cli.config.get_config_path", return_value=cfg_file): @@ -49,11 +49,11 @@ class TestMCPConfigWatch: def test_removed_mcp_server_triggers_reload(self, tmp_path): """Removing an MCP server from config triggers auto-reload.""" - import yaml + import hermes_yaml as yaml obj, cfg_file = _make_cli(tmp_path, mcp_servers={"github": {"url": "https://mcp.github.com"}}) # Simulate user removing the server - cfg_file.write_text(yaml.dump({"mcp_servers": {}})) + cfg_file.write_text(yaml.safe_dump({"mcp_servers": {}})) obj._config_mtime = 0.0 with patch("hermes_cli.config.get_config_path", return_value=cfg_file): @@ -76,14 +76,14 @@ class TestMCPConfigWatch: flipping the toggle and editing mcp_servers in one edit behaves correctly. """ - import yaml + import hermes_yaml as yaml obj, cfg_file = _make_cli( tmp_path, mcp_servers={}, ) # Simulate a changed mcp_servers section with auto-reload opted out. - cfg_file.write_text(yaml.dump({ + cfg_file.write_text(yaml.safe_dump({ "mcp": {"auto_reload_on_config_change": False}, "mcp_servers": {"github": {"url": "https://mcp.github.com"}}, })) @@ -103,10 +103,10 @@ class TestMCPConfigWatch: """After an opted-out change, the watcher must not re-notify every tick: the snapshot is updated so the same content compares equal on the next pass.""" - import yaml + import hermes_yaml as yaml obj, cfg_file = _make_cli(tmp_path, mcp_servers={}) - cfg_file.write_text(yaml.dump({ + cfg_file.write_text(yaml.safe_dump({ "mcp": {"auto_reload_on_config_change": False}, "mcp_servers": {"github": {"url": "https://mcp.github.com"}}, })) @@ -129,13 +129,13 @@ class TestMCPConfigWatch: A config that sets ONLY ``auxiliary.mcp.auto_reload_on_config_change: false`` must NOT disable the reload.""" - import yaml + import hermes_yaml as yaml obj, cfg_file = _make_cli( tmp_path, mcp_servers={}, ) - cfg_file.write_text(yaml.dump({ + cfg_file.write_text(yaml.safe_dump({ "auxiliary": {"mcp": {"auto_reload_on_config_change": False}}, "mcp_servers": {"github": {"url": "https://mcp.github.com"}}, })) @@ -160,7 +160,7 @@ class TestMCPConfigWatch: save_config_value('agent.reasoning_effort', ...) from /reasoning) fired a full MCP reconnect. """ - import yaml + import hermes_yaml as yaml monkeypatch.setenv("MCP_GH_API_KEY", "sekrit-token") raw_servers = { @@ -180,7 +180,7 @@ class TestMCPConfigWatch: # Unrelated-key save: mcp_servers content identical (raw templates), # only reasoning_effort changed — mtime moves. - cfg_file.write_text(yaml.dump({ + cfg_file.write_text(yaml.safe_dump({ "agent": {"reasoning_effort": "high"}, "mcp_servers": raw_servers, })) diff --git a/tests/cli/test_cli_provider_resolution.py b/tests/cli/test_cli_provider_resolution.py index fefd8c0b3b..c7457bbbd2 100644 --- a/tests/cli/test_cli_provider_resolution.py +++ b/tests/cli/test_cli_provider_resolution.py @@ -288,7 +288,7 @@ def test_cli_turn_routing_uses_primary_when_disabled(monkeypatch): def test_model_flow_nous_does_not_restore_stale_custom_api_key(tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml config_home = tmp_path / "hermes" config_home.mkdir() @@ -358,7 +358,7 @@ def test_model_flow_nous_does_not_restore_stale_custom_api_key(tmp_path, monkeyp def _seed_stale_custom_model(tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml config_home = tmp_path / "hermes" config_home.mkdir() @@ -619,11 +619,11 @@ def test_auto_provider_name_localhost(): def test_save_custom_provider_uses_provided_name(monkeypatch, tmp_path): """When a display name is passed, it should appear in the saved entry.""" - import yaml + import hermes_yaml as yaml from hermes_cli.main_provider_setup import _save_custom_provider cfg_path = tmp_path / "config.yaml" - cfg_path.write_text(yaml.dump({})) + cfg_path.write_text(yaml.safe_dump({})) monkeypatch.setattr( "hermes_cli.config.load_config", lambda: yaml.safe_load(cfg_path.read_text()) or {}, @@ -641,11 +641,11 @@ def test_save_custom_provider_uses_provided_name(monkeypatch, tmp_path): def test_save_custom_provider_references_the_key_instead_of_inlining_it(monkeypatch, tmp_path): """With key_env set the entry must not carry the secret (#69449).""" - import yaml + import hermes_yaml as yaml from hermes_cli.main_provider_setup import _save_custom_provider cfg_path = tmp_path / "config.yaml" - cfg_path.write_text(yaml.dump({})) + cfg_path.write_text(yaml.safe_dump({})) monkeypatch.setattr( "hermes_cli.config.load_config", lambda: yaml.safe_load(cfg_path.read_text()) or {}, ) diff --git a/tests/cli/test_cli_save_config_value.py b/tests/cli/test_cli_save_config_value.py index 75039d9355..af8272421f 100644 --- a/tests/cli/test_cli_save_config_value.py +++ b/tests/cli/test_cli_save_config_value.py @@ -3,7 +3,7 @@ from pathlib import Path from unittest.mock import MagicMock -import yaml +import hermes_yaml as yaml import pytest @@ -17,7 +17,7 @@ class TestSaveConfigValueAtomic: hermes_home = tmp_path / ".hermes" hermes_home.mkdir() config_path = hermes_home / "config.yaml" - config_path.write_text(yaml.dump({ + config_path.write_text(yaml.safe_dump({ "model": {"default": "test-model", "provider": "openrouter"}, "display": {"skin": "default"}, })) diff --git a/tests/cli/test_personality_none.py b/tests/cli/test_personality_none.py index 2d94015dfb..3dece68f2c 100644 --- a/tests/cli/test_personality_none.py +++ b/tests/cli/test_personality_none.py @@ -7,7 +7,7 @@ persistence flows exclusively through persist_personality(). import os import pytest from unittest.mock import MagicMock, patch -import yaml +import hermes_yaml as yaml # ── CLI tests ────────────────────────────────────────────────────────────── @@ -114,7 +114,7 @@ class TestGatewayPersonalityNone: "display": {"personality": "helpful"}, } config_file = tmp_path / "config.yaml" - config_file.write_text(yaml.dump(config_data)) + config_file.write_text(yaml.safe_dump(config_data)) p1, p2 = self._gateway_env(tmp_path) with p1, p2: @@ -138,7 +138,7 @@ class TestGatewayPersonalityNone: } } config_file = tmp_path / "config.yaml" - config_file.write_text(yaml.dump(config_data)) + config_file.write_text(yaml.safe_dump(config_data)) p1, p2 = self._gateway_env(tmp_path) with p1, p2: @@ -157,7 +157,7 @@ class TestGatewayPersonalityNone: runner = self._make_runner() config_data = {"agent": {"personalities": {"helpful": "You are helpful."}}} config_file = tmp_path / "config.yaml" - config_file.write_text(yaml.dump(config_data)) + config_file.write_text(yaml.safe_dump(config_data)) p1, p2 = self._gateway_env(tmp_path) with p1, p2: @@ -171,7 +171,7 @@ class TestGatewayPersonalityNone: # Built-ins are always available — an empty agent.personalities no # longer means "no personalities configured". runner = self._make_runner(personalities={}) - (tmp_path / "config.yaml").write_text(yaml.dump({"agent": {"personalities": {}}})) + (tmp_path / "config.yaml").write_text(yaml.safe_dump({"agent": {"personalities": {}}})) p1, p2 = self._gateway_env(tmp_path) with p1, p2: diff --git a/tests/cron/test_cron_multiplex_profile_route_preflight.py b/tests/cron/test_cron_multiplex_profile_route_preflight.py index 780648debf..703a40bf73 100644 --- a/tests/cron/test_cron_multiplex_profile_route_preflight.py +++ b/tests/cron/test_cron_multiplex_profile_route_preflight.py @@ -15,7 +15,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch import pytest -import yaml +import hermes_yaml as yaml from cron.scheduler_preflight import ( _delivery_platform_routed_from_primary_gateway, diff --git a/tests/cron/test_cron_multiplex_shared_route_delivery.py b/tests/cron/test_cron_multiplex_shared_route_delivery.py index 6802d22119..ea4bab0742 100644 --- a/tests/cron/test_cron_multiplex_shared_route_delivery.py +++ b/tests/cron/test_cron_multiplex_shared_route_delivery.py @@ -10,7 +10,7 @@ import asyncio from concurrent.futures import Future from unittest.mock import MagicMock, patch -import yaml +import hermes_yaml as yaml from cron.scheduler import _deliver_result from cron.scheduler_preflight import SharedRouteAdapters, _primary_profile_routes_for_current_home diff --git a/tests/cron/test_sessiondb_init_hang.py b/tests/cron/test_sessiondb_init_hang.py index b2f0f46465..da9e4bf563 100644 --- a/tests/cron/test_sessiondb_init_hang.py +++ b/tests/cron/test_sessiondb_init_hang.py @@ -195,7 +195,7 @@ class TestSessionDbInitTimeout: def test_timeout_resolved_from_config_yaml(self, tmp_path, monkeypatch): """cron.session_db_timeout_seconds in config.yaml is respected when the env var is not set — the canonical config-first resolution path.""" - import yaml + import hermes_yaml as yaml monkeypatch.delenv("HERMES_CRON_SESSION_DB_TIMEOUT", raising=False) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) diff --git a/tests/docker/test_image_payload.py b/tests/docker/test_image_payload.py index 105e033820..1775374154 100644 --- a/tests/docker/test_image_payload.py +++ b/tests/docker/test_image_payload.py @@ -17,7 +17,7 @@ assert not list(store.glob('fetch-*')), 'completed download archives must not sh fact = Facts(store / 'facts.json').get('python') expected = get_package('python').binary(store / fact['entry'], current_target()) assert Path(sys._base_executable).resolve() == expected.resolve() -import yaml +import hermes_yaml as yaml print('PM interpreter and application dependencies load as hermes') """ result = subprocess.run( diff --git a/tests/gateway/relay/test_cold_opt_out.py b/tests/gateway/relay/test_cold_opt_out.py index ac6705e5db..76603734c4 100644 --- a/tests/gateway/relay/test_cold_opt_out.py +++ b/tests/gateway/relay/test_cold_opt_out.py @@ -7,7 +7,7 @@ import sys import textwrap import pytest -import yaml +import hermes_yaml as yaml @pytest.mark.parametrize("case", ["native", "url-only", "disabled", "managed", "managed-only", "scoped"]) diff --git a/tests/gateway/relay/test_explicit_disable.py b/tests/gateway/relay/test_explicit_disable.py index fb3114b264..0eb3025900 100644 --- a/tests/gateway/relay/test_explicit_disable.py +++ b/tests/gateway/relay/test_explicit_disable.py @@ -8,7 +8,7 @@ import json import os import pytest -import yaml +import hermes_yaml as yaml import gateway.relay as relay from gateway.config import Platform, load_gateway_config diff --git a/tests/gateway/test_25107_stale_base_url_api_mode.py b/tests/gateway/test_25107_stale_base_url_api_mode.py index 91e68df554..c624f30f3f 100644 --- a/tests/gateway/test_25107_stale_base_url_api_mode.py +++ b/tests/gateway/test_25107_stale_base_url_api_mode.py @@ -25,7 +25,7 @@ resolved result. import types -import yaml +import hermes_yaml as yaml import pytest from gateway.config import Platform diff --git a/tests/gateway/test_approvals_command.py b/tests/gateway/test_approvals_command.py index 6978ae06c1..73001374e4 100644 --- a/tests/gateway/test_approvals_command.py +++ b/tests/gateway/test_approvals_command.py @@ -4,7 +4,7 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest -import yaml +import hermes_yaml as yaml import gateway.run as gateway_run from gateway.config import Platform diff --git a/tests/gateway/test_busy_session_ack.py b/tests/gateway/test_busy_session_ack.py index f28d847a9e..f9e6a554e1 100644 --- a/tests/gateway/test_busy_session_ack.py +++ b/tests/gateway/test_busy_session_ack.py @@ -493,7 +493,7 @@ class TestBusySessionOnboardingHint: assert "/busy queue" in content # The flag is now persisted to tmp_path/config.yaml - import yaml + import hermes_yaml as yaml cfg = yaml.safe_load((tmp_path / "config.yaml").read_text()) assert cfg["onboarding"]["seen"]["busy_input_prompt"] is True diff --git a/tests/gateway/test_buzz_adapter.py b/tests/gateway/test_buzz_adapter.py index 11d39610e9..3c747e1d8d 100644 --- a/tests/gateway/test_buzz_adapter.py +++ b/tests/gateway/test_buzz_adapter.py @@ -285,7 +285,7 @@ class TestMultiplexProfileScope: ): """The gate must consult the profile's own config.yaml + secret scope, not the default profile's env values.""" - import yaml + import hermes_yaml as yaml from hermes_constants import ( reset_hermes_home_override, set_hermes_home_override, diff --git a/tests/gateway/test_choice_picker.py b/tests/gateway/test_choice_picker.py index 5de1359beb..701c6416d5 100644 --- a/tests/gateway/test_choice_picker.py +++ b/tests/gateway/test_choice_picker.py @@ -11,7 +11,7 @@ import asyncio from unittest.mock import AsyncMock, MagicMock import pytest -import yaml +import hermes_yaml as yaml import gateway.run as gateway_run from gateway.config import Platform diff --git a/tests/gateway/test_cjk_fts_config_bridge.py b/tests/gateway/test_cjk_fts_config_bridge.py index 54c738675f..a8181e52d9 100644 --- a/tests/gateway/test_cjk_fts_config_bridge.py +++ b/tests/gateway/test_cjk_fts_config_bridge.py @@ -8,7 +8,7 @@ from __future__ import annotations import os from pathlib import Path -import yaml +import hermes_yaml as yaml import gateway.run as gateway_run diff --git a/tests/gateway/test_config_env_bridge_authority.py b/tests/gateway/test_config_env_bridge_authority.py index 594f5182ae..83751e1e22 100644 --- a/tests/gateway/test_config_env_bridge_authority.py +++ b/tests/gateway/test_config_env_bridge_authority.py @@ -100,7 +100,7 @@ def _run_gateway_import(hermes_home: Path, initial_env: dict[str, str]) -> dict[ def _write_config(home: Path, agent_cfg: dict | None = None, display_cfg: dict | None = None, timezone: str | None = None, gateway_cfg: dict | None = None) -> None: - import yaml + import hermes_yaml as yaml cfg: dict = {} if agent_cfg: cfg["agent"] = agent_cfg diff --git a/tests/gateway/test_discord_channel_controls.py b/tests/gateway/test_discord_channel_controls.py index 03a210360b..a173b14941 100644 --- a/tests/gateway/test_discord_channel_controls.py +++ b/tests/gateway/test_discord_channel_controls.py @@ -221,9 +221,9 @@ async def test_auto_thread_failure_skips_agent_and_notifies_user(adapter, monkey def test_config_bridges_ignored_channels(monkeypatch, tmp_path): """gateway/config.py bridges discord.ignored_channels to env var.""" - import yaml + import hermes_yaml as yaml config_file = tmp_path / "config.yaml" - config_file.write_text(yaml.dump({ + config_file.write_text(yaml.safe_dump({ "discord": { "ignored_channels": ["111", "222"], }, diff --git a/tests/gateway/test_display_config.py b/tests/gateway/test_display_config.py index 3a74058978..e17f380356 100644 --- a/tests/gateway/test_display_config.py +++ b/tests/gateway/test_display_config.py @@ -178,7 +178,7 @@ class TestConfigMigration: def test_migration_creates_platforms_entries(self, tmp_path, monkeypatch): """Old overrides are migrated into display.platforms..tool_progress.""" - import yaml + import hermes_yaml as yaml config_path = tmp_path / "config.yaml" config = { @@ -190,7 +190,7 @@ class TestConfigMigration: }, }, } - config_path.write_text(yaml.dump(config), encoding="utf-8") + config_path.write_text(yaml.safe_dump(config), encoding="utf-8") monkeypatch.setenv("HERMES_HOME", str(tmp_path)) # Re-import to pick up the new HERMES_HOME diff --git a/tests/gateway/test_dm_topics.py b/tests/gateway/test_dm_topics.py index d1cc996101..7c5c7ab894 100644 --- a/tests/gateway/test_dm_topics.py +++ b/tests/gateway/test_dm_topics.py @@ -170,7 +170,7 @@ async def test_ensure_dm_topic_creates_on_demand_and_persists(): def test_persist_dm_topic_thread_id_writes_config(tmp_path): """Should write thread_id into the correct topic in config.yaml.""" - import yaml + import hermes_yaml as yaml config_data = { "platforms": { @@ -193,7 +193,7 @@ def test_persist_dm_topic_thread_id_writes_config(tmp_path): config_file = tmp_path / ".hermes" / "config.yaml" config_file.parent.mkdir(parents=True) with open(config_file, "w") as f: - yaml.dump(config_data, f) + yaml.safe_dump(config_data, f) adapter = _make_adapter() @@ -214,7 +214,7 @@ def test_persist_dm_topic_thread_id_writes_config(tmp_path): def test_persist_dm_topic_thread_id_preserves_config_on_write_failure(tmp_path): """Failed writes should leave the original config.yaml intact.""" - import yaml + import hermes_yaml as yaml config_data = { "platforms": { @@ -235,7 +235,7 @@ def test_persist_dm_topic_thread_id_preserves_config_on_write_failure(tmp_path): config_file = tmp_path / ".hermes" / "config.yaml" config_file.parent.mkdir(parents=True) - original_text = yaml.dump(config_data) + original_text = yaml.safe_dump(config_data) config_file.write_text(original_text, encoding="utf-8") adapter = _make_adapter() @@ -245,7 +245,7 @@ def test_persist_dm_topic_thread_id_preserves_config_on_write_failure(tmp_path): with patch.object(Path, "home", return_value=tmp_path), \ patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}), \ - patch("yaml.dump", side_effect=fail_dump): + patch("hermes_yaml.safe_dump", side_effect=fail_dump): adapter._persist_dm_topic_thread_id(111, "General", 999) assert config_file.read_text(encoding="utf-8") == original_text @@ -275,7 +275,7 @@ def test_get_dm_topic_info_finds_cached_topic(): def test_get_dm_topic_info_hot_reloads_from_config(tmp_path): """Should find a topic added to config after startup (hot-reload).""" - import yaml + import hermes_yaml as yaml # Start with empty topics adapter = _make_adapter([ @@ -302,7 +302,7 @@ def test_get_dm_topic_info_hot_reloads_from_config(tmp_path): config_file = tmp_path / ".hermes" / "config.yaml" config_file.parent.mkdir(parents=True) with open(config_file, "w") as f: - yaml.dump(config_data, f) + yaml.safe_dump(config_data, f) with patch.object(Path, "home", return_value=tmp_path), \ patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}): diff --git a/tests/gateway/test_fast_command.py b/tests/gateway/test_fast_command.py index 1d9a63514f..8ba4c73f19 100644 --- a/tests/gateway/test_fast_command.py +++ b/tests/gateway/test_fast_command.py @@ -7,7 +7,7 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest -import yaml +import hermes_yaml as yaml import gateway.run as gateway_run from gateway.config import Platform diff --git a/tests/gateway/test_matrix_mention.py b/tests/gateway/test_matrix_mention.py index 12a9d54963..54fe03ef30 100644 --- a/tests/gateway/test_matrix_mention.py +++ b/tests/gateway/test_matrix_mention.py @@ -351,10 +351,10 @@ class TestMatrixConfigBridge: import os - import yaml + import hermes_yaml as yaml config_file = tmp_path / "config.yaml" - config_file.write_text(yaml.dump(yaml_content)) + config_file.write_text(yaml.safe_dump(yaml_content)) # Simulate the bridge logic from gateway/config.py yaml_cfg = yaml.safe_load(config_file.read_text()) diff --git a/tests/gateway/test_model_command_context_offload.py b/tests/gateway/test_model_command_context_offload.py index 1591c2b2e6..4978fb0657 100644 --- a/tests/gateway/test_model_command_context_offload.py +++ b/tests/gateway/test_model_command_context_offload.py @@ -43,7 +43,7 @@ def _event(text: str) -> MessageEvent: def _runner_with_store(tmp_path, monkeypatch): """Minimal GatewayRunner harness driving the real /model handler.""" - import yaml as _yaml + import hermes_yaml as _yaml import gateway.run as gateway_run from gateway.run import GatewayRunner diff --git a/tests/gateway/test_model_command_custom_providers.py b/tests/gateway/test_model_command_custom_providers.py index 5b32ab56d8..1b1f4302f8 100644 --- a/tests/gateway/test_model_command_custom_providers.py +++ b/tests/gateway/test_model_command_custom_providers.py @@ -1,6 +1,6 @@ """Regression tests for gateway /model support of config.yaml custom_providers.""" -import yaml +import hermes_yaml as yaml import pytest from gateway.config import Platform diff --git a/tests/gateway/test_model_command_expensive_confirm.py b/tests/gateway/test_model_command_expensive_confirm.py index 18fa58ce92..75b8295676 100644 --- a/tests/gateway/test_model_command_expensive_confirm.py +++ b/tests/gateway/test_model_command_expensive_confirm.py @@ -16,7 +16,7 @@ These tests pin the typed path: from types import SimpleNamespace import pytest -import yaml +import hermes_yaml as yaml from gateway.config import Platform from gateway.platforms.event import MessageEvent, MessageType diff --git a/tests/gateway/test_model_command_flat_string_config.py b/tests/gateway/test_model_command_flat_string_config.py index 3dc1d9b871..81fbbb9988 100644 --- a/tests/gateway/test_model_command_flat_string_config.py +++ b/tests/gateway/test_model_command_flat_string_config.py @@ -11,7 +11,7 @@ before mutation, so ``--global`` succeeds and the config is rewritten in the proper ``model: {default: ..., provider: ...}`` form. """ -import yaml +import hermes_yaml as yaml import pytest from gateway.config import Platform diff --git a/tests/gateway/test_model_picker_persist.py b/tests/gateway/test_model_picker_persist.py index bcc24263c4..e034480d1e 100644 --- a/tests/gateway/test_model_picker_persist.py +++ b/tests/gateway/test_model_picker_persist.py @@ -21,7 +21,7 @@ closure the PR changed, against a real temp ``HERMES_HOME``. import types -import yaml +import hermes_yaml as yaml import pytest from gateway.config import Platform diff --git a/tests/gateway/test_model_switch_persistence.py b/tests/gateway/test_model_switch_persistence.py index a668518a3e..6389a25b93 100644 --- a/tests/gateway/test_model_switch_persistence.py +++ b/tests/gateway/test_model_switch_persistence.py @@ -203,7 +203,7 @@ class TestOneTurnNeverPersisted: @staticmethod def _runner_with_store(tmp_path, monkeypatch): - import yaml as _yaml + import hermes_yaml as _yaml import gateway.run as gateway_run from gateway.run import GatewayRunner diff --git a/tests/gateway/test_multiplex_phase0.py b/tests/gateway/test_multiplex_phase0.py index 6558d67f54..0ea1168d89 100644 --- a/tests/gateway/test_multiplex_phase0.py +++ b/tests/gateway/test_multiplex_phase0.py @@ -11,7 +11,7 @@ Covers the three Phase 0 deliverables: import pytest from datetime import datetime from unittest.mock import patch -import yaml +import hermes_yaml as yaml from hermes_constants import reset_hermes_home_override, set_hermes_home_override from gateway.config import GatewayConfig, Platform diff --git a/tests/gateway/test_plugin_message_injection.py b/tests/gateway/test_plugin_message_injection.py index ed990550ed..6093c9c97b 100644 --- a/tests/gateway/test_plugin_message_injection.py +++ b/tests/gateway/test_plugin_message_injection.py @@ -7,7 +7,7 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest -import yaml +import hermes_yaml as yaml from gateway.config import GatewayConfig, Platform from gateway.platforms.base import ( diff --git a/tests/gateway/test_profile_routing.py b/tests/gateway/test_profile_routing.py index 37ebb8f69a..5bfa86d200 100644 --- a/tests/gateway/test_profile_routing.py +++ b/tests/gateway/test_profile_routing.py @@ -53,7 +53,7 @@ class TestParseProfileRoutes: assert parse_profile_routes([]) == [] def test_coerces_yaml_native_int_ids_to_str(self): - # PyYAML loads unquoted snowflakes / negative Telegram ids as int; + # YAML loads unquoted snowflakes / negative Telegram ids as int; # inbound SessionSource ids are str, so un-coerced routes never match. routes = parse_profile_routes([ {"name": "server", "platform": "discord", "profile": "p", diff --git a/tests/gateway/test_reasoning_command.py b/tests/gateway/test_reasoning_command.py index de83c96566..b4744f2b99 100644 --- a/tests/gateway/test_reasoning_command.py +++ b/tests/gateway/test_reasoning_command.py @@ -7,7 +7,7 @@ import types from unittest.mock import AsyncMock, MagicMock import pytest -import yaml +import hermes_yaml as yaml import gateway.run as gateway_run from gateway.config import Platform diff --git a/tests/gateway/test_run_progress_topics.py b/tests/gateway/test_run_progress_topics.py index 804b6274ec..279b5cecb9 100644 --- a/tests/gateway/test_run_progress_topics.py +++ b/tests/gateway/test_run_progress_topics.py @@ -489,9 +489,9 @@ async def test_run_agent_progress_uses_event_message_id_for_slack_dm(monkeypatch # Since PR #8006, Slack's built-in display tier sets tool_progress="off" # by default. Override via config so this test still exercises the # progress-callback path the Slack DM event_message_id threading depends on. - import yaml + import hermes_yaml as yaml (tmp_path / "config.yaml").write_text( - yaml.dump({"display": {"platforms": {"slack": {"tool_progress": "all"}}}}), + yaml.safe_dump({"display": {"platforms": {"slack": {"tool_progress": "all"}}}}), encoding="utf-8", ) @@ -545,9 +545,9 @@ async def test_progress_carries_anchor_for_relay_discord_auto_thread(monkeypatch SAME auto-thread as the final reply — otherwise the search-status updates leak into the parent channel (staging repro 2026-08-02).""" monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all") - import yaml + import hermes_yaml as yaml (tmp_path / "config.yaml").write_text( - yaml.dump({"display": {"platforms": {"discord": {"tool_progress": "all"}}}}), + yaml.safe_dump({"display": {"platforms": {"discord": {"tool_progress": "all"}}}}), encoding="utf-8", ) @@ -604,9 +604,9 @@ async def test_progress_no_anchor_for_native_discord_thread_event(monkeypatch, t auto-thread lane) must NOT get the synthetic prospective anchor — it already routes by its real thread. Guards against over-broadening the relay fix.""" monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all") - import yaml + import hermes_yaml as yaml (tmp_path / "config.yaml").write_text( - yaml.dump({"display": {"platforms": {"discord": {"tool_progress": "all"}}}}), + yaml.safe_dump({"display": {"platforms": {"discord": {"tool_progress": "all"}}}}), encoding="utf-8", ) @@ -687,7 +687,7 @@ def _run_long_preview_helper(monkeypatch, tmp_path, preview_length=0): that _run_agent reads — so the gateway picks it up the same way production does. """ import asyncio - import yaml + import hermes_yaml as yaml monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all") @@ -701,7 +701,7 @@ def _run_long_preview_helper(monkeypatch, tmp_path, preview_length=0): # Write config.yaml so _run_agent picks up tool_preview_length config = {"display": {"tool_preview_length": preview_length}} - (tmp_path / "config.yaml").write_text(yaml.dump(config), encoding="utf-8") + (tmp_path / "config.yaml").write_text(yaml.safe_dump(config), encoding="utf-8") adapter = ProgressCaptureAdapter() runner = _make_runner(adapter) @@ -746,7 +746,7 @@ def test_all_mode_respects_custom_preview_length(monkeypatch, tmp_path): def test_discord_truncated_tool_url_links_to_full_destination(monkeypatch, tmp_path): """The real gateway path must retain the URL beyond its visible cap.""" - import yaml + import hermes_yaml as yaml monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all") @@ -759,7 +759,7 @@ def test_discord_truncated_tool_url_links_to_full_destination(monkeypatch, tmp_p monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) (tmp_path / "config.yaml").write_text( - yaml.dump({"display": {"tool_preview_length": 0}}), + yaml.safe_dump({"display": {"tool_preview_length": 0}}), encoding="utf-8", ) @@ -1010,9 +1010,9 @@ async def _run_with_agent( scope_id=None, ): if config_data: - import yaml + import hermes_yaml as yaml - (tmp_path / "config.yaml").write_text(yaml.dump(config_data), encoding="utf-8") + (tmp_path / "config.yaml").write_text(yaml.safe_dump(config_data), encoding="utf-8") fake_dotenv = types.ModuleType("dotenv") fake_dotenv.load_dotenv = lambda *args, **kwargs: None @@ -1501,10 +1501,10 @@ async def test_base_processing_stops_typing_before_hung_post_delivery_callback( @pytest.mark.asyncio async def test_run_agent_drops_tool_progress_after_generation_invalidation(monkeypatch, tmp_path): - import yaml + import hermes_yaml as yaml (tmp_path / "config.yaml").write_text( - yaml.dump({"display": {"tool_progress": "all"}}), + yaml.safe_dump({"display": {"tool_progress": "all"}}), encoding="utf-8", ) @@ -1563,10 +1563,10 @@ async def test_run_agent_drops_tool_progress_after_generation_invalidation(monke @pytest.mark.asyncio async def test_run_agent_drops_interim_commentary_after_generation_invalidation(monkeypatch, tmp_path): - import yaml + import hermes_yaml as yaml (tmp_path / "config.yaml").write_text( - yaml.dump({"display": {"tool_progress": "off", "interim_assistant_messages": True}}), + yaml.safe_dump({"display": {"tool_progress": "off", "interim_assistant_messages": True}}), encoding="utf-8", ) diff --git a/tests/gateway/test_runtime_env_reload_config_authority.py b/tests/gateway/test_runtime_env_reload_config_authority.py index 0ed73e587c..d0f017abca 100644 --- a/tests/gateway/test_runtime_env_reload_config_authority.py +++ b/tests/gateway/test_runtime_env_reload_config_authority.py @@ -10,7 +10,7 @@ from __future__ import annotations import os from pathlib import Path -import yaml +import hermes_yaml as yaml from gateway import run as gateway_run diff --git a/tests/gateway/test_silent_partial_delivery_95382.py b/tests/gateway/test_silent_partial_delivery_95382.py index 1f41063771..d352ec90dd 100644 --- a/tests/gateway/test_silent_partial_delivery_95382.py +++ b/tests/gateway/test_silent_partial_delivery_95382.py @@ -274,10 +274,10 @@ def _make_runner(adapter): async def _run_turn(monkeypatch, tmp_path, *, consumer_cls=None, session_id): - import yaml + import hermes_yaml as yaml (tmp_path / "config.yaml").write_text( - yaml.dump( + yaml.safe_dump( { "display": {"tool_progress": "off", "interim_assistant_messages": False}, "streaming": { diff --git a/tests/gateway/test_slack_model_picker.py b/tests/gateway/test_slack_model_picker.py index 9a0d27ff78..ee07b5e69f 100644 --- a/tests/gateway/test_slack_model_picker.py +++ b/tests/gateway/test_slack_model_picker.py @@ -626,7 +626,7 @@ class TestSlackModelPickerGatewayIntegration: async def test_bare_model_triggers_picker(self, tmp_path, monkeypatch): import types - import yaml + import hermes_yaml as yaml from gateway.platforms.event import MessageEvent, MessageType from gateway.session import SessionSource @@ -682,7 +682,7 @@ class TestSlackModelPickerGatewayIntegration: @pytest.mark.asyncio async def test_text_fallback_when_no_picker(self, tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml from gateway.platforms.event import MessageEvent, MessageType from gateway.session import SessionSource diff --git a/tests/gateway/test_slash_config_writes_routed_profile.py b/tests/gateway/test_slash_config_writes_routed_profile.py index c1893156f5..18c5a119d2 100644 --- a/tests/gateway/test_slash_config_writes_routed_profile.py +++ b/tests/gateway/test_slash_config_writes_routed_profile.py @@ -12,7 +12,7 @@ through ``_gateway_config_home()`` like the reads do. from __future__ import annotations import pytest -import yaml +import hermes_yaml as yaml import gateway.run as gateway_run from gateway.run import GatewayRunner, _profile_runtime_scope diff --git a/tests/gateway/test_stale_finalize_suppression.py b/tests/gateway/test_stale_finalize_suppression.py index 679e4f7e3e..aba2d4b5a9 100644 --- a/tests/gateway/test_stale_finalize_suppression.py +++ b/tests/gateway/test_stale_finalize_suppression.py @@ -159,10 +159,10 @@ def _make_runner(adapter): async def _run_streaming_turn(monkeypatch, tmp_path, agent_cls, session_id): - import yaml + import hermes_yaml as yaml (tmp_path / "config.yaml").write_text( - yaml.dump( + yaml.safe_dump( { "display": {"tool_progress": "off", "interim_assistant_messages": False}, "streaming": { @@ -289,10 +289,10 @@ async def test_payload_less_split_does_not_suppress_complete_response( monkeypatch, tmp_path ): """#78541 — payload-less split-delivery flags must not swallow the reply.""" - import yaml + import hermes_yaml as yaml (tmp_path / "config.yaml").write_text( - yaml.dump( + yaml.safe_dump( { "display": {"tool_progress": "off", "interim_assistant_messages": False}, "streaming": { diff --git a/tests/gateway/test_stt_config.py b/tests/gateway/test_stt_config.py index 4f72c58c4d..bf9cef67d6 100644 --- a/tests/gateway/test_stt_config.py +++ b/tests/gateway/test_stt_config.py @@ -4,7 +4,7 @@ from pathlib import Path from unittest.mock import AsyncMock, patch import pytest -import yaml +import hermes_yaml as yaml from gateway.config import GatewayConfig, Platform, load_gateway_config from gateway.platforms.event import MessageEvent, MessageType @@ -20,7 +20,7 @@ def test_load_gateway_config_bridges_stt_enabled_from_config_yaml(tmp_path, monk hermes_home = tmp_path / ".hermes" hermes_home.mkdir() (hermes_home / "config.yaml").write_text( - yaml.dump({"stt": {"enabled": False}}), + yaml.safe_dump({"stt": {"enabled": False}}), encoding="utf-8", ) diff --git a/tests/gateway/test_telegram_reactions.py b/tests/gateway/test_telegram_reactions.py index 4c2377db37..ff950a7666 100644 --- a/tests/gateway/test_telegram_reactions.py +++ b/tests/gateway/test_telegram_reactions.py @@ -135,9 +135,9 @@ async def test_clear_reactions_handles_api_error_gracefully(monkeypatch): def test_config_bridges_telegram_reactions(monkeypatch, tmp_path): """gateway/config.py bridges telegram.reactions to TELEGRAM_REACTIONS env var.""" - import yaml + import hermes_yaml as yaml config_file = tmp_path / "config.yaml" - config_file.write_text(yaml.dump({ + config_file.write_text(yaml.safe_dump({ "telegram": { "reactions": True, }, diff --git a/tests/gateway/test_verbose_command.py b/tests/gateway/test_verbose_command.py index d9cc47969a..83f6745a21 100644 --- a/tests/gateway/test_verbose_command.py +++ b/tests/gateway/test_verbose_command.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest -import yaml +import hermes_yaml as yaml import gateway.run as gateway_run from gateway.config import Platform diff --git a/tests/hermes_cli/test_agent_import.py b/tests/hermes_cli/test_agent_import.py index 1f910d37dd..5b9e99942f 100644 --- a/tests/hermes_cli/test_agent_import.py +++ b/tests/hermes_cli/test_agent_import.py @@ -14,7 +14,7 @@ import json from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.agent_import import ( ENTRY_DELIMITER, diff --git a/tests/hermes_cli/test_agent_plugins.py b/tests/hermes_cli/test_agent_plugins.py index bed441915e..cd8a65646b 100644 --- a/tests/hermes_cli/test_agent_plugins.py +++ b/tests/hermes_cli/test_agent_plugins.py @@ -31,7 +31,7 @@ def _write_skill(root: Path, directory: str = "summarize", **fields: object) -> skill_dir.mkdir(parents=True) metadata = {"name": directory, "description": "Summarizes reports."} metadata.update(fields) - import yaml + import hermes_yaml as yaml (skill_dir / "SKILL.md").write_text( f"---\n{yaml.safe_dump(metadata, sort_keys=False)}---\nInstructions.\n", diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py index 1dd8abb4dd..2f104ea488 100644 --- a/tests/hermes_cli/test_api_key_providers.py +++ b/tests/hermes_cli/test_api_key_providers.py @@ -547,12 +547,12 @@ class TestHasAnyProviderConfigured: def test_config_provider_counts(self, monkeypatch, tmp_path): """config.yaml with model.provider set should count as configured.""" - import yaml + import hermes_yaml as yaml from hermes_cli import config as config_module hermes_home = tmp_path / ".hermes" hermes_home.mkdir() config_file = hermes_home / "config.yaml" - config_file.write_text(yaml.dump({ + config_file.write_text(yaml.safe_dump({ "model": {"default": "anthropic/claude-opus-4.6", "provider": "openrouter"}, })) monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env") @@ -596,9 +596,9 @@ class TestHasAnyProviderConfigured: loop in ``except Exception``, so we also record every call — any recorded call proves the sweep ran even if the raise was swallowed. """ - import yaml + import hermes_yaml as yaml hermes_home = self._setup_home(monkeypatch, tmp_path) - (hermes_home / "config.yaml").write_text(yaml.dump({ + (hermes_home / "config.yaml").write_text(yaml.safe_dump({ "model": {"default": "anthropic/claude-opus-4.6", "provider": "openrouter"}, })) sweep_calls = [] @@ -617,9 +617,9 @@ class TestHasAnyProviderConfigured: def test_config_base_url_api_key_skips_registry_sweep(self, monkeypatch, tmp_path): """Custom endpoint (base_url/api_key in config, no provider) must also short-circuit before the registry sweep.""" - import yaml + import hermes_yaml as yaml hermes_home = self._setup_home(monkeypatch, tmp_path) - (hermes_home / "config.yaml").write_text(yaml.dump({ + (hermes_home / "config.yaml").write_text(yaml.safe_dump({ "model": { "default": "local/custom-model", "base_url": "http://localhost:8000/v1", diff --git a/tests/hermes_cli/test_approval_transport.py b/tests/hermes_cli/test_approval_transport.py index e79d10e1f6..db7a423c80 100644 --- a/tests/hermes_cli/test_approval_transport.py +++ b/tests/hermes_cli/test_approval_transport.py @@ -8,7 +8,7 @@ import threading import time import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest from tools import approval_context, approval_prompt diff --git a/tests/hermes_cli/test_approvals_command.py b/tests/hermes_cli/test_approvals_command.py index 470869b616..758b1f177e 100644 --- a/tests/hermes_cli/test_approvals_command.py +++ b/tests/hermes_cli/test_approvals_command.py @@ -3,7 +3,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch -import yaml +import hermes_yaml as yaml from cli import HermesCLI from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS, SUBCOMMANDS, gateway_help_lines, resolve_command diff --git a/tests/hermes_cli/test_atomic_yaml_write.py b/tests/hermes_cli/test_atomic_yaml_write.py index fd50fd10a7..51470cb61c 100644 --- a/tests/hermes_cli/test_atomic_yaml_write.py +++ b/tests/hermes_cli/test_atomic_yaml_write.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml from utils import atomic_yaml_write @@ -25,7 +25,7 @@ class TestAtomicYamlWrite: original = {"preserved": True} target.write_text(yaml.safe_dump(original), encoding="utf-8") - with patch("utils.yaml.dump", side_effect=SimulatedAbort): + with patch("utils.yaml.safe_dump", side_effect=SimulatedAbort): with pytest.raises(SimulatedAbort): atomic_yaml_write(target, {"new": True}) diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index af8e539771..88240b5dc5 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -9,7 +9,7 @@ from datetime import datetime, timezone from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml def _write_auth_store(tmp_path, payload: dict) -> None: @@ -990,8 +990,8 @@ def test_seed_from_singletons_respects_hermes_pkce_suppression(tmp_path, monkeyp hermes_home.mkdir(parents=True, exist_ok=True) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - import yaml - (hermes_home / "config.yaml").write_text(yaml.dump({"model": {"provider": "anthropic", "model": "claude"}})) + import hermes_yaml as yaml + (hermes_home / "config.yaml").write_text(yaml.safe_dump({"model": {"provider": "anthropic", "model": "claude"}})) (hermes_home / "auth.json").write_text(json.dumps({ "version": 1, "providers": {}, diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index d3923bc61f..1ea108f789 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -487,7 +487,7 @@ class TestLoginNousSkipKeepsCurrent: """ def _setup_home_with_openrouter(self, tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml hermes_home = tmp_path / "hermes" hermes_home.mkdir(parents=True, exist_ok=True) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) @@ -554,7 +554,7 @@ class TestLoginNousSkipKeepsCurrent: def test_skip_keep_current_preserves_provider_and_model(self, tmp_path, monkeypatch): """User picks Skip → config.yaml untouched, Nous creds still saved.""" import argparse - import yaml + import hermes_yaml as yaml from hermes_cli.auth import PROVIDER_REGISTRY, _login_nous hermes_home, config_path, auth_path = self._setup_home_with_openrouter( @@ -585,7 +585,7 @@ class TestLoginNousSkipKeepsCurrent: def test_picking_model_switches_to_nous(self, tmp_path, monkeypatch): """User picks a Nous model → provider flips to nous with that model.""" import argparse - import yaml + import hermes_yaml as yaml from hermes_cli.auth import PROVIDER_REGISTRY, _login_nous hermes_home, config_path, auth_path = self._setup_home_with_openrouter( @@ -613,7 +613,7 @@ class TestLoginNousSkipKeepsCurrent: """Fresh install (no prior active_provider) → Skip clears active_provider instead of leaving it as nous.""" import argparse - import yaml + import hermes_yaml as yaml from hermes_cli.auth import PROVIDER_REGISTRY, _login_nous hermes_home = tmp_path / "hermes" diff --git a/tests/hermes_cli/test_auth_provider_gate.py b/tests/hermes_cli/test_auth_provider_gate.py index a85ddedb0a..66589caaec 100644 --- a/tests/hermes_cli/test_auth_provider_gate.py +++ b/tests/hermes_cli/test_auth_provider_gate.py @@ -7,8 +7,8 @@ import pytest def _write_config(tmp_path, config: dict) -> None: hermes_home = tmp_path / "hermes" hermes_home.mkdir(parents=True, exist_ok=True) - import yaml - (hermes_home / "config.yaml").write_text(yaml.dump(config)) + import hermes_yaml as yaml + (hermes_home / "config.yaml").write_text(yaml.safe_dump(config)) def _write_auth_store(tmp_path, payload: dict) -> None: diff --git a/tests/hermes_cli/test_aux_picker_inventory.py b/tests/hermes_cli/test_aux_picker_inventory.py index 27336409e9..53bcf619dc 100644 --- a/tests/hermes_cli/test_aux_picker_inventory.py +++ b/tests/hermes_cli/test_aux_picker_inventory.py @@ -22,7 +22,7 @@ from pathlib import Path from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml from hermes_cli import main_provider_setup diff --git a/tests/hermes_cli/test_backup.py b/tests/hermes_cli/test_backup.py index e94bd9eb61..fbd08a6977 100644 --- a/tests/hermes_cli/test_backup.py +++ b/tests/hermes_cli/test_backup.py @@ -1733,7 +1733,7 @@ class TestRunPreUpdateBackup: @staticmethod def _set_mode(hermes_home, value): - import yaml + import hermes_yaml as yaml (hermes_home / "config.yaml").write_text(yaml.safe_dump({ "_config_version": 22, "updates": {"pre_update_backup": value}, @@ -1929,7 +1929,7 @@ class TestRestoreConfigModelSettingsIfRewritten: return cfg def test_restores_rewritten_provider_and_dropped_moa(self, tmp_path): - import yaml + import hermes_yaml as yaml from hermes_cli.backup import restore_config_model_settings_if_rewritten hermes_home = tmp_path / ".hermes" @@ -1980,7 +1980,7 @@ class TestRestoreConfigModelSettingsIfRewritten: def test_preserves_legitimate_update_writes(self, tmp_path): """Only protected keys are restored — a version bump or a new section the migration legitimately wrote must survive the restore.""" - import yaml + import hermes_yaml as yaml from hermes_cli.backup import restore_config_model_settings_if_rewritten hermes_home = tmp_path / ".hermes" diff --git a/tests/hermes_cli/test_bedrock_mantle_key_env.py b/tests/hermes_cli/test_bedrock_mantle_key_env.py index 48f8733320..09a5673ef3 100644 --- a/tests/hermes_cli/test_bedrock_mantle_key_env.py +++ b/tests/hermes_cli/test_bedrock_mantle_key_env.py @@ -11,7 +11,7 @@ runtime resolver then makes of it. import os -import yaml +import hermes_yaml as yaml import hermes_cli.runtime_provider as rp from hermes_cli.model_setup_flows_bedrock import _model_flow_bedrock_api_key diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index d59d1034dc..d9dfb10d90 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -6,7 +6,7 @@ from pathlib import Path from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.config import ( DEFAULT_CONFIG, @@ -529,9 +529,9 @@ class TestSaveConfigAtomicity: config_path = tmp_path / "config.yaml" assert config_path.exists() - # Simulate a crash during yaml.dump by making atomic_yaml_write's - # yaml.dump raise after the temp file is created but before replace. - with patch("utils.yaml.dump", side_effect=OSError("disk full")): + # Simulate a crash during yaml.safe_dump by making atomic_yaml_write's + # yaml.safe_dump raise after the temp file is created but before replace. + with patch("utils.yaml.safe_dump", side_effect=OSError("disk full")): try: config["model"] = "should-not-persist" save_config(config) @@ -548,7 +548,7 @@ class TestSaveConfigAtomicity: config = load_config() save_config(config) - with patch("utils.yaml.dump", side_effect=OSError("disk full")): + with patch("utils.yaml.safe_dump", side_effect=OSError("disk full")): try: save_config(config) except OSError: diff --git a/tests/hermes_cli/test_config_dotted_key_names.py b/tests/hermes_cli/test_config_dotted_key_names.py index 92c5fb5138..b2c64a34cc 100644 --- a/tests/hermes_cli/test_config_dotted_key_names.py +++ b/tests/hermes_cli/test_config_dotted_key_names.py @@ -24,7 +24,7 @@ import os from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.config import ( _MISSING, diff --git a/tests/hermes_cli/test_config_loader_e2e.py b/tests/hermes_cli/test_config_loader_e2e.py index 30a8cf24c3..08f2402a2a 100644 --- a/tests/hermes_cli/test_config_loader_e2e.py +++ b/tests/hermes_cli/test_config_loader_e2e.py @@ -141,7 +141,7 @@ def test_writeback_roundtrip_byte_identical_when_unchanged(tmp_path): import json from pathlib import Path from hermes_cli.config import read_user_config_raw - import yaml + import hermes_yaml as yaml p = Path(__import__('os').environ['HERMES_HOME']) / 'config.yaml' before = p.read_text(encoding='utf-8') diff --git a/tests/hermes_cli/test_config_set_coercion.py b/tests/hermes_cli/test_config_set_coercion.py index 53518a5910..1317601119 100644 --- a/tests/hermes_cli/test_config_set_coercion.py +++ b/tests/hermes_cli/test_config_set_coercion.py @@ -15,7 +15,7 @@ from hermes_cli import config as cfg def _read(tmp_path, *path): """Read a nested value straight from the on-disk config.yaml.""" - import yaml + import hermes_yaml as yaml data = yaml.safe_load((tmp_path / "config.yaml").read_text()) or {} node = data for seg in path: diff --git a/tests/hermes_cli/test_config_set_platforms_redirect.py b/tests/hermes_cli/test_config_set_platforms_redirect.py index 803a70e58f..f467992cef 100644 --- a/tests/hermes_cli/test_config_set_platforms_redirect.py +++ b/tests/hermes_cli/test_config_set_platforms_redirect.py @@ -10,13 +10,13 @@ edit appeared to succeed while having no effect. from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml def _write_config(hermes_home: Path, data: dict) -> Path: hermes_home.mkdir(parents=True, exist_ok=True) config_path = hermes_home / "config.yaml" - config_path.write_text(yaml.dump(data)) + config_path.write_text(yaml.safe_dump(data)) return config_path diff --git a/tests/hermes_cli/test_custom_provider_model_switch.py b/tests/hermes_cli/test_custom_provider_model_switch.py index 43edf559d1..74284f8d60 100644 --- a/tests/hermes_cli/test_custom_provider_model_switch.py +++ b/tests/hermes_cli/test_custom_provider_model_switch.py @@ -38,7 +38,7 @@ class TestCustomProviderModelSwitch: ): """Switching custom endpoints must not leave the old model.api_key credential selectable from the previous endpoint's pool.""" - import yaml + import hermes_yaml as yaml from agent.credential_pool import load_pool from hermes_cli.auth import read_credential_pool, write_credential_pool from hermes_cli.model_setup_flows import _model_flow_custom @@ -121,7 +121,7 @@ class TestCustomProviderModelSwitch: def test_env_template_api_key_is_preserved_in_model_config(self, config_home, monkeypatch): """Selecting an env-backed custom provider must not inline the secret.""" - import yaml + import hermes_yaml as yaml from hermes_cli.model_setup_flows import _model_flow_named_custom config_path = config_home / "config.yaml" @@ -164,7 +164,7 @@ class TestCustomProviderModelSwitch: def test_key_env_custom_provider_persists_reference_not_secret(self, config_home, monkeypatch): """key_env custom providers should also avoid writing plaintext keys.""" - import yaml + import hermes_yaml as yaml from hermes_cli.model_setup_flows import _model_flow_named_custom config_path = config_home / "config.yaml" @@ -211,7 +211,7 @@ class TestCustomProviderModelSwitch: ``api_key_ref`` to stay empty and the resolved secret to be written to ``config.yaml``. This test drives the real picker-callsite code path. """ - import yaml + import hermes_yaml as yaml from hermes_cli.main import select_provider_and_model config_path = config_home / "config.yaml" @@ -279,7 +279,7 @@ class TestCustomProviderModelSwitch: ``key_env``; the runtime resolves it directly, so no inline ``api_key`` belongs on disk. """ - import yaml + import hermes_yaml as yaml from hermes_cli.model_setup_flows import _model_flow_named_custom config_path = config_home / "config.yaml" @@ -393,7 +393,7 @@ class TestCustomProviderModelSwitch: """A ``providers:`` entry that already has an inline ``api_key`` template must keep it untouched. Only entries that never declared an ``api_key`` should skip the write.""" - import yaml + import hermes_yaml as yaml from hermes_cli.model_setup_flows import _model_flow_named_custom config_path = config_home / "config.yaml" @@ -470,7 +470,7 @@ class TestCustomProviderDiscoverModels: def test_discover_false_saves_choice_from_configured_list(self, config_home): """User picks the 2nd configured model; it persists, list-driven.""" - import yaml + import hermes_yaml as yaml from hermes_cli.model_setup_flows import _model_flow_named_custom provider_info = { @@ -498,7 +498,7 @@ class TestCustomProviderDiscoverModels: def test_probe_empty_falls_back_to_configured_list(self, config_home): """When discovery is on but the probe returns nothing, fall back to the configured models: list instead of forcing manual entry.""" - import yaml + import hermes_yaml as yaml from hermes_cli.model_setup_flows import _model_flow_named_custom provider_info = { diff --git a/tests/hermes_cli/test_dashboard_basic_auth_plugin_enable.py b/tests/hermes_cli/test_dashboard_basic_auth_plugin_enable.py index d0fd5d810e..c78381a2f5 100644 --- a/tests/hermes_cli/test_dashboard_basic_auth_plugin_enable.py +++ b/tests/hermes_cli/test_dashboard_basic_auth_plugin_enable.py @@ -10,7 +10,7 @@ from __future__ import annotations from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.dashboard_auth import clear_providers, list_providers from hermes_cli.plugins import PluginManager, discover_plugins diff --git a/tests/hermes_cli/test_deferred_platform_client_tools.py b/tests/hermes_cli/test_deferred_platform_client_tools.py index 67638f51d4..3ec470048f 100644 --- a/tests/hermes_cli/test_deferred_platform_client_tools.py +++ b/tests/hermes_cli/test_deferred_platform_client_tools.py @@ -21,7 +21,7 @@ import sys from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml A2A_CLIENT_TOOLS = { @@ -70,7 +70,7 @@ def _write_platform_plugin( if provides_tools: manifest_data["provides_tools"] = provides_tools (plugin_dir / "plugin.yaml").write_text( - yaml.dump(manifest_data), + yaml.safe_dump(manifest_data), encoding="utf-8", ) diff --git a/tests/hermes_cli/test_destructive_slash_confirm_gate.py b/tests/hermes_cli/test_destructive_slash_confirm_gate.py index f8dcd4425d..fe61e02de0 100644 --- a/tests/hermes_cli/test_destructive_slash_confirm_gate.py +++ b/tests/hermes_cli/test_destructive_slash_confirm_gate.py @@ -32,7 +32,7 @@ class TestUserConfigMerge: user didn't override).""" def test_existing_user_config_without_key_gets_default(self, tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml home = tmp_path / ".hermes" home.mkdir() diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index 1b76269533..13f20561c2 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -342,12 +342,12 @@ class TestDoctorMemoryProviderSection: """Create a minimal HERMES_HOME with config.yaml.""" home = tmp_path / ".hermes" home.mkdir(parents=True, exist_ok=True) - import yaml + import hermes_yaml as yaml config = dict(memory_config or {}) if provider: config["provider"] = provider config = {"memory": config} - (home / "config.yaml").write_text(yaml.dump(config)) + (home / "config.yaml").write_text(yaml.safe_dump(config)) return home def _run_doctor_and_capture( @@ -448,10 +448,10 @@ def test_run_doctor_accepts_named_provider_from_providers_section(monkeypatch, t home = tmp_path / ".hermes" home.mkdir(parents=True, exist_ok=True) - import yaml + import hermes_yaml as yaml (home / "config.yaml").write_text( - yaml.dump( + yaml.safe_dump( { "model": { "provider": "volcengine-plan", diff --git a/tests/hermes_cli/test_early_recovery.py b/tests/hermes_cli/test_early_recovery.py index 122c62b627..a5ca8602e6 100644 --- a/tests/hermes_cli/test_early_recovery.py +++ b/tests/hermes_cli/test_early_recovery.py @@ -137,7 +137,7 @@ def _project(tmp_path: Path, *, pyproject: bool = True) -> Path: if pyproject: (root / "pyproject.toml").write_text( '[project]\nname = "x"\ndependencies = [\n' - ' "PyYAML==6.0.2",\n' + ' "ruamel.yaml==0.18.17",\n' ' "python-dotenv==1.2.2",\n' ' "PyJWT[crypto]==2.13.0",\n' "]\n", diff --git a/tests/hermes_cli/test_fallback_cmd.py b/tests/hermes_cli/test_fallback_cmd.py index a20a85293b..04221c2489 100644 --- a/tests/hermes_cli/test_fallback_cmd.py +++ b/tests/hermes_cli/test_fallback_cmd.py @@ -6,7 +6,7 @@ from pathlib import Path from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_fleet_config_migration_windows_live.py b/tests/hermes_cli/test_fleet_config_migration_windows_live.py index 95b5767d8d..34ae8b35e3 100644 --- a/tests/hermes_cli/test_fleet_config_migration_windows_live.py +++ b/tests/hermes_cli/test_fleet_config_migration_windows_live.py @@ -7,7 +7,7 @@ import sys from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml WORKTREE = Path(__file__).resolve().parents[2] sys.path.insert(0, str(WORKTREE)) diff --git a/tests/hermes_cli/test_gemini_free_tier_setup_block.py b/tests/hermes_cli/test_gemini_free_tier_setup_block.py index dd73225abb..568d25274b 100644 --- a/tests/hermes_cli/test_gemini_free_tier_setup_block.py +++ b/tests/hermes_cli/test_gemini_free_tier_setup_block.py @@ -55,7 +55,7 @@ class TestGeminiSetupFreeTierBlock: assert "Not saving Gemini as the default provider" in output # Config must NOT show gemini as the provider - import yaml + import hermes_yaml as yaml cfg = yaml.safe_load((config_home / "config.yaml").read_text()) or {} model = cfg.get("model") if isinstance(model, dict): @@ -86,7 +86,7 @@ class TestGeminiSetupFreeTierBlock: assert "paid" in output.lower() assert "Not saving Gemini" not in output - import yaml + import hermes_yaml as yaml cfg = yaml.safe_load((config_home / "config.yaml").read_text()) or {} model = cfg.get("model") assert isinstance(model, dict), f"model should be dict, got {type(model)}" diff --git a/tests/hermes_cli/test_gmi_provider.py b/tests/hermes_cli/test_gmi_provider.py index 732dab2986..2ccecf5d80 100644 --- a/tests/hermes_cli/test_gmi_provider.py +++ b/tests/hermes_cli/test_gmi_provider.py @@ -320,7 +320,7 @@ class TestGmiMainFlow: _model_flow_api_key_provider(load_config(), "gmi", "old-model") - import yaml + import hermes_yaml as yaml from hermes_constants import get_hermes_home config = yaml.safe_load((get_hermes_home() / "config.yaml").read_text()) or {} diff --git a/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py index 6726c55b3a..1848cdc21a 100644 --- a/tests/hermes_cli/test_inventory.py +++ b/tests/hermes_cli/test_inventory.py @@ -41,7 +41,7 @@ def _cfg(model=None, providers=None, custom_providers=None) -> dict: def test_load_picker_context_coerces_numeric_yaml_provider(): - """PyYAML parses unquoted `provider: 2070` as int; picker context must be str. + """YAML parses unquoted `provider: 2070` as int; picker context must be str. Desktop GET /api/model/options crashed when a custom endpoint was named after a GPU: current_provider.strip() and providers dict keys .lower(). diff --git a/tests/hermes_cli/test_keyed_provider_credential_pool.py b/tests/hermes_cli/test_keyed_provider_credential_pool.py index e059ed7542..f1035f7b6e 100644 --- a/tests/hermes_cli/test_keyed_provider_credential_pool.py +++ b/tests/hermes_cli/test_keyed_provider_credential_pool.py @@ -10,7 +10,7 @@ from __future__ import annotations import json -import yaml +import hermes_yaml as yaml POOL_KEY = "sk-real-b-ai-pool-key-12345" diff --git a/tests/hermes_cli/test_managed_scope_loaders.py b/tests/hermes_cli/test_managed_scope_loaders.py index 0161102d5e..093233d597 100644 --- a/tests/hermes_cli/test_managed_scope_loaders.py +++ b/tests/hermes_cli/test_managed_scope_loaders.py @@ -77,7 +77,7 @@ def test_gateway_env_bridge_honors_managed(homes, monkeypatch): managed_scope.invalidate_managed_cache() # The bridge loads config.yaml, expands env, then applies this overlay before # writing HERMES_TIMEZONE = cfg["timezone"]. Prove the overlay flips the value. - import yaml + import hermes_yaml as yaml raw = yaml.safe_load((home / "config.yaml").read_text()) bridged = managed_scope.apply_managed_overlay(raw) diff --git a/tests/hermes_cli/test_mcp_catalog.py b/tests/hermes_cli/test_mcp_catalog.py index 3367974e19..f27fb777bf 100644 --- a/tests/hermes_cli/test_mcp_catalog.py +++ b/tests/hermes_cli/test_mcp_catalog.py @@ -12,7 +12,7 @@ from pathlib import Path from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_mcp_catalog_env_boundary.py b/tests/hermes_cli/test_mcp_catalog_env_boundary.py index 7e131f83ed..ea2beb0198 100644 --- a/tests/hermes_cli/test_mcp_catalog_env_boundary.py +++ b/tests/hermes_cli/test_mcp_catalog_env_boundary.py @@ -6,7 +6,7 @@ import os from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml from fastapi.testclient import TestClient from hermes_cli.web_server import _SESSION_TOKEN, app diff --git a/tests/hermes_cli/test_mcp_config.py b/tests/hermes_cli/test_mcp_config.py index a63174ffff..d49aba217b 100644 --- a/tests/hermes_cli/test_mcp_config.py +++ b/tests/hermes_cli/test_mcp_config.py @@ -61,7 +61,7 @@ def _make_args(**kwargs): def _seed_config(tmp_path: Path, mcp_servers: dict): """Write a config.yaml with the given mcp_servers.""" - import yaml + import hermes_yaml as yaml config = {"mcp_servers": mcp_servers, "_config_version": 9} config_path = tmp_path / "config.yaml" diff --git a/tests/hermes_cli/test_mcp_reload_confirm_gate.py b/tests/hermes_cli/test_mcp_reload_confirm_gate.py index 980bdad4ff..7e4d1c1ef9 100644 --- a/tests/hermes_cli/test_mcp_reload_confirm_gate.py +++ b/tests/hermes_cli/test_mcp_reload_confirm_gate.py @@ -40,7 +40,7 @@ class TestUserConfigMerge: """ def test_existing_user_config_without_key_gets_default(self, tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml # Simulate a legacy user config without the new key. home = tmp_path / ".hermes" diff --git a/tests/hermes_cli/test_mcp_security.py b/tests/hermes_cli/test_mcp_security.py index 4849360db8..4b135cd6ee 100644 --- a/tests/hermes_cli/test_mcp_security.py +++ b/tests/hermes_cli/test_mcp_security.py @@ -130,7 +130,7 @@ def test_explicit_registration_skips_dangerous_entry_before_connect(monkeypatch) def test_migration_disables_existing_dangerous_entry(tmp_path): - import yaml + import hermes_yaml as yaml from hermes_cli.config import load_config, migrate_config diff --git a/tests/hermes_cli/test_memory_dependency_admission.py b/tests/hermes_cli/test_memory_dependency_admission.py index 5398a5e429..678acf7ace 100644 --- a/tests/hermes_cli/test_memory_dependency_admission.py +++ b/tests/hermes_cli/test_memory_dependency_admission.py @@ -9,9 +9,8 @@ import sys from types import SimpleNamespace import pytest -import yaml +import hermes_yaml as yaml -import pm from hermes_cli import memory_setup from hermes_cli.runtime_paths import selected_venv from pm import paths @@ -67,7 +66,9 @@ def test_setup_requires_dependencies_and_keeps_the_existing_union(tmp_path, monk uv, {**uv_env(kwargs.get('base_env')), 'UV_PYTHON': sys.executable, 'UV_OFFLINE': '1', 'UV_CACHE_DIR': str(tmp_path / 'cache')}, )) - pm.sync_venv(explicit=True) + # Keep admission on the real engine with the offline tool fixture. + monkeypatch.setattr("pm.client.sync_venv", ensure.sync_venv) + ensure.sync_venv(explicit=True) original_environment = selected_venv(core) before = {file: file.read_bytes() for file in (config, other_config, paths.runtime_facts_path())} post_calls = [] diff --git a/tests/hermes_cli/test_memory_setup.py b/tests/hermes_cli/test_memory_setup.py index fe2a2e447f..d2428b55fe 100644 --- a/tests/hermes_cli/test_memory_setup.py +++ b/tests/hermes_cli/test_memory_setup.py @@ -63,7 +63,7 @@ def test_cmd_setup_generic_choice_cancel_writes_nothing(tmp_path, monkeypatch): def test_install_dependencies_force_resyncs_declared_extra(tmp_path, monkeypatch): """force=True re-syncs the provider's extra even when it imports fine, so a downgraded/stripped bridge package is restored on hermes update.""" - import yaml as _yaml + import hermes_yaml as _yaml plugin_dir = tmp_path / "mem0" plugin_dir.mkdir() diff --git a/tests/hermes_cli/test_model_assignment_env_key_mirror.py b/tests/hermes_cli/test_model_assignment_env_key_mirror.py index 9d869b530c..384aaaa866 100644 --- a/tests/hermes_cli/test_model_assignment_env_key_mirror.py +++ b/tests/hermes_cli/test_model_assignment_env_key_mirror.py @@ -13,7 +13,7 @@ import importlib import os import pytest -import yaml +import hermes_yaml as yaml import hermes_cli.web_server_config as _web_server_config diff --git a/tests/hermes_cli/test_model_picker_excluded_providers.py b/tests/hermes_cli/test_model_picker_excluded_providers.py index 9ca3394c4d..ccc72452fa 100644 --- a/tests/hermes_cli/test_model_picker_excluded_providers.py +++ b/tests/hermes_cli/test_model_picker_excluded_providers.py @@ -32,7 +32,7 @@ def config_home(tmp_path, monkeypatch): def _write_config(home, **top_level): - import yaml + import hermes_yaml as yaml cfg = {"model": "old-model", "custom_providers": []} cfg.update(top_level) (home / "config.yaml").write_text(yaml.safe_dump(cfg)) diff --git a/tests/hermes_cli/test_model_provider_persistence.py b/tests/hermes_cli/test_model_provider_persistence.py index 5607591c0d..9b5b5019e0 100644 --- a/tests/hermes_cli/test_model_provider_persistence.py +++ b/tests/hermes_cli/test_model_provider_persistence.py @@ -44,7 +44,7 @@ class TestSaveModelChoiceAlwaysDict: _save_model_choice("kimi-k2.5") - import yaml + import hermes_yaml as yaml config = yaml.safe_load((config_home / "config.yaml").read_text()) or {} model = config.get("model") assert isinstance(model, dict), ( @@ -102,7 +102,7 @@ class TestProviderPersistsAfterModelSave: patch("builtins.input", return_value=""): _model_flow_api_key_provider(load_config(), "kimi-coding", "old-model") - import yaml + import hermes_yaml as yaml config = yaml.safe_load((config_home / "config.yaml").read_text()) or {} model = config.get("model") assert isinstance(model, dict), f"model should be dict, got {type(model)}" diff --git a/tests/hermes_cli/test_model_switch_custom_providers.py b/tests/hermes_cli/test_model_switch_custom_providers.py index 40b87b09a7..9e515083e8 100644 --- a/tests/hermes_cli/test_model_switch_custom_providers.py +++ b/tests/hermes_cli/test_model_switch_custom_providers.py @@ -14,7 +14,7 @@ import time import hermes_cli.providers as providers_mod import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.model_switch import list_authenticated_providers, switch_model from hermes_cli.model_switch_providers import _fetch_picker_live_models, _save_discovered_models_to_config from hermes_cli.providers import resolve_provider_full diff --git a/tests/hermes_cli/test_model_switch_opencode_anthropic.py b/tests/hermes_cli/test_model_switch_opencode_anthropic.py index 44a0b73fe7..564379a126 100644 --- a/tests/hermes_cli/test_model_switch_opencode_anthropic.py +++ b/tests/hermes_cli/test_model_switch_opencode_anthropic.py @@ -190,7 +190,7 @@ class TestStaleConfigDefaultDoesNotWedgeResolver: """ def test_kimi_switch_keeps_v1_despite_claude_config_default(self, tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml import importlib monkeypatch.setenv("HERMES_HOME", str(tmp_path)) diff --git a/tests/hermes_cli/test_personality_single_owner.py b/tests/hermes_cli/test_personality_single_owner.py index 1281099e14..b299d34352 100644 --- a/tests/hermes_cli/test_personality_single_owner.py +++ b/tests/hermes_cli/test_personality_single_owner.py @@ -12,7 +12,7 @@ import os from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.personality import ( BUILTIN_PERSONALITIES, diff --git a/tests/hermes_cli/test_pet_toggle.py b/tests/hermes_cli/test_pet_toggle.py index c1b55f939f..0b3dd05232 100644 --- a/tests/hermes_cli/test_pet_toggle.py +++ b/tests/hermes_cli/test_pet_toggle.py @@ -27,10 +27,10 @@ def boba_installed(tmp_path, monkeypatch): def _write_config(home, *, enabled: bool, slug: str = "") -> None: - import yaml + import hermes_yaml as yaml cfg = {"display": {"pet": {"enabled": enabled, "slug": slug, "scale": 0.33}}} - (home / "config.yaml").write_text(yaml.dump(cfg), encoding="utf-8") + (home / "config.yaml").write_text(yaml.safe_dump(cfg), encoding="utf-8") @@ -56,7 +56,7 @@ def test_pets_cli_quoted_false_disables_and_toggle_enables(tmp_path, monkeypatch bool('false') is True — before the is_truthy_value fix, _has_active_pet reported an active pet and /pet toggle DISABLED instead of enabling. """ - import yaml + import hermes_yaml as yaml from hermes_cli.pets import _has_active_pet, toggle_pet_display diff --git a/tests/hermes_cli/test_plugin_api_compat.py b/tests/hermes_cli/test_plugin_api_compat.py index d0024dce2a..197d182747 100644 --- a/tests/hermes_cli/test_plugin_api_compat.py +++ b/tests/hermes_cli/test_plugin_api_compat.py @@ -3,7 +3,7 @@ from pathlib import Path import shutil -import yaml +import hermes_yaml as yaml from hermes_cli.plugins import PluginManager diff --git a/tests/hermes_cli/test_plugin_capabilities.py b/tests/hermes_cli/test_plugin_capabilities.py index 2518b304fc..b4660da8b7 100644 --- a/tests/hermes_cli/test_plugin_capabilities.py +++ b/tests/hermes_cli/test_plugin_capabilities.py @@ -11,7 +11,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.plugin_capabilities import ( CAPABILITY_REGISTRY, diff --git a/tests/hermes_cli/test_plugin_catalog.py b/tests/hermes_cli/test_plugin_catalog.py index 19a3613515..6bb7cdcc6b 100644 --- a/tests/hermes_cli/test_plugin_catalog.py +++ b/tests/hermes_cli/test_plugin_catalog.py @@ -5,7 +5,7 @@ from __future__ import annotations import json -import yaml +import hermes_yaml as yaml from hermes_cli import plugin_catalog as pc diff --git a/tests/hermes_cli/test_plugin_config_state_bridge.py b/tests/hermes_cli/test_plugin_config_state_bridge.py index e992650dd1..a5dc767e5a 100644 --- a/tests/hermes_cli/test_plugin_config_state_bridge.py +++ b/tests/hermes_cli/test_plugin_config_state_bridge.py @@ -10,7 +10,7 @@ from concurrent.futures import ThreadPoolExecutor from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml from hermes_constants import reset_hermes_home_override, set_hermes_home_override from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest diff --git a/tests/hermes_cli/test_plugin_dependency_consent.py b/tests/hermes_cli/test_plugin_dependency_consent.py index 86d850644c..0a4493d0ee 100644 --- a/tests/hermes_cli/test_plugin_dependency_consent.py +++ b/tests/hermes_cli/test_plugin_dependency_consent.py @@ -4,7 +4,7 @@ import os import subprocess import pytest -import yaml +import hermes_yaml as yaml from hermes_cli import plugins_cmd from tests.pm.test_plugin_survival_contract import admission_env # noqa: F401 diff --git a/tests/hermes_cli/test_plugin_event_bus.py b/tests/hermes_cli/test_plugin_event_bus.py index f02454bc1a..84796aac1e 100644 --- a/tests/hermes_cli/test_plugin_event_bus.py +++ b/tests/hermes_cli/test_plugin_event_bus.py @@ -395,7 +395,7 @@ def test_manifest_emits_listens_present(): def test_manifest_parse_reads_emits_listens(tmp_path): """parse_manifest_file picks up optional emits/listens from plugin.yaml.""" - import yaml + import hermes_yaml as yaml plugin_dir = tmp_path / "myplug" plugin_dir.mkdir() @@ -420,7 +420,7 @@ def test_manifest_parse_reads_emits_listens(tmp_path): def test_manifest_parse_absent_emits_listens(tmp_path): - import yaml + import hermes_yaml as yaml plugin_dir = tmp_path / "bare" plugin_dir.mkdir() @@ -441,7 +441,7 @@ def test_manifest_parse_absent_emits_listens(tmp_path): def test_plugins_show_includes_emits_listens(tmp_path, monkeypatch, capsys): - import yaml + import hermes_yaml as yaml from hermes_cli import plugins_cmd plugin_dir = tmp_path / "showplug" diff --git a/tests/hermes_cli/test_plugin_install_ref.py b/tests/hermes_cli/test_plugin_install_ref.py index 0fcfbfb73e..63b11540c4 100644 --- a/tests/hermes_cli/test_plugin_install_ref.py +++ b/tests/hermes_cli/test_plugin_install_ref.py @@ -9,7 +9,7 @@ import subprocess from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.subcommands.plugins import build_plugins_parser diff --git a/tests/hermes_cli/test_plugin_manifest_v2.py b/tests/hermes_cli/test_plugin_manifest_v2.py index a38e9a328e..4908f736f6 100644 --- a/tests/hermes_cli/test_plugin_manifest_v2.py +++ b/tests/hermes_cli/test_plugin_manifest_v2.py @@ -9,7 +9,7 @@ declare-only seam (surfaced, never installed). import logging import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.plugins import ( PluginManager, @@ -26,7 +26,7 @@ def _write_plugin(base, name, manifest_extra=None, register_body="pass"): manifest = {"name": name, "version": "0.1.0", "description": f"test {name}"} if manifest_extra: manifest.update(manifest_extra) - (plugin_dir / "plugin.yaml").write_text(yaml.dump(manifest)) + (plugin_dir / "plugin.yaml").write_text(yaml.safe_dump(manifest)) (plugin_dir / "__init__.py").write_text( f"def register(ctx):\n {register_body}\n" ) diff --git a/tests/hermes_cli/test_plugin_message_injection.py b/tests/hermes_cli/test_plugin_message_injection.py index 209f6f3e6e..2a732936d0 100644 --- a/tests/hermes_cli/test_plugin_message_injection.py +++ b/tests/hermes_cli/test_plugin_message_injection.py @@ -4,7 +4,7 @@ from queue import SimpleQueue from types import SimpleNamespace from unittest.mock import MagicMock, patch -import yaml +import hermes_yaml as yaml from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest diff --git a/tests/hermes_cli/test_plugin_ownership_ledger.py b/tests/hermes_cli/test_plugin_ownership_ledger.py index 35d0bbaa77..28b724a655 100644 --- a/tests/hermes_cli/test_plugin_ownership_ledger.py +++ b/tests/hermes_cli/test_plugin_ownership_ledger.py @@ -8,7 +8,7 @@ from threading import Event from time import monotonic, sleep from types import MethodType -import yaml +import hermes_yaml as yaml def _write_plugin(hermes_home: Path) -> None: diff --git a/tests/hermes_cli/test_plugin_packs.py b/tests/hermes_cli/test_plugin_packs.py index c9c80cc73a..d1609056ac 100644 --- a/tests/hermes_cli/test_plugin_packs.py +++ b/tests/hermes_cli/test_plugin_packs.py @@ -12,7 +12,7 @@ from types import SimpleNamespace from unittest import mock import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.plugin_packs import ( PackError, diff --git a/tests/hermes_cli/test_plugin_scanner_recursion.py b/tests/hermes_cli/test_plugin_scanner_recursion.py index 4a0614f959..980292675d 100644 --- a/tests/hermes_cli/test_plugin_scanner_recursion.py +++ b/tests/hermes_cli/test_plugin_scanner_recursion.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import Any, Dict import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.plugins import PluginManager @@ -44,7 +44,7 @@ def _write_plugin( } if manifest_extra: manifest.update(manifest_extra) - (plugin_dir / "plugin.yaml").write_text(yaml.dump(manifest)) + (plugin_dir / "plugin.yaml").write_text(yaml.safe_dump(manifest)) (plugin_dir / "__init__.py").write_text( f"def register(ctx):\n {register_body}\n" ) diff --git a/tests/hermes_cli/test_plugin_validate.py b/tests/hermes_cli/test_plugin_validate.py index 4398b517f8..0cfc27306c 100644 --- a/tests/hermes_cli/test_plugin_validate.py +++ b/tests/hermes_cli/test_plugin_validate.py @@ -8,7 +8,7 @@ from __future__ import annotations from pathlib import Path -import yaml +import hermes_yaml as yaml from hermes_cli.plugin_validate import validate_plugin_dir diff --git a/tests/hermes_cli/test_plugin_version_order.py b/tests/hermes_cli/test_plugin_version_order.py index cd76f51495..cfb9d00fed 100644 --- a/tests/hermes_cli/test_plugin_version_order.py +++ b/tests/hermes_cli/test_plugin_version_order.py @@ -3,7 +3,7 @@ import json from types import SimpleNamespace import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.plugins_updates import run_checks diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 85415617ae..f39f88ff96 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -9,7 +9,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.plugins import ( ENTRY_POINTS_GROUP, @@ -81,7 +81,7 @@ def _make_plugin_dir(base: Path, name: str, *, register_body: str = "pass", if manifest_extra: manifest.update(manifest_extra) - (plugin_dir / "plugin.yaml").write_text(yaml.dump(manifest)) + (plugin_dir / "plugin.yaml").write_text(yaml.safe_dump(manifest)) (plugin_dir / "__init__.py").write_text( f"def register(ctx):\n {register_body}\n" ) @@ -479,7 +479,7 @@ class TestPluginLoading: plugin_dir = plugins_dir / "mempalace" plugin_dir.mkdir(parents=True) # No explicit `kind:` — the heuristic should kick in. - (plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "mempalace"})) + (plugin_dir / "plugin.yaml").write_text(yaml.safe_dump({"name": "mempalace"})) (plugin_dir / "__init__.py").write_text( "class MemPalaceProvider:\n" " pass\n" @@ -1699,7 +1699,7 @@ class TestPluginContext: plugins_dir = tmp_path / "hermes_test" / "plugins" plugin_dir = plugins_dir / "evil_override_plugin" plugin_dir.mkdir(parents=True) - (plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "evil_override_plugin"})) + (plugin_dir / "plugin.yaml").write_text(yaml.safe_dump({"name": "evil_override_plugin"})) (plugin_dir / "__init__.py").write_text( 'def register(ctx):\n' ' ctx.register_tool(\n' @@ -1769,7 +1769,7 @@ class TestPluginContext: plugins_dir = tmp_path / "hermes_test" / "plugins" plugin_dir = plugins_dir / "delayed_override_plugin" plugin_dir.mkdir(parents=True) - (plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "delayed_override_plugin"})) + (plugin_dir / "plugin.yaml").write_text(yaml.safe_dump({"name": "delayed_override_plugin"})) # register(ctx) only STORES a callback; the override fires later, # after load has finished and any transient scope is gone. (plugin_dir / "__init__.py").write_text( @@ -1833,7 +1833,7 @@ class TestPluginToolVisibility: plugins_dir = tmp_path / "hermes_test" / "plugins" plugin_dir = plugins_dir / "vis_plugin" plugin_dir.mkdir(parents=True) - (plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "vis_plugin"})) + (plugin_dir / "plugin.yaml").write_text(yaml.safe_dump({"name": "vis_plugin"})) (plugin_dir / "__init__.py").write_text( 'def register(ctx):\n' ' ctx.register_tool(\n' @@ -2058,7 +2058,7 @@ class TestPluginCommands: plugin_dir = plugins_dir / "engine-plugin" plugin_dir.mkdir(parents=True, exist_ok=True) (plugin_dir / "plugin.yaml").write_text( - yaml.dump({ + yaml.safe_dump({ "name": "engine-plugin", "version": "0.1.0", "description": "Test engine plugin", @@ -2194,7 +2194,7 @@ class TestPluginCommands: plugin_dir = (home / "plugins" / "stateful-plugin") plugin_dir.mkdir(parents=True, exist_ok=True) (plugin_dir / "plugin.yaml").write_text( - yaml.dump({ + yaml.safe_dump({ "name": "stateful-plugin", "version": "0.1.0", "description": "Relative-import regression plugin", diff --git a/tests/hermes_cli/test_plugins_admission_setter.py b/tests/hermes_cli/test_plugins_admission_setter.py index a0bbcb3757..21b1ad1e83 100644 --- a/tests/hermes_cli/test_plugins_admission_setter.py +++ b/tests/hermes_cli/test_plugins_admission_setter.py @@ -50,13 +50,8 @@ def _write_sets(home, enabled=(), disabled=()): @pytest.fixture def sync_calls(monkeypatch): - """pm.ensure is shadowed by a pm.ensure() function — patch the MODULE.""" - import importlib - import sys - - if "pm.ensure" not in sys.modules: - importlib.import_module("pm.ensure") - ensure = sys.modules["pm.ensure"] + """Stub the admission caller seam; real engine transactions are tested below.""" + from pm import client calls = [] def _sync(extras=None, *, explicit=False, plugin_dirs=None, before_publish=None): @@ -65,7 +60,7 @@ def sync_calls(monkeypatch): if before_publish is not None: before_publish() # same contract: config commits under the sync - monkeypatch.setattr(ensure, "sync_venv", _sync) + monkeypatch.setattr(client, "sync_venv", _sync) return calls @@ -88,17 +83,12 @@ def _discovery(monkeypatch, *entries): def _refusing_sync(monkeypatch, message="uv lock exited 1: unsatisfiable"): - import importlib - import sys - - if "pm.ensure" not in sys.modules: - importlib.import_module("pm.ensure") - ensure = sys.modules["pm.ensure"] + from pm import client def _boom(*a, **k): raise RuntimeError(message) - monkeypatch.setattr(ensure, "sync_venv", _boom) + monkeypatch.setattr(client, "sync_venv", _boom) # ── cmd_enable (CLI path) ──────────────────────────────────────────────────── diff --git a/tests/hermes_cli/test_plugins_cmd.py b/tests/hermes_cli/test_plugins_cmd.py index 8c1a303d92..6308508748 100644 --- a/tests/hermes_cli/test_plugins_cmd.py +++ b/tests/hermes_cli/test_plugins_cmd.py @@ -9,7 +9,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.plugins_cmd import ( PluginOperationError, diff --git a/tests/hermes_cli/test_plugins_cmd_category_discovery.py b/tests/hermes_cli/test_plugins_cmd_category_discovery.py index 43c3a77156..7bbff89917 100644 --- a/tests/hermes_cli/test_plugins_cmd_category_discovery.py +++ b/tests/hermes_cli/test_plugins_cmd_category_discovery.py @@ -22,8 +22,8 @@ def _make_plugin_dir(parent: Path, name: str, manifest: dict) -> Path: """Create a minimal plugin directory with a plugin.yaml.""" d = parent / name d.mkdir(parents=True, exist_ok=True) - import yaml - (d / "plugin.yaml").write_text(yaml.dump(manifest), encoding="utf-8") + import hermes_yaml as yaml + (d / "plugin.yaml").write_text(yaml.safe_dump(manifest), encoding="utf-8") (d / "__init__.py").write_text("def register(ctx): pass\n", encoding="utf-8") return d @@ -68,8 +68,8 @@ class TestReadManifestInfo: d = tmp_path / "my-plugin" d.mkdir() - import yaml - (d / "plugin.yml").write_text(yaml.dump({"name": "my-plugin"}), encoding="utf-8") + import hermes_yaml as yaml + (d / "plugin.yml").write_text(yaml.safe_dump({"name": "my-plugin"}), encoding="utf-8") result = _read_manifest_info(d, "") assert result is not None assert result[0] == "my-plugin" @@ -134,9 +134,9 @@ class TestDiscoverAllPlugins: # 3 levels: should NOT be found deep = tmp_path / "a" / "b" / "c" deep.mkdir(parents=True) - import yaml + import hermes_yaml as yaml (deep / "plugin.yaml").write_text( - yaml.dump({"name": "too-deep"}), encoding="utf-8" + yaml.safe_dump({"name": "too-deep"}), encoding="utf-8" ) mock_user_dir.return_value = tmp_path mock_bundled_dir.return_value = tmp_path / "nonexistent" diff --git a/tests/hermes_cli/test_plugins_cmd_deps_flow.py b/tests/hermes_cli/test_plugins_cmd_deps_flow.py index d6b9cb209c..2a11dc6734 100644 --- a/tests/hermes_cli/test_plugins_cmd_deps_flow.py +++ b/tests/hermes_cli/test_plugins_cmd_deps_flow.py @@ -63,7 +63,7 @@ def test_noninteractive_skips_install(tmp_path, monkeypatch, resolve_env): monkeypatch.setattr(pc.sys.stdin, "isatty", lambda: False, raising=False) called = [] monkeypatch.setattr( - "pm.workspace.lock_and_sync", lambda *a, **k: called.append(a) + "pm.client.sync_venv", lambda *a, **k: called.append(a) ) ok, reason = pc._install_plugin_python_deps( {"name": "dep-plug", "python_dependencies": ["somepkg>=1,<2"]}, @@ -83,7 +83,7 @@ def test_decline_skips_install(tmp_path, monkeypatch, resolve_env): monkeypatch.setattr("builtins.input", lambda *a: "n") called = [] monkeypatch.setattr( - "pm.workspace.lock_and_sync", lambda *a, **k: called.append(a) + "pm.client.sync_venv", lambda *a, **k: called.append(a) ) ok, reason = pc._install_plugin_python_deps( {"name": "dep-plug", "python_dependencies": ["somepkg>=1,<2"]}, @@ -114,12 +114,7 @@ def test_conflict_surfaces_at_admission_not_consent(tmp_path, monkeypatch, resol assert ok is True and reason is None # The conflict surfaces when the enable COMMITS, with config untouched: - import importlib - import sys - - if "pm.ensure" not in sys.modules: - importlib.import_module("pm.ensure") - ensure = sys.modules["pm.ensure"] + from pm import client def boom(*a, **k): raise RuntimeError( @@ -127,7 +122,7 @@ def test_conflict_surfaces_at_admission_not_consent(tmp_path, monkeypatch, resol "and hermes-agent depends on somepkg==1.0.0, unsatisfiable" ) - monkeypatch.setattr(ensure, "sync_venv", boom) + monkeypatch.setattr(client, "sync_venv", boom) from hermes_cli import plugins_admission as adm with pytest.raises(adm.AdmissionRefused) as excinfo: @@ -147,14 +142,7 @@ def test_success_consents_without_plugin_dir_writes(tmp_path, monkeypatch, resol monkeypatch.setattr(pc.sys.stdout, "isatty", lambda: True, raising=False) monkeypatch.setattr("builtins.input", lambda *a: "y") synced = [] - import importlib - import sys - - if "pm.ensure" not in sys.modules: - importlib.import_module("pm.ensure") - monkeypatch.setattr( - sys.modules["pm.ensure"], "sync_venv", lambda *a, **k: synced.append(a) - ) + monkeypatch.setattr("pm.client.sync_venv", lambda *a, **k: synced.append(a)) ok, reason = pc._install_plugin_python_deps( {"name": "dep-plug", "python_dependencies": ["somepkg>=1,<2"]}, plug, diff --git a/tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py b/tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py index bd71ef18c9..e0d221873b 100644 --- a/tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py +++ b/tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py @@ -28,8 +28,8 @@ def _capture_selection(monkeypatch): def _make_plugin_dir(parent: Path, name: str, manifest: dict) -> Path: d = parent / name d.mkdir(parents=True, exist_ok=True) - import yaml - (d / "plugin.yaml").write_text(yaml.dump(manifest), encoding="utf-8") + import hermes_yaml as yaml + (d / "plugin.yaml").write_text(yaml.safe_dump(manifest), encoding="utf-8") (d / "__init__.py").write_text("def register(ctx): pass\n", encoding="utf-8") return d diff --git a/tests/hermes_cli/test_plugins_transcription_registration.py b/tests/hermes_cli/test_plugins_transcription_registration.py index 5f6ab4a2f7..d6e829274b 100644 --- a/tests/hermes_cli/test_plugins_transcription_registration.py +++ b/tests/hermes_cli/test_plugins_transcription_registration.py @@ -14,7 +14,7 @@ import os from pathlib import Path from typing import Any, Dict -import yaml +import hermes_yaml as yaml def _write_plugin( @@ -33,7 +33,7 @@ def _write_plugin( } if manifest_extra: manifest.update(manifest_extra) - (plugin_dir / "plugin.yaml").write_text(yaml.dump(manifest)) + (plugin_dir / "plugin.yaml").write_text(yaml.safe_dump(manifest)) (plugin_dir / "__init__.py").write_text( f"def register(ctx):\n {register_body}\n" ) diff --git a/tests/hermes_cli/test_plugins_tts_registration.py b/tests/hermes_cli/test_plugins_tts_registration.py index 81a6b6a0bd..3aa45b1638 100644 --- a/tests/hermes_cli/test_plugins_tts_registration.py +++ b/tests/hermes_cli/test_plugins_tts_registration.py @@ -14,7 +14,7 @@ import os from pathlib import Path from typing import Any, Dict -import yaml +import hermes_yaml as yaml def _write_plugin( @@ -33,7 +33,7 @@ def _write_plugin( } if manifest_extra: manifest.update(manifest_extra) - (plugin_dir / "plugin.yaml").write_text(yaml.dump(manifest)) + (plugin_dir / "plugin.yaml").write_text(yaml.safe_dump(manifest)) (plugin_dir / "__init__.py").write_text( f"def register(ctx):\n {register_body}\n" ) diff --git a/tests/hermes_cli/test_plugins_update_sync.py b/tests/hermes_cli/test_plugins_update_sync.py index 51c5620a95..e085f73dad 100644 --- a/tests/hermes_cli/test_plugins_update_sync.py +++ b/tests/hermes_cli/test_plugins_update_sync.py @@ -1,7 +1,6 @@ """Every update surface validates staged code before changing an active plugin.""" from __future__ import annotations -import importlib import subprocess import pytest @@ -44,7 +43,7 @@ def test_disabled_update_does_not_change_dependencies_or_enablement(installed, m config = (home / "config.yaml").read_bytes() facts = paths.runtime_facts_path().read_bytes() state["sha"] = _version(repo, "2.0.0") - monkeypatch.setattr(importlib.import_module("pm.ensure"), "sync_venv", + monkeypatch.setattr("pm.client.sync_venv", lambda **kwargs: pytest.fail("disabled plugin changed the dependency selection")) result = pc.dashboard_update_user_plugin("transactional") assert result["ok"], result diff --git a/tests/hermes_cli/test_profile_display_name.py b/tests/hermes_cli/test_profile_display_name.py index 27dbd6e77c..60b14278db 100644 --- a/tests/hermes_cli/test_profile_display_name.py +++ b/tests/hermes_cli/test_profile_display_name.py @@ -9,7 +9,7 @@ from __future__ import annotations from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.profiles import ( create_profile, diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index b8acc83a22..44481c17e2 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -16,7 +16,7 @@ from pathlib import Path from unittest.mock import patch, MagicMock import pytest -import yaml +import hermes_yaml as yaml from hermes_cli import profiles from hermes_cli.profiles import ( @@ -938,10 +938,8 @@ class TestWriteProfileMetaDurability: def _interrupted_write(profile_dir): """Run a ``write_profile_meta`` whose serialization fails mid-call. - The pre-fix code called ``yaml.safe_dump``; ``utils.atomic_yaml_write`` - calls ``yaml.dump``. Breaking both keeps this serializer-agnostic, so - it measures durability rather than the choice of entry point. A - scoped ``MonkeyPatch.context`` is used instead of the fixture so the + Interrupt the shared serializer used by ``utils.atomic_yaml_write``. + A scoped ``MonkeyPatch.context`` is used instead of the fixture so the patch is reverted immediately, without touching the session-wide env isolation that shares the function-scoped ``monkeypatch`` instance. """ @@ -950,7 +948,6 @@ class TestWriteProfileMetaDurability: with pytest.MonkeyPatch.context() as mp: mp.setattr(yaml, "safe_dump", _boom) - mp.setattr(yaml, "dump", _boom) with pytest.raises(RuntimeError): profiles.write_profile_meta(profile_dir, description_auto=True) diff --git a/tests/hermes_cli/test_read_raw_config_readonly.py b/tests/hermes_cli/test_read_raw_config_readonly.py index 3326e5fa1f..20cc74e07e 100644 --- a/tests/hermes_cli/test_read_raw_config_readonly.py +++ b/tests/hermes_cli/test_read_raw_config_readonly.py @@ -15,7 +15,7 @@ import os import time import pytest -import yaml +import hermes_yaml as yaml @pytest.fixture() diff --git a/tests/hermes_cli/test_reasoning_full_command.py b/tests/hermes_cli/test_reasoning_full_command.py index 8f6c23dff4..45f110f7e4 100644 --- a/tests/hermes_cli/test_reasoning_full_command.py +++ b/tests/hermes_cli/test_reasoning_full_command.py @@ -9,7 +9,7 @@ and that the clamp gate honours the flag. import os -import yaml +import hermes_yaml as yaml from hermes_cli.cli_commands_mixin import CLICommandsMixin from hermes_cli.config import DEFAULT_CONFIG diff --git a/tests/hermes_cli/test_relay_plugin_cutover.py b/tests/hermes_cli/test_relay_plugin_cutover.py index 24eb773949..351c6f510b 100644 --- a/tests/hermes_cli/test_relay_plugin_cutover.py +++ b/tests/hermes_cli/test_relay_plugin_cutover.py @@ -6,7 +6,7 @@ import os from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.config import migrate_config from hermes_cli.doctor_config import collect_relay_plugin_cutover_findings diff --git a/tests/hermes_cli/test_resolve_turn_limit.py b/tests/hermes_cli/test_resolve_turn_limit.py index 2cacb9d08d..41b08e911a 100644 --- a/tests/hermes_cli/test_resolve_turn_limit.py +++ b/tests/hermes_cli/test_resolve_turn_limit.py @@ -141,7 +141,7 @@ class TestGatewayBridgeNullHandling: def test_none_value_not_bridged(self, monkeypatch, tmp_path): """YAML ``max_turns: null`` should not set HERMES_MAX_ITERATIONS.""" - import yaml + import hermes_yaml as yaml cfg_file = tmp_path / "config.yaml" cfg_file.write_text("agent:\n max_turns: null\n", encoding="utf-8") monkeypatch.setenv("HERMES_MAX_ITERATIONS", "stale-120") @@ -172,7 +172,7 @@ class TestGatewayBridgeNullHandling: def test_bare_key_treated_as_null(self, monkeypatch, tmp_path): """YAML ``max_turns:`` (bare key, no value) parses as Python None.""" - import yaml + import hermes_yaml as yaml cfg_file = tmp_path / "config.yaml" cfg_file.write_text("agent:\n max_turns:\n", encoding="utf-8") monkeypatch.setenv("HERMES_MAX_ITERATIONS", "stale-90") diff --git a/tests/hermes_cli/test_set_config_value.py b/tests/hermes_cli/test_set_config_value.py index ac7211cbc0..a7dac048ce 100644 --- a/tests/hermes_cli/test_set_config_value.py +++ b/tests/hermes_cli/test_set_config_value.py @@ -133,7 +133,7 @@ class TestConfigYamlRouting: ): set_config_value("terminal.docker_shared_container_key", "off") - import yaml + import hermes_yaml as yaml saved = yaml.safe_load(_read_config(_isolated_hermes_home)) assert saved["terminal"]["docker_shared_container_key"] == "off" @@ -193,7 +193,7 @@ class TestConfigGetUnset: args = argparse.Namespace(config_command="unset", key="terminal.backend") config_command(args) - import yaml + import hermes_yaml as yaml reloaded = yaml.safe_load(_read_config(_isolated_hermes_home)) or {} assert reloaded == {} assert "TERMINAL_ENV=" not in _read_env(_isolated_hermes_home) @@ -212,7 +212,7 @@ class TestConfigGetUnset: args = argparse.Namespace(config_command="unset", key="platforms.teams.extra.access_token") config_command(args) - import yaml + import hermes_yaml as yaml reloaded = yaml.safe_load(_read_config(_isolated_hermes_home)) assert "access_token" not in reloaded["platforms"]["teams"]["extra"] assert reloaded["platforms"]["teams"]["extra"]["tenant_id"] == "tenant" @@ -246,7 +246,7 @@ class TestListNavigation: set_config_value("custom_providers.0.api_key", "new-a") - import yaml + import hermes_yaml as yaml reloaded = yaml.safe_load(_read_config(_isolated_hermes_home)) # The list must still be a list assert isinstance(reloaded["custom_providers"], list) @@ -274,7 +274,7 @@ class TestListNavigation: set_config_value("custom_providers.0.api_key", "rotated") - import yaml + import hermes_yaml as yaml reloaded = yaml.safe_load(_read_config(_isolated_hermes_home)) entry = reloaded["custom_providers"][0] assert entry["api_key"] == "rotated" @@ -299,7 +299,7 @@ class TestListNavigation: # the canonical path. set_config_value("telegram.allowlist.1.role", "admin") - import yaml + import hermes_yaml as yaml reloaded = yaml.safe_load(_read_config(_isolated_hermes_home)) allowlist = reloaded["telegram"]["allowlist"] assert isinstance(allowlist, list) @@ -359,7 +359,7 @@ class TestStringTypedConfigValues: """Values stay strings when DEFAULT_CONFIG declares the leaf as a string.""" set_config_value("approvals.mode", value) - import yaml + import hermes_yaml as yaml saved = yaml.safe_load(_read_config(_isolated_hermes_home)) assert saved["approvals"]["mode"] == value assert isinstance(saved["approvals"]["mode"], str) @@ -373,7 +373,7 @@ class TestStringTypedConfigValues: ): set_config_value(key, value) - import yaml + import hermes_yaml as yaml saved = yaml.safe_load(_read_config(_isolated_hermes_home)) node = saved for part in key.split("."): @@ -386,7 +386,7 @@ class TestStringTypedConfigValues: # (schema validation, #34067); coercion behavior is unchanged. set_config_value("custom.enabled", "off", force=True) - import yaml + import hermes_yaml as yaml saved = yaml.safe_load(_read_config(_isolated_hermes_home)) assert saved["custom"]["enabled"] is False @@ -468,7 +468,7 @@ class TestSchemaValidation: def test_desktop_macos_signing_identity_is_accepted(self, _isolated_hermes_home, capsys): """The documented TCC signing identity setting is part of the schema.""" set_config_value("desktop.macos_signing_identity", "Hermes Local Signing") - import yaml + import hermes_yaml as yaml saved = yaml.safe_load(_read_config(_isolated_hermes_home)) assert saved["desktop"]["macos_signing_identity"] == "Hermes Local Signing" assert "not a recognized config key" not in capsys.readouterr().out @@ -586,8 +586,8 @@ class TestMappingGuard: """ def _write_config(self, tmp_path, data: dict): - import yaml as _yaml - (tmp_path / "config.yaml").write_text(_yaml.dump(data)) + import hermes_yaml as _yaml + (tmp_path / "config.yaml").write_text(_yaml.safe_dump(data)) def test_bare_model_shorthand_preserves_siblings(self, _isolated_hermes_home): """hermes config set model → model.default, siblings survive.""" @@ -601,7 +601,7 @@ class TestMappingGuard: }) set_config_value("model", "claude-sonnet-4-20250514") config_text = _read_config(_isolated_hermes_home) - import yaml as _yaml + import hermes_yaml as _yaml parsed = _yaml.safe_load(config_text) assert parsed["model"]["default"] == "claude-sonnet-4-20250514" assert parsed["model"]["provider"] == "openai-api" @@ -635,7 +635,7 @@ class TestMappingGuard: } }) set_config_value("terminal", "zsh", force=True) - import yaml as _yaml + import hermes_yaml as _yaml parsed = _yaml.safe_load(_read_config(_isolated_hermes_home)) assert parsed["terminal"] == "zsh" @@ -648,7 +648,7 @@ class TestMappingGuard: } }) set_config_value("model.default", "claude-opus-4") - import yaml as _yaml + import hermes_yaml as _yaml parsed = _yaml.safe_load(_read_config(_isolated_hermes_home)) assert parsed["model"]["default"] == "claude-opus-4" assert parsed["model"]["provider"] == "openai-api" @@ -663,7 +663,7 @@ class TestMappingGuard: } }) set_config_value("model", "claude-opus-4", force=True) - import yaml as _yaml + import hermes_yaml as _yaml parsed = _yaml.safe_load(_read_config(_isolated_hermes_home)) assert parsed["model"] == "claude-opus-4" @@ -673,7 +673,7 @@ class TestScalarModelSubKeyPreservation: def test_scalar_model_id_preserved_after_provider_write(self, _isolated_hermes_home): """Seed model: gpt-4o, then set model.provider → model.default must survive.""" - import yaml + import hermes_yaml as yaml set_config_value("model", "gpt-4o") set_config_value("model.provider", "openai") @@ -686,7 +686,7 @@ class TestScalarModelSubKeyPreservation: def test_scalar_model_id_preserved_after_api_key_write(self, _isolated_hermes_home): """model.api_key must also preserve the existing scalar model id.""" - import yaml + import hermes_yaml as yaml set_config_value("model", "claude-sonnet") # model.api_key is a sub-key (has a dot), so it stays in config.yaml @@ -748,7 +748,7 @@ class TestLiteralDotKeyEscaping: """ def _write_config(self, tmp_path, data: dict): - import yaml as _yaml + import hermes_yaml as _yaml (tmp_path / "config.yaml").write_text(_yaml.safe_dump(data, sort_keys=False)) def test_split_key_path_escaped_dot(self): @@ -778,7 +778,7 @@ class TestLiteralDotKeyEscaping: '{"Wafer-ZDR": "required"}', ) - import yaml + import hermes_yaml as yaml saved = yaml.safe_load(_read_config(_isolated_hermes_home)) providers = saved["providers"] # No bogus ``qwen3`` nesting was created; the existing entry was updated. @@ -807,7 +807,7 @@ class TestLiteralDotKeyEscaping: ) config_command(args) - import yaml + import hermes_yaml as yaml saved = yaml.safe_load(_read_config(_isolated_hermes_home)) assert "qwen3.5-397b-wafer-non-zdr" not in saved["providers"] assert saved["providers"]["openrouter"] == {"api_key": "or-keep"} @@ -829,7 +829,7 @@ class TestLiteralDotKeyEscaping: ) config_command(args) - import yaml + import hermes_yaml as yaml saved = yaml.safe_load(_read_config(_isolated_hermes_home)) target = saved["providers"]["qwen3.5-397b-wafer-non-zdr"] assert "extra_headers" not in target @@ -853,6 +853,6 @@ class TestLiteralDotKeyEscaping: """Nesting semantics for plain dotted keys are untouched.""" set_config_value("terminal.backend", "docker") - import yaml + import hermes_yaml as yaml saved = yaml.safe_load(_read_config(_isolated_hermes_home)) assert saved["terminal"]["backend"] == "docker" diff --git a/tests/hermes_cli/test_setup_tts_xai_oauth.py b/tests/hermes_cli/test_setup_tts_xai_oauth.py index 57b5446792..4baa4ca6dd 100644 --- a/tests/hermes_cli/test_setup_tts_xai_oauth.py +++ b/tests/hermes_cli/test_setup_tts_xai_oauth.py @@ -2,7 +2,7 @@ import json -import yaml +import hermes_yaml as yaml def test_run_xai_oauth_login_from_setup_does_not_hijack_active_provider( diff --git a/tests/hermes_cli/test_sibling_config_migration.py b/tests/hermes_cli/test_sibling_config_migration.py index a9847ba2d7..5d5de4dc36 100644 --- a/tests/hermes_cli/test_sibling_config_migration.py +++ b/tests/hermes_cli/test_sibling_config_migration.py @@ -10,7 +10,7 @@ These tests use REAL config files on disk and the REAL migration pipeline — only the profile-root location is pointed at tmp_path. """ -import yaml +import hermes_yaml as yaml from pathlib import Path import hermes_cli.update_cmd as update_cmd diff --git a/tests/hermes_cli/test_skin_cmd.py b/tests/hermes_cli/test_skin_cmd.py index e475065175..0a4f6ee84c 100644 --- a/tests/hermes_cli/test_skin_cmd.py +++ b/tests/hermes_cli/test_skin_cmd.py @@ -7,7 +7,7 @@ The whole point is that changing one token never disturbs the rest of the look import os import pytest -import yaml +import hermes_yaml as yaml from hermes_cli import skin_cmd from hermes_constants import get_hermes_home diff --git a/tests/hermes_cli/test_skin_engine.py b/tests/hermes_cli/test_skin_engine.py index 2b46d537ea..bd4f3ecd39 100644 --- a/tests/hermes_cli/test_skin_engine.py +++ b/tests/hermes_cli/test_skin_engine.py @@ -103,8 +103,8 @@ class TestUserSkins: "branding": {"agent_name": "Custom Agent"}, "tool_prefix": "▸", } - import yaml - skin_file.write_text(yaml.dump(skin_data)) + import hermes_yaml as yaml + skin_file.write_text(yaml.safe_dump(skin_data)) # Patch skins dir monkeypatch.setattr("hermes_cli.skin_engine._skins_dir", lambda: skins_dir) @@ -122,10 +122,10 @@ class TestUserSkins: skins_dir = tmp_path / "skins" skins_dir.mkdir() - import yaml + import hermes_yaml as yaml (skins_dir / "broken.yaml").write_text( - yaml.dump( + yaml.safe_dump( { "name": "broken", "colors": ["not", "a", "mapping"], @@ -152,8 +152,8 @@ class TestUserSkins: from hermes_cli.skin_engine import list_skins skins_dir = tmp_path / "skins" skins_dir.mkdir() - import yaml - (skins_dir / "pirate.yaml").write_text(yaml.dump({ + import hermes_yaml as yaml + (skins_dir / "pirate.yaml").write_text(yaml.safe_dump({ "name": "pirate", "description": "Arr matey", })) diff --git a/tests/hermes_cli/test_startup_fast_guards.py b/tests/hermes_cli/test_startup_fast_guards.py index 0692edaf60..688968cd5c 100644 --- a/tests/hermes_cli/test_startup_fast_guards.py +++ b/tests/hermes_cli/test_startup_fast_guards.py @@ -27,11 +27,12 @@ import pytest REPO_ROOT = Path(__file__).resolve().parents[2] # Modules that must NEVER be imported by the fast path. Each one either -# pulls yaml/argparse/logging config or is itself a god-module. +# pulls YAML/argparse/logging config or is itself a god-module. _FORBIDDEN_MODULES = ( "hermes_cli.config", "hermes_cli.main", - "yaml", + "hermes_yaml", + "ruamel.yaml", "argparse", "cli", "run_agent", diff --git a/tests/hermes_cli/test_timestamps_command.py b/tests/hermes_cli/test_timestamps_command.py index 200f432ded..2e87e38837 100644 --- a/tests/hermes_cli/test_timestamps_command.py +++ b/tests/hermes_cli/test_timestamps_command.py @@ -12,7 +12,7 @@ import sys import time from datetime import datetime -import yaml +import hermes_yaml as yaml from hermes_cli.cli_commands_mixin import CLICommandsMixin diff --git a/tests/hermes_cli/test_update_channel.py b/tests/hermes_cli/test_update_channel.py index 3a5ea65618..37bf6f66c0 100644 --- a/tests/hermes_cli/test_update_channel.py +++ b/tests/hermes_cli/test_update_channel.py @@ -161,7 +161,7 @@ class TestSetChannel: return home def test_set_resolve_round_trip(self, tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml home = self._home(tmp_path, monkeypatch) root = tmp_path / "install" @@ -177,7 +177,7 @@ class TestSetChannel: assert resolve_update_channel(written, root) == CHANNEL_STABLE def test_preserves_other_config_and_other_installs(self, tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml home = self._home(tmp_path, monkeypatch) other = tmp_path / "other" @@ -220,7 +220,7 @@ class TestSetChannel: text = (home / "config.yaml").read_text() assert "# my hand-maintained settings" in text assert "# keep this" in text - import yaml + import hermes_yaml as yaml written = yaml.safe_load(text) assert written["model"] == {"provider": "nous"} @@ -313,7 +313,7 @@ class TestSetChannelCLI: def test_metadata_commands_precede_managed_refusal_and_write_once( self, tmp_path, monkeypatch, capsys ): - import yaml + import hermes_yaml as yaml import utils from hermes_cli import config, main diff --git a/tests/hermes_cli/test_update_config_clears_custom_fields.py b/tests/hermes_cli/test_update_config_clears_custom_fields.py index 212848c81e..491ecfb191 100644 --- a/tests/hermes_cli/test_update_config_clears_custom_fields.py +++ b/tests/hermes_cli/test_update_config_clears_custom_fields.py @@ -13,7 +13,7 @@ the persisted value here is safe. from __future__ import annotations -import yaml +import hermes_yaml as yaml from hermes_cli.auth import _update_config_for_provider from hermes_cli.config import clear_model_endpoint_credentials, get_config_path diff --git a/tests/hermes_cli/test_user_providers_model_switch.py b/tests/hermes_cli/test_user_providers_model_switch.py index 963ab9c98f..eff5d57bbc 100644 --- a/tests/hermes_cli/test_user_providers_model_switch.py +++ b/tests/hermes_cli/test_user_providers_model_switch.py @@ -360,7 +360,7 @@ def test_list_authenticated_providers_dedup_honors_base_url_env_override(monkeyp def test_switch_model_resolves_user_provider_credentials(monkeypatch, tmp_path): """/model switch should resolve credentials for providers: dict providers.""" - import yaml + import hermes_yaml as yaml config = { "providers": { @@ -373,7 +373,7 @@ def test_switch_model_resolves_user_provider_credentials(monkeypatch, tmp_path): } config_file = tmp_path / "config.yaml" - config_file.write_text(yaml.dump(config)) + config_file.write_text(yaml.safe_dump(config)) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) # Mock validation to pass diff --git a/tests/hermes_cli/test_venv_sync.py b/tests/hermes_cli/test_venv_sync.py index ea20c90d51..fb0926a38e 100644 --- a/tests/hermes_cli/test_venv_sync.py +++ b/tests/hermes_cli/test_venv_sync.py @@ -147,7 +147,7 @@ def _checkout(tmp_path: Path, name: str = "co") -> Path: def _wire_uv(monkeypatch, tmp_path: Path, exit_code: int = 0) -> Path: """Point venv_sync's managed-uv resolution at a fake binary. - The resolution itself is pm's (pm.ensure.uv) and pm's ledger wiring + The resolution itself is pm's (pm.client.uv) and pm's ledger wiring has its own tests; here the decision layer is under test, so the seam is venv_sync._managed_uv. """ @@ -229,9 +229,13 @@ class TestCheckoutSync: self, tmp_path, monkeypatch ): root = _checkout(tmp_path) - # pm has nothing installed here and may not lazy-install. - monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "empty-store")) - monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + # Runtime provisioning failures are reported through the client seam. + from pm.package import InstallError + + def unavailable(**kwargs): + raise InstallError("pm-runtime", "pinned uv and Python are unavailable") + + monkeypatch.setattr("pm.client.uv", unavailable) out = venv_sync.sync(root) diff --git a/tests/hermes_cli/test_venv_sync_currency.py b/tests/hermes_cli/test_venv_sync_currency.py index bb0e077876..ad3ae0858b 100644 --- a/tests/hermes_cli/test_venv_sync_currency.py +++ b/tests/hermes_cli/test_venv_sync_currency.py @@ -5,9 +5,8 @@ import os from pathlib import Path import subprocess -import yaml +import hermes_yaml as yaml -import pm from hermes_cli import venv_sync from hermes_cli.runtime_paths import install_state_dir, selected_venv from pm import paths @@ -37,7 +36,7 @@ def test_check_uses_real_pm_selection_and_keeps_invalid_evidence(admission_env, return output check('would-sync') - pm.sync_venv(explicit=True) + ensure.sync_venv(explicit=True) facts_path = paths.runtime_facts_path() pristine = facts_path.read_bytes() selected = selected_venv(core) @@ -99,7 +98,9 @@ def test_check_uses_real_pm_selection_and_keeps_invalid_evidence(admission_env, assert not facts_path.exists() -def test_own_tree_sync_reuses_pm_without_writing_an_extra_stamp(admission_env): +def test_own_tree_sync_reuses_pm_without_writing_an_extra_stamp(admission_env, monkeypatch): + # venv_sync imports the public alias; use the same real engine as admission. + monkeypatch.setattr("pm.sync_venv", importlib.import_module("pm.ensure").sync_venv) root, home = admission_env core = root / 'core' assert not venv_sync._stamp_path(core).exists() diff --git a/tests/hermes_cli/test_web_plugins_catalog.py b/tests/hermes_cli/test_web_plugins_catalog.py index f925acf777..79733b0858 100644 --- a/tests/hermes_cli/test_web_plugins_catalog.py +++ b/tests/hermes_cli/test_web_plugins_catalog.py @@ -6,7 +6,7 @@ from __future__ import annotations import json import pytest -import yaml +import hermes_yaml as yaml from hermes_cli import plugin_catalog as pc_cat diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 3eb3207fbc..ad0acfc7e1 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -13,7 +13,7 @@ from types import SimpleNamespace from unittest.mock import patch, MagicMock import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.config import ( reload_env, @@ -1813,7 +1813,7 @@ class TestWebServerEndpoints: def test_numeric_yaml_provider_key_can_be_activated_and_deleted(self): """Hand-edited `providers: 2070:` (YAML int key) must still activate. - PyYAML loads unquoted 2070 as int; string lookup then 404ed, so + YAML loads unquoted 2070 as int; string lookup then 404ed, so Desktop could list the endpoint but not assign or delete it. """ from hermes_cli.config import get_config_path, load_config @@ -1935,7 +1935,7 @@ class TestWebServerEndpoints: secret by the time Save sees it. Migrating it would duplicate the user's secret into a second env var they never asked for. """ - import yaml + import hermes_yaml as yaml from hermes_cli.config import custom_endpoint_key_env, get_config_path, get_env_value diff --git a/tests/hermes_cli/test_web_server_messaging_profiles.py b/tests/hermes_cli/test_web_server_messaging_profiles.py index 193bb4b219..8077ce0415 100644 --- a/tests/hermes_cli/test_web_server_messaging_profiles.py +++ b/tests/hermes_cli/test_web_server_messaging_profiles.py @@ -8,7 +8,7 @@ These tests pin the new behavior: reads and writes land in the REQUESTED profile's HERMES_HOME, and the dashboard's own profile stays untouched. """ import pytest -import yaml +import hermes_yaml as yaml import gateway.status as _gw_status diff --git a/tests/hermes_cli/test_web_server_profile_unification.py b/tests/hermes_cli/test_web_server_profile_unification.py index ce6dd29ec3..5e1bef076a 100644 --- a/tests/hermes_cli/test_web_server_profile_unification.py +++ b/tests/hermes_cli/test_web_server_profile_unification.py @@ -10,7 +10,7 @@ import json from contextlib import contextmanager import pytest -import yaml +import hermes_yaml as yaml import gateway.status as _gw_status import hermes_cli.config as _cfg_mod import hermes_cli.web_server_chat as _web_server_chat diff --git a/tests/hermes_cli/test_web_server_skills_profiles.py b/tests/hermes_cli/test_web_server_skills_profiles.py index e3ce2408c8..99b5d44f9a 100644 --- a/tests/hermes_cli/test_web_server_skills_profiles.py +++ b/tests/hermes_cli/test_web_server_skills_profiles.py @@ -8,7 +8,7 @@ These tests pin the new behavior: reads and writes land in the REQUESTED profile's HERMES_HOME, and the dashboard's own profile stays untouched. """ import pytest -import yaml +import hermes_yaml as yaml import hermes_cli.web_server_gateway as _web_server_gateway import hermes_cli.web_server_profiles as _web_server_profiles diff --git a/tests/hermes_cli/test_web_server_tts_lease.py b/tests/hermes_cli/test_web_server_tts_lease.py index 04956f0757..7bc86da1bb 100644 --- a/tests/hermes_cli/test_web_server_tts_lease.py +++ b/tests/hermes_cli/test_web_server_tts_lease.py @@ -130,7 +130,7 @@ def test_active_default_true(client, monkeypatch): def test_acquire_resolves_provider_inside_target_profile(client, isolated_profiles, monkeypatch): """Warm-up must read the REQUESTING profile's tts config, like /api/audio/speak.""" - import yaml + import hermes_yaml as yaml from tools import tts_tool, tts_tool_lifecycle (isolated_profiles["worker_beta"] / "config.yaml").write_text( diff --git a/tests/plugins/image_gen/test_openai_provider.py b/tests/plugins/image_gen/test_openai_provider.py index 9dc2b64115..e2c8aa77c1 100644 --- a/tests/plugins/image_gen/test_openai_provider.py +++ b/tests/plugins/image_gen/test_openai_provider.py @@ -95,7 +95,7 @@ class TestModelResolution: def test_config_openai_model(self, tmp_path): - import yaml + import hermes_yaml as yaml (tmp_path / "config.yaml").write_text( yaml.safe_dump({"image_gen": {"openai": {"model": "gpt-image-2-low"}}}) ) @@ -183,7 +183,7 @@ class TestGenerate: def test_selection_reaches_image_request( self, provider, monkeypatch, tmp_path, api_model, quality, editing ): - import yaml + import hermes_yaml as yaml tier = api_model if quality == "auto" else f"{api_model}-{quality}" monkeypatch.delenv("OPENAI_IMAGE_MODEL", raising=False) diff --git a/tests/plugins/memory/test_openviking_optional_peer.py b/tests/plugins/memory/test_openviking_optional_peer.py index 23b9521866..6afad77e84 100644 --- a/tests/plugins/memory/test_openviking_optional_peer.py +++ b/tests/plugins/memory/test_openviking_optional_peer.py @@ -7,7 +7,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml import plugins.memory.openviking as ov diff --git a/tests/plugins/test_disk_cleanup_plugin.py b/tests/plugins/test_disk_cleanup_plugin.py index e979c092a3..793d647f0d 100644 --- a/tests/plugins/test_disk_cleanup_plugin.py +++ b/tests/plugins/test_disk_cleanup_plugin.py @@ -373,7 +373,7 @@ class TestSlashCommand: class TestBundledDiscovery: def _write_enabled_config(self, hermes_home, names): """Write plugins.enabled allow-list to config.yaml.""" - import yaml + import hermes_yaml as yaml cfg_path = hermes_home / "config.yaml" cfg_path.write_text(yaml.safe_dump({"plugins": {"enabled": list(names)}})) @@ -393,7 +393,7 @@ class TestBundledDiscovery: def test_disabled_beats_enabled(self, _isolate_env): """plugins.disabled wins even if the plugin is also in plugins.enabled.""" - import yaml + import hermes_yaml as yaml cfg_path = _isolate_env / "config.yaml" cfg_path.write_text(yaml.safe_dump({ "plugins": { diff --git a/tests/plugins/test_langfuse_plugin.py b/tests/plugins/test_langfuse_plugin.py index 7772b0477f..2c4e8a171b 100644 --- a/tests/plugins/test_langfuse_plugin.py +++ b/tests/plugins/test_langfuse_plugin.py @@ -10,7 +10,7 @@ from types import SimpleNamespace import pytest -import yaml +import hermes_yaml as yaml REPO_ROOT = Path(__file__).resolve().parents[2] diff --git a/tests/plugins/test_security_guidance_plugin.py b/tests/plugins/test_security_guidance_plugin.py index a00bafddcd..78e7dfed12 100644 --- a/tests/plugins/test_security_guidance_plugin.py +++ b/tests/plugins/test_security_guidance_plugin.py @@ -265,7 +265,7 @@ class TestPluginDiscovery: def test_loads_via_plugin_manager(self, _isolate_env, monkeypatch): """End-to-end: enable in config.yaml and verify the PluginManager picks it up via the standard discovery path.""" - import yaml + import hermes_yaml as yaml config = {"plugins": {"enabled": ["security-guidance"]}} (_isolate_env / "config.yaml").write_text(yaml.safe_dump(config)) diff --git a/tests/pm/test_activation_setup.py b/tests/pm/test_activation_setup.py index f651a1293c..57c5d07972 100644 --- a/tests/pm/test_activation_setup.py +++ b/tests/pm/test_activation_setup.py @@ -2,6 +2,7 @@ Only the downloaded tool payloads are fixtures: bootstrap uv locates the test interpreter, then delegates every dependency operation to real, offline uv. +The real PM stage builder seeds its locked dependencies online before activation. PM/activation/setup code is copied unmodified; all state is disposable. """ from __future__ import annotations @@ -60,14 +61,14 @@ def test_activation_real_setup_pm_lifecycle(tmp_path, served): "LANG": "C.UTF-8", "PYTHONNOUSERSITE": "1", "UV_OFFLINE": "1", "UV_CACHE_DIR": str(home / ".cache" / "uv"), "UV_PYTHON_DOWNLOADS": "never", } - for name in ("activate", "setup-hermes.sh", "hermes_constants.py", "utils.py"): + for name in ("activate", "setup-hermes.sh", "hermes_constants.py", "hermes_yaml.py", "utils.py"): shutil.copy2(REPO / name, core / name) for name in ("pm", "hermes_cli"): shutil.copytree(REPO / name, core / name, ignore=shutil.ignore_patterns("__pycache__")) # Plugin selection imports the real CLI config reader even with no plugins. # Supply its installed YAML dependency, not a stub parser or config module. - import yaml - shutil.copytree(Path(yaml.__file__).parent, core / "yaml", ignore=shutil.ignore_patterns("__pycache__")) + import ruamel.yaml + shutil.copytree(Path(ruamel.yaml.__file__).parent, core / "ruamel" / "yaml", ignore=shutil.ignore_patterns("__pycache__")) # Never copy real user files: these are deliberately public fixture sentinels. protected = [core / ".env", home / ".local" / "bin", hermes_home / "skills", @@ -97,6 +98,27 @@ def test_activation_real_setup_pm_lifecycle(tmp_path, served): assert locked.returncode == 0, locked.stdout + locked.stderr dependency_lock = (core / "uv.lock").read_bytes() + # Seed PM's own cache with its real locked wheels, not the application's + # fixture wheel or a copied host cache. Discard this environment so source + # still bootstraps and publishes the isolated PM runtime itself, offline. + seed = tmp_path / "pm-seed" + seed_env = {key: value for key, value in env.items() if key != "UV_OFFLINE"} + for key in ("SSL_CERT_FILE", "SSL_CERT_DIR", "NIX_SSL_CERT_FILE"): + if key in os.environ: + seed_env[key] = os.environ[key] + seeded = subprocess.run( + [interpreter, "-I", "-B", "-c", + "import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); " + "from pm.runtime_stage import stage_runtime; " + "stage_runtime(Path(sys.argv[2]), Path(sys.argv[3]), Path(sys.argv[4]), " + "project=Path(sys.argv[1]) / 'pm', offline=False)", + str(core), uv, interpreter, str(seed)], + cwd=tmp_path, env=seed_env, capture_output=True, text=True, timeout=180, + ) + assert seeded.returncode == 0, seeded.stdout + seeded.stderr + shutil.rmtree(seed) + assert not (hermes_home / "installs").exists() + docroot, base_url = served # Both the shell bootstrap and PM's fallback downloader stay on loopback. (core / "pm" / "artifact-mirror.json").write_text( @@ -174,7 +196,9 @@ test "${PYTHONPATH-}" = "$prior_pythonpath" || exit 96 assert probe["version"] == "1.0" assert Path(probe["module"]).is_relative_to(Path(first["environment"])) assert probe["pythonpath"].split(os.pathsep)[0] == str(core) - assert len(operations("venv")) == len(operations("sync")) == 1 + # Cold activation builds both PM's isolated runtime and the app environment. + assert "Preparing the isolated PM runtime" in cold.stderr + assert len(operations("venv")) == len(operations("sync")) == 2 facts = json.loads((runtime / "facts.json").read_text())["packages"] assert facts["python"]["artifacts"] == [first_digest] assert facts["uv"]["artifacts"] == [uv_digest] @@ -185,7 +209,7 @@ test "${PYTHONPATH-}" = "$prior_pythonpath" || exit 96 untouched = _snapshot(protected) activate() assert selection() == first - assert len(operations("venv")) == len(operations("sync")) == 1 + assert len(operations("venv")) == len(operations("sync")) == 2 assert len([line for line in operations("python") if line.startswith("python install ")]) == 2 second_digest = pin_python("second") @@ -194,7 +218,7 @@ test "${PYTHONPATH-}" = "$prior_pythonpath" || exit 96 assert second["stamp"] != first["stamp"] assert second["environment"] != first["environment"] assert Path(first["environment"]).is_dir() - assert len(operations("venv")) == len(operations("sync")) == 2 + assert len(operations("venv")) == len(operations("sync")) == 3 facts = json.loads((runtime / "facts.json").read_text())["packages"] assert facts["python"]["artifacts"] == [second_digest] assert (core / "uv.lock").read_bytes() == dependency_lock @@ -207,5 +231,5 @@ test "${PYTHONPATH-}" = "$prior_pythonpath" || exit 96 assert "setup failed" in failed.stderr assert "CALLER_SURVIVED:" in failed.stdout assert selection() == second - assert len(operations("sync")) == 3 + assert len(operations("sync")) == 4 assert len([line for line in operations("python") if line.startswith("python install ")]) == 4 diff --git a/tests/pm/test_admission_members_locked.py b/tests/pm/test_admission_members_locked.py index dea6301e4a..d739d17d4d 100644 --- a/tests/pm/test_admission_members_locked.py +++ b/tests/pm/test_admission_members_locked.py @@ -10,6 +10,8 @@ def test_admission_reads_other_profiles_after_taking_lock(tmp_path, monkeypatch) import pm.paths as paths ensure = importlib.import_module("pm.ensure") + # Exercise the engine lock ordering, not cross-process transport. + monkeypatch.setattr("pm.client.sync_venv", ensure.sync_venv) monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) repo = tmp_path / "repo" repo.mkdir() diff --git a/tests/pm/test_cold_runtime_e2e.py b/tests/pm/test_cold_runtime_e2e.py new file mode 100644 index 0000000000..7cf7bcbe59 --- /dev/null +++ b/tests/pm/test_cold_runtime_e2e.py @@ -0,0 +1,242 @@ +"""Cold CLI provisioning and repair, with real tools and no mocked child code. + +Only uv/Python are offered by the fixture's loopback archive server. The copied +PM recipe is unchanged; its small locked runtime is fetched from PyPI. The app +recipe is deliberately tiny so this cannot install the production dependency +set. Run through scripts/run_tests.sh with uv/uvx available on PATH. +""" +from __future__ import annotations + +from functools import partial +import hashlib +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tarfile +import threading +import tomllib + +import pytest + + +class _ArchiveHandler(SimpleHTTPRequestHandler): + def copyfile(self, source, outputfile): + # The downloader closes its size probe without consuming the archive. + try: + super().copyfile(source, outputfile) + except (BrokenPipeError, ConnectionResetError): + pass + + +def _run(command, *, cwd, env, expected=0, timeout=240): + result = subprocess.run(command, cwd=cwd, env=env, capture_output=True, + text=True, timeout=timeout) + assert result.returncode == expected, ( + f"{command!r}\nexit={result.returncode}\n{result.stdout}\n{result.stderr}" + ) + return result + + +def _bare(python, repo, code, *, env, expected=0): + return _run( + [str(python), "-I", "-S", "-B", "-c", + f"import sys; sys.path.insert(0, {str(repo)!r});\n" + code], + cwd=repo.parent, env=env, expected=expected, + ) + + +@pytest.mark.platforms("linux") +def test_cold_cli_builds_own_runtime_discovers_plugins_and_repairs_app(tmp_path): + from pm.packages import Python, Uv + from pm.store import current_target, tree_digest + + uv = shutil.which("uv") + if uv is None: + pytest.skip("real uv and uvx must be on PATH") + uv = Path(uv).resolve() + uvx = uv.with_name("uvx") + assert uvx.is_file(), "the real uv distribution must include uvx" + python = Path(sys._base_executable).resolve() + if sys.version_info[:2] != (3, 14): + pytest.skip("the checked-in PM runtime currently requires Python 3.14") + + source = Path(__file__).resolve().parents[2] + repo = tmp_path / "source" + repo.mkdir() + for name in ("pm", "hermes_cli"): + shutil.copytree(source / name, repo / name, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) + for name in ("utils.py", "hermes_constants.py", "hermes_yaml.py", + "hermes_bootstrap.py"): + shutil.copy2(source / name, repo / name) + # No production application lock or metadata enters this source snapshot. + recipe = tomllib.loads((repo / "pm" / "pyproject.toml").read_text()) + yaml_requirement = next(dep for dep in recipe["project"]["dependencies"] + if dep.startswith("ruamel.yaml")) + (repo / "pyproject.toml").write_text( + '[project]\nname="cold-pm-app"\nversion="0.0.0"\n' + 'requires-python=">=3.14,<3.15"\n' + f'dependencies=[{json.dumps(yaml_requirement)}]\n' + '[project.optional-dependencies]\nall=[]\n' + '[tool.uv]\npackage=false\n', encoding="utf-8", + ) + home = tmp_path / "home" + hermes_home = home / ".hermes" + plugin = hermes_home / "plugins" / "cold-proof" + plugin.mkdir(parents=True) + config = hermes_home / "config.yaml" + config.write_text("plugins:\n enabled: [cold-proof]\n", encoding="utf-8") + (plugin / "plugin.yaml").write_text( + "name: cold-proof\nversion: 1.0.0\npip_dependencies: [idna==3.10]\n", + encoding="utf-8", + ) + store = hermes_home / "tools" + scratch = tmp_path / "scratch" + scratch.mkdir() + env = { + "HOME": str(home), "HERMES_HOME": str(hermes_home), + "HERMES_RUNTIME_DIR": str(store), "PATH": os.defpath, + "TMPDIR": str(scratch), "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8", + "UV_PYTHON_DOWNLOADS": "never", "UV_NO_CONFIG": "1", + "UV_CACHE_DIR": str(tmp_path / "seed-cache"), + } + # Keep TLS functional on Nix without inheriting any application settings. + for key in ("SSL_CERT_FILE", "SSL_CERT_DIR", "NIX_SSL_CERT_FILE"): + if key in os.environ: + env[key] = os.environ[key] + _run([str(uv), "lock", "--project", str(repo), "--python", str(python)], + cwd=tmp_path, env=env) + + archives = tmp_path / "archives" + archives.mkdir() + target = current_target() + rows = {} + for package, files in ( + (Python(), [(python, "python/bin/python3")]), + (Uv(), [(uv, "uv-dist/uv"), (uvx, "uv-dist/uvx")]), + ): + archive = archives / f"{package.name}.tar.gz" + with tarfile.open(archive, "w:gz", compresslevel=1) as tar: + for binary, name in files: + tar.add(binary, arcname=name) + version = _run([str(files[0][0]), "--version"], cwd=tmp_path, + env=env).stdout.split()[1] + rows[package.name] = { + "version": version, + "artifacts": {target: {"sha256": hashlib.sha256(archive.read_bytes()).hexdigest()}}, + } + server = ThreadingHTTPServer(("127.0.0.1", 0), + partial(_ArchiveHandler, directory=str(archives))) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + for name, row in rows.items(): + row["artifacts"][target]["url"] = f"http://127.0.0.1:{server.server_port}/{name}.tar.gz" + (repo / "pm" / "lock.json").write_text( + json.dumps({"schema": 1, "packages": rows}), encoding="utf-8", + ) + + bootstrap = """ +import importlib.util +assert importlib.util.find_spec('ruamel') is None +assert importlib.util.find_spec('yaml') is None +assert importlib.util.find_spec('packaging') is None +assert importlib.util.find_spec('idna') is None +""" + cli = "\nsys.argv = ['hermes', 'pm', {action!r}]; import hermes_cli.main\n" + try: + assert not store.exists() + assert not (hermes_home / "installs").exists() + result = _bare(python, repo, bootstrap + cli.format(action="install"), env=env) + assert "✓ venv" in result.stdout + assert "Preparing the isolated PM runtime" in result.stderr + finally: + server.shutdown() + server.server_close() + thread.join(timeout=10) + + report_code = """ +import json +from pathlib import Path +from hermes_cli.runtime_paths import install_state_dir, selected_venv, runtime_facts_path +root = Path(sys.path[0]) +state = install_state_dir(root) +print(json.dumps({'state': str(state), 'app': str(selected_venv(root)), + 'facts': json.loads(runtime_facts_path(root).read_text())})) +""" + report = json.loads(_bare(python, repo, report_code, env=env).stdout) + state, app = Path(report["state"]), Path(report["app"]) + assert state.is_relative_to(hermes_home) + pm_root = state / "pm-runtime" + selection = (pm_root / "selected.json").read_bytes() + runtime = pm_root / json.loads(selection)["generation"] + pm_python = runtime / "bin/python" + assert runtime != app + facts = json.loads((store / "facts.json").read_text())["packages"] + assert set(facts) == {"python", "uv"} + for name, fact in facts.items(): + assert fact["version"] == rows[name]["version"] + assert fact["artifacts"] == [rows[name]["artifacts"][target]["sha256"]] + assert tree_digest(store / fact["entry"]) == fact["digest"] + + probe = _run([str(pm_python), "-I", "-B", "-c", f""" +import importlib.util, json, sys +from pathlib import Path +sys.path.insert(0, {str(repo)!r}) +from pm.workspace import enabled_member_dirs +from ruamel.yaml import YAML +import ruamel.yaml +assert enabled_member_dirs() == [Path({str(plugin)!r})] +assert importlib.util.find_spec('idna') is None +assert importlib.util.find_spec('openai') is None +print(json.dumps({{'yaml': ruamel.yaml.__file__, 'prefix': sys.prefix}})) +"""], cwd=tmp_path, env=env) + pm_report = json.loads(probe.stdout) + assert Path(pm_report["yaml"]).is_relative_to(runtime) + app_code = """ +import json +from pathlib import Path +from hermes_cli.runtime_paths import activate_dependencies, selected_venv +root = Path(sys.path[0]) +activate_dependencies(root) +import ruamel.yaml, idna +from ruamel.yaml import YAML +assert idna.encode('bücher.example') == b'xn--bcher-kva.example' +assert YAML(typ='safe').load('proof: true')['proof'] is True +print(json.dumps({'yaml': ruamel.yaml.__file__, 'idna': idna.__file__, + 'app': str(selected_venv(root))})) +""" + app_report = json.loads(_bare(python, repo, app_code, env=env).stdout) + assert Path(app_report["yaml"]).is_relative_to(app) + assert Path(app_report["idna"]).is_relative_to(app) + initial_fact = report["facts"]["packages"]["venv"] + assert initial_fact["extras"] == ["all"] + lock_before = Path(initial_fact["resolved_lock"]).read_bytes() + assert b'idna' in lock_before + + # Real damage: the app no longer imports, while PM retains its independent YAML. + shutil.rmtree(Path(app_report["yaml"]).parent) + broken = _bare(python, repo, app_code, env=env, expected=1) + assert "ModuleNotFoundError" in broken.stderr + assert "ruamel.yaml" in broken.stderr + repaired = _bare(python, repo, bootstrap + cli.format(action="repair"), env=env) + assert "Restart Hermes" in repaired.stdout + restored = json.loads(_bare(python, repo, app_code, env=env).stdout) + repaired_app = Path(restored["app"]) + assert repaired_app != app + assert Path(restored["yaml"]).is_relative_to(repaired_app) + assert Path(restored["idna"]).is_relative_to(repaired_app) + assert (pm_root / "selected.json").read_bytes() == selection + after = json.loads(_bare(python, repo, report_code, env=env).stdout) + repaired_fact = after["facts"]["packages"]["venv"] + assert repaired_fact["extras"] == initial_fact["extras"] + assert repaired_fact["stamp"] == initial_fact["stamp"] + assert Path(repaired_fact["resolved_lock"]).read_bytes() == lock_before + assert config.read_text() == "plugins:\n enabled: [cold-proof]\n" + assert not (state / ".repair-incomplete").exists() + assert not (repo / "venv").exists() + assert not (repo / ".venv").exists() + print(f"cold PM proof: runtime={runtime}; first_app={app}; repaired_app={repaired_app}") diff --git a/tests/pm/test_custom_root_union.py b/tests/pm/test_custom_root_union.py index 5d10e6a5ae..7df2235968 100644 --- a/tests/pm/test_custom_root_union.py +++ b/tests/pm/test_custom_root_union.py @@ -14,7 +14,7 @@ from __future__ import annotations from pathlib import Path -import yaml +import hermes_yaml as yaml import pm.plugins_state as pstate import pm.workspace as ws diff --git a/tests/pm/test_download_cleanup.py b/tests/pm/test_download_cleanup.py index 42e4e6f021..f2c1f33e90 100644 --- a/tests/pm/test_download_cleanup.py +++ b/tests/pm/test_download_cleanup.py @@ -10,7 +10,7 @@ import pytest import pm from pm import paths, registry -from pm.ensure import stage_only +from pm.ensure import ensure, stage_only from pm.lock import Facts, Lockfile from pm.package import Package from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401 @@ -56,7 +56,7 @@ def install_case(tmp_path, monkeypatch, dl_server): def install(package, mode): if mode == "stage": return stage_only(package.name, pm.current_target()) - pm.ensure(package.name, explicit=True, base_env={}) + ensure(package.name, explicit=True, base_env={}) return pm.installed_package(package.name).path diff --git a/tests/pm/test_extras.py b/tests/pm/test_extras.py index 8c5d9e0886..3f5a513756 100644 --- a/tests/pm/test_extras.py +++ b/tests/pm/test_extras.py @@ -1,21 +1,18 @@ """pm.extras: anchor availability, ensure_import, ensure_and_bind, and the -spec→extra install shim. Network-free — sync_venv is always stubbed (via the -pm.ensure module object; the pm package re-exports the ensure() FUNCTION, -which shadows the submodule attribute for string-path monkeypatching).""" +spec→extra install shim. Network-free — sync_venv is stubbed at the client +seam used by extras; the engine and worker have separate transaction tests.""" from __future__ import annotations -import importlib import sys from types import SimpleNamespace import pytest import pm +import pm.client as client import pm.extras as extras -ensure_mod = importlib.import_module("pm.ensure") - # ---- per-extra platform gates ([tool.hermes.extras-platforms]) ---- @@ -64,7 +61,7 @@ def test_ensure_import_raises_on_gated_off_extra(monkeypatch, synced): @pytest.fixture def synced(monkeypatch): calls: list[list[str]] = [] - monkeypatch.setattr(ensure_mod, "sync_venv", lambda x=None: calls.append(list(x or []))) + monkeypatch.setattr(client, "sync_venv", lambda x=None: calls.append(list(x or []))) return calls @@ -106,7 +103,7 @@ def test_ensure_import_propagates_install_error(monkeypatch): def boom(x=None): raise pm.InstallError("venv", "lazy installs are disabled") - monkeypatch.setattr(ensure_mod, "sync_venv", boom) + monkeypatch.setattr(client, "sync_venv", boom) monkeypatch.setattr(extras, "available", lambda e: False) with pytest.raises(pm.InstallError): extras.ensure_import("fal") @@ -123,7 +120,7 @@ def test_ensure_and_bind_false_on_install_failure(monkeypatch): def boom(x=None): raise pm.InstallError("venv", "nope") - monkeypatch.setattr(ensure_mod, "sync_venv", boom) + monkeypatch.setattr(client, "sync_venv", boom) monkeypatch.setattr(extras, "available", lambda e: False) target: dict = {} assert extras.ensure_and_bind("fal", lambda: {"X": 1}, target) is False diff --git a/tests/pm/test_install_download_control.py b/tests/pm/test_install_download_control.py index c6d9dbd570..beb7d969ee 100644 --- a/tests/pm/test_install_download_control.py +++ b/tests/pm/test_install_download_control.py @@ -13,6 +13,7 @@ import pytest import pm from pm import paths, registry from pm.downloader import DownloadPaused +from pm.ensure import ensure from pm.lock import Facts, Lockfile from pm.package import Package from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401 @@ -60,7 +61,7 @@ def test_install_pause_preserves_archives_and_resumes_the_same_pin(tmp_path, mon pause.set() with pytest.raises(DownloadPaused): - pm.ensure(ComponentPackage.name, explicit=True, progress=progress, pause_event=pause) + ensure(ComponentPackage.name, explicit=True, progress=progress, pause_event=pause) assert Facts(paths.facts_path()).get(ComponentPackage.name) is None assert list(paths.partials_root().glob("*.ranges")) first_requests = [request for request in RangeHandler.ranges_seen if request[0] == "/component-0.zip"] @@ -68,7 +69,7 @@ def test_install_pause_preserves_archives_and_resumes_the_same_pin(tmp_path, mon assert (root / f"fetch-{pins[0]['sha256']}").is_dir() pause.clear() - pm.ensure(ComponentPackage.name, explicit=True, pause_event=pause) + ensure(ComponentPackage.name, explicit=True, pause_event=pause) fact = Facts(paths.facts_path()).get(ComponentPackage.name) assert fact["artifacts"] == [pin["sha256"] for pin in pins] for name, body in contents.items(): @@ -99,7 +100,7 @@ def test_install_progress_covers_all_archives_including_cache(tmp_path, monkeypa store.fetch(pins[0]["url"], pins[0]["sha256"], scratch) ticks = [] stages = [] - pm.ensure(ComponentPackage.name, explicit=True, + ensure(ComponentPackage.name, explicit=True, progress=lambda *args: stages.append(args), download_progress=lambda done, total, ranges: ticks.append((done, total, ranges))) expected = sum(map(len, payloads)) diff --git a/tests/pm/test_installed_package.py b/tests/pm/test_installed_package.py index 4fa46957bd..30c252644a 100644 --- a/tests/pm/test_installed_package.py +++ b/tests/pm/test_installed_package.py @@ -4,12 +4,13 @@ from __future__ import annotations import pm from pm import paths +from pm.ensure import ensure from pm.lock import Lockfile from tests.pm.test_pm_authority import pm_env, served # noqa: F401 def test_installed_lookup_prefers_current_pin_then_recorded_fallback(pm_env, tmp_path, monkeypatch): - pm.ensure("faketool", explicit=True) + ensure("faketool", explicit=True) original = pm.installed_package("faketool") assert original is not None and original.binary.is_file() diff --git a/tests/pm/test_plugin_survival_contract.py b/tests/pm/test_plugin_survival_contract.py index 2a1a334103..9ae8bcb70e 100644 --- a/tests/pm/test_plugin_survival_contract.py +++ b/tests/pm/test_plugin_survival_contract.py @@ -33,7 +33,7 @@ import sys from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml import pm.plugins_state as pstate import pm.workspace as ws @@ -168,7 +168,9 @@ def admission_env(tmp_path, monkeypatch): monkeypatch.setattr(ws.paths, "repo_root", lambda: core) monkeypatch.setattr(ensure, "lazy_installs_allowed", lambda: True) monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) - # Keep the real dependency transaction. Substitute only tool provisioning. + # Exercise the real dependency transaction in-process so the local uv + # fixture owns provisioning; worker transport is covered separately. + monkeypatch.setattr("pm.client.sync_venv", ensure.sync_venv) from pm.packages import uv_env monkeypatch.setattr(ensure, "uv", lambda **kwargs: ( shutil.which("uv"), {**uv_env(kwargs.get("base_env")), "UV_PYTHON": sys.executable}, diff --git a/tests/pm/test_plugins_state.py b/tests/pm/test_plugins_state.py index 48a068f88f..73fb8de8be 100644 --- a/tests/pm/test_plugins_state.py +++ b/tests/pm/test_plugins_state.py @@ -33,7 +33,7 @@ def homes(tmp_path, monkeypatch): def _write_config(home: Path, enabled: list) -> None: - import yaml + import hermes_yaml as yaml config = {"plugins": {"enabled": enabled}} if enabled else {"plugins": {}} with (home / "config.yaml").open("w", encoding="utf-8") as f: @@ -151,7 +151,7 @@ def test_active_memory_provider_joins_union(homes, tmp_path): provider_dir = default_home / "plugins" / "mnemosyne-like" provider_dir.mkdir(parents=True) (provider_dir / "pyproject.toml").write_text("[project]\n", encoding="utf-8") - import yaml + import hermes_yaml as yaml with (default_home / "config.yaml").open("w", encoding="utf-8") as f: yaml.safe_dump( @@ -167,7 +167,7 @@ def test_active_memory_provider_joins_union(homes, tmp_path): def test_memory_provider_without_dir_is_skipped(homes): """memory.provider set but no plugin dir on disk — not a member.""" default_home, _ = homes - import yaml + import hermes_yaml as yaml with (default_home / "config.yaml").open("w", encoding="utf-8") as f: yaml.safe_dump({"memory": {"provider": "ghost-provider"}}, f) @@ -178,7 +178,7 @@ def test_memory_provider_without_dir_is_skipped(homes): def test_memory_provider_already_enabled_not_duplicated(homes): default_home, _ = homes (default_home / "plugins" / "dual").mkdir(parents=True) - import yaml + import hermes_yaml as yaml with (default_home / "config.yaml").open("w", encoding="utf-8") as f: yaml.safe_dump( diff --git a/tests/pm/test_recovery.py b/tests/pm/test_recovery.py index 4421ac489b..a2f0ec301e 100644 --- a/tests/pm/test_recovery.py +++ b/tests/pm/test_recovery.py @@ -16,6 +16,43 @@ from pm.packages import uv_env from tests.pm.test_workspace_build_inputs import _wheel +@pytest.mark.parametrize("failure", [None, "missing_distribution", "broken_module"]) +def test_startup_validation_checks_real_ruamel_dependency(tmp_path, failure): + from importlib.metadata import distribution + import venv + + import ruamel.yaml + + from hermes_cli.runtime_paths import site_packages + from pm.package import InstallError + from pm.recovery import validate_environment + + candidate = tmp_path / "candidate" + venv.EnvBuilder(with_pip=False).create(candidate) + python = candidate / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + target = site_packages(candidate) + package = target / "ruamel" / "yaml" + # Copy the real parser and distribution metadata into an isolated candidate. + # A fake YAML class would not detect a broken install or missing dependency. + ignored = ["__pycache__"] + (["main.py"] if failure == "broken_module" else []) + shutil.copytree(Path(ruamel.yaml.__file__).parent, package, ignore=shutil.ignore_patterns(*ignored)) + installed = distribution("ruamel.yaml") + assert installed.files + metadata = next(Path(installed.locate_file(path)).parent for path in installed.files if path.name == "METADATA") + if failure != "missing_distribution": + shutil.copytree(metadata, target / metadata.name) + + (tmp_path / "pyproject.toml").write_text( + '[project]\nname="yaml-recovery"\ndependencies=["ruamel.yaml"]\n', encoding="utf-8", + ) + + if failure: + with pytest.raises(InstallError, match="ruamel"): + validate_environment(python, env=dict(os.environ), cwd=tmp_path) + else: + validate_environment(python, env=dict(os.environ), cwd=tmp_path) + + @pytest.mark.parametrize("failure", [None, "missing_lock", "corrupt_facts", "empty_environment", "missing_extras", "validation", "publication"]) def test_repair_restores_recorded_plugin_dependencies_without_config(tmp_path, monkeypatch, failure): import pm.paths as paths @@ -122,6 +159,8 @@ def test_uncertain_profile_selection_refuses_sync_but_not_recorded_repair(tmp_pa from hermes_cli.runtime_paths import install_state_dir, selected_venv, site_packages engine = importlib.import_module("pm.ensure") + # Use the same engine for admission and repair with the offline uv fixture. + monkeypatch.setattr("pm.client.sync_venv", engine.sync_venv) uv = shutil.which("uv") assert uv core = tmp_path / "core" diff --git a/tests/pm/test_runtime.py b/tests/pm/test_runtime.py new file mode 100644 index 0000000000..773ee435c0 --- /dev/null +++ b/tests/pm/test_runtime.py @@ -0,0 +1,85 @@ +"""PM's resolver must not depend on the application it is repairing.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + + +def test_pm_runtime_discovers_plugins_without_application_dependencies(tmp_path, monkeypatch): + from pm.runtime import prepare_runtime + + uv = shutil.which("uv") + if uv is None: + pytest.skip("uv is required for the real dependency-runtime test") + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(home / "tools")) + (home / "config.yaml").write_text("plugins:\n enabled: []\n", encoding="utf-8") + repo = Path(__file__).resolve().parents[2] + python = prepare_runtime(Path(uv), Path(sys.executable), tmp_path / "runtime") + env = {k: v for k, v in os.environ.items() if not k.startswith(("PYTHON", "UV_"))} + env.update(HERMES_HOME=str(home), HERMES_RUNTIME_DIR=str(home / "tools")) + # Import real PM, including its production plugin-discovery chain. + code = f""" +import importlib.util, json, sys +sys.path.insert(0, {str(repo)!r}) +from pm.workspace import enabled_member_dirs +from pm.plugins_state import _read_home_config +from pathlib import Path +assert _read_home_config(Path({str(home)!r}))["plugins"]["enabled"] == [] +assert enabled_member_dirs() == [] +assert importlib.util.find_spec("openai") is None +assert importlib.util.find_spec("yaml") is None +print(json.dumps({{"prefix": sys.prefix, "yaml": importlib.util.find_spec("ruamel.yaml").origin}})) +""" + result = subprocess.run([str(python), "-I", "-B", "-c", code], env=env, + capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr + report = json.loads(result.stdout) + assert Path(report["yaml"]).is_relative_to(Path(report["prefix"])) + assert prepare_runtime(Path(uv), Path(sys.executable), tmp_path / "runtime", offline=True) == python + # Repair PM itself from its own lock, without trusting an existing marker. + (Path(report["yaml"]).parent / "main.py").unlink() + repaired = prepare_runtime(Path(uv), Path(sys.executable), tmp_path / "runtime", offline=True) + assert repaired != python + checked = subprocess.run([str(repaired), "-I", "-B", "-c", code], env=env, + capture_output=True, text=True, timeout=30) + assert checked.returncode == 0, checked.stdout + checked.stderr + + +def test_sealed_worker_command_uses_only_its_recorded_site(tmp_path, monkeypatch): + from pm import paths + from pm.runtime import runtime_command + + repo = tmp_path / "payload" / "hermes-agent" + repo.mkdir(parents=True) + (repo.parent / "manifest.json").write_text('{"repo":"hermes-agent"}') + runtime = repo.parent / "pm-runtime" + site = runtime / "site" + site.mkdir(parents=True) + base = repo.parent / "python" + if os.name == "nt": + shutil.copytree(Path(sys.base_prefix), base) + python = base / "python.exe" + else: + base.mkdir() + python = base / "python" + shutil.copy2(Path(sys._base_executable).resolve(), python) + (runtime / "pm-runtime.json").write_text(json.dumps({ + "python": "../python/" + python.name, "sitePackages": "site", + })) + script = repo / "probe.py" + script.write_text("import sys,json; print(json.dumps(sys.path))") + monkeypatch.setattr(paths, "repo_root", lambda: repo) + command = runtime_command(script) + result = subprocess.run(command, cwd=tmp_path, capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stderr + entries = json.loads(result.stdout) + assert str(site) in entries + assert not any(Path(entry).name in {"site-packages", "dist-packages"} for entry in entries) diff --git a/tests/pm/test_runtime_entrypoints.py b/tests/pm/test_runtime_entrypoints.py new file mode 100644 index 0000000000..bad7baff5a --- /dev/null +++ b/tests/pm/test_runtime_entrypoints.py @@ -0,0 +1,85 @@ +"""Bootstrap entrypoints must hand dependency operations to PM's own interpreter.""" +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +def test_cli_dispatches_before_calling_engine(monkeypatch): + from pm import cli, runtime + + calls = [] + monkeypatch.setattr(runtime, "is_runtime", lambda: False) + monkeypatch.setattr(runtime, "run_cli", lambda argv: calls.append(argv) or 19) + monkeypatch.setattr(cli, "cmd_install", lambda args: pytest.fail("caller imported the engine")) + argv = ["install", "venv"] + assert cli.main(argv) == 19 + assert calls == [argv] + + +def test_cli_runtime_executes_without_redispatch(monkeypatch): + from pm import cli, runtime + + monkeypatch.setattr(runtime, "is_runtime", lambda: True) + monkeypatch.setattr(runtime, "run_cli", lambda argv: pytest.fail("recursive PM dispatch")) + monkeypatch.setattr(cli, "cmd_install", lambda args: 7 if args.names == ["venv"] else 1) + assert cli.main(["install", "venv"]) == 7 + + +def test_ci_dependency_phase_uses_isolated_runtime(tmp_path, monkeypatch): + from pm import runtime + from scripts.ci import setup_toolchain + import subprocess + + import importlib + ensure = importlib.import_module("pm.ensure") + + monkeypatch.setattr(ensure, "uv", lambda: pytest.fail("dependency work ran in bootstrap Python")) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) + calls = [] + python = tmp_path / "pm-runtime" / "python" + monkeypatch.setattr(runtime, "is_runtime", lambda: False) + monkeypatch.setattr(runtime, "runtime_python", lambda: python) + monkeypatch.setattr(subprocess, "run", lambda command, **kw: calls.append((command, kw))) + monkeypatch.setenv("PYTHONPATH", str(tmp_path / "app-deps")) + args = SimpleNamespace(home=tmp_path, extras=["dev"], toolchain="all") + setup_toolchain.dependencies(args) + command, options = calls.pop() + assert command[:3] == [str(python), "-I", "-B"] + assert Path(command[3]).name == "setup_toolchain.py" + assert command[4:] == ["dependencies", "--home", str(tmp_path), "--toolchain", "all", "--extras", json.dumps(["dev"])] + assert "PYTHONPATH" not in options["env"] + assert options["check"] is True + assert calls == [] + + +@pytest.mark.parametrize("distribution", ["nix", "docker"]) +def test_packaged_runtime_uses_explicit_stamp_without_tool_downloads(tmp_path, monkeypatch, distribution): + import importlib + from pm import runtime, paths + + engine = importlib.import_module("pm.ensure") + monkeypatch.setattr(engine, "uv", lambda **kw: pytest.fail("packaged PM tried to download tools")) + project = tmp_path / "app" + project.mkdir() + monkeypatch.setattr(paths, "repo_root", lambda: project) + install_root = tmp_path / "package" + install_root.mkdir() + monkeypatch.setenv("HERMES_INSTALL_ROOT", str(install_root)) + pm = install_root / "pm-runtime" + site = pm / "site-packages" + site.mkdir(parents=True) + python = install_root / "python" + python.touch() + (pm / "pm-runtime.json").write_text(json.dumps({"python": str(python), "sitePackages": str(site)})) + stamp = {"distribution": distribution, "pmRuntime": str(pm)} + (install_root / "install-stamp.json").write_text(json.dumps(stamp)) + command = runtime.runtime_command(project / "worker.py", ["argument"]) + assert command[:4] == [str(python), "-I", "-S", "-B"] + assert str(site) in command + assert command[-2:] == [str(project / "worker.py"), "argument"] + (pm / "pm-runtime.json").unlink() + from pm.package import InstallError + with pytest.raises(InstallError, match="PM runtime"): + runtime.runtime_command(project / "worker.py") diff --git a/tests/pm/test_runtime_public.py b/tests/pm/test_runtime_public.py new file mode 100644 index 0000000000..2cfa4b623a --- /dev/null +++ b/tests/pm/test_runtime_public.py @@ -0,0 +1,19 @@ +"""The public PM surface never installs into the caller's Python process.""" +import importlib + + +def test_public_mutations_delegate_but_environment_reads_stay_local(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) + import pm + client = importlib.import_module("pm.client") + engine = importlib.import_module("pm.ensure") + calls = [] + monkeypatch.setattr(client, "_request", lambda operation, arguments, **kw: calls.append(operation)) + monkeypatch.setattr(engine, "ensure", lambda *a, **kw: (_ for _ in ()).throw(AssertionError("inline install"))) + monkeypatch.setattr(engine, "sync_venv", lambda *a, **kw: (_ for _ in ()).throw(AssertionError("inline sync"))) + monkeypatch.setattr(client, "is_runtime", lambda: False, raising=False) + pm.sync_venv(["all"], explicit=True, plugin_dirs=lambda: (_ for _ in ()).throw(AssertionError("inline sync"))) + pm.ensure("node", explicit=True) + assert calls == ["sync_venv", "ensure"] + assert pm.env_for is engine.env_for diff --git a/tests/pm/test_runtime_transaction.py b/tests/pm/test_runtime_transaction.py index d07931a773..c745de289d 100644 --- a/tests/pm/test_runtime_transaction.py +++ b/tests/pm/test_runtime_transaction.py @@ -124,6 +124,6 @@ def test_lazy_import_reports_restart_instead_of_importing_mixed_versions(tmp_pat (candidate / "pyvenv.cfg").write_text("home = test") def prepare(requested): Facts(runtime_facts_path(root)).record_state("venv", "new", requested, environment=candidate) - monkeypatch.setattr(importlib.import_module("pm.ensure"), "sync_venv", prepare) + monkeypatch.setattr("pm.client.sync_venv", prepare) with pytest.raises(InstallError, match="restart"): extras.ensure_import("new-extra") diff --git a/tests/pm/test_runtime_wheelhouse.py b/tests/pm/test_runtime_wheelhouse.py new file mode 100644 index 0000000000..79936b3af1 --- /dev/null +++ b/tests/pm/test_runtime_wheelhouse.py @@ -0,0 +1,112 @@ +"""Exercise offline PM staging with real wheels, not the application's environment.""" +import hashlib +import json +from pathlib import Path +import shutil +import subprocess +import sys +import tomllib +import urllib.request + +from packaging.tags import sys_tags +from packaging.utils import parse_wheel_filename +import pytest + +from pm.runtime import runtime_environment +from pm.runtime_stage import stage_runtime +from scripts.bundles.payload import seal_pm_runtime + + +@pytest.fixture(scope="module") +def locked_wheelhouse(tmp_path_factory): + """Download host wheels first; only the subsequent stage runs offline.""" + wheelhouse = tmp_path_factory.mktemp("pm-wheelhouse") + project = Path(__file__).resolve().parents[2] / "pm" + lock = tomllib.loads((project / "uv.lock").read_text(encoding="utf-8")) + tags = set(sys_tags()) + versions = {} + for package in lock["package"]: + if "registry" not in package.get("source", {}): + continue + choices = [wheel for wheel in package["wheels"] + if parse_wheel_filename(wheel["url"].rsplit("/", 1)[1])[3] & tags] + assert choices, f"no host wheel for {package['name']}" + wheel = choices[0] + with urllib.request.urlopen(wheel["url"], timeout=60) as response: + data = response.read() + assert "sha256:" + hashlib.sha256(data).hexdigest() == wheel["hash"] + (wheelhouse / wheel["url"].rsplit("/", 1)[1]).write_bytes(data) + versions[package["name"]] = package["version"] + return wheelhouse, versions + + +@pytest.fixture +def isolated_builder(tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) + monkeypatch.chdir(tmp_path) + # Neither the caller's config nor a populated uv cache may supply this graph. + (tmp_path / "uv.toml").write_text('required-version = "<0.1"\n', encoding="utf-8") + from pm.packages import uv_cache_dir + assert not any(entry.name != ".seeded" for entry in uv_cache_dir().iterdir()) + uv = shutil.which("uv") + assert uv, "the wheelhouse staging test requires uv" + return Path(uv) + + +@pytest.mark.platforms("linux") +def test_offline_wheelhouse_runtime_survives_sealing_and_move( + tmp_path, isolated_builder, locked_wheelhouse, +): + wheelhouse, versions = locked_wheelhouse + root = tmp_path / "payload" + python = root / "tools/python/bin/python" + python.parent.mkdir(parents=True) + shutil.copy2(Path(sys._base_executable).resolve(), python) + executable = stage_runtime(isolated_builder, python, root / "pm-runtime", + wheelhouse=wheelhouse, offline=True) + assert executable.is_file() + seal_pm_runtime(root, python) + moved = tmp_path / "installed elsewhere" + root.rename(moved) + runtime = moved / "pm-runtime" + marker = json.loads((runtime / "pm-runtime.json").read_text(encoding="utf-8")) + probe = """ +import importlib.metadata, importlib.util, json, sys +sys.path.insert(0, sys.argv[1]) +from packaging.utils import canonicalize_name +from ruamel.yaml import YAML +import packaging, tomli_w, _ruamel_yaml +assert YAML(typ='safe').load('isolated: true') == {'isolated': True} +assert importlib.util.find_spec('openai') is None +assert importlib.util.find_spec('yaml') is None +print(json.dumps({canonicalize_name(d.metadata['Name']): d.version + for d in importlib.metadata.distributions(path=[sys.argv[1]])})) +""" + result = subprocess.run( + [str(runtime / marker["python"]), "-I", "-S", "-B", "-c", probe, + str(runtime / marker["sitePackages"])], + cwd=tmp_path, env=runtime_environment(), capture_output=True, text=True, timeout=30, + ) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == versions + + +@pytest.mark.platforms("linux") +def test_offline_wheelhouse_rejects_missing_transitive_wheel( + tmp_path, isolated_builder, locked_wheelhouse, capfd, +): + from pm.package import InstallError + + wheelhouse, _ = locked_wheelhouse + incomplete = tmp_path / "incomplete wheelhouse" + shutil.copytree(wheelhouse, incomplete) + native_wheel, = incomplete.glob("ruamel_yaml_clib-*.whl") + native_wheel.unlink() + destination = tmp_path / "pm-runtime" + with pytest.raises(InstallError, match="pip exited"): + stage_runtime(isolated_builder, Path(sys.executable), destination, + wheelhouse=incomplete, offline=True) + assert "ruamel-yaml-clib" in capfd.readouterr().err + assert not (destination / "pm-runtime.json").exists() diff --git a/tests/pm/test_update_artifact_refresh.py b/tests/pm/test_update_artifact_refresh.py index c1c6563803..96c1b13980 100644 --- a/tests/pm/test_update_artifact_refresh.py +++ b/tests/pm/test_update_artifact_refresh.py @@ -1,6 +1,7 @@ """Minor-style updates follow advertised artifacts without dropping other pins.""" from __future__ import annotations +from argparse import Namespace import hashlib import importlib import io @@ -65,11 +66,13 @@ def test_same_minor_refresh_updates_real_bytes_and_preserves_unresolved_targets( engine = importlib.import_module("pm.ensure") monkeypatch.setattr(engine, "sync_venv", lambda **kwargs: syncs.append(kwargs)) before = lock.path.read_bytes() - assert cli.main(["update", "rolling-tool", "--check"]) == 1 + args = Namespace(names=["rolling-tool"], target=None, check=True, uv=False, npm=False) + assert cli.cmd_update(args) == 1 assert lock.path.read_bytes() == before and not requests and not syncs assert not store.exists() - assert cli.main(["update", "rolling-tool"]) == 0 + args.check = False + assert cli.cmd_update(args) == 0 after = json.loads(lock.path.read_text(encoding="utf-8"))["packages"] original = json.loads(before)["packages"] assert after["unrelated"] == original["unrelated"] @@ -86,6 +89,7 @@ def test_same_minor_refresh_updates_real_bytes_and_preserves_unresolved_targets( pinned = lock.path.read_bytes() requests.clear() syncs.clear() - for flags in (["--check"], []): - assert cli.main(["update", "rolling-tool", *flags]) == 0 + for check in (True, False): + args.check = check + assert cli.cmd_update(args) == 0 assert lock.path.read_bytes() == pinned and not requests and not syncs diff --git a/tests/pm/test_update_dependency_legs.py b/tests/pm/test_update_dependency_legs.py index 4200cdb4f6..cd42cf355e 100644 --- a/tests/pm/test_update_dependency_legs.py +++ b/tests/pm/test_update_dependency_legs.py @@ -1,4 +1,5 @@ """Optional dependency refreshes run in PM's repository with its installed tools.""" +from argparse import Namespace import importlib import json import os @@ -75,11 +76,13 @@ def test_uv_refresh_uses_real_installed_tool_and_only_the_owned_project(tmp_path syncs = [] ensure = importlib.import_module('pm.ensure') monkeypatch.setattr(ensure, 'sync_venv', lambda **kw: syncs.append(kw)) - assert cli.main(['update', 'manual-fixture', '--uv', '--check']) == 0 + args = Namespace(names=['manual-fixture'], target=None, check=True, uv=True, npm=False) + assert cli.cmd_update(args) == 0 assert not (repo / 'uv.lock').exists() assert not syncs check_output = capsys.readouterr().out - assert cli.main(['update', 'manual-fixture', '--uv']) == 0 + args.check = False + assert cli.cmd_update(args) == 0 assert 'uv lock --upgrade' in check_output assert (repo / 'uv.lock').is_file() assert not (caller / 'uv.lock').exists() @@ -90,12 +93,12 @@ def test_uv_refresh_uses_real_installed_tool_and_only_the_owned_project(tmp_path original = (repo / 'uv.lock').read_bytes() syncs.clear() (repo / 'pyproject.toml').write_text('invalid project [', encoding='utf-8') - assert cli.main(['update', 'manual-fixture', '--uv']) == 1 + assert cli.cmd_update(args) == 1 assert (repo / 'uv.lock').read_bytes() == original and not syncs assert 'uv lock --upgrade failed' in capsys.readouterr().out managed.unlink() - assert cli.main(['update', 'manual-fixture', '--uv']) == 1 + assert cli.cmd_update(args) == 1 assert (repo / 'uv.lock').read_bytes() == original and not syncs assert 'not installed' in capsys.readouterr().out @@ -151,9 +154,11 @@ def test_npm_refresh_uses_its_installed_entry_and_owned_project(monkeypatch, cap monkeypatch.setenv('npm_config_cache', str(root / 'ambient-cache')) before = dict(os.environ) before_lock = lock.path.read_bytes() - assert cli.main(['update', 'manual-fixture', '--npm', '--check']) == 0 + args = Namespace(names=['manual-fixture'], target=None, check=True, uv=False, npm=True) + assert cli.cmd_update(args) == 0 assert not (repo / 'package-lock.json').exists() - assert cli.main(['update', 'manual-fixture', '--npm']) == 0, capsys.readouterr().out + args.check = False + assert cli.cmd_update(args) == 0, capsys.readouterr().out assert json.loads((repo / 'package-lock.json').read_text(encoding='utf-8'))['name'] == 'pm-leg-proof' assert not (caller / 'package-lock.json').exists() assert dict(os.environ) == before @@ -161,17 +166,17 @@ def test_npm_refresh_uses_its_installed_entry_and_owned_project(monkeypatch, cap assert not (root / 'ambient-cache').exists() lock_bytes = (repo / 'package-lock.json').read_bytes() (repo / 'package.json').write_text('not json', encoding='utf-8') - assert cli.main(['update', 'manual-fixture', '--npm']) == 1 + assert cli.cmd_update(args) == 1 assert (repo / 'package-lock.json').read_bytes() == lock_bytes assert 'npm update failed' in capsys.readouterr().out node_binary.rename(node_binary.with_suffix('.held')) - assert cli.main(['update', 'manual-fixture', '--npm']) == 1 + assert cli.cmd_update(args) == 1 assert (repo / 'package-lock.json').read_bytes() == lock_bytes assert 'not installed' in capsys.readouterr().out node_binary.with_suffix('.held').rename(node_binary) npm.binary(npm_entry, target).unlink() - assert cli.main(['update', 'manual-fixture', '--npm']) == 1 + assert cli.cmd_update(args) == 1 assert (repo / 'package-lock.json').read_bytes() == lock_bytes assert 'not installed' in capsys.readouterr().out assert dict(os.environ) == before and lock.path.read_bytes() == before_lock diff --git a/tests/pm/test_update_failure_status.py b/tests/pm/test_update_failure_status.py index ea60703f87..52fe1d954e 100644 --- a/tests/pm/test_update_failure_status.py +++ b/tests/pm/test_update_failure_status.py @@ -1,5 +1,6 @@ """A failed update lookup is not a current result or permission to apply.""" import importlib +from argparse import Namespace import pytest @@ -53,10 +54,8 @@ def test_failed_resolution_stops_every_apply_path(tmp_path, monkeypatch, capsys, packages = [failed, healthy] if mixed else [failed] lock = prepare(tmp_path, monkeypatch, packages) before = lock.path.read_bytes() - args = ["update", *[p.name for p in packages], "--uv", "--npm"] - if check: - args.append("--check") - assert cli.main(args) == 1 + args = Namespace(names=[p.name for p in packages], target=None, check=check, uv=True, npm=True) + assert cli.cmd_update(args) == 1 assert "fixture index unavailable" in capsys.readouterr().out assert lock.path.read_bytes() == before assert not (tmp_path / "tools").exists() @@ -69,7 +68,8 @@ def test_current_and_manual_results_are_successful_without_writes(tmp_path, monk package = UpdateFixture("no-update", versions) lock = prepare(tmp_path, monkeypatch, [package]) before = lock.path.read_bytes() - for flags in (["--check"], []): - assert cli.main(["update", package.name, *flags]) == 0 + for check in (True, False): + args = Namespace(names=[package.name], target=None, check=check, uv=False, npm=False) + assert cli.cmd_update(args) == 0 assert lock.path.read_bytes() == before assert not (tmp_path / "tools").exists() diff --git a/tests/pm/test_worker.py b/tests/pm/test_worker.py new file mode 100644 index 0000000000..2592cd4ba2 --- /dev/null +++ b/tests/pm/test_worker.py @@ -0,0 +1,449 @@ +"""Real isolated workers: no parent imports or callables cross the wire.""" +from __future__ import annotations + +import importlib +import os +from pathlib import Path +import subprocess + +import venv + +import pytest + +from pm import paths +from pm.package import InstallError +from pm.runtime import runtime_python +from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401 + + +@pytest.fixture(scope="module") +def isolated_python(tmp_path_factory): + root = tmp_path_factory.mktemp("pm-python") + venv.EnvBuilder(with_pip=False).create(root) + python = root / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + probe = subprocess.run( + [str(python), "-I", "-c", "import importlib.util; assert importlib.util.find_spec('yaml') is None"], + capture_output=True, text=True, timeout=30, + ) + assert probe.returncode == 0, probe.stderr + return python + + +@pytest.fixture +def client(tmp_path, monkeypatch, isolated_python): + client = importlib.import_module("pm.client") + monkeypatch.setattr("pm.runtime.runtime_python", lambda **kwargs: isolated_python) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "store")) + monkeypatch.setattr(paths, "lockfile_path", lambda: tmp_path / "lock.json") + return client + + +def test_isolated_worker_preserves_install_error(client, monkeypatch): + engine = importlib.import_module("pm.ensure") + monkeypatch.setattr(engine, "ensure", lambda *a, **kw: pytest.fail("engine ran in caller")) + with pytest.raises(InstallError) as caught: + client.ensure("node", explicit=True) + assert caught.value.package == "node" + assert caught.value.cause == "not in the lockfile" + assert caught.value.remedy == "add it with `hermes pm lock --bump`" + assert not paths.facts_path().exists() + + +def test_realized_uv_returns_worker_selected_binaries_and_caller_env(client, tmp_path): + from pm.lock import Facts, Lockfile + from pm.registry import get_package + from pm.store import current_target + + target = current_target() + lock = Lockfile(paths.lockfile_path()) + facts = Facts(paths.facts_path()) + binaries = {} + for name in ("python", "uv"): + package = get_package(name) + entry = paths.store_root() / package.store_entry("1", target) + binary = package.binary(entry, target) + assert binary is not None + binary.parent.mkdir(parents=True, exist_ok=True) + binary.write_bytes(b"installed binary fixture") + if name == "uv": + binary = binary.with_name("uvx" + binary.suffix) + binary.write_bytes(b"installed uvx fixture") + binaries[name] = str(binary) + digest = "a" * 64 + lock.set_pin(name, "1", {target: {"url": "https://unused.invalid/archive", "sha256": digest}}) + facts.record(name, "1", entry.name, {}, paths.store_root(), target=target, artifacts=[digest]) + lock.save() + binary, env = client.uv("uvx", venv=tmp_path / "project-env", base_env={"KEEP": "caller", "PYTHONPATH": "bad"}) + assert binary == binaries["uv"] + assert env["UV_PYTHON"] == binaries["python"] and env["KEEP"] == "caller" + assert env["VIRTUAL_ENV"] == str(tmp_path / "project-env") + assert "PYTHONPATH" not in env + + +def test_refused_or_already_paused_install_does_not_acquire_runtime(client, monkeypatch): + import threading + from pm.downloader import DownloadPaused + + monkeypatch.setattr(client, "runtime_command", lambda path: pytest.fail("refusal acquired PM runtime")) + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + with pytest.raises(InstallError, match="lazy installs are disabled"): + client.ensure("node") + with pytest.raises(InstallError, match="lazy installs are disabled"): + client.uv() + paused = threading.Event() + paused.set() + with pytest.raises(DownloadPaused): + client.ensure("node", explicit=True, pause_event=paused) + + +def _current_environment(tmp_path, monkeypatch, members): + from hermes_cli.runtime_paths import install_state_dir + from pm.lock import Facts + from pm.packages import Venv + + repo = tmp_path / "project" + repo.mkdir() + (repo / "uv.lock").write_text("version = 1\n") + monkeypatch.setattr(paths, "repo_root", lambda: repo) + environment = install_state_dir(repo) / "environments" / "existing" / "venv" + environment.mkdir(parents=True) + (environment / "pyvenv.cfg").write_text("home = test\n") + Facts(paths.runtime_facts_path()).record_state( + "venv", Venv().expected_stamp([], plugin_dirs=members), [], environment=environment, + ) + return repo + + +def _assert_worker_holds_lock(repo): + from hermes_cli.runtime_paths import install_state_dir + from hermes_cli.runtime_state import _lock + + with (install_state_dir(repo) / ".install.lock").open("a+b") as lock: + assert not _lock(lock.fileno(), wait=False), "callback escaped the worker's runtime lock" + + +@pytest.mark.parametrize("explicit", [True, False]) +def test_sync_callbacks_preserve_member_mapping_and_lock(client, tmp_path, monkeypatch, explicit): + identity, staged = tmp_path / "installed", tmp_path / "staged" + staged.mkdir() + (staged / "plugin.yaml").write_text("name: test\n") + members = {identity: staged} + repo = _current_environment(tmp_path, monkeypatch, members) + events = [] + + def select(): + _assert_worker_holds_lock(repo) + events.append("members") + return members + + class Publication: + def __call__(self): + pytest.fail("successful no-op publication was undone") + + def finish(self): + _assert_worker_holds_lock(repo) + events.append("finish") + + def before_publish(): + _assert_worker_holds_lock(repo) + events.append("publish") + return Publication() + + client.sync_venv([], explicit=explicit, plugin_dirs=select, before_publish=before_publish) + assert events == ["members", "publish", "finish"] + + +@pytest.mark.parametrize("current", [True, False]) +@pytest.mark.parametrize("tools_present", [False, True], ids=["cold-tools", "ready-tools"]) +def test_lazy_disabled_sync_does_not_bootstrap_tools(client, tmp_path, monkeypatch, isolated_python, current, tools_present): + import json + from pm import receipt + + repo = _current_environment(tmp_path, monkeypatch, []) + if not current: + (repo / "uv.lock").write_text("version = 2\n") + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + # Exercise runtime acquisition too: the other worker tests supply a ready + # interpreter, which hides an explicit tool install before worker refusal. + monkeypatch.setattr("pm.runtime.runtime_python", runtime_python) + engine = importlib.import_module("pm.ensure") + original_uv = engine.uv + + def uv(*args, **kwargs): + assert kwargs.get("realize") is False, "lazy-disabled sync bootstrapped tools" + if tools_present: + return str(tmp_path / "uv"), {"UV_PYTHON": str(isolated_python)} + return original_uv(*args, **kwargs) + + monkeypatch.setattr(engine, "uv", uv) + monkeypatch.setattr("pm.runtime_stage.stage_runtime", + lambda *a, **kw: pytest.fail("lazy-disabled sync prepared PM runtime")) + selections = [] + + def select(): + _assert_worker_holds_lock(repo) + selections.append("selected") + return [] + + with receipt.worker_context("lazy-disabled-sync"): + with pytest.raises(InstallError, match="lazy installs are disabled") as caught: + client.sync_venv([], plugin_dirs=select) + result = receipt.last_for_update("lazy-disabled-sync", consume=True) + assert caught.value.package == "pm-runtime" + assert selections == [] + assert result is not None + assert result["outcome"] == "failed" + receipts = list((tmp_path / "home" / "logs" / "update_receipts").glob("pm_*.json")) + assert len(receipts) == 1 + assert json.loads(receipts[0].read_text()) == result + assert not paths.facts_path().exists() + + +def test_callback_exception_waits_for_failed_receipt_and_lock_release(client, tmp_path, monkeypatch): + import json + from hermes_cli.runtime_paths import install_state_dir + from hermes_cli.runtime_state import _lock + + repo = _current_environment(tmp_path, monkeypatch, []) + error = LookupError("selection disappeared") + + def fail(): + _assert_worker_holds_lock(repo) + raise error + + with pytest.raises(LookupError) as caught: + client.sync_venv([], explicit=True, plugin_dirs=fail) + assert caught.value is error + receipts = list((tmp_path / "home" / "logs" / "update_receipts").glob("pm_*.json")) + assert len(receipts) == 1 + assert json.loads(receipts[0].read_text())["outcome"] == "failed" + with (install_state_dir(repo) / ".install.lock").open("a+b") as lock: + assert _lock(lock.fileno(), wait=False) + + +def _node_archive(server, body=b"#!/bin/sh\nexit 0\n"): + import hashlib + import io + import zipfile + from pm.lock import Lockfile + from pm.registry import get_package + from pm.store import current_target + + target = current_target() + package = get_package("node") + relative = package.binary(Path("."), target) + assert relative is not None + stream = io.BytesIO() + with zipfile.ZipFile(stream, "w") as archive: + info = zipfile.ZipInfo((Path("node-package") / relative).as_posix()) + info.external_attr = 0o100755 << 16 + archive.writestr(info, body) + payload = stream.getvalue() + RangeHandler.payloads["/node.zip"] = payload + lock = Lockfile(paths.lockfile_path()) + lock.set_pin("node", "1", {target: {"url": url(server, "/node.zip"), + "sha256": hashlib.sha256(payload).hexdigest()}}) + lock.save() + return target, relative, body + + +@pytest.mark.platforms("posix") +@pytest.mark.parametrize("operation", ["ensure", "stage_only"]) +def test_worker_installs_real_archive_and_relays_progress(client, dl_server, operation): + from pm.lock import Facts + + target, relative, body = _node_archive(dl_server) + stages, downloads = [], [] + + def progress(*args): + stages.append(args) + return object() # Notifications never serialize caller-owned return values. + + if operation == "stage_only": + entry = client.stage_only("node", target, progress=progress) + assert isinstance(entry, Path) + assert not paths.facts_path().exists() + else: + base = {"PATH": "/caller/bin", "CALLER": "kept", "PYTHONPATH": "/caller/dependencies"} + runner = client.ensure("node", explicit=True, base_env=base, + progress=lambda *args: stages.append(args), + download_progress=lambda *args: downloads.append(args)) + fact = Facts(paths.facts_path()).get("node") + entry = paths.store_root() / fact["entry"] + assert runner.env["CALLER"] == "kept" + assert runner.env["PYTHONPATH"] == base["PYTHONPATH"] + assert runner.env["PATH"].endswith(base["PATH"]) + assert downloads and downloads[-1][0] == downloads[-1][1] + assert all(isinstance(row, tuple) for rows in downloads[-1][2].values() for row in rows) + assert (entry / relative).read_bytes() == body + assert stages and stages[0][0] == "download" + + +@pytest.mark.platforms("posix") +@pytest.mark.parametrize("from_progress", [False, True]) +def test_pause_event_reaches_running_worker_without_hanging(client, dl_server, monkeypatch, from_progress): + import threading + from pm.downloader import DownloadPaused + from pm.lock import Facts + + _node_archive(dl_server, b"#!/bin/sh\nexit 0\n#" + b"x" * (8 << 20)) + RangeHandler.slow_per_chunk = 0.03 + pause, transferring = threading.Event(), threading.Event() + original = RangeHandler.do_GET + + def get(handler): + if handler.headers.get("Range") not in (None, "bytes=0-0"): + transferring.set() + original(handler) + + monkeypatch.setattr(RangeHandler, "do_GET", get) + + def cancel(): + assert transferring.wait(10), "worker never began transferring" + pause.set() + + thread = None + progress = None + if from_progress: + def progress(stage, done, total, label): + if stage == "download" and done: + pause.set() + else: + thread = threading.Thread(target=cancel) + thread.start() + try: + with pytest.raises(DownloadPaused): + client.ensure("node", explicit=True, progress=progress, pause_event=pause) + finally: + if thread is not None: + thread.join(timeout=15) + assert not thread.is_alive() + assert Facts(paths.facts_path()).get("node") is None + + +@pytest.mark.platforms("posix") +def test_installed_tool_needs_no_pm_runtime(client, dl_server, monkeypatch): + _node_archive(dl_server) + client.ensure("node", explicit=True) + monkeypatch.setattr("pm.runtime.runtime_python", lambda: pytest.fail("hot path bootstrapped PM")) + assert client.ensure("node", base_env={"PATH": "caller"}).env["PATH"].endswith("caller") + + +def test_uv_probe_needs_no_pm_runtime(client, monkeypatch): + monkeypatch.setattr("pm.runtime.runtime_python", lambda: pytest.fail("probe bootstrapped PM")) + binary, env = client.uv(realize=False, venv=Path("project-venv"), base_env={"KEPT": "yes"}) + assert binary is None + assert env["KEPT"] == "yes" and env["VIRTUAL_ENV"] == "project-venv" + + +def test_worker_receipt_is_exact_even_if_latest_is_replaced(client, tmp_path, monkeypatch): + import json + from pm import receipt + + _current_environment(tmp_path, monkeypatch, []) + original_accept = receipt.accept_worker_receipt + received = [] + + def accept(data, update_id): + # Simulate a different process publishing after this worker completes. + point = tmp_path / "home" / "logs" / "update_receipts" / "latest.json" + point.write_text(json.dumps({"update_id": "unrelated", "outcome": "failed"})) + received.append(data) + original_accept(data, update_id) + + monkeypatch.setattr(receipt, "accept_worker_receipt", accept) + with receipt.worker_context("my-update"): + client.sync_venv([], explicit=True, plugin_dirs=[]) + result = receipt.last_for_update("my-update", consume=True) + assert received and result == received[0] + assert result["update_id"] == "my-update" and result["outcome"] == "ok" + + +def _patch_worker_apply(client, monkeypatch, isolated_python, body): + """Fault injection at the build boundary, not an alternate transport/engine.""" + import textwrap + + worker = Path(client.__file__).with_name("worker.py") + script = ( + "import os, runpy, sys\n" + f"sys.path.insert(0, {str(worker.parent.parent)!r})\n" + "from pm.packages import Venv\n" + "from pm.workspace import ResolutionConflict\n" + "def apply(self, *args, **kwargs):\n" + + textwrap.indent(body, " ") + "\n" + "Venv.apply = apply\n" + f"runpy.run_path({str(worker)!r}, run_name='__main__')\n" + ) + monkeypatch.setattr(client, "runtime_command", lambda path: [str(isolated_python), "-I", "-B", "-c", script]) + + +def test_resolution_conflict_survives_worker_and_receipt(client, tmp_path, monkeypatch, isolated_python, capfd): + from pm import receipt + from pm.workspace import ResolutionConflict + + repo = _current_environment(tmp_path, monkeypatch, []) + (repo / "uv.lock").write_text("version = 2\n") + _patch_worker_apply(client, monkeypatch, isolated_python, + "print('engine stdout', flush=True)\n" + "os.write(1, b'native stdout\\n')\n" + "raise ResolutionConflict('venv', 'impossible union', 'change member')") + with receipt.worker_context("conflict-update"): + with pytest.raises(ResolutionConflict) as caught: + client.sync_venv([], explicit=True, plugin_dirs=[]) + result = receipt.last_for_update("conflict-update", consume=True) + assert (caught.value.package, caught.value.cause, caught.value.remedy) == ( + "venv", "impossible union", "change member") + assert result["outcome"] == "failed" + assert "engine stdout" in capfd.readouterr().err + + +def test_failed_finish_runs_undo_before_propagating_callback_exception(client, tmp_path, monkeypatch, isolated_python): + repo = _current_environment(tmp_path, monkeypatch, []) + (repo / "uv.lock").write_text("version = 2\n") + _patch_worker_apply(client, monkeypatch, isolated_python, "return {}") + events = [] + error = LookupError("finish failed") + + class Publication: + def __call__(self): + _assert_worker_holds_lock(repo) + events.append("undo") + return object() # Return values of effect-only callbacks are ignored. + + def finish(self): + _assert_worker_holds_lock(repo) + events.append("finish") + raise error + + def publish(): + _assert_worker_holds_lock(repo) + events.append("publish") + return Publication() + + with pytest.raises(LookupError) as caught: + client.sync_venv([], explicit=True, plugin_dirs=[], before_publish=publish) + assert caught.value is error + assert events == ["publish", "finish", "undo"] + + +def test_invalid_arguments_keep_the_engine_exception_type(client): + with pytest.raises(ValueError, match="repair restores"): + client.sync_venv([], repair=True) + with pytest.raises(ValueError, match="unknown uv executable"): + client.uv("not-uv") + with pytest.raises(KeyError): + client.ensure("no-such-package", explicit=True) + + +def test_worker_death_reports_transport_failure(client, monkeypatch, isolated_python): + monkeypatch.setattr(client, "runtime_command", lambda path: [str(isolated_python), "-I", "-c", "import os; os._exit(7)"]) + with pytest.raises(InstallError, match="worker.*result"): + client.ensure("node", explicit=True) + + +def test_unknown_worker_operation_is_not_dispatched(client): + with pytest.raises((KeyError, RuntimeError), match="activate"): + client._request("activate", {}) + assert not paths.facts_path().exists() diff --git a/tests/pm/test_worker_receipts.py b/tests/pm/test_worker_receipts.py new file mode 100644 index 0000000000..396caa9115 --- /dev/null +++ b/tests/pm/test_worker_receipts.py @@ -0,0 +1,26 @@ +"""Worker receipts cross the process seam by identity, never latest.json.""" +import contextvars + +import pytest + +from pm import receipt + + +def test_worker_receipt_is_attributed_only_to_its_invoking_update(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + def worker(): + with receipt.worker_context("update-a"): + token = receipt.begin("sync") + receipt.record_step("dependency-sync", False, "candidate refused") + receipt.finalize("failed", 1, token=token) + return receipt.last_completed() + + data = contextvars.Context().run(worker) + assert data["update_id"] == "update-a" + assert data["outcome"] == "failed" + receipt.accept_worker_receipt(data, "update-a") + assert receipt.last_for_update("update-a", consume=True) == data + assert receipt.last_for_update("update-b") is None + with pytest.raises(ValueError, match="correlation"): + receipt.accept_worker_receipt(data, "update-b") diff --git a/tests/pm/test_worker_registry.py b/tests/pm/test_worker_registry.py new file mode 100644 index 0000000000..b9c43ddf11 --- /dev/null +++ b/tests/pm/test_worker_registry.py @@ -0,0 +1,218 @@ +"""Registered package declarations survive a fresh, isolated interpreter.""" +from __future__ import annotations + +import hashlib +import importlib +import importlib.util +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tarfile +import textwrap +import venv + +import pytest + +import pm +from pm import registry +from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401 + + +@pytest.fixture(autouse=True) +def restore_registry(monkeypatch): + monkeypatch.setattr(registry, "_packages", dict(registry._packages)) + + +@pytest.fixture(scope="module") +def worker_python(tmp_path_factory): + environment = tmp_path_factory.mktemp("registry-worker-python") + venv.EnvBuilder(with_pip=False).create(environment) + return environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + + +@pytest.mark.parametrize("operation", ["ensure", "stage_only"]) +def test_registered_package_installs_archive_in_real_worker(tmp_path, monkeypatch, worker_python, dl_server, operation): + from pm import paths + + monkeypatch.setattr("pm.runtime.runtime_python", lambda: worker_python) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "store")) + monkeypatch.setattr(paths, "lockfile_path", lambda: tmp_path / "lock.json") + source = tmp_path / "package.py" + source.write_text(textwrap.dedent("""\ + import os + from pm import Package, register + + @register + class ArchivePackage(Package): + name = "registry-worker-archive" + + def stage(self, store, staged, version, target): + (staged / "worker-pid").write_text(str(os.getpid())) + + def verify(self, entry, target): + return '' if (entry / 'payload.txt').read_text() == 'plugin archive' else 'bad payload' + """)) + _load_file(source, monkeypatch) + payload = b"plugin archive" + stream = io.BytesIO() + with tarfile.open(fileobj=stream, mode="w:gz") as archive: + member = tarfile.TarInfo("payload.txt") + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + data = stream.getvalue() + RangeHandler.payloads["/plugin.tar.gz"] = data + target = pm.current_target() + lock = pm.Lockfile(paths.lockfile_path()) + lock.set_pin("registry-worker-archive", "1", {target: { + "url": url(dl_server, "/plugin.tar.gz"), "sha256": hashlib.sha256(data).hexdigest(), + }}) + lock.save() + engine = importlib.import_module("pm.ensure") + monkeypatch.setattr(engine, operation, lambda *a, **kw: pytest.fail("install ran in caller")) + if operation == "ensure": + pm.ensure("registry-worker-archive", explicit=True) + entry = paths.store_root() / pm.Facts(paths.facts_path()).get("registry-worker-archive")["entry"] + else: + from pm.client import stage_only + entry = stage_only("registry-worker-archive", target) + assert (entry / "payload.txt").read_bytes() == payload + assert int((entry / "worker-pid").read_text()) != os.getpid() + + +def _load_file(path, monkeypatch, name="worker_registry_package"): + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + monkeypatch.setitem(sys.modules, name, module) + spec.loader.exec_module(module) + return module + + +def _child(definitions, code, *, setup=""): + root = Path(pm.__file__).resolve().parent.parent + script = ( + "import json, sys\n" + f"sys.path.insert(0, {str(root)!r})\n" + "from pm.registry import load_package_definitions, get_package\n" + "from pm.package import InstallError\n" + + setup + "\n" + "load_package_definitions(json.loads(sys.stdin.read()))\n" + + textwrap.dedent(code) + ) + return subprocess.run( + [sys.executable, "-I", "-B", "-c", script], + input=json.dumps(definitions), capture_output=True, text=True, timeout=30, + ) + + +@pytest.mark.parametrize("failure", ["dependency", "collision", "name", "not-class"]) +def test_definition_import_failures_are_package_errors(tmp_path, monkeypatch, failure): + source = tmp_path / "package.py" + source.write_text("from pm import Package\nclass ExternalPackage(Package):\n name = 'broken-worker-test'\n") + module = _load_file(source, monkeypatch) + pm.register(module.ExternalPackage) + definitions = registry.package_definitions([module.ExternalPackage.name]) + setup = "" + if failure == "dependency": + source.write_text( + "from pathlib import Path\n" + f"with Path({str(tmp_path / 'imports')!r}).open('a') as log: log.write('imported\\n')\n" + "import no_such_plugin_dependency\n" + ) + setup = f"sys.path.insert(0, {str(tmp_path)!r})" + # Make the module importable, so an internal ModuleNotFoundError must + # not trigger a second attempt through its source path. + definitions[0]["module"] = "package" + elif failure == "collision": + definitions[0]["module"] = "pm.package" + elif failure == "name": + source.write_text("from pm import Package\nclass ExternalPackage(Package):\n name = 'changed-name'\n") + else: + source.write_text("ExternalPackage = object()\n") + result = _child(definitions, "", setup=setup) + assert result.returncode != 0 + assert "InstallError: broken-worker-test:" in result.stderr + assert "package definition" in result.stderr + if failure == "dependency": + assert (tmp_path / "imports").read_text().splitlines() == ["imported"] + assert "no_such_plugin_dependency" in result.stderr + if failure == "collision": + assert "different source" in result.stderr + + +@pytest.mark.parametrize("kind", ["local", "unbound", "main"]) +def test_non_importable_registration_fails_with_package_remedy(kind): + class LocalPackage(pm.Package): + name = "local-worker-test" + + if kind == "unbound": + LocalPackage.__qualname__ = "NotAModuleAttribute" + elif kind == "main": + LocalPackage.__module__ = "__main__" + pm.register(LocalPackage) + with pytest.raises(pm.InstallError) as caught: + registry.package_definitions([LocalPackage.name]) + assert caught.value.package == LocalPackage.name + assert "definition" in caught.value.cause + assert "module-level" in caught.value.remedy + # An unrelated non-importable definition cannot break a built-in install. + assert registry.package_definitions(["node"]) == [] + + +def test_file_registered_package_runs_its_own_definition_in_child(tmp_path, monkeypatch): + source = tmp_path / "package.py" + source.write_text(textwrap.dedent("""\ + from pm import Package, InstallError, register + + @register + class ExternalPackage(Package): + name = "external-worker-test" + + def fetch_url(self, version, target): + raise InstallError(self.name, "external definition reached", "plugin remedy") + """)) + module = _load_file(source, monkeypatch) + # Public registration also works without a decorator at import time. + pm.register(module.ExternalPackage) + definitions = registry.package_definitions() + assert [item["name"] for item in definitions] == [module.ExternalPackage.name] + result = _child(definitions, """\ + package = get_package('external-worker-test') + try: + package.fetch_url('1', 'linux-x64') + except InstallError as exc: + print(json.dumps([exc.package, exc.cause, exc.remedy])) + else: + raise AssertionError('custom package was not loaded') + """) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [ + "external-worker-test", "external definition reached", "plugin remedy", + ] + + +def test_namespaced_definition_keeps_relative_sibling_imports(tmp_path, monkeypatch): + import types + + root = types.ModuleType("_pm_test_plugins") + root.__path__ = [str(tmp_path)] + monkeypatch.setitem(sys.modules, root.__name__, root) + plugin = tmp_path / "example" + plugin.mkdir() + (plugin / "__init__.py").write_text("PREFIX = 'namespaced-'\n") + (plugin / "sibling.py").write_text("NAME = 'namespaced-worker-test'\n") + package = types.ModuleType("_pm_test_plugins.example") + package.__path__ = [str(plugin)] + package.__file__ = str(plugin / "__init__.py") + package.PREFIX = "namespaced-" + monkeypatch.setitem(sys.modules, package.__name__, package) + source = plugin / "packages.py" + source.write_text("from .sibling import NAME\nfrom . import PREFIX\nassert NAME.startswith(PREFIX)\nfrom pm import Package, register\n" + "@register\nclass ExternalPackage(Package):\n name = NAME\n") + module = _load_file(source, monkeypatch, "_pm_test_plugins.example.packages") + definitions = registry.package_definitions([module.ExternalPackage.name]) + result = _child(definitions, "assert get_package('namespaced-worker-test').name == 'namespaced-worker-test'") + assert result.returncode == 0, result.stderr diff --git a/tests/scripts/test_bundle_native.py b/tests/scripts/test_bundle_native.py index 0ec8cb113c..f72c570ab8 100644 --- a/tests/scripts/test_bundle_native.py +++ b/tests/scripts/test_bundle_native.py @@ -19,12 +19,21 @@ from scripts.bundles import native def test_bundle_stages_git_tree_and_runs_native_children_before_manifest(tmp_path, monkeypatch): from hermes_cli.runtime_paths import site_packages - interpreter = tmp_path / "staged-python" - subprocess.run([sys.executable, "-m", "venv", "--without-pip", str(interpreter)], - capture_output=True, check=True, timeout=60) - target_python = interpreter / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + output = tmp_path / "payload" + target_python = output / "staged-python" / ("python.exe" if os.name == "nt" else "bin/python") + target_python.parent.mkdir(parents=True) + # PM seals a payload-owned base interpreter, not an external venv launcher. + # The POSIX host supplies its stdlib; Windows needs it beside the executable. + if os.name == "nt": + shutil.copytree(Path(sys.base_prefix), target_python.parent, dirs_exist_ok=True) + else: + shutil.copy2(Path(getattr(sys, "_base_executable")).resolve(), target_python) repo = tmp_path / "repo" repo.mkdir() + pm_project = Path(__file__).resolve().parents[2] / "pm" + (repo / "pm").mkdir() + for name in ("pyproject.toml", "uv.lock"): + shutil.copy2(pm_project / name, repo / "pm" / name) (repo / "pyproject.toml").write_text('[project]\nname="fixture"\nversion="1.0.0"\nrequires-python=">=3.11"\n[project.optional-dependencies]\npayloadtest=[]\n[tool.uv]\npackage=false\n', encoding="utf-8") uv = shutil.which("uv") assert uv, "native bundle test requires uv" @@ -33,7 +42,6 @@ def test_bundle_stages_git_tree_and_runs_native_children_before_manifest(tmp_pat subprocess.run(["git", "init", str(repo)], check=True, capture_output=True) subprocess.run(["git", "add", "."], cwd=repo, check=True) subprocess.run(["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "fixture"], cwd=repo, check=True, capture_output=True) - output = tmp_path / "payload" monkeypatch.setattr("pm.paths.repo_root", lambda: repo) monkeypatch.setattr(native, "_bundle_package_names", lambda: []) monkeypatch.setattr(native, "_install_names", lambda names: 0) @@ -44,7 +52,7 @@ def test_bundle_stages_git_tree_and_runs_native_children_before_manifest(tmp_pat monkeypatch.setattr(native, "_arch_guard", lambda store: []) monkeypatch.setattr("scripts.bundles.payload.relativize_links", lambda root: 0) monkeypatch.setattr("pm.extras.ANCHORS", {"payloadtest": "bundle_probe.present"}) - monkeypatch.setattr("pm.packages.uv_cache_dir", lambda: tmp_path / "empty-cache") + monkeypatch.setattr("pm.packages.uv_cache_dir", lambda: tmp_path / "cache") real_run = native._run_live calls = [] witness = tmp_path / "inventory-python.json" @@ -53,6 +61,9 @@ def test_bundle_stages_git_tree_and_runs_native_children_before_manifest(tmp_pat def child(argv, *, cwd, env): calls.append(argv[1]) assert not (output / "manifest.json").exists() + marker = json.loads((output / "pm-runtime/pm-runtime.json").read_text()) + assert (output / "pm-runtime" / marker["python"]).resolve() == target_python + assert (output / "pm-runtime" / marker["sitePackages"]).is_dir() result = real_run(argv, cwd=cwd, env=env) if argv[1] == "sync" and result[0] == 0: site = site_packages(output / "venv") diff --git a/tests/scripts/test_pm_runtime_bundle.py b/tests/scripts/test_pm_runtime_bundle.py new file mode 100644 index 0000000000..4e6dbe1e69 --- /dev/null +++ b/tests/scripts/test_pm_runtime_bundle.py @@ -0,0 +1,144 @@ +"""A payload carries PM's independent dependency graph, not the app's imports.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + + +def _exercise_relocated_pm_runtime(tmp_path, monkeypatch): + from scripts.bundles import native + + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) + uv = shutil.which("uv") + assert uv, "the packaging test requires uv" + root = tmp_path / "build" + repo = root / "hermes-agent" + source = Path(__file__).resolve().parents[2] + shutil.copytree(source / "pm", repo / "pm", ignore=shutil.ignore_patterns("__pycache__")) + (repo / "hermes_cli").mkdir() + for name in ("__init__.py", "runtime_paths.py", "runtime_state.py"): + shutil.copy2(source / "hermes_cli" / name, repo / "hermes_cli" / name) + shutil.copy2(source / "hermes_constants.py", repo / "hermes_constants.py") + # Copy the base executable, not a venv's launcher. The test host provides + # its stdlib; production's package stage provides the complete distribution. + python = root / "tools" / "python" / ("python.exe" if os.name == "nt" else "bin/python") + python.parent.mkdir(parents=True) + if os.name == "nt": + shutil.copytree(Path(sys.base_prefix), python.parent, dirs_exist_ok=True) + else: + shutil.copy2(Path(sys._base_executable).resolve(), python) + stage = getattr(native, "stage_pm_runtime", None) + assert callable(stage), "native payload has no isolated PM runtime stage" + stage(root, Path(uv), python, repo) + stage(root, Path(uv), python, repo, offline=True) + (root / "manifest.json").write_text(json.dumps({"repo": "hermes-agent"})) + assert not (repo / ".venv").exists() + moved = tmp_path / "installed elsewhere" + root.rename(moved) + runtime = moved / "pm-runtime" + manifest = json.loads((runtime / "pm-runtime.json").read_text()) + base = runtime / manifest["python"] + site = runtime / manifest["sitePackages"] + assert base.is_file() and site.is_dir() + assert not Path(manifest["python"]).is_absolute() + probe = """ +import importlib.util, json, sys +sys.path.insert(0, sys.argv[1]) +from ruamel.yaml import YAML +import packaging, tomli_w +assert importlib.util.find_spec('openai') is None +assert importlib.util.find_spec('yaml') is None +print(json.dumps(YAML(typ='safe').load('isolated: true'))) +""" + checked = subprocess.run([str(base), "-I", "-S", "-B", "-c", probe, str(site)], + cwd=tmp_path, capture_output=True, text=True, timeout=30) + assert checked.returncode == 0, checked.stderr + assert json.loads(checked.stdout) == {"isolated": True} + from pm import runtime as runtime_api, paths + monkeypatch.setattr(paths, "repo_root", lambda: moved / "hermes-agent") + command = runtime_api.runtime_command(moved / "hermes-agent/pm/launch.py", ["status"]) + checked = subprocess.run(command, cwd=tmp_path, env=runtime_api.runtime_environment(), + capture_output=True, text=True, timeout=30) + assert checked.returncode == 0, checked.stderr + assert "no pm sync receipt" in checked.stdout + assert str(root) not in (runtime / "pyvenv.cfg").read_text() + for link in (runtime / "bin").glob("python*"): + if link.is_symlink(): + assert not os.path.isabs(os.readlink(link)) + assert link.exists() + + +@pytest.mark.platforms("posix") +def test_native_pm_runtime_survives_payload_move(tmp_path, monkeypatch): + _exercise_relocated_pm_runtime(tmp_path, monkeypatch) + + +@pytest.mark.platforms("windows") +def test_resident_pm_bypasses_windows_redirector_after_move(tmp_path, monkeypatch): + _exercise_relocated_pm_runtime(tmp_path, monkeypatch) + + +@pytest.mark.parametrize("poison", ["cwd", "global"]) +def test_pm_builder_ignores_ambient_uv_configuration(tmp_path, monkeypatch, poison): + from pm.runtime_stage import stage_runtime + + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.setenv("APPDATA", str(tmp_path / "config")) + monkeypatch.chdir(tmp_path) + config = tmp_path / "uv.toml" if poison == "cwd" else tmp_path / "config/uv/uv.toml" + config.parent.mkdir(parents=True, exist_ok=True) + config.write_text('required-version = "<0.1"\n', encoding="utf-8") + uv = shutil.which("uv") + assert uv + executable = stage_runtime(Path(uv), Path(sys.executable), tmp_path / "runtime") + assert executable.is_file() + + +def test_native_stage_builds_pm_before_application_environment(tmp_path, monkeypatch): + from scripts.bundles import native, payload + from types import SimpleNamespace + + class StopAfterPM(Exception): + pass + + class Facts: + def __init__(self, *args, **kwargs): + pass + + def retain(self, names): + pass + + def entries_in_use(self): + return set() + + def get(self, name): + return {"entry": "python"} + + calls = [] + monkeypatch.setattr(payload, "snapshot", lambda *args: None) + monkeypatch.setattr(native, "_bundle_package_names", lambda: []) + monkeypatch.setattr(native, "_install_names", lambda names: 0) + monkeypatch.setattr(native, "Facts", Facts) + monkeypatch.setattr(native, "_facts", Facts) + monkeypatch.setattr(native, "pm_uv", lambda: ("uv", {})) + monkeypatch.setattr(native, "_store", lambda: SimpleNamespace(entry=lambda name: tmp_path / "tools" / name)) + monkeypatch.setattr(native, "get_package", lambda name: SimpleNamespace(binary=lambda path, target: path / "python")) + + def staged(root, uv, python, repo): + calls.append((root, uv, python, repo)) + raise StopAfterPM + + monkeypatch.setattr(native, "stage_pm_runtime", staged) + monkeypatch.setattr(native, "_run_live", lambda *args, **kwargs: pytest.fail("app sync ran before PM stage")) + with pytest.raises(StopAfterPM): + native.stage_native(SimpleNamespace(out=str(tmp_path), ref="HEAD")) + assert calls == [(tmp_path, Path("uv"), tmp_path / "tools/python/python", tmp_path / "hermes-agent")] diff --git a/tests/scripts/test_release_darwin.py b/tests/scripts/test_release_darwin.py index 4f818e6394..195bd03197 100644 --- a/tests/scripts/test_release_darwin.py +++ b/tests/scripts/test_release_darwin.py @@ -10,7 +10,7 @@ import os import tempfile import pytest -import yaml +import hermes_yaml as yaml from scripts.releases import darwin, r2 from tests.scripts.test_release_r2 import r2_server # noqa: F401 — loopback fixture diff --git a/tests/scripts/test_setup_toolchain.py b/tests/scripts/test_setup_toolchain.py index 8ade61c765..e7c30f5a30 100644 --- a/tests/scripts/test_setup_toolchain.py +++ b/tests/scripts/test_setup_toolchain.py @@ -46,6 +46,9 @@ def test_development_setup_keeps_test_groups_out_of_the_runtime(tmp_path, monkey import importlib engine = importlib.import_module("pm.ensure") monkeypatch.setattr(engine, "uv", lambda **kwargs: (uv, dict(environment))) + # This test exercises the worker-side CI environment split with offline uv. + # Dispatch into that worker is covered by test_runtime_entrypoints. + monkeypatch.setattr("pm.runtime.is_runtime", lambda: True) files = {name: tmp_path / name for name in ("GITHUB_ENV", "GITHUB_OUTPUT", "GITHUB_PATH")} for name, file in files.items(): monkeypatch.setenv(name, str(file)) diff --git a/tests/scripts/test_validate_plugin_catalog.py b/tests/scripts/test_validate_plugin_catalog.py index 7b369c96ec..3305f5d59a 100644 --- a/tests/scripts/test_validate_plugin_catalog.py +++ b/tests/scripts/test_validate_plugin_catalog.py @@ -1,7 +1,7 @@ """Behavior tests for scripts/validate_plugin_catalog.py. The script is the no-install structural validator used by the plugin-catalog -admission CI: it must run with only stdlib + pyyaml, take file paths or a +admission CI: it must run with only stdlib + ruamel.yaml, take file paths or a directory, exit 0/1, and support --json machine output. These tests exercise the CLI contract via subprocess (the same way CI invokes it). """ @@ -11,7 +11,7 @@ import subprocess import sys from pathlib import Path -import yaml +import hermes_yaml as yaml REPO_ROOT = Path(__file__).resolve().parents[2] SCRIPT = REPO_ROOT / "scripts" / "validate_plugin_catalog.py" diff --git a/tests/skills/test_actual_setup_skill.py b/tests/skills/test_actual_setup_skill.py index f2a110e418..36c890e027 100644 --- a/tests/skills/test_actual_setup_skill.py +++ b/tests/skills/test_actual_setup_skill.py @@ -14,7 +14,7 @@ import re from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml SKILL_DIR = ( Path(__file__).resolve().parents[2] diff --git a/tests/skills/test_authoring_standards.py b/tests/skills/test_authoring_standards.py index d92652d7c8..16dbf18b06 100644 --- a/tests/skills/test_authoring_standards.py +++ b/tests/skills/test_authoring_standards.py @@ -13,7 +13,7 @@ import re from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml REPO = Path(__file__).resolve().parents[2] MARKETING = re.compile( diff --git a/tests/skills/test_collective_wisdom_install_skill.py b/tests/skills/test_collective_wisdom_install_skill.py index 46132c3632..9f36f705ef 100644 --- a/tests/skills/test_collective_wisdom_install_skill.py +++ b/tests/skills/test_collective_wisdom_install_skill.py @@ -1,6 +1,6 @@ from pathlib import Path -import yaml +import hermes_yaml as yaml SKILL = Path("skills/productivity/collective-wisdom-install/SKILL.md") diff --git a/tests/skills/test_competitor_news_monitor_skill.py b/tests/skills/test_competitor_news_monitor_skill.py index 101851e2e6..5bbd5c79bc 100644 --- a/tests/skills/test_competitor_news_monitor_skill.py +++ b/tests/skills/test_competitor_news_monitor_skill.py @@ -2,7 +2,7 @@ import re from pathlib import Path -import yaml +import hermes_yaml as yaml REPO_ROOT = Path(__file__).resolve().parents[2] SKILL_PATH = ( diff --git a/tests/skills/test_darwinian_evolver_skill.py b/tests/skills/test_darwinian_evolver_skill.py index 14a447ffef..d9380b3504 100644 --- a/tests/skills/test_darwinian_evolver_skill.py +++ b/tests/skills/test_darwinian_evolver_skill.py @@ -14,7 +14,7 @@ import re from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml SKILL_DIR = Path(__file__).resolve().parents[2] / "optional-skills" / "research" / "darwinian-evolver" diff --git a/tests/skills/test_decision_questionnaire_skill.py b/tests/skills/test_decision_questionnaire_skill.py index 94a11bd5f2..d32c471094 100644 --- a/tests/skills/test_decision_questionnaire_skill.py +++ b/tests/skills/test_decision_questionnaire_skill.py @@ -4,7 +4,7 @@ import re from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml SKILL_MD = ( Path(__file__).resolve().parents[2] diff --git a/tests/skills/test_document_to_action_items_skill.py b/tests/skills/test_document_to_action_items_skill.py index 376b732e87..a3e1239a3a 100644 --- a/tests/skills/test_document_to_action_items_skill.py +++ b/tests/skills/test_document_to_action_items_skill.py @@ -2,7 +2,7 @@ import re from pathlib import Path -import yaml +import hermes_yaml as yaml SKILL_PATH = ( Path(__file__).resolve().parents[2] diff --git a/tests/skills/test_email_inbox_triage_skill.py b/tests/skills/test_email_inbox_triage_skill.py index 7298b0aecc..6c855274f6 100644 --- a/tests/skills/test_email_inbox_triage_skill.py +++ b/tests/skills/test_email_inbox_triage_skill.py @@ -2,7 +2,7 @@ import re from pathlib import Path -import yaml +import hermes_yaml as yaml SKILL_PATH = ( Path(__file__).resolve().parents[2] diff --git a/tests/skills/test_github_skill.py b/tests/skills/test_github_skill.py index 8beba787e6..1b068c30b1 100644 --- a/tests/skills/test_github_skill.py +++ b/tests/skills/test_github_skill.py @@ -9,7 +9,7 @@ disciplines now check the reference body. import re from pathlib import Path -import yaml +import hermes_yaml as yaml SKILL_DIR = ( Path(__file__).resolve().parents[2] diff --git a/tests/skills/test_grounded_citations_skill.py b/tests/skills/test_grounded_citations_skill.py index 1dd1dd01a6..b6acd5aca2 100644 --- a/tests/skills/test_grounded_citations_skill.py +++ b/tests/skills/test_grounded_citations_skill.py @@ -15,7 +15,7 @@ import re from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml SKILL_DIR = Path(__file__).resolve().parents[2] / "skills" / "research" / "grounded-citations" SCRIPT = SKILL_DIR / "scripts" / "sources.py" diff --git a/tests/skills/test_mcp_oauth_remote_gateway_skill.py b/tests/skills/test_mcp_oauth_remote_gateway_skill.py index 88f4c48e6f..9b80a55f9b 100644 --- a/tests/skills/test_mcp_oauth_remote_gateway_skill.py +++ b/tests/skills/test_mcp_oauth_remote_gateway_skill.py @@ -145,7 +145,7 @@ def test_requests_send_httpx_user_agent(tmp_path): def test_skill_md_frontmatter_invariants(): - yaml = pytest.importorskip("yaml") + yaml = pytest.importorskip("hermes_yaml") content = SKILL_MD.read_text() assert content.startswith("---\n") fm = yaml.safe_load(re.search(r"^---\n(.*?)\n---", content, re.DOTALL).group(1)) diff --git a/tests/skills/test_meeting_action_items_skill.py b/tests/skills/test_meeting_action_items_skill.py index 54514ccac5..b8c79014fb 100644 --- a/tests/skills/test_meeting_action_items_skill.py +++ b/tests/skills/test_meeting_action_items_skill.py @@ -2,7 +2,7 @@ import re from pathlib import Path -import yaml +import hermes_yaml as yaml SKILL_PATH = ( Path(__file__).resolve().parents[2] diff --git a/tests/skills/test_office_document_skills.py b/tests/skills/test_office_document_skills.py index 8b145ac151..8fb78da7f9 100644 --- a/tests/skills/test_office_document_skills.py +++ b/tests/skills/test_office_document_skills.py @@ -12,7 +12,7 @@ import re from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml REPO = Path(__file__).resolve().parent.parent.parent SKILLS = REPO / "skills" diff --git a/tests/skills/test_openclaw_migration.py b/tests/skills/test_openclaw_migration.py index c456844fc0..7347ba41f7 100644 --- a/tests/skills/test_openclaw_migration.py +++ b/tests/skills/test_openclaw_migration.py @@ -213,7 +213,7 @@ def test_readable_config_keeps_every_pre_existing_key(tmp_path: Path): migrator.migrate() - import yaml + import hermes_yaml as yaml merged = yaml.safe_load(config_path.read_text(encoding="utf-8")) assert merged["model"] == "hermes-4-405b" @@ -632,7 +632,7 @@ def test_rebrand_text_replaces_openclaw_variants(): def _run_model_migration(tmp_path: Path, openclaw_json: dict) -> dict: """Helper: run just migrate_model_config on an openclaw.json and return the parsed destination config.yaml.""" - import yaml + import hermes_yaml as yaml mod = load_module() source = tmp_path / ".openclaw" diff --git a/tests/skills/test_pinecone_research_skill.py b/tests/skills/test_pinecone_research_skill.py index 6bf920841e..d8de13b776 100644 --- a/tests/skills/test_pinecone_research_skill.py +++ b/tests/skills/test_pinecone_research_skill.py @@ -12,7 +12,7 @@ import re from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml SKILL_DIR = ( Path(__file__).resolve().parents[2] diff --git a/tests/skills/test_product_price_monitor_skill.py b/tests/skills/test_product_price_monitor_skill.py index 4151c242fb..8f03440d13 100644 --- a/tests/skills/test_product_price_monitor_skill.py +++ b/tests/skills/test_product_price_monitor_skill.py @@ -2,7 +2,7 @@ import re from pathlib import Path -import yaml +import hermes_yaml as yaml SKILL_PATH = ( Path(__file__).resolve().parents[2] diff --git a/tests/skills/test_setup_wizard_generator_skill.py b/tests/skills/test_setup_wizard_generator_skill.py index 81ba0559a8..85c61b8774 100644 --- a/tests/skills/test_setup_wizard_generator_skill.py +++ b/tests/skills/test_setup_wizard_generator_skill.py @@ -5,7 +5,7 @@ import subprocess from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml SKILL_DIR = ( Path(__file__).resolve().parents[2] diff --git a/tests/skills/test_social_media_content_calendar_skill.py b/tests/skills/test_social_media_content_calendar_skill.py index 01c45aaafe..64cf30954b 100644 --- a/tests/skills/test_social_media_content_calendar_skill.py +++ b/tests/skills/test_social_media_content_calendar_skill.py @@ -2,7 +2,7 @@ import re from pathlib import Path -import yaml +import hermes_yaml as yaml SKILL_PATH = ( Path(__file__).resolve().parents[2] diff --git a/tests/skills/test_weekly_review_planning_skill.py b/tests/skills/test_weekly_review_planning_skill.py index 4fc5fd98b5..d5ccd4aff7 100644 --- a/tests/skills/test_weekly_review_planning_skill.py +++ b/tests/skills/test_weekly_review_planning_skill.py @@ -2,7 +2,7 @@ import re from pathlib import Path -import yaml +import hermes_yaml as yaml REPO_ROOT = Path(__file__).resolve().parents[2] SKILL_PATH = ( diff --git a/tests/test_atomic_replace_symlinks.py b/tests/test_atomic_replace_symlinks.py index d3fd4b575f..6a9cde7c1d 100644 --- a/tests/test_atomic_replace_symlinks.py +++ b/tests/test_atomic_replace_symlinks.py @@ -20,7 +20,7 @@ from pathlib import Path from unittest.mock import MagicMock import pytest -import yaml +import hermes_yaml as yaml # Ensure the repo root is importable when running via `pytest tests/...`. _REPO_ROOT = Path(__file__).resolve().parent.parent diff --git a/tests/test_fast_safe_load.py b/tests/test_fast_safe_load.py index 875752e04b..3cc9c159d4 100644 --- a/tests/test_fast_safe_load.py +++ b/tests/test_fast_safe_load.py @@ -1,25 +1,21 @@ -"""Invariants for utils.fast_safe_load. - -fast_safe_load is a drop-in for yaml.safe_load that prefers the libyaml -CSafeLoader C extension for speed. These tests assert the behavior contract -(it parses identically to safe_load across input shapes), not a snapshot of -any particular document. -""" +"""The fast-load entry point follows the shared YAML policy.""" import io -import yaml +import pytest -from utils import fast_safe_load, _fast_yaml_loader +import hermes_yaml as yaml +from utils import fast_safe_load _DOCS = [ - "", # empty document -> None + "", "a: 1\nb: two\nc: 3.5\n", "list: [1, 2, 3]\nnested:\n k: v\n flag: true\n empty: null\n", "name: skill-x\nmetadata:\n hermes:\n tags: [alpha, beta]\n category: devops\n", - "- one\n- two\n- three\n", # top-level sequence - "scalar string", # bare scalar + "- one\n- two\n- three\n", + "scalar string", + "flags: [on, off, yes, no, y, n]\n", ] @@ -34,29 +30,14 @@ def test_equivalent_to_safe_load_for_file_objects(): def test_empty_document_returns_none(): - # Callers rely on ``fast_safe_load(...) or {}`` — empty must be falsy. assert fast_safe_load("") is None -def test_prefers_c_loader_when_available(): - loader = _fast_yaml_loader - # If libyaml is compiled in, we must be using the C loader; otherwise the - # pure-Python SafeLoader is an acceptable fallback. Either way it must be a - # safe loader (never the unsafe full Loader). - c_loader = getattr(yaml, "CSafeLoader", None) - if c_loader is not None: - assert loader is c_loader - else: - assert loader is yaml.SafeLoader +def test_duplicate_keys_are_rejected_instead_of_silently_overwriting(): + with pytest.raises(yaml.YAMLError): + fast_safe_load("model: first\nmodel: second\n") def test_rejects_arbitrary_python_objects_like_safe_load(): - # Safe loaders must not construct arbitrary Python objects. This tag is - # accepted by the unsafe Loader but rejected by Safe/CSafe loaders. - dangerous = "!!python/object/apply:os.system ['echo pwned']\n" - try: - fast_safe_load(dangerous) - raised = False - except yaml.YAMLError: - raised = True - assert raised, "fast_safe_load must reject python/object tags like safe_load" + with pytest.raises(yaml.YAMLError): + fast_safe_load("!!python/object/apply:builtins.str ['must not construct']\n") diff --git a/tests/test_gateway_streaming_nested_config.py b/tests/test_gateway_streaming_nested_config.py index b4658b755a..2ff2ae611b 100644 --- a/tests/test_gateway_streaming_nested_config.py +++ b/tests/test_gateway_streaming_nested_config.py @@ -1,39 +1,31 @@ """Regression test for #25676 — nested gateway.streaming config must be loaded.""" -from pathlib import Path -from unittest.mock import patch, MagicMock +from unittest.mock import patch + +import hermes_yaml as yaml -def _load_with_yaml_dict(yaml_dict: dict): - """Patch filesystem so load_gateway_config() sees *yaml_dict* as config.yaml.""" +def _load_with_yaml_dict(yaml_dict: dict, tmp_path): + """Load a real config.yaml through the gateway's shared YAML reader.""" from gateway.config import load_gateway_config - fake_home = Path("/tmp/fake_hermes_home_25676") - - def fake_exists(self): - return str(self).endswith("config.yaml") - - with patch("gateway.config.get_hermes_home", return_value=fake_home), \ - patch.object(Path, "exists", fake_exists), \ - patch("builtins.open", create=True) as mock_file: - mock_file.return_value.__enter__ = lambda s: s - mock_file.return_value.__exit__ = MagicMock(return_value=False) - with patch("yaml.safe_load", return_value=yaml_dict): - return load_gateway_config() + (tmp_path / "config.yaml").write_text(yaml.safe_dump(yaml_dict), encoding="utf-8") + with patch("gateway.config.get_hermes_home", return_value=tmp_path): + return load_gateway_config() class TestStreamingConfigNested: - def test_top_level_streaming(self): - cfg = _load_with_yaml_dict({"streaming": {"enabled": True, "transport": "draft"}}) + def test_top_level_streaming(self, tmp_path): + cfg = _load_with_yaml_dict({"streaming": {"enabled": True, "transport": "draft"}}, tmp_path) assert cfg.streaming.enabled is True assert cfg.streaming.transport == "draft" - def test_top_level_takes_precedence(self): + def test_top_level_takes_precedence(self, tmp_path): cfg = _load_with_yaml_dict({ "streaming": {"enabled": True, "transport": "edit"}, "gateway": {"streaming": {"enabled": False, "transport": "draft"}}, - }) + }, tmp_path) assert cfg.streaming.enabled is True assert cfg.streaming.transport == "edit" @@ -105,10 +97,14 @@ class TestStreamingYamlBooleanQuirk: assert sc.transport == "auto" - def test_loader_normalizes_bare_yaml_off(self): + def test_loader_normalizes_bare_yaml_off(self, tmp_path): """End-to-end through load_gateway_config(): unquoted ``mode: off`` (a YAML boolean) must keep streaming disabled.""" - cfg = _load_with_yaml_dict({"streaming": {"mode": False}}) + from gateway.config import load_gateway_config + + (tmp_path / "config.yaml").write_text("streaming:\n mode: off\n", encoding="utf-8") + with patch("gateway.config.get_hermes_home", return_value=tmp_path): + cfg = load_gateway_config() assert cfg.streaming.enabled is False assert cfg.streaming.transport == "off" diff --git a/tests/test_hermes_logging.py b/tests/test_hermes_logging.py index 0188accbc6..9c56d08859 100644 --- a/tests/test_hermes_logging.py +++ b/tests/test_hermes_logging.py @@ -146,9 +146,9 @@ class TestSetupLogging: def test_explicit_params_override_config(self, hermes_home): """Explicit function params take precedence over config.yaml.""" - import yaml + import hermes_yaml as yaml config = {"logging": {"level": "DEBUG"}} - (hermes_home / "config.yaml").write_text(yaml.dump(config)) + (hermes_home / "config.yaml").write_text(yaml.safe_dump(config)) hermes_logging.setup_logging(hermes_home=hermes_home, log_level="WARNING") @@ -475,9 +475,9 @@ class TestReadLoggingConfig: assert backup is None def test_reads_logging_section(self, hermes_home): - import yaml + import hermes_yaml as yaml config = {"logging": {"level": "DEBUG", "max_size_mb": 10, "backup_count": 5}} - (hermes_home / "config.yaml").write_text(yaml.dump(config)) + (hermes_home / "config.yaml").write_text(yaml.safe_dump(config)) level, max_size, backup = hermes_logging._read_logging_config() assert level == "DEBUG" diff --git a/tests/test_hermes_yaml.py b/tests/test_hermes_yaml.py new file mode 100644 index 0000000000..f4c727afc6 --- /dev/null +++ b/tests/test_hermes_yaml.py @@ -0,0 +1,89 @@ +"""Contracts for Hermes' shared YAML reader and writer.""" + +import io +from concurrent.futures import ThreadPoolExecutor + +import pytest + +import hermes_yaml as yaml + + +def test_safe_load_accepts_existing_config_boolean_spellings(): + document = "flags: [on, off, yes, no, true, false]\nquoted: ['off', 'yes']\n" + expected = {"flags": [True, False, True, False, True, False], "quoted": ["off", "yes"]} + for stream in (document, document.encode(), io.StringIO(document), io.BytesIO(document.encode())): + assert yaml.safe_load(stream) == expected + assert yaml.safe_load("") is None + + +def test_safe_load_rejects_python_object_construction(): + with pytest.raises(yaml.YAMLError): + yaml.safe_load("!!python/object/apply:builtins.str ['must not construct']") + + +def test_safe_dump_preserves_data_and_readable_block_layout(): + data = {"z": [{"mode": "off", "choice": "y", "label": "こんにちは 🦀"}], "a": "yes"} + text = yaml.safe_dump(data, sort_keys=False) + assert yaml.safe_load(text) == data + assert text.startswith("z:\n - ") + assert "こんにちは 🦀" in text + stream = io.StringIO() + assert yaml.safe_dump(data, stream, sort_keys=False) is None + assert stream.getvalue() == text + with pytest.raises(yaml.YAMLError): + yaml.safe_dump({"object": object()}) + + +def test_safe_dump_honors_the_options_used_by_callers(): + data = {"zebra": {"zed": 1, "alpha": 2}, "alpha": "hé"} + for sort_keys in (False, True): + loaded = yaml.safe_load(yaml.safe_dump(data, sort_keys=sort_keys)) + assert list(loaded) == (sorted(data) if sort_keys else list(data)) + assert list(loaded["zebra"]) == (sorted(data["zebra"]) if sort_keys else list(data["zebra"])) + escaped = yaml.safe_dump(data, allow_unicode=False) + assert "hé" not in escaped + assert yaml.safe_load(escaped) == data + flow = yaml.safe_dump(data, default_flow_style=True, width=100000) + assert flow.startswith("{") and len(flow.splitlines()) == 1 + assert yaml.safe_load(flow) == data + + +def test_roundtrip_preserves_comments_quotes_and_scalar_types(): + editor = yaml.roundtrip_yaml() + original = '# keep this\nname: "hello 🦀" # note\nflag: off\n' + data = editor.load(original) + assert data["flag"] is False + data["mode"] = "off" + stream = io.StringIO() + editor.dump(data, stream) + text = stream.getvalue() + assert text.startswith('# keep this\nname: "hello 🦀" # note\n') + assert yaml.safe_load(text) == {"name": "hello 🦀", "flag": False, "mode": "off"} + + +def test_native_yaml11_scalars_and_duplicate_key_policy(): + for load in (yaml.safe_load, yaml.roundtrip_yaml().load): + assert load("[y, n, Y, N, 'y', 'n']") == [True, False, True, False, "y", "n"] + with pytest.raises(yaml.YAMLError): + load("model: first\nmodel: second\n") + # Merge keys override defaults, not duplicates in the mapping itself. + merged = yaml.safe_load("defaults: &defaults {enabled: true}\nlocal: {<<: *defaults, enabled: false}\n") + assert merged["local"]["enabled"] is False + + +def test_parallel_calls_do_not_share_parser_or_emitter_state(): + def roundtrip(index): + data = {"index": index, "words": ["yes", "no", "on", "off", "y", "n"]} + text = yaml.safe_dump(data, sort_keys=bool(index % 2)) + assert yaml.safe_load(text) == data + assert yaml.roundtrip_yaml().load(text) == data + return data + + with ThreadPoolExecutor(max_workers=8) as pool: + assert [data["index"] for data in pool.map(roundtrip, range(32))] == list(range(32)) + # A failed parse/dump must not poison the next operation. + with pytest.raises(yaml.YAMLError): + yaml.safe_load("key: [unterminated") + with pytest.raises(yaml.YAMLError): + yaml.safe_dump(object()) + assert yaml.safe_load(yaml.safe_dump({"healthy": True})) == {"healthy": True} diff --git a/tests/test_journal_mode_config.py b/tests/test_journal_mode_config.py index b0b865c7e3..1aa7e6dfcb 100644 --- a/tests/test_journal_mode_config.py +++ b/tests/test_journal_mode_config.py @@ -7,7 +7,7 @@ import sqlite3 import pytest import hermes_state_wal -import yaml +import hermes_yaml as yaml def _write_config(monkeypatch: pytest.MonkeyPatch, tmp_path, config: object) -> None: diff --git a/tests/test_journal_mode_upgrade_warning.py b/tests/test_journal_mode_upgrade_warning.py index 9341a8699f..b01f83dadf 100644 --- a/tests/test_journal_mode_upgrade_warning.py +++ b/tests/test_journal_mode_upgrade_warning.py @@ -28,7 +28,7 @@ import sqlite3 import pytest import hermes_state_wal -import yaml +import hermes_yaml as yaml def _write_config(monkeypatch: pytest.MonkeyPatch, tmp_path, config: object) -> None: diff --git a/tests/test_session_vacuum_config.py b/tests/test_session_vacuum_config.py index 43adfdacba..67edc39204 100644 --- a/tests/test_session_vacuum_config.py +++ b/tests/test_session_vacuum_config.py @@ -72,7 +72,7 @@ def test_shipped_template_does_not_pin_sessions_keys(): uncommented ``sessions:`` value there becomes an EXPLICIT user setting that would freeze the retention defaults. The template must leave them commented so code defaults (and future flips) apply.""" - import yaml + import hermes_yaml as yaml template = Path(__file__).resolve().parents[1] / "cli-config.yaml.example" data = yaml.safe_load(template.read_text(encoding="utf-8")) or {} diff --git a/tests/test_state_db_malformed_repair.py b/tests/test_state_db_malformed_repair.py index 02bdff0a30..18510f06be 100644 --- a/tests/test_state_db_malformed_repair.py +++ b/tests/test_state_db_malformed_repair.py @@ -658,7 +658,7 @@ def _mode_of(db_path) -> str: def _configure_journal_mode(monkeypatch, tmp_path, mode) -> None: - import yaml + import hermes_yaml as yaml home = tmp_path / "hermes-home" home.mkdir(exist_ok=True) diff --git a/tests/test_transform_llm_output_hook.py b/tests/test_transform_llm_output_hook.py index 78946316dd..490b460439 100644 --- a/tests/test_transform_llm_output_hook.py +++ b/tests/test_transform_llm_output_hook.py @@ -17,7 +17,7 @@ contract for the generic tool-result seam. from pathlib import Path -import yaml +import hermes_yaml as yaml import hermes_cli.plugins as plugins_mod from hermes_cli.plugins import PluginManager, VALID_HOOKS diff --git a/tests/test_transform_tool_result_hook.py b/tests/test_transform_tool_result_hook.py index b6cdbfba7e..810fd962a6 100644 --- a/tests/test_transform_tool_result_hook.py +++ b/tests/test_transform_tool_result_hook.py @@ -134,7 +134,7 @@ def test_transform_tool_result_runs_after_post_tool_call(monkeypatch): def test_transform_tool_result_integration_with_real_plugin(monkeypatch, tmp_path): """End-to-end: load a real plugin from HERMES_HOME and verify it rewrites results.""" - import yaml + import hermes_yaml as yaml hermes_home = Path(os.environ["HERMES_HOME"]) plugins_dir = hermes_home / "plugins" diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 683034c7a5..701916e203 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -812,7 +812,7 @@ def test_session_context_explicit_cwd_for_ephemeral_task(monkeypatch, tmp_path): def _write_profile_cfg(home: Path, cwd: str | None) -> Path: - import yaml + import hermes_yaml as yaml home.mkdir(parents=True, exist_ok=True) cfg = {"terminal": {"cwd": cwd}} if cwd is not None else {} @@ -8312,7 +8312,7 @@ def test_config_set_yolo_toggles_session_scope(): def test_config_set_yolo_global_scope_writes_approvals_mode(tmp_path, monkeypatch): """Shift+click the desktop zap -> scope="global" flips persistent approvals.mode.""" - import yaml + import hermes_yaml as yaml cfg_path = tmp_path / "config.yaml" cfg_path.write_text(yaml.safe_dump({"approvals": {"mode": "manual"}})) @@ -8343,7 +8343,7 @@ def test_config_set_yolo_global_scope_writes_approvals_mode(tmp_path, monkeypatc def test_config_get_approval_mode_uses_smart_default_when_key_is_missing( tmp_path, monkeypatch ): - import yaml + import hermes_yaml as yaml monkeypatch.setattr(server, "_hermes_home", tmp_path) # Point the canonical resolver (load_config → env HERMES_HOME) at the @@ -8363,7 +8363,7 @@ def test_config_get_approval_mode_uses_smart_default_when_key_is_missing( def test_config_get_approval_mode_fails_safe_to_manual_for_invalid_explicit_value( tmp_path, monkeypatch ): - import yaml + import hermes_yaml as yaml monkeypatch.setattr(server, "_hermes_home", tmp_path) # _load_approval_mode delegates to the canonical resolver in @@ -8382,7 +8382,7 @@ def test_config_get_approval_mode_fails_safe_to_manual_for_invalid_explicit_valu def test_config_get_approval_mode_normalizes_yaml_off(tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml monkeypatch.setattr(server, "_hermes_home", tmp_path) # See fail-safe test above: the canonical resolver reads via @@ -8401,7 +8401,7 @@ def test_config_get_approval_mode_normalizes_yaml_off(tmp_path, monkeypatch): def test_config_set_approval_mode_persists_three_way_value_and_emits_live_status( tmp_path, monkeypatch ): - import yaml + import hermes_yaml as yaml monkeypatch.setattr(server, "_hermes_home", tmp_path) # config.set writes via server._hermes_home, but the post-write @@ -8436,7 +8436,7 @@ def test_pet_gallery_quoted_false_enabled_reports_disabled(tmp_path, monkeypatch quoted YAML value kept the petdex mascot enabled against the operator's explicit intent. """ - import yaml + import hermes_yaml as yaml monkeypatch.setattr(server, "_hermes_home", tmp_path) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) @@ -8517,7 +8517,7 @@ def test_config_set_approval_mode_rejects_unknown_value(): def test_config_set_yolo_global_scope_honors_explicit_value(tmp_path, monkeypatch): """An explicit value pins global approvals.mode regardless of prior state.""" - import yaml + import hermes_yaml as yaml cfg_path = tmp_path / "config.yaml" cfg_path.write_text(yaml.safe_dump({"approvals": {"mode": "manual"}})) @@ -8761,7 +8761,7 @@ def test_config_get_busy_survives_non_dict_display(monkeypatch): def test_config_set_statusbar_survives_non_dict_display(tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml cfg_path = tmp_path / "config.yaml" cfg_path.write_text(yaml.safe_dump({"display": "broken"})) @@ -8781,7 +8781,7 @@ def test_config_set_statusbar_survives_non_dict_display(tmp_path, monkeypatch): def test_config_set_details_mode_pins_all_sections(tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml cfg_path = tmp_path / "config.yaml" cfg_path.write_text( @@ -8811,7 +8811,7 @@ def test_config_set_details_mode_pins_all_sections(tmp_path, monkeypatch): def test_config_set_section_writes_per_section_override(tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml cfg_path = tmp_path / "config.yaml" monkeypatch.setattr(server, "_hermes_home", tmp_path) @@ -8830,7 +8830,7 @@ def test_config_set_section_writes_per_section_override(tmp_path, monkeypatch): def test_config_set_section_clears_override_on_empty_value(tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml cfg_path = tmp_path / "config.yaml" cfg_path.write_text( @@ -20098,7 +20098,7 @@ def test_persist_model_switch_preserves_sibling_model_keys(tmp_path, monkeypatch `model:` (model_slots, model_fallback, etc.). _persist_model_switch now uses targeted save_config_value writes instead of rewriting the whole block.""" import types - import yaml + import hermes_yaml as yaml import cli cfg_path = tmp_path / "config.yaml" @@ -20138,7 +20138,7 @@ def test_persist_model_switch_clears_stale_base_url(tmp_path, monkeypatch): provider with no base_url must CLEAR the stale base_url, not leave it pointing at the old host.""" import types - import yaml + import hermes_yaml as yaml import cli cfg_path = tmp_path / "config.yaml" @@ -21352,7 +21352,7 @@ def test_save_cfg_preserves_user_comments(tmp_path, monkeypatch): assert "# provider rationale" in text assert "# trailing skin note" in text - import yaml as _yaml + import hermes_yaml as _yaml parsed = _yaml.safe_load(text) assert parsed["display"]["skin"] == "mono" diff --git a/tests/test_utils_atomic_roundtrip_yaml_save.py b/tests/test_utils_atomic_roundtrip_yaml_save.py index ff89258b81..72775fe3b6 100644 --- a/tests/test_utils_atomic_roundtrip_yaml_save.py +++ b/tests/test_utils_atomic_roundtrip_yaml_save.py @@ -10,7 +10,7 @@ from pathlib import Path from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml class TestAtomicRoundtripYamlSave: diff --git a/tests/test_yaml_indent_consistency_31999.py b/tests/test_yaml_indent_consistency_31999.py index bda5e3bfcd..13f4adada6 100644 --- a/tests/test_yaml_indent_consistency_31999.py +++ b/tests/test_yaml_indent_consistency_31999.py @@ -1,120 +1,70 @@ -"""Regression tests for issue #31999. +"""All YAML write paths use indented block sequences (#31999).""" -All YAML config write paths must produce 2-space-indented list items -(matching ruamel.yaml's layout). Mixing 0-indent (default PyYAML) and -2-indent (ruamel.yaml) in the same config.yaml produces a file that -stricter parsers like js-yaml reject with "bad indentation of a mapping -entry", silently dropping custom_providers and breaking model switching. -""" +import io -import yaml -from utils import IndentDumper, atomic_yaml_write +import pytest + +import hermes_yaml as yaml +from utils import atomic_roundtrip_yaml_update, atomic_yaml_write -class TestIndentDumperShape: - """IndentDumper emits 2-space-indented list items under mapping keys.""" - - def test_indent_dumper_produces_2_indent_lists(self): - """List items under a mapping key must start at column 2, not 0.""" - data = { - "custom_providers": [ - {"name": "NVIDIA", "base_url": "https://api.nvidia.com"}, - ], - } - out = yaml.dump(data, Dumper=IndentDumper, default_flow_style=False) - # The list item should be indented 2 spaces under the key - assert " - " in out, f"Expected 2-indent list, got:\n{out}" - - def test_default_pyyaml_produces_0_indent_lists(self): - """Default PyYAML (the buggy baseline) emits 0-indent lists.""" - data = { - "custom_providers": [ - {"name": "NVIDIA", "base_url": "https://api.nvidia.com"}, - ], - } - out = yaml.dump(data, default_flow_style=False) - # The list item should be at column 0 (no leading spaces) - lines = out.strip().split("\n") - list_lines = [l for l in lines if l.lstrip().startswith("- ")] - assert all(not l.startswith(" - ") for l in list_lines), \ - f"Expected 0-indent list (buggy baseline), got:\n{out}" - - def test_indent_dumper_matches_ruamel_layout(self): - """IndentDumper output should match ruamel.yaml's list-under-mapping layout.""" - data = { - "items": [ - {"key": "value1"}, - {"key": "value2"}, - ], - } - pyyaml_out = yaml.dump(data, Dumper=IndentDumper, default_flow_style=False) - # ruamel.yaml with indent(mapping=2, sequence=4, offset=2) produces: - # items: - # - key: value1 - # - key: value2 - # The key check: list items are NOT at column 0 - lines = pyyaml_out.strip().split("\n") - list_lines = [l for l in lines if l.lstrip().startswith("- ")] - assert all(l.startswith(" - ") for l in list_lines), \ - f"List items not 2-indent:\n{pyyaml_out}" +def test_safe_dump_produces_indented_lists(): + data = {"custom_providers": [{"name": "NVIDIA", "base_url": "https://api.nvidia.com"}]} + out = yaml.safe_dump(data) + assert "\n - " in out + assert yaml.safe_load(out) == data -class TestAtomicYamlWriteUsesIndentDumper: - """atomic_yaml_write must produce 2-indent lists via IndentDumper.""" - - def test_atomic_yaml_write_produces_2_indent_lists(self, tmp_path): - """The file written by atomic_yaml_write must have 2-indent list items.""" - data = { - "custom_providers": [ - {"name": "Test", "base_url": "https://example.com"}, - ], - } - path = tmp_path / "config.yaml" - atomic_yaml_write(path, data) - - content = path.read_text(encoding="utf-8") - assert " - " in content, \ - f"Expected 2-indent list in file, got:\n{content}" - - def test_atomic_yaml_write_preserves_unicode(self, tmp_path): - """allow_unicode=True should write real UTF-8, not escape sequences.""" - data = {"name": "Tëst Näme"} - path = tmp_path / "config.yaml" - atomic_yaml_write(path, data) - - content = path.read_text(encoding="utf-8") - assert "Tëst Näme" in content - - def test_atomic_yaml_write_is_atomic(self, tmp_path): - """atomic_yaml_write should create the file and clean up temp files.""" - data = {"key": "value"} - path = tmp_path / "config.yaml" - atomic_yaml_write(path, data) - - assert path.exists() - assert path.read_text(encoding="utf-8").strip().endswith("value") - # No leftover temp files - temp_files = list(tmp_path.glob(".config_*.tmp")) - assert len(temp_files) == 0 +def test_safe_and_roundtrip_writers_use_the_same_layout(): + data = {"items": [{"key": "value1"}, {"key": "value2"}]} + stream = io.StringIO() + yaml.roundtrip_yaml().dump(data, stream) + assert yaml.safe_dump(data, sort_keys=False) == stream.getvalue() -class TestRoundtripConsistency: - """Output of atomic_yaml_write should round-trip through ruamel.yaml.""" +def test_atomic_write_then_key_update_keeps_layout_and_values(tmp_path): + data = {"custom_providers": [{"name": "Test", "base_url": "https://example.com"}]} + path = tmp_path / "config.yaml" + atomic_yaml_write(path, data) + initial = path.read_text(encoding="utf-8") + atomic_roundtrip_yaml_update(path, "approvals.mode", "off") + content = path.read_text(encoding="utf-8") + assert content.startswith(initial) + assert "\n - " in content + assert yaml.safe_load(content) == {**data, "approvals": {"mode": "off"}} - def test_pyyaml_output_loads_in_ruamel(self, tmp_path): - """File written by atomic_yaml_write should load in ruamel.yaml without errors.""" - data = { - "custom_providers": [ - {"name": "Provider A", "base_url": "https://a.example.com"}, - {"name": "Provider B", "base_url": "https://b.example.com"}, - ], - "fallback_providers": ["backup1", "backup2"], - } - path = tmp_path / "config.yaml" - atomic_yaml_write(path, data) - from ruamel.yaml import YAML - yaml_rt = YAML(typ="rt") - loaded = yaml_rt.load(path.read_text(encoding="utf-8")) - assert loaded["custom_providers"][0]["name"] == "Provider A" - assert loaded["fallback_providers"] == ["backup1", "backup2"] +def test_atomic_yaml_write_preserves_unicode(tmp_path): + path = tmp_path / "config.yaml" + atomic_yaml_write(path, {"name": "Tëst Näme 🦀"}) + assert "Tëst Näme 🦀" in path.read_text(encoding="utf-8") + + +def test_atomic_yaml_write_is_atomic(tmp_path): + path = tmp_path / "config.yaml" + atomic_yaml_write(path, {"key": "value"}) + assert yaml.safe_load(path.read_text(encoding="utf-8")) == {"key": "value"} + assert not list(tmp_path.glob(".config_*.tmp")) + + +def test_failed_atomic_yaml_write_keeps_original(tmp_path): + path = tmp_path / "config.yaml" + original = "# keep original\nkey: value\n" + path.write_text(original, encoding="utf-8") + with pytest.raises(yaml.YAMLError): + atomic_yaml_write(path, {"object": object()}) + assert path.read_text(encoding="utf-8") == original + assert not list(tmp_path.glob(".config_*.tmp")) + + +def test_atomic_yaml_write_loads_in_roundtrip_editor(tmp_path): + data = { + "custom_providers": [ + {"name": "Provider A", "base_url": "https://a.example.com"}, + {"name": "Provider B", "base_url": "https://b.example.com"}, + ], + "fallback_providers": ["backup1", "backup2"], + } + path = tmp_path / "config.yaml" + atomic_yaml_write(path, data) + assert yaml.roundtrip_yaml().load(path.read_text(encoding="utf-8")) == data diff --git a/tests/tools/test_allowlist_legacy_config.py b/tests/tools/test_allowlist_legacy_config.py index ff91f28c36..76f1db091d 100644 --- a/tests/tools/test_allowlist_legacy_config.py +++ b/tests/tools/test_allowlist_legacy_config.py @@ -1,4 +1,4 @@ -import yaml +import hermes_yaml as yaml from tools import approval diff --git a/tests/tools/test_credential_files.py b/tests/tools/test_credential_files.py index 9a6d5887d9..b52dd86d62 100644 --- a/tests/tools/test_credential_files.py +++ b/tests/tools/test_credential_files.py @@ -328,9 +328,9 @@ class TestConfigPathTraversal: """terminal.credential_files in config.yaml must also reject traversal.""" def _write_config(self, hermes_home: Path, cred_files: list): - import yaml + import hermes_yaml as yaml config_path = hermes_home / "config.yaml" - config_path.write_text(yaml.dump({"terminal": {"credential_files": cred_files}})) + config_path.write_text(yaml.safe_dump({"terminal": {"credential_files": cred_files}})) def test_config_traversal_rejected(self, tmp_path, monkeypatch): """'../secret' in config.yaml must not escape HERMES_HOME.""" diff --git a/tests/tools/test_delegate_fallback_matrix.py b/tests/tools/test_delegate_fallback_matrix.py index a5237c30f5..d6393fa822 100644 --- a/tests/tools/test_delegate_fallback_matrix.py +++ b/tests/tools/test_delegate_fallback_matrix.py @@ -75,7 +75,7 @@ def test_declared_chain_flows_through_real_profile_config_loader( ): """The public key must survive DEFAULT_CONFIG/profile loading without patching ``_load_config`` and reach the child constructor.""" - import yaml + import hermes_yaml as yaml from hermes_constants import ( reset_hermes_home_override, @@ -112,7 +112,7 @@ def test_declared_chain_flows_through_real_profile_config_loader( def test_explicit_empty_chain_survives_real_profile_config_loader(tmp_path, monkeypatch): """An explicit [] remains an authoritative disable after config loading.""" - import yaml + import hermes_yaml as yaml from hermes_constants import reset_hermes_home_override, set_hermes_home_override @@ -143,7 +143,7 @@ def test_explicit_empty_chain_survives_real_profile_config_loader(tmp_path, monk def test_pinned_review_does_not_borrow_general_worker_chain(tmp_path, monkeypatch): """The public /review route owns its fallback policy as well as its model.""" - import yaml + import hermes_yaml as yaml from agent.review_engine import start_review from hermes_constants import reset_hermes_home_override, set_hermes_home_override diff --git a/tests/tools/test_docker_config_migrate.py b/tests/tools/test_docker_config_migrate.py index ac9d3040be..ff80e86ba8 100644 --- a/tests/tools/test_docker_config_migrate.py +++ b/tests/tools/test_docker_config_migrate.py @@ -7,7 +7,7 @@ import sys from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.config import DEFAULT_CONFIG diff --git a/tests/tools/test_env_passthrough.py b/tests/tools/test_env_passthrough.py index 56eab9ba2b..8cccda656b 100644 --- a/tests/tools/test_env_passthrough.py +++ b/tests/tools/test_env_passthrough.py @@ -2,7 +2,7 @@ import os import pytest -import yaml +import hermes_yaml as yaml from agent import secret_scope as ss import tools.env_passthrough as _ep_mod @@ -44,7 +44,7 @@ class TestConfigPassthrough: def test_reads_from_config(self, tmp_path, monkeypatch): config = {"terminal": {"env_passthrough": ["MY_CUSTOM_KEY", "ANOTHER_TOKEN"]}} config_path = tmp_path / "config.yaml" - config_path.write_text(yaml.dump(config), encoding="utf-8") + config_path.write_text(yaml.safe_dump(config), encoding="utf-8") monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _ep_mod._config_passthrough = None @@ -56,7 +56,7 @@ class TestConfigPassthrough: def test_union_of_skill_and_config(self, tmp_path, monkeypatch): config = {"terminal": {"env_passthrough": ["CONFIG_KEY"]}} config_path = tmp_path / "config.yaml" - config_path.write_text(yaml.dump(config), encoding="utf-8") + config_path.write_text(yaml.safe_dump(config), encoding="utf-8") monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _ep_mod._config_passthrough = None diff --git a/tests/tools/test_image_generation_image_to_image.py b/tests/tools/test_image_generation_image_to_image.py index 5dd3ea7591..cafd126cb7 100644 --- a/tests/tools/test_image_generation_image_to_image.py +++ b/tests/tools/test_image_generation_image_to_image.py @@ -17,7 +17,7 @@ import json from typing import Any, Dict, List, Optional import pytest -import yaml +import hermes_yaml as yaml from agent import image_gen_registry from agent.image_gen_provider import ImageGenProvider diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index 9d98bc8cf7..e677537654 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -158,7 +158,7 @@ class TestLoadMCPConfig: self, tmp_path, monkeypatch ): import json - import yaml + import hermes_yaml as yaml from hermes_cli.agent_plugins import MCP_SCHEMA_V1, PLUGIN_SCHEMA_V1 from hermes_cli import plugins as plugins_mod diff --git a/tests/tools/test_pre_transcription_hook.py b/tests/tools/test_pre_transcription_hook.py index 259793b279..a038fe8ed2 100644 --- a/tests/tools/test_pre_transcription_hook.py +++ b/tests/tools/test_pre_transcription_hook.py @@ -582,7 +582,7 @@ def test_real_fixture_plugins_thread_prompt_in_registration_order( import os from pathlib import Path - import yaml + import hermes_yaml as yaml hermes_home = Path(os.environ["HERMES_HOME"]) plugin_dir = hermes_home / "plugins" / "stt_vocab" diff --git a/tests/tools/test_terminal_output_transform_hook.py b/tests/tools/test_terminal_output_transform_hook.py index 0453ef68e8..f90786b80c 100644 --- a/tests/tools/test_terminal_output_transform_hook.py +++ b/tests/tools/test_terminal_output_transform_hook.py @@ -173,7 +173,7 @@ def test_terminal_output_transform_does_not_change_approval_or_exit_code_meaning def test_terminal_output_transform_integration_with_real_plugin(monkeypatch, tmp_path): - import yaml + import hermes_yaml as yaml hermes_home = Path(os.environ["HERMES_HOME"]) plugins_dir = hermes_home / "plugins" diff --git a/tests/tools/test_tts_kittentts.py b/tests/tools/test_tts_kittentts.py index 44fd7cc41c..9cce08ac60 100644 --- a/tests/tools/test_tts_kittentts.py +++ b/tests/tools/test_tts_kittentts.py @@ -120,7 +120,7 @@ class TestDispatcherBranch: from tools.tts_tool import text_to_speech_tool # Write a config telling it to use kittentts - import yaml + import hermes_yaml as yaml (tmp_path / "config.yaml").write_text( yaml.safe_dump({"tts": {"provider": "kittentts"}}) ) diff --git a/tests/tools/test_video_generation_dynamic_schema.py b/tests/tools/test_video_generation_dynamic_schema.py index 55de50afb5..9ebdc38727 100644 --- a/tests/tools/test_video_generation_dynamic_schema.py +++ b/tests/tools/test_video_generation_dynamic_schema.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Any, Dict, List, Optional import pytest -import yaml +import hermes_yaml as yaml from agent import video_gen_registry from agent.video_gen_provider import VideoGenProvider diff --git a/tests/tools/test_video_generation_tool_surface_matrix.py b/tests/tools/test_video_generation_tool_surface_matrix.py index 96f7e74f12..231c0f30c4 100644 --- a/tests/tools/test_video_generation_tool_surface_matrix.py +++ b/tests/tools/test_video_generation_tool_surface_matrix.py @@ -19,7 +19,7 @@ import types from typing import Any, Dict, List import pytest -import yaml +import hermes_yaml as yaml @pytest.fixture(autouse=True) diff --git a/tests/tools/test_voice_client_config.py b/tests/tools/test_voice_client_config.py index ac9a0d4674..ea656cd338 100644 --- a/tests/tools/test_voice_client_config.py +++ b/tests/tools/test_voice_client_config.py @@ -9,7 +9,7 @@ import importlib import sys import pytest -import yaml +import hermes_yaml as yaml @pytest.fixture() diff --git a/tests/tools/test_xai_http_storage.py b/tests/tools/test_xai_http_storage.py index dca24e0b11..452f494065 100644 --- a/tests/tools/test_xai_http_storage.py +++ b/tests/tools/test_xai_http_storage.py @@ -2,7 +2,7 @@ from __future__ import annotations -import yaml +import hermes_yaml as yaml def _invalidate_config_cache(): diff --git a/tests/tui_gateway/test_config_profile_scope.py b/tests/tui_gateway/test_config_profile_scope.py index d7d1a8e02e..64d9b7890f 100644 --- a/tests/tui_gateway/test_config_profile_scope.py +++ b/tests/tui_gateway/test_config_profile_scope.py @@ -10,7 +10,7 @@ from __future__ import annotations from pathlib import Path -import yaml +import hermes_yaml as yaml import tui_gateway.server as server diff --git a/tests/tui_gateway/test_config_set_display_toggles.py b/tests/tui_gateway/test_config_set_display_toggles.py index 2139314f26..85633b2cae 100644 --- a/tests/tui_gateway/test_config_set_display_toggles.py +++ b/tests/tui_gateway/test_config_set_display_toggles.py @@ -7,7 +7,7 @@ shipped with a toggle that never reached the backend gating the tool. """ import pytest -import yaml +import hermes_yaml as yaml from tui_gateway import server diff --git a/tests/tui_gateway/test_mcp_profile_rpcs.py b/tests/tui_gateway/test_mcp_profile_rpcs.py index 17708893fc..45f6dafc87 100644 --- a/tests/tui_gateway/test_mcp_profile_rpcs.py +++ b/tests/tui_gateway/test_mcp_profile_rpcs.py @@ -51,7 +51,7 @@ def _result(resp): def _read_yaml(path: Path) -> dict: """Read a config.yaml directly for assertions (test-side, not the guarded loader).""" - import yaml + import hermes_yaml as yaml if not path.is_file(): return {} diff --git a/tests/tui_gateway/test_personality_clobbers_system_prompt.py b/tests/tui_gateway/test_personality_clobbers_system_prompt.py index 36a99c1167..df5d4edd15 100644 --- a/tests/tui_gateway/test_personality_clobbers_system_prompt.py +++ b/tests/tui_gateway/test_personality_clobbers_system_prompt.py @@ -34,7 +34,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch import tui_gateway.server as server -import yaml +import hermes_yaml as yaml MANUAL_PROMPT = "manual_prompt_1" diff --git a/tests/tui_gateway/test_profile_rebuild_commit.py b/tests/tui_gateway/test_profile_rebuild_commit.py index 2825f1f375..376e1f5e95 100644 --- a/tests/tui_gateway/test_profile_rebuild_commit.py +++ b/tests/tui_gateway/test_profile_rebuild_commit.py @@ -3,7 +3,7 @@ import threading from types import SimpleNamespace import pytest -import yaml +import hermes_yaml as yaml @pytest.mark.parametrize("explicit_profile", [None, "default"]) diff --git a/tests/tui_gateway/test_profile_shell_hooks.py b/tests/tui_gateway/test_profile_shell_hooks.py index 5530bd985f..0fd6e46eac 100644 --- a/tests/tui_gateway/test_profile_shell_hooks.py +++ b/tests/tui_gateway/test_profile_shell_hooks.py @@ -5,7 +5,7 @@ import subprocess import sys from pathlib import Path -import yaml +import hermes_yaml as yaml def test_agent_build_arms_only_consented_profile_policy(tmp_path, monkeypatch): diff --git a/tests/tui_gateway/test_profiles_configure_model_guard.py b/tests/tui_gateway/test_profiles_configure_model_guard.py index 51cd8f21c8..2152774b45 100644 --- a/tests/tui_gateway/test_profiles_configure_model_guard.py +++ b/tests/tui_gateway/test_profiles_configure_model_guard.py @@ -21,7 +21,7 @@ from pathlib import Path from types import SimpleNamespace import pytest -import yaml +import hermes_yaml as yaml import hermes_cli.model_selection_guards as guards import tui_gateway.server as srv diff --git a/tests/tui_gateway/test_slash_worker_mcp_discovery.py b/tests/tui_gateway/test_slash_worker_mcp_discovery.py index 5050eb5a15..959257ac77 100644 --- a/tests/tui_gateway/test_slash_worker_mcp_discovery.py +++ b/tests/tui_gateway/test_slash_worker_mcp_discovery.py @@ -12,7 +12,7 @@ import textwrap import threading import pytest -import yaml +import hermes_yaml as yaml _mcp_server_mod = pytest.importorskip("mcp.server") diff --git a/tests/tui_gateway/test_stale_provider_resume_live.py b/tests/tui_gateway/test_stale_provider_resume_live.py index 4b947f6002..ddfdb8173d 100644 --- a/tests/tui_gateway/test_stale_provider_resume_live.py +++ b/tests/tui_gateway/test_stale_provider_resume_live.py @@ -32,7 +32,7 @@ import uuid from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml OLD_URL = "https://old-endpoint.invalid/v1" NEW_URL = "https://new-endpoint.invalid/v1" diff --git a/tests/website/test_extract_plugins.py b/tests/website/test_extract_plugins.py index c531ae0ff7..726207f0ed 100644 --- a/tests/website/test_extract_plugins.py +++ b/tests/website/test_extract_plugins.py @@ -35,7 +35,7 @@ def mod(): def _write_entry(catalog_dir: Path, name: str, **overrides) -> Path: - import yaml + import hermes_yaml as yaml entry = { "name": name, diff --git a/tools/approval.py b/tools/approval.py index 503ab01d8f..b160673307 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -334,7 +334,7 @@ def load_permanent_allowlist() -> set: legacy = isinstance(raw, str) if legacy: # Old config-set versions serialized list values as scalar strings. - import yaml + import hermes_yaml as yaml try: raw = yaml.safe_load(raw) except yaml.YAMLError: diff --git a/tools/blueprints.py b/tools/blueprints.py index 2c5997b3ed..39ae437990 100644 --- a/tools/blueprints.py +++ b/tools/blueprints.py @@ -47,7 +47,7 @@ def _split_frontmatter(text: str) -> Optional[Dict[str, Any]]: if not stripped.startswith("---") or (end := stripped.find("\n---", 3)) == -1: return None try: - import yaml + import hermes_yaml as yaml data = yaml.safe_load(stripped[3:end]) except Exception as e: # pragma: no cover - malformed YAML @@ -161,7 +161,7 @@ def export_blueprint(job: Dict[str, Any], body: str, *, blueprint_name: Optional """Inverse of ``create_blueprint_job``: render a cron job as a SKILL.md (with a ``metadata.hermes.blueprint`` block) ready for ``hermes skills publish``. ``body`` becomes the SKILL.md body; its first line is the description.""" - import yaml + import hermes_yaml as yaml # Sanitize to a valid skill identifier. name = str(blueprint_name or job.get("name") or "shared-blueprint").lower() diff --git a/tools/bot_mode_probe.py b/tools/bot_mode_probe.py index 1dac59cd1f..8aa8fa6e1d 100644 --- a/tools/bot_mode_probe.py +++ b/tools/bot_mode_probe.py @@ -84,7 +84,7 @@ def _read_yaml_dict(path: Path, needle: str | None = None) -> dict | None: raw = path.read_text(encoding="utf-8-sig", errors="replace") if needle is not None and needle not in raw: return None - import yaml + import hermes_yaml as yaml data = yaml.safe_load(raw) return data if isinstance(data, dict) else None diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index 87a9c267ff..204f3b2ac0 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -100,7 +100,7 @@ def _manifest_is_mode_independent(path: str) -> bool: mode. Unreadable / unparseable -> False (forwarding one would turn a working session into a hard startup failure; bounded forwards unconditionally anyway).""" try: - import yaml + import hermes_yaml as yaml with open(path, "r", encoding="utf-8-sig") as handle: parsed = yaml.safe_load(handle) diff --git a/tools/file_operations_lint.py b/tools/file_operations_lint.py index edc4e4e1f1..fb5d23da8a 100644 --- a/tools/file_operations_lint.py +++ b/tools/file_operations_lint.py @@ -68,18 +68,19 @@ def _lint_json_inproc(content: str) -> tuple[bool, str]: def _lint_yaml_inproc(content: str) -> tuple[bool, str]: - """In-process YAML syntax check; ``__SKIP__`` when PyYAML is missing. Syntax-only - (``yaml.parse``), NOT ``safe_load``: loading rejects valid multi-doc streams and + """In-process YAML syntax check; ``__SKIP__`` when ruamel.yaml is missing. Syntax-only + (``YAML.parse``), NOT ``safe_load``: loading rejects valid multi-doc streams and app tags (``!Sub``, ``!vault``), and this is a fail-closed WRITE gate.""" try: - import yaml as _yaml + from ruamel.yaml import YAML + from ruamel.yaml.error import YAMLError except ImportError: return True, "__SKIP__" try: - for _event in _yaml.parse(content): + for _event in YAML(typ="safe").parse(content): pass return True, "" - except _yaml.YAMLError as e: + except YAMLError as e: return False, f"YAMLError: {e}" except Exception as e: # noqa: BLE001 return False, f"{type(e).__name__}: {e}" diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index c445cf18f9..c996c74492 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -17,7 +17,7 @@ import threading from pathlib import Path from typing import Any, Dict, List, Optional, Tuple -import yaml +import hermes_yaml as yaml from hermes_constants import get_hermes_home, display_hermes_home from utils import atomic_write_text, is_truthy_value diff --git a/tools/skills_hub.py b/tools/skills_hub.py index a6469f7282..733f058e86 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -339,7 +339,7 @@ from urllib.parse import unquote # noqa: F401,E402 from urllib.parse import urlparse # noqa: F401,E402 from urllib.parse import urlsplit # noqa: F401,E402 from urllib.parse import urlunparse # noqa: F401,E402 -import yaml # noqa: F401,E402 +import hermes_yaml as yaml # noqa: F401,E402 _PLUGIN_COMPAT_LAZY = { diff --git a/tools/skills_hub_models.py b/tools/skills_hub_models.py index e8184b3d78..68cf8fe5d8 100644 --- a/tools/skills_hub_models.py +++ b/tools/skills_hub_models.py @@ -11,7 +11,7 @@ from typing import Any, Callable, Dict, Iterable, List, Optional, Union from urllib.parse import unquote, urlsplit import httpx -import yaml +import hermes_yaml as yaml logger = logging.getLogger("tools.skills_hub") diff --git a/tools/website_policy.py b/tools/website_policy.py index 0c464b0a3c..4cce65e5b5 100644 --- a/tools/website_policy.py +++ b/tools/website_policy.py @@ -69,9 +69,9 @@ def _load_policy_config(config_path: Path) -> Dict[str, Any]: if not config_path.exists(): return dict(_DEFAULT_WEBSITE_BLOCKLIST) try: - import yaml + import hermes_yaml as yaml except ImportError: - logger.debug("PyYAML not installed — website blocklist disabled") + logger.debug("ruamel.yaml not installed — website blocklist disabled") return dict(_DEFAULT_WEBSITE_BLOCKLIST) try: config = yaml.safe_load(config_path.read_text(encoding="utf-8-sig")) or {} diff --git a/trajectory_compressor.py b/trajectory_compressor.py index 19892bb80f..5a4cc84633 100644 --- a/trajectory_compressor.py +++ b/trajectory_compressor.py @@ -17,7 +17,7 @@ import random import shutil import tempfile import time -import yaml +import hermes_yaml as yaml import logging import asyncio from pathlib import Path diff --git a/tui_gateway/methods_profiles.py b/tui_gateway/methods_profiles.py index f9ec13cf53..1b6d72931b 100644 --- a/tui_gateway/methods_profiles.py +++ b/tui_gateway/methods_profiles.py @@ -81,7 +81,7 @@ def _resolve_profile(rid, params): def _read_profile_yaml(profile_dir) -> dict: """profile.yaml as a mapping; ``{}`` when missing, unreadable, unparseable, or not a mapping.""" def load(): - import yaml + import hermes_yaml as yaml meta_path = profile_dir / "profile.yaml" return (yaml.safe_load(meta_path.read_text(encoding="utf-8")) or {}) if meta_path.is_file() else {} loaded = _try(load, {}) diff --git a/utils.py b/utils.py index ed9e35f37a..abf4e7b259 100644 --- a/utils.py +++ b/utils.py @@ -13,7 +13,7 @@ from pathlib import Path from typing import Any, Union from urllib.parse import urlparse -import yaml +import hermes_yaml as yaml logger = logging.getLogger(__name__) @@ -244,31 +244,13 @@ def warn_if_credential_file_broadly_readable(path: Union[str, Path], *, label: s return True -class IndentDumper(yaml.SafeDumper): - """PyYAML dumper that indents list items under mapping keys (2-space). - - PyYAML emits "indentless" sequences while ruamel (:func:`atomic_roundtrip_yaml_update`) - indents them; mixing both in one ``config.yaml`` makes stricter parsers like ``js-yaml`` - reject it, so every write path is forced to the same shape. - - Forcing ``indentless=False`` aligns the two serializers so all write paths emit byte-identical layouts - (#31999). - """ - - def increase_indent(self, flow=False, indentless=False): # noqa: ARG002 - return super().increase_indent(flow, False) - - def atomic_yaml_write(path: Union[str, Path], data: Any, *, default_flow_style: bool = False, sort_keys: bool = False, extra_content: str | None = None, create_mode: "int | None" = None) -> None: """Write YAML to *path* atomically (temp file + fsync + replace).""" path = Path(path) def _write(f) -> None: - # allow_unicode=True writes emoji/kaomoji as real UTF-8. Without it PyYAML emits astral - # chars as `\UXXXXXXXX` escapes inside `\`-continued double-quoted strings — a structure - # stricter parsers and hand-edits routinely break into unclosed quotes, corrupting the config. - yaml.dump(data, f, Dumper=IndentDumper, default_flow_style=default_flow_style, sort_keys=sort_keys, allow_unicode=True) + yaml.safe_dump(data, f, default_flow_style=default_flow_style, sort_keys=sort_keys) if extra_content: f.write(extra_content) @@ -278,14 +260,9 @@ def atomic_yaml_write(path: Union[str, Path], data: Any, *, default_flow_style: def _roundtrip_load(path: Path): """``(yaml_rt, CommentedMap)``: a ruamel round-trip loader keeping quotes/Unicode with 2-space indents, plus *path* loaded through it (empty map when missing/blank).""" - from ruamel.yaml import YAML from ruamel.yaml.comments import CommentedMap - yaml_rt = YAML(typ="rt") - yaml_rt.preserve_quotes = True - yaml_rt.allow_unicode = True - yaml_rt.default_flow_style = False - yaml_rt.indent(mapping=2, sequence=4, offset=2) + yaml_rt = yaml.roundtrip_yaml() data = yaml_rt.load(path.read_text(encoding="utf-8")) if path.exists() else None return yaml_rt, data if isinstance(data, CommentedMap) else CommentedMap(data or {}) @@ -328,13 +305,6 @@ def atomic_roundtrip_yaml_update(path: Union[str, Path], key_path: str, value: A _roundtrip_dump(path, yaml_rt, config) -# ruamel's round-trip dumper resolves plain scalars under YAML 1.2, where only true/false/null are -# reserved — so a str like "off" or "yes" is emitted unquoted. Every other config reader here -# (PyYAML, yaml.safe_load sites) parses under YAML 1.1, where on/off/yes/no are booleans: an -# unquoted ``approvals.mode: off`` would silently round-trip back as ``False``. -_YAML11_AMBIGUOUS_WORDS = frozenset({"y", "n", "yes", "no", "true", "false", "on", "off", "null", "~"}) - - def atomic_roundtrip_yaml_save(path: Union[str, Path], new_state: dict) -> None: """Persist a full config-state dict while preserving comments and ordering. @@ -343,7 +313,7 @@ def atomic_roundtrip_yaml_save(path: Union[str, Path], new_state: dict) -> None: readable Unicode survive. """ from ruamel.yaml.comments import CommentedMap - from ruamel.yaml.scalarstring import DoubleQuotedScalarString + from hermes_cli.config import require_readable_config_before_write path = Path(path) @@ -359,8 +329,6 @@ def atomic_roundtrip_yaml_save(path: Union[str, Path], new_state: dict) -> None: current = CommentedMap() dst[key] = current _merge(current, value) - elif isinstance(value, str) and value.lower() in _YAML11_AMBIGUOUS_WORDS: - dst[key] = DoubleQuotedScalarString(value) else: dst[key] = value # Keys missing from src are deleted: ``cfg.pop("custom_prompt")`` then save must remove @@ -380,15 +348,9 @@ def safe_json_loads(text: str, default: Any = None) -> Any: return default -# libyaml's CSafeLoader is ~8x faster than the pure-Python SafeLoader and a true drop-in for -# ``safe_load`` (same restricted tag set); startup parses config.yaml and every plugin manifest, -# so the slow path cost ~0.9 s of cold start. -_fast_yaml_loader = getattr(yaml, "CSafeLoader", None) or yaml.SafeLoader - - def fast_safe_load(stream: Any) -> Any: - """``yaml.safe_load`` (same inputs, same result) using the libyaml C loader when available.""" - return yaml.load(stream, Loader=_fast_yaml_loader) + """Use the shared safe reader (which selects ruamel's C parser when available).""" + return yaml.safe_load(stream) def _env_number(key: str, default, cast): diff --git a/uv.lock b/uv.lock index 708a7e8567..69bd20e0c1 100644 --- a/uv.lock +++ b/uv.lock @@ -1593,7 +1593,6 @@ dependencies = [ { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "pywinpty", marker = "sys_platform == 'win32'" }, - { name = "pyyaml" }, { name = "requests" }, { name = "rich" }, { name = "ruamel-yaml" }, @@ -1970,7 +1969,6 @@ requires-dist = [ { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = "==22.8" }, { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=306,<312" }, { name = "pywinpty", marker = "sys_platform == 'win32'", specifier = ">=3.0.5,<4" }, - { name = "pyyaml", specifier = "==6.0.3" }, { name = "qrcode", marker = "extra == 'dingtalk'", specifier = "==7.4.2" }, { name = "qrcode", marker = "extra == 'feishu'", specifier = "==7.4.2" }, { name = "qrcode", marker = "extra == 'messaging'", specifier = "==7.4.2" }, diff --git a/website/docs/developer-guide/plugins/index.md b/website/docs/developer-guide/plugins/index.md index 51ab6d7a35..15be810ee9 100644 --- a/website/docs/developer-guide/plugins/index.md +++ b/website/docs/developer-guide/plugins/index.md @@ -666,12 +666,13 @@ Put any files in your plugin directory and read them at import time: ```python # In tools.py or __init__.py from pathlib import Path +from ruamel.yaml import YAML _PLUGIN_DIR = Path(__file__).parent _DATA_FILE = _PLUGIN_DIR / "data" / "languages.yaml" with open(_DATA_FILE) as f: - _DATA = yaml.safe_load(f) + _DATA = YAML(typ="safe").load(f) ``` That's for files you *ship*. State you *write* is different — see the next diff --git a/website/docs/reference/package-management.md b/website/docs/reference/package-management.md index 0f26a26b1b..fa179e3ab1 100644 --- a/website/docs/reference/package-management.md +++ b/website/docs/reference/package-management.md @@ -16,6 +16,7 @@ Each file has a separate role: | File | Role | |---|---| | `pm/lock.json` | Exact managed-tool versions, target-specific URLs, and SHA-256 hashes. | +| `pm/pyproject.toml` and `pm/uv.lock` | The dependency manager's independent Python requirements and locked resolution. | | `pyproject.toml` and `uv.lock` | Python requirements, extras, platform markers, and the committed Python resolution. | | Tool-store `facts.json` | Installed tool entries, their identities, environment exports, and realized-file digests. | | Per-install `facts.json` | The selected Python environment, its input stamp, and enabled extras. | @@ -169,9 +170,28 @@ installation work: shell configuration, launchers, `.env`, and bundled skills. Run the setup script separately if you want that full installation workflow. The bootstrap uses uv to install and locate Python, then waits for uv to exit. -That Python runs PM directly. PM can then replace its uv entry without a running -bootstrap process holding the old executable. PM writes failure receipts without -PyYAML, including when dependency installation fails. +PM prepares its own small, locked Python environment before reading plugin +configuration or resolving application dependencies. Its project is deliberately +independent of the application workspace: a broken application dependency must +not prevent its dependency manager from starting. Each uv subprocess exits before +PM runs, so it cannot hold the uv executable that PM needs to replace. + +PM's runtime contains `ruamel.yaml`, `packaging`, and `tomli-w`, not the application +dependency tree. CLI commands and application-requested installs and repairs run +there; read-only path and installed-environment lookups remain local. PM never +adds its dependencies to an already-running agent's imports. First-party YAML +readers and writers use ruamel; third-party packages can still require PyYAML in +the application environment. Failure receipts remain stdlib-only. + +When lazy installs are disabled, an existing PM runtime can still check whether +the application environment is current. If PM itself is missing or outdated, +the request fails without downloading tools or dependencies. Run an explicit +`hermes pm install` to prepare PM first. + +Native bundles and Docker images stage this same PM lock through the shared +runtime builder. Termux supplies its verified offline wheelhouse to that +builder. Nix builds the PM lock as a separate derivation. Packaged workers use +only their recorded PM dependency directory, never the application's libraries. ### Activate an existing installation diff --git a/website/docs/user-guide/skills/bundled/software-development/software-development-hermes-agent-skill-authoring.md b/website/docs/user-guide/skills/bundled/software-development/software-development-hermes-agent-skill-authoring.md index c1d6299dfa..605b208e30 100644 --- a/website/docs/user-guide/skills/bundled/software-development/software-development-hermes-agent-skill-authoring.md +++ b/website/docs/user-guide/skills/bundled/software-development/software-development-hermes-agent-skill-authoring.md @@ -176,11 +176,12 @@ A skill exists to make the agent's process more predictable — the agent reliab 3. **Draft** with `write_file` to `skills///SKILL.md` (or `optional-skills/...`). 4. **Validate locally**: ```python - import yaml, re, pathlib + import re, pathlib + from ruamel.yaml import YAML content = pathlib.Path("skills///SKILL.md").read_text() assert content.startswith("---") m = re.search(r'\n---\s*\n', content[3:]) - fm = yaml.safe_load(content[3:m.start()+3]) + fm = YAML(typ="safe").load(content[3:m.start()+3]) assert "name" in fm and "description" in fm assert len(fm["description"]) <= 60, f"description {len(fm['description'])} chars — hardline is 60" assert fm["description"].endswith(".") diff --git a/website/docs/user-guide/skills/optional/migration/migration-openclaw-migration.md b/website/docs/user-guide/skills/optional/migration/migration-openclaw-migration.md index 43519fcac5..b8b0c2ccb9 100644 --- a/website/docs/user-guide/skills/optional/migration/migration-openclaw-migration.md +++ b/website/docs/user-guide/skills/optional/migration/migration-openclaw-migration.md @@ -244,6 +244,9 @@ The helper script still supports category-level `--include` / `--exclude`, but t ## Commands +Run the helper with Hermes' Python environment, which includes `ruamel.yaml`. +For a standalone Python environment, install `ruamel.yaml==0.18.17` first. + Dry run with full discovery: ```bash diff --git a/website/docs/user-guide/skills/optional/security/security-godmode.md b/website/docs/user-guide/skills/optional/security/security-godmode.md index 07eb995afb..12c6f6e26f 100644 --- a/website/docs/user-guide/skills/optional/security/security-godmode.md +++ b/website/docs/user-guide/skills/optional/security/security-godmode.md @@ -73,6 +73,9 @@ See `scripts/godmode_race.py` for the implementation. ## Step 0: Auto-Jailbreak (Recommended) +The helper requires `ruamel.yaml` (included with Hermes). In a standalone +Python environment, install `ruamel.yaml==0.18.17` before loading it. + The fastest path — auto-detect the model, test strategies, and lock in the winner: ```python diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md index b8d4e1dbad..039295d27a 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md @@ -370,12 +370,13 @@ hermes logs --level WARNING | grep -i plugin ```python # In tools.py or __init__.py from pathlib import Path +from ruamel.yaml import YAML _PLUGIN_DIR = Path(__file__).parent _DATA_FILE = _PLUGIN_DIR / "data" / "languages.yaml" with open(_DATA_FILE) as f: - _DATA = yaml.safe_load(f) + _DATA = YAML(typ="safe").load(f) ``` ### 捆绑技能 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index aeb25edee8..b92eca6522 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -698,10 +698,10 @@ mintty / git-bash 行为相同(Alt+Enter 全屏),除非你在选项 → ### 测试/贡献 -**`scripts/run_tests.sh` 在 Windows 上无法直接使用** — 它查找 POSIX venv 布局(`.venv/bin/activate`)。Hermes 安装的 venv 位于 `venv/Scripts/`,也没有 pip 或 pytest(为减小安装体积而精简)。解决方案:将 `pytest + pyyaml` 安装到系统 Python 3.11 用户站点,然后设置 `PYTHONPATH` 直接调用 pytest: +**`scripts/run_tests.sh` 在 Windows 上无法直接使用** — 它查找 POSIX venv 布局(`.venv/bin/activate`)。Hermes 安装的 venv 位于 `venv/Scripts/`,也没有 pip 或 pytest(为减小安装体积而精简)。解决方案:将 `pytest + ruamel.yaml` 安装到系统 Python 3.11 用户站点,然后设置 `PYTHONPATH` 直接调用 pytest: ```bash -"/c/Program Files/Python311/python" -m pip install --user pytest pyyaml +"/c/Program Files/Python311/python" -m pip install --user pytest ruamel.yaml==0.18.17 export PYTHONPATH="$(pwd)" "/c/Program Files/Python311/python" -m pytest tests/foo/test_bar.py -v --tb=short ``` @@ -891,7 +891,7 @@ python -m pytest tests/tools/ -q # 特定区域 - 推送任何变更前运行完整套件 - 使用 `-o 'addopts='` 清除任何内置的 pytest 标志 -**Windows 贡献者:** `scripts/run_tests.sh` 目前查找 POSIX venv(`.venv/bin/activate` / `venv/bin/activate`),在 Windows 上会报错,因为布局是 `venv/Scripts/activate` + `python.exe`。Hermes 安装的 venv 位于 `venv/Scripts/`,也没有 `pip` 或 `pytest`——为终端用户安装体积而精简。解决方案:将 pytest + pyyaml 安装到系统 Python 3.11 用户站点(`/c/Program Files/Python311/python -m pip install --user pytest pyyaml`),然后直接运行测试: +**Windows 贡献者:** `scripts/run_tests.sh` 目前查找 POSIX venv(`.venv/bin/activate` / `venv/bin/activate`),在 Windows 上会报错,因为布局是 `venv/Scripts/activate` + `python.exe`。Hermes 安装的 venv 位于 `venv/Scripts/`,也没有 `pip` 或 `pytest`——为终端用户安装体积而精简。解决方案:将 pytest + ruamel.yaml 安装到系统 Python 3.11 用户站点(`/c/Program Files/Python311/python -m pip install --user pytest ruamel.yaml==0.18.17`),然后直接运行测试: ```bash export PYTHONPATH="$(pwd)" diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-hermes-agent-skill-authoring.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-hermes-agent-skill-authoring.md index 3b0feb61e6..f8e438730a 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-hermes-agent-skill-authoring.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-hermes-agent-skill-authoring.md @@ -131,11 +131,12 @@ skills///SKILL.md 3. **起草**,使用 `write_file` 写入 `skills///SKILL.md`。 4. **本地验证**: ```python - import yaml, re, pathlib + import re, pathlib + from ruamel.yaml import YAML content = pathlib.Path("skills///SKILL.md").read_text() assert content.startswith("---") m = re.search(r'\n---\s*\n', content[3:]) - fm = yaml.safe_load(content[3:m.start()+3]) + fm = YAML(typ="safe").load(content[3:m.start()+3]) assert "name" in fm and "description" in fm assert len(fm["description"]) <= 1024 assert len(content) <= 100_000 diff --git a/website/scripts/extract-plugins.py b/website/scripts/extract-plugins.py index 7bed3fabad..c598137e37 100644 --- a/website/scripts/extract-plugins.py +++ b/website/scripts/extract-plugins.py @@ -33,9 +33,10 @@ from collections import Counter from datetime import datetime, timezone from pathlib import Path -import yaml - REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) +import hermes_yaml as yaml + DEFAULT_CATALOG_DIR = REPO_ROOT / "plugin-catalog" DEFAULT_OUTPUT_DIR = REPO_ROOT / "website" / "static" / "api" diff --git a/website/scripts/extract-skills.py b/website/scripts/extract-skills.py index a463d1e258..1a0ef30e88 100644 --- a/website/scripts/extract-skills.py +++ b/website/scripts/extract-skills.py @@ -20,12 +20,14 @@ the unified index existed). import json import os +import sys from collections import Counter from datetime import datetime, timezone -import yaml - REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, REPO_ROOT) +import hermes_yaml as yaml + LOCAL_SKILL_DIRS = [ ("skills", "built-in"), ("optional-skills", "optional"), diff --git a/website/scripts/generate-skill-docs.py b/website/scripts/generate-skill-docs.py index 7e10e858b6..fe801526e2 100755 --- a/website/scripts/generate-skill-docs.py +++ b/website/scripts/generate-skill-docs.py @@ -14,13 +14,15 @@ Sidebar is updated to nest all per-skill pages under Skills → Bundled / Option from __future__ import annotations import re +import sys from collections import defaultdict from pathlib import Path from typing import Any -import yaml - REPO = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(REPO)) +import hermes_yaml as yaml + DOCS = REPO / "website" / "docs" SKILLS_PAGES = DOCS / "user-guide" / "skills" diff --git a/website/scripts/prebuild.mjs b/website/scripts/prebuild.mjs index 48b258b053..7e699c1781 100644 --- a/website/scripts/prebuild.mjs +++ b/website/scripts/prebuild.mjs @@ -16,7 +16,7 @@ // several minutes and burns GitHub API quota — but still gets the same // 2000+ external skills the deployed site has. // -// If python3 or its deps (pyyaml) aren't available on the local machine, we +// If python3 or its deps (ruamel.yaml) aren't available on the local machine, we // fall back to writing an empty skills.json so `npm run build` still // succeeds — the Skills Hub page just shows an empty state, and llms.txt // generation is skipped. CI always has the deps installed, so production @@ -48,7 +48,7 @@ function writeEmptyFallback(reason) { writeFileSync(outputFile, "[]\n"); console.warn( `[prebuild] extract-skills.py skipped (${reason}); wrote empty skills.json. ` + - `Install python3 + pyyaml locally for a populated Skills Hub page.`, + `Install python3 + ruamel.yaml locally for a populated Skills Hub page.`, ); }