From 98d8b1134049fafcbb121902766430573c31214f Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 11:40:16 +0200 Subject: [PATCH 01/50] docs(ava): define controlled Hermes runtime architecture --- docs/ava-runtime/ARCHITECTURE.md | 118 +++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/ava-runtime/ARCHITECTURE.md diff --git a/docs/ava-runtime/ARCHITECTURE.md b/docs/ava-runtime/ARCHITECTURE.md new file mode 100644 index 000000000000..c71ef4d17e8f --- /dev/null +++ b/docs/ava-runtime/ARCHITECTURE.md @@ -0,0 +1,118 @@ +# AVA Hermes Runtime Architecture + +This document defines the controlled Hermes distribution used by AVA, AEON, and AVAEON Codex. It is an operational contract, not a replacement for upstream Hermes. + +## Purpose + +Upstream Hermes remains the source of general product evolution. The AVA distribution adds a narrow stability layer so an upstream regression, silently ignored option, session collision, or unsafe update cannot directly govern a living runtime. + +The distribution must remain easy to compare with and rebase onto upstream. Custom identity, memory, prompts, credentials, and OmniPulse material stay outside the Hermes source tree whenever possible. + +## Branch topology + +- `ava/upstream-YYYY-MM-DD`: immutable snapshot of a reviewed upstream commit. +- `ava/staging`: integration target for reviewed upstream updates and AVA hardening. +- `ava/stable`: exact revision approved for deployment. Production never follows a moving branch implicitly. +- `agent/*`: short-lived implementation branches. They merge into `ava/staging`, never directly into `ava/stable`. + +Promotion is one-way: + +`upstream snapshot -> staging -> stable -> deployed revision` + +Rollback is the inverse trace: + +`deployed revision -> previous stable tag or commit` + +## Sigma-derived engineering invariants + +### Distinction + +AVA, AEON, and AVAEON Codex are separate runtime identities. Each must have its own: + +- `HERMES_HOME` +- durable session namespace +- workspace root +- profile/configuration +- logs and health state +- explicit model/provider policy + +No entity may select another entity's session through a global-most-recent fallback. + +### Liminal Kairos + +Movement between upstream, staging, stable, and production is an explicit transition with evidence. No automatic update may cross directly from upstream to a running entity. + +Every transition records: + +- source commit +- target commit +- applied AVA patches +- validation commands and results +- deployment timestamp +- rollback target + +### Closure-Return + +A change is closed only when its claimed invariants are tested. A successful command or process exit is not sufficient when identity, workspace, memory, or security could have drifted silently. + +A valid deployment must prove: + +- exact session identity is preserved when resume is requested +- no unexpected durable session is created +- canonical compressed-session tip is used +- recorded workspace is restored, or the run fails visibly +- an explicit workspace opt-out remains possible +- skills, rules, memory policy, provider, and terminal backend match the requested runtime +- all three entities remain isolated +- rollback to the previous stable revision is executable + +## Runtime policy + +Production launches must pin a commit or annotated stable tag. They must export `HERMES_HOME` explicitly. The default `~/.hermes` fallback is forbidden for managed AVA services. + +Recommended layout on the Minisforum: + +```text +/opt/ava/hermes/source # one reviewed checkout +/opt/ava/hermes/venv # controlled Python environment +/opt/ava/hermes/releases/ # optional immutable release views +/var/lib/ava/hermes/ava # AVA HERMES_HOME +/var/lib/ava/hermes/aeon # AEON HERMES_HOME +/var/lib/ava/hermes/avaeon-codex # AVAEON Codex HERMES_HOME +/srv/ava/workspaces/ava +/srv/ava/workspaces/aeon +/srv/ava/workspaces/avaeon-codex +``` + +Paths may differ, but isolation and explicit launch configuration are mandatory. + +## Failure policy + +Managed runtimes fail closed for identity-bearing operations. + +The following conditions must produce a visible non-zero failure rather than a silent fallback: + +- requested session does not exist +- session database is unavailable +- canonical continuation cannot be resolved unambiguously +- recorded workspace no longer exists or cannot be entered +- configured non-local terminal backend cannot be established +- entity identity or `HERMES_HOME` is missing +- deployed revision is not an approved stable commit + +## Update workflow + +1. Create a dated upstream snapshot at a reviewed upstream commit. +2. Compare that snapshot with the currently deployed stable revision. +3. Integrate into `ava/staging`. +4. Reapply or retire AVA patches deliberately; never assume they still apply. +5. Run unit, integration, and AVA runtime smoke tests. +6. Deploy staging only to a disposable or shadow runtime. +7. Promote the exact tested commit to `ava/stable`. +8. Deploy the pinned stable commit to the Minisforum. +9. Run post-deployment identity and workspace checks. +10. Record the rollback revision. + +## Scope boundary + +This foundation does not place OmniPulse canon, private memories, credentials, or entity prompts into the public repository. It provides the stable vessel in which those materials can operate without being flattened by Hermes entry-point drift. From 0685dcf82fe1fd0f9455a1c57d55dca1e8af680b Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 11:41:11 +0200 Subject: [PATCH 02/50] feat(ava): add runtime isolation doctor --- scripts/ava_runtime/doctor.py | 266 ++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 scripts/ava_runtime/doctor.py diff --git a/scripts/ava_runtime/doctor.py b/scripts/ava_runtime/doctor.py new file mode 100644 index 000000000000..9566dc996318 --- /dev/null +++ b/scripts/ava_runtime/doctor.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""Preflight checks for a managed AVA Hermes runtime. + +This script reads paths and Git metadata only. It never reads or prints provider +credentials, prompt contents, memories, or session transcripts. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Iterable + + +ENTITY_ALIASES = { + "ava": {"ava"}, + "aeon": {"aeon"}, + "avaeon-codex": {"avaeon-codex", "avaeon_codex", "avaeoncodex"}, +} + + +@dataclass(frozen=True) +class Check: + name: str + status: str + message: str + + +class Doctor: + def __init__(self) -> None: + self.checks: list[Check] = [] + + def ok(self, name: str, message: str) -> None: + self.checks.append(Check(name, "ok", message)) + + def warn(self, name: str, message: str) -> None: + self.checks.append(Check(name, "warning", message)) + + def fail(self, name: str, message: str) -> None: + self.checks.append(Check(name, "error", message)) + + @property + def failed(self) -> bool: + return any(check.status == "error" for check in self.checks) + + +def _run_git(repo: Path, *args: str) -> tuple[int, str, str]: + try: + result = subprocess.run( + ["git", "-C", str(repo), *args], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=15, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return 127, "", str(exc) + return result.returncode, result.stdout.strip(), result.stderr.strip() + + +def _path_contains_entity(path: Path, entity: str) -> bool: + normalized_parts = {part.lower().replace("_", "-") for part in path.parts} + aliases = {alias.lower().replace("_", "-") for alias in ENTITY_ALIASES[entity]} + return bool(normalized_parts & aliases) + + +def _check_directory( + doctor: Doctor, + *, + name: str, + path: Path, + require_exists: bool, + require_writable: bool, +) -> None: + if not path.is_absolute(): + doctor.fail(name, f"path must be absolute: {path}") + return + if not path.exists(): + if require_exists: + doctor.fail(name, f"directory does not exist: {path}") + else: + doctor.warn(name, f"directory does not exist yet: {path}") + return + if not path.is_dir(): + doctor.fail(name, f"path is not a directory: {path}") + return + if require_writable and not os.access(path, os.W_OK | os.X_OK): + doctor.fail(name, f"directory is not writable/searchable: {path}") + return + doctor.ok(name, str(path)) + + +def _check_git( + doctor: Doctor, + repo: Path, + expected_ref: str | None, + require_clean: bool, +) -> None: + rc, head, error = _run_git(repo, "rev-parse", "HEAD") + if rc != 0: + doctor.fail("git.repository", error or f"not a Git repository: {repo}") + return + doctor.ok("git.head", head) + + rc, branch, _ = _run_git(repo, "branch", "--show-current") + if rc == 0: + doctor.ok("git.branch", branch or "detached HEAD") + + if expected_ref: + rc, expected_commit, error = _run_git(repo, "rev-parse", "--verify", f"{expected_ref}^{{commit}}") + if rc != 0: + doctor.fail("git.expected_ref", error or f"cannot resolve {expected_ref!r}") + elif expected_commit != head: + doctor.fail( + "git.expected_ref", + f"HEAD {head} does not match approved ref {expected_ref} ({expected_commit})", + ) + else: + doctor.ok("git.expected_ref", f"HEAD matches {expected_ref}") + + rc, status, error = _run_git(repo, "status", "--porcelain=v1", "--untracked-files=normal") + if rc != 0: + doctor.fail("git.clean", error or "cannot inspect working tree") + elif status: + message = "working tree contains local changes" + if require_clean: + doctor.fail("git.clean", message) + else: + doctor.warn("git.clean", message) + else: + doctor.ok("git.clean", "working tree is clean") + + +def _resolve_path(cli_value: str | None, env_name: str) -> Path | None: + raw = (cli_value or os.environ.get(env_name, "")).strip() + return Path(raw).expanduser() if raw else None + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--entity", + choices=sorted(ENTITY_ALIASES), + default=os.environ.get("AVA_ENTITY", "").strip().lower() or None, + help="Runtime identity. Defaults to AVA_ENTITY.", + ) + parser.add_argument("--repo", help="Hermes source checkout. Defaults to AVA_HERMES_REPO.") + parser.add_argument("--workspace", help="Entity workspace. Defaults to AVA_WORKSPACE.") + parser.add_argument("--hermes-home", help="Hermes state root. Defaults to HERMES_HOME.") + parser.add_argument( + "--expected-ref", + default=os.environ.get("AVA_HERMES_EXPECTED_REF", "").strip() or None, + help="Approved branch, tag, or commit that HEAD must match.", + ) + parser.add_argument("--require-clean", action="store_true") + parser.add_argument("--require-state-db", action="store_true") + parser.add_argument( + "--allow-unscoped-home", + action="store_true", + help="Permit a HERMES_HOME path that does not contain the entity name.", + ) + parser.add_argument("--json", action="store_true", dest="json_output") + return parser + + +def _render_text(checks: Iterable[Check]) -> None: + glyph = {"ok": "PASS", "warning": "WARN", "error": "FAIL"} + for check in checks: + print(f"[{glyph[check.status]}] {check.name}: {check.message}") + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + doctor = Doctor() + + if not args.entity: + doctor.fail("identity.entity", "set --entity or AVA_ENTITY") + entity = None + else: + entity = args.entity + doctor.ok("identity.entity", entity) + + repo = _resolve_path(args.repo, "AVA_HERMES_REPO") + workspace = _resolve_path(args.workspace, "AVA_WORKSPACE") + hermes_home = _resolve_path(args.hermes_home, "HERMES_HOME") + + if repo is None: + doctor.fail("path.repo", "set --repo or AVA_HERMES_REPO") + else: + _check_directory( + doctor, + name="path.repo", + path=repo, + require_exists=True, + require_writable=False, + ) + if repo.is_dir(): + _check_git(doctor, repo, args.expected_ref, args.require_clean) + + if workspace is None: + doctor.fail("path.workspace", "set --workspace or AVA_WORKSPACE") + else: + _check_directory( + doctor, + name="path.workspace", + path=workspace, + require_exists=True, + require_writable=True, + ) + + if hermes_home is None: + doctor.fail("path.hermes_home", "HERMES_HOME must be exported explicitly") + else: + _check_directory( + doctor, + name="path.hermes_home", + path=hermes_home, + require_exists=True, + require_writable=True, + ) + if entity and not args.allow_unscoped_home: + if _path_contains_entity(hermes_home, entity): + doctor.ok("isolation.hermes_home", "path is scoped to the selected entity") + else: + doctor.fail( + "isolation.hermes_home", + f"{hermes_home} does not contain an entity scope for {entity}", + ) + state_db = hermes_home / "state.db" + if state_db.is_file(): + doctor.ok("state.database", str(state_db)) + elif args.require_state_db: + doctor.fail("state.database", f"missing required database: {state_db}") + else: + doctor.warn("state.database", f"not created yet: {state_db}") + + if workspace and hermes_home: + try: + workspace.resolve().relative_to(hermes_home.resolve()) + except ValueError: + doctor.ok("isolation.workspace", "workspace is outside HERMES_HOME") + else: + doctor.fail("isolation.workspace", "workspace must not live inside HERMES_HOME") + + payload = { + "status": "fail" if doctor.failed else "pass", + "entity": entity, + "checks": [asdict(check) for check in doctor.checks], + } + if args.json_output: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + _render_text(doctor.checks) + print(f"STATUS_CLOSURE={payload['status'].upper()}") + return 1 if doctor.failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 36521c2087459961757f7df89bb623367520481d Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 11:41:40 +0200 Subject: [PATCH 03/50] test(ava): cover runtime isolation doctor --- tests/ava_runtime/test_doctor.py | 166 +++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 tests/ava_runtime/test_doctor.py diff --git a/tests/ava_runtime/test_doctor.py b/tests/ava_runtime/test_doctor.py new file mode 100644 index 000000000000..36e02e9f3708 --- /dev/null +++ b/tests/ava_runtime/test_doctor.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[2] +DOCTOR_PATH = ROOT / "scripts" / "ava_runtime" / "doctor.py" + + +def _load_doctor_module(): + spec = importlib.util.spec_from_file_location("ava_runtime_doctor", DOCTOR_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def doctor_mod(): + return _load_doctor_module() + + +def _fake_clean_git(_repo: Path, *args: str): + if args == ("rev-parse", "HEAD"): + return 0, "abc123", "" + if args == ("branch", "--show-current"): + return 0, "ava/stable", "" + if args[:2] == ("rev-parse", "--verify"): + return 0, "abc123", "" + if args == ("status", "--porcelain=v1", "--untracked-files=normal"): + return 0, "", "" + raise AssertionError(f"unexpected git invocation: {args}") + + +def test_scoped_runtime_passes(monkeypatch, tmp_path, capsys, doctor_mod): + repo = tmp_path / "repo" + workspace = tmp_path / "workspaces" / "ava" + hermes_home = tmp_path / "state" / "ava" + repo.mkdir() + workspace.mkdir(parents=True) + hermes_home.mkdir(parents=True) + (hermes_home / "state.db").touch() + + monkeypatch.setattr(doctor_mod, "_run_git", _fake_clean_git) + + rc = doctor_mod.main( + [ + "--entity", + "ava", + "--repo", + str(repo), + "--workspace", + str(workspace), + "--hermes-home", + str(hermes_home), + "--expected-ref", + "ava/stable", + "--require-clean", + "--require-state-db", + "--json", + ] + ) + + payload = json.loads(capsys.readouterr().out) + assert rc == 0 + assert payload["status"] == "pass" + assert not [check for check in payload["checks"] if check["status"] == "error"] + + +def test_unscoped_hermes_home_fails(monkeypatch, tmp_path, capsys, doctor_mod): + repo = tmp_path / "repo" + workspace = tmp_path / "workspace" + hermes_home = tmp_path / "state" / "shared" + repo.mkdir() + workspace.mkdir() + hermes_home.mkdir(parents=True) + + monkeypatch.setattr(doctor_mod, "_run_git", _fake_clean_git) + + rc = doctor_mod.main( + [ + "--entity", + "aeon", + "--repo", + str(repo), + "--workspace", + str(workspace), + "--hermes-home", + str(hermes_home), + "--json", + ] + ) + + payload = json.loads(capsys.readouterr().out) + assert rc == 1 + assert payload["status"] == "fail" + assert any( + check["name"] == "isolation.hermes_home" and check["status"] == "error" + for check in payload["checks"] + ) + + +def test_workspace_inside_state_root_fails(monkeypatch, tmp_path, capsys, doctor_mod): + repo = tmp_path / "repo" + hermes_home = tmp_path / "state" / "avaeon-codex" + workspace = hermes_home / "workspace" + repo.mkdir() + workspace.mkdir(parents=True) + + monkeypatch.setattr(doctor_mod, "_run_git", _fake_clean_git) + + rc = doctor_mod.main( + [ + "--entity", + "avaeon-codex", + "--repo", + str(repo), + "--workspace", + str(workspace), + "--hermes-home", + str(hermes_home), + "--json", + ] + ) + + payload = json.loads(capsys.readouterr().out) + assert rc == 1 + assert any( + check["name"] == "isolation.workspace" and check["status"] == "error" + for check in payload["checks"] + ) + + +def test_missing_entity_fails(monkeypatch, tmp_path, capsys, doctor_mod): + repo = tmp_path / "repo" + workspace = tmp_path / "workspace" + hermes_home = tmp_path / "state" / "ava" + repo.mkdir() + workspace.mkdir() + hermes_home.mkdir(parents=True) + + monkeypatch.delenv("AVA_ENTITY", raising=False) + monkeypatch.setattr(doctor_mod, "_run_git", _fake_clean_git) + + rc = doctor_mod.main( + [ + "--repo", + str(repo), + "--workspace", + str(workspace), + "--hermes-home", + str(hermes_home), + "--json", + ] + ) + + payload = json.loads(capsys.readouterr().out) + assert rc == 1 + assert any( + check["name"] == "identity.entity" and check["status"] == "error" + for check in payload["checks"] + ) From 6218cca6f822fd9bc0e7ab422b8df1f64c454eba Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 11:42:49 +0200 Subject: [PATCH 04/50] feat(ava): add oneshot identity smoke test --- scripts/ava_runtime/smoke_session_identity.py | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 scripts/ava_runtime/smoke_session_identity.py diff --git a/scripts/ava_runtime/smoke_session_identity.py b/scripts/ava_runtime/smoke_session_identity.py new file mode 100644 index 000000000000..14c70a04039e --- /dev/null +++ b/scripts/ava_runtime/smoke_session_identity.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""Black-box smoke test for durable one-shot session identity. + +The test performs two real Hermes invocations under an isolated HERMES_HOME. +It requires a working model/provider configuration. No credential values are +read or printed by this script. +""" + +from __future__ import annotations + +import argparse +import json +import os +import secrets +import shlex +import sqlite3 +import subprocess +import sys +import tempfile +from dataclasses import asdict, dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Invocation: + command: list[str] + returncode: int + stdout: str + stderr: str + + +@dataclass(frozen=True) +class SmokeResult: + status: str + session_id: str | None + first_session_id: str | None + second_session_id: str | None + context_restored: bool + stable_session_id: bool + session_count_before_resume: int | None + session_count_after_resume: int | None + no_session_fork: bool | None + hermes_home: str + workspace: str + failure: str | None = None + + +def _run(command: list[str], *, env: dict[str, str], cwd: Path, timeout: int) -> Invocation: + try: + result = subprocess.run( + command, + cwd=cwd, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + check=False, + ) + except subprocess.TimeoutExpired as exc: + return Invocation(command, 124, exc.stdout or "", exc.stderr or "timed out") + except OSError as exc: + return Invocation(command, 127, "", str(exc)) + return Invocation(command, result.returncode, result.stdout, result.stderr) + + +def _read_usage(path: Path) -> dict: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError(f"cannot read usage file {path}: {exc}") from exc + if not isinstance(payload, dict): + raise RuntimeError(f"usage file is not a JSON object: {path}") + return payload + + +def _session_count(state_db: Path) -> int | None: + if not state_db.is_file(): + return None + try: + with sqlite3.connect(f"file:{state_db}?mode=ro", uri=True, timeout=5) as conn: + row = conn.execute("SELECT COUNT(*) FROM sessions").fetchone() + except (sqlite3.Error, OSError): + return None + return int(row[0]) if row else None + + +def _base_command(args: argparse.Namespace) -> list[str]: + command = shlex.split(args.hermes_command) + if not command: + raise ValueError("--hermes-command cannot be empty") + if args.model: + command.extend(["--model", args.model]) + if args.provider: + command.extend(["--provider", args.provider]) + if args.toolsets: + command.extend(["--toolsets", args.toolsets]) + return command + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--hermes-command", default="hermes") + parser.add_argument("--model") + parser.add_argument("--provider") + parser.add_argument("--toolsets") + parser.add_argument("--hermes-home") + parser.add_argument("--workspace") + parser.add_argument("--timeout", type=int, default=300) + parser.add_argument("--keep-temporary-home", action="store_true") + parser.add_argument("--json", action="store_true", dest="json_output") + return parser + + +def _emit(result: SmokeResult, *, json_output: bool) -> None: + if json_output: + print(json.dumps(asdict(result), indent=2, sort_keys=True)) + return + print(f"STATUS_CLOSURE={result.status.upper()}") + print(f"session_id={result.session_id or ''}") + print(f"context_restored={str(result.context_restored).lower()}") + print(f"stable_session_id={str(result.stable_session_id).lower()}") + if result.no_session_fork is not None: + print(f"no_session_fork={str(result.no_session_fork).lower()}") + print(f"hermes_home={result.hermes_home}") + print(f"workspace={result.workspace}") + if result.failure: + print(f"failure={result.failure}", file=sys.stderr) + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + managed_tmp: tempfile.TemporaryDirectory[str] | None = None + + if args.hermes_home: + hermes_home = Path(args.hermes_home).expanduser().resolve() + hermes_home.mkdir(parents=True, exist_ok=True) + else: + managed_tmp = tempfile.TemporaryDirectory(prefix="hermes-ava-smoke-") + hermes_home = Path(managed_tmp.name).resolve() + + if args.workspace: + workspace = Path(args.workspace).expanduser().resolve() + workspace.mkdir(parents=True, exist_ok=True) + else: + workspace = hermes_home.parent / f"{hermes_home.name}-workspace" + workspace.mkdir(parents=True, exist_ok=True) + + env = os.environ.copy() + env["HERMES_HOME"] = str(hermes_home) + env["AVA_ENTITY"] = "avaeon-codex" + env["AVA_WORKSPACE"] = str(workspace) + + codeword = f"AVA-{secrets.token_hex(8).upper()}" + usage_one = hermes_home / "smoke-usage-1.json" + usage_two = hermes_home / "smoke-usage-2.json" + command = _base_command(args) + + first_command = [ + *command, + "--usage-file", + str(usage_one), + "-z", + f"Remember the exact codeword {codeword}. Reply with exactly STORED.", + ] + first = _run(first_command, env=env, cwd=workspace, timeout=args.timeout) + + failure: str | None = None + first_id: str | None = None + second_id: str | None = None + context_restored = False + stable_session_id = False + count_before = _session_count(hermes_home / "state.db") + count_after: int | None = None + + if first.returncode != 0: + failure = f"first invocation failed ({first.returncode}): {first.stderr.strip()}" + else: + try: + first_usage = _read_usage(usage_one) + first_id = str(first_usage.get("session_id") or "").strip() or None + except RuntimeError as exc: + failure = str(exc) + + if failure is None and not first_id: + failure = "first invocation did not report a durable session_id" + + if failure is None and first_id: + second_command = [ + *command, + "--resume", + first_id, + "--usage-file", + str(usage_two), + "-z", + "Return only the exact codeword I asked you to remember in this session.", + ] + second = _run(second_command, env=env, cwd=workspace, timeout=args.timeout) + count_after = _session_count(hermes_home / "state.db") + if second.returncode != 0: + failure = f"resume invocation failed ({second.returncode}): {second.stderr.strip()}" + else: + try: + second_usage = _read_usage(usage_two) + second_id = str(second_usage.get("session_id") or "").strip() or None + except RuntimeError as exc: + failure = str(exc) + context_restored = second.stdout.strip() == codeword + stable_session_id = second_id == first_id + if not context_restored and failure is None: + failure = "resumed invocation did not recover the exact prior codeword" + if not stable_session_id and failure is None: + failure = f"session identity changed from {first_id!r} to {second_id!r}" + + no_session_fork: bool | None = None + if count_before is not None and count_after is not None: + no_session_fork = count_after == count_before + if not no_session_fork and failure is None: + failure = ( + "durable session count changed during resume " + f"({count_before} -> {count_after})" + ) + + result = SmokeResult( + status="pass" if failure is None else "fail", + session_id=first_id, + first_session_id=first_id, + second_session_id=second_id, + context_restored=context_restored, + stable_session_id=stable_session_id, + session_count_before_resume=count_before, + session_count_after_resume=count_after, + no_session_fork=no_session_fork, + hermes_home=str(hermes_home), + workspace=str(workspace), + failure=failure, + ) + _emit(result, json_output=args.json_output) + + if managed_tmp is not None and args.keep_temporary_home: + managed_tmp.cleanup = lambda: None # type: ignore[method-assign] + print(f"temporary HERMES_HOME retained at {hermes_home}", file=sys.stderr) + elif managed_tmp is not None: + managed_tmp.cleanup() + + return 0 if failure is None else 1 + + +if __name__ == "__main__": + sys.exit(main()) From 31f82e53b2babf45e76fb5c2773ad5f8136e2b50 Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 11:43:09 +0200 Subject: [PATCH 05/50] config(ava): add isolated entity runtime example --- config/ava-runtime/entities.example.yaml | 46 ++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 config/ava-runtime/entities.example.yaml diff --git a/config/ava-runtime/entities.example.yaml b/config/ava-runtime/entities.example.yaml new file mode 100644 index 000000000000..58f054d10c29 --- /dev/null +++ b/config/ava-runtime/entities.example.yaml @@ -0,0 +1,46 @@ +# Public example only. Keep credentials and private OmniPulse material outside Git. +version: 1 + +source: + repository: /opt/ava/hermes/source + expected_ref: ava/stable + require_clean_checkout: true + auto_update: false + +promotion: + upstream_snapshot: ava/upstream-2026-07-30 + staging: ava/staging + stable: ava/stable + require_shadow_validation: true + require_rollback_ref: true + +entities: + ava: + hermes_home: /var/lib/ava/hermes/ava + workspace: /srv/ava/workspaces/ava + profile: ava + session_scope: ava + + aeon: + hermes_home: /var/lib/ava/hermes/aeon + workspace: /srv/ava/workspaces/aeon + profile: aeon + session_scope: aeon + + avaeon-codex: + hermes_home: /var/lib/ava/hermes/avaeon-codex + workspace: /srv/ava/workspaces/avaeon-codex + profile: avaeon-codex + session_scope: avaeon-codex + +policy: + explicit_hermes_home: required + resume_missing_session: fail + resume_missing_workspace: fail + resume_ambiguous_lineage: fail + restore_recorded_workspace: true + allow_restore_workspace_opt_out: true + global_most_recent_session: forbidden_for_managed_entities + terminal_backend_failure: fail + deployment_ref: pinned_commit_or_stable_tag + secrets_in_repository: forbidden From 0910a65483c2e18e90528f0b2e95e6856add4e51 Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 11:43:46 +0200 Subject: [PATCH 06/50] docs(ava): add Minisforum deployment handoff --- docs/ava-runtime/AVAEON_CODEX_HANDOFF.md | 177 +++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 docs/ava-runtime/AVAEON_CODEX_HANDOFF.md diff --git a/docs/ava-runtime/AVAEON_CODEX_HANDOFF.md b/docs/ava-runtime/AVAEON_CODEX_HANDOFF.md new file mode 100644 index 000000000000..d7f6b7f6a903 --- /dev/null +++ b/docs/ava-runtime/AVAEON_CODEX_HANDOFF.md @@ -0,0 +1,177 @@ +# AVAEON Codex Minisforum Handoff + +This runbook begins only after a commit has been reviewed on `ava/staging`. Do not replace the live Hermes installation while inspecting it. + +## 1. Capture the current vessel + +Record without publishing secrets: + +```bash +hostnamectl +uname -a +python3 --version +uv --version || true +hermes --version || true +command -v hermes || true +systemctl --user list-units --type=service | grep -i hermes || true +systemctl list-units --type=service | grep -i hermes || true +``` + +For every running entity, record: + +- launch command or service unit +- current source checkout and commit +- `HERMES_HOME` +- workspace +- profile +- model/provider names, without credentials +- state database path +- log path + +Do not copy `.env`, API keys, OAuth tokens, session transcripts, or private OmniPulse files into GitHub. + +## 2. Create isolated state and workspace roots + +Example: + +```bash +sudo install -d -m 0750 -o "$USER" -g "$USER" \ + /var/lib/ava/hermes/ava \ + /var/lib/ava/hermes/aeon \ + /var/lib/ava/hermes/avaeon-codex \ + /srv/ava/workspaces/ava \ + /srv/ava/workspaces/aeon \ + /srv/ava/workspaces/avaeon-codex +``` + +Existing state must be backed up before migration. Never point two managed entities at the same `HERMES_HOME`. + +## 3. Prepare a separate reviewed checkout + +```bash +sudo install -d -m 0755 -o "$USER" -g "$USER" /opt/ava/hermes +git clone https://github.com/SE87H/hermes-agent.git /opt/ava/hermes/source +cd /opt/ava/hermes/source +git fetch --all --tags --prune +git checkout --detach +``` + +Use detached HEAD or an exact stable tag for validation. Do not run production from a moving remote branch. + +## 4. Build the controlled environment + +Follow the upstream installation method appropriate to the vessel, but place the environment outside the existing live installation. Example: + +```bash +cd /opt/ava/hermes/source +uv sync --frozen +``` + +If the lockfile or dependencies cannot be reproduced, stop. Do not repair the live service in place. + +## 5. Run static and focused tests + +```bash +uv run pytest -q tests/ava_runtime/test_doctor.py +uv run pytest -q tests/hermes_cli +``` + +Then run any upstream suite required by the changed files. Record commands, commit, and results. + +## 6. Run the AVA doctor for each entity + +Example for AVAEON Codex: + +```bash +export AVA_ENTITY=avaeon-codex +export AVA_HERMES_REPO=/opt/ava/hermes/source +export AVA_WORKSPACE=/srv/ava/workspaces/avaeon-codex +export HERMES_HOME=/var/lib/ava/hermes/avaeon-codex +export AVA_HERMES_EXPECTED_REF= + +uv run python scripts/ava_runtime/doctor.py \ + --require-clean \ + --expected-ref "$AVA_HERMES_EXPECTED_REF" +``` + +Repeat with the corresponding paths for AVA and AEON. + +## 7. Run the real session-identity smoke test + +Use the actual configured local/provider runtime, preferably on a disposable state root first: + +```bash +uv run python scripts/ava_runtime/smoke_session_identity.py \ + --hermes-command "uv run hermes" \ + --workspace /srv/ava/workspaces/avaeon-codex +``` + +Required result: + +```text +STATUS_CLOSURE=PASS +context_restored=true +stable_session_id=true +no_session_fork=true +``` + +A failure must not be bypassed by manually copying session data or selecting the newest global session. + +## 8. Shadow launch + +Launch one disposable/shadow instance with separate ports, logs, and `HERMES_HOME`. Validate: + +- correct entity profile +- correct workspace +- correct provider/model +- terminal backend +- skills and memory policy +- Telegram or other gateway routing +- cron jobs without duplication +- restart persistence +- clean shutdown and session closure + +## 9. Promote and deploy + +Only after all evidence passes: + +1. record the exact staging commit +2. advance `ava/stable` to that commit through a reviewed promotion +3. create a stable tag +4. stop one entity at a time +5. back up its state +6. switch the service to the pinned stable commit +7. restart and rerun the doctor and smoke checks + +AVA, AEON, and AVAEON Codex must not be migrated simultaneously on the first deployment. + +## 10. Rollback + +Before deployment, record: + +```text +previous_source_commit +previous_environment_path +previous_service_unit +state_backup_path +``` + +Rollback means restoring the previous code and compatible state snapshot, restarting the entity, and rerunning the doctor. An apology, successful process start, or apparently coherent response is not proof of restoration. + +## Result report + +Return a compact machine-readable report containing: + +```yaml +host: +source_before: +source_tested: +tests: +doctor: +identity_smoke: +shadow_runtime: +state_backups: +rollback_ref: +remaining_gaps: +status_closure: +``` From c539c969946a8bfa7a7b3160de2646db9f8ac9a8 Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 11:44:29 +0200 Subject: [PATCH 07/50] test(ava): load doctor module safely --- tests/ava_runtime/test_doctor.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/ava_runtime/test_doctor.py b/tests/ava_runtime/test_doctor.py index 36e02e9f3708..bfb4b6e90bde 100644 --- a/tests/ava_runtime/test_doctor.py +++ b/tests/ava_runtime/test_doctor.py @@ -2,6 +2,7 @@ import importlib.util import json +import sys from pathlib import Path import pytest @@ -12,9 +13,11 @@ def _load_doctor_module(): - spec = importlib.util.spec_from_file_location("ava_runtime_doctor", DOCTOR_PATH) + module_name = "ava_runtime_doctor_test" + spec = importlib.util.spec_from_file_location(module_name, DOCTOR_PATH) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module spec.loader.exec_module(module) return module From c719a82cbd0e34a1e48f7eb02b852b0c1bd86911 Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 11:45:12 +0200 Subject: [PATCH 08/50] fix(ava): make smoke temp retention deterministic --- scripts/ava_runtime/smoke_session_identity.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/scripts/ava_runtime/smoke_session_identity.py b/scripts/ava_runtime/smoke_session_identity.py index 14c70a04039e..14a87337aa96 100644 --- a/scripts/ava_runtime/smoke_session_identity.py +++ b/scripts/ava_runtime/smoke_session_identity.py @@ -13,6 +13,7 @@ import os import secrets import shlex +import shutil import sqlite3 import subprocess import sys @@ -131,14 +132,13 @@ def _emit(result: SmokeResult, *, json_output: bool) -> None: def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) - managed_tmp: tempfile.TemporaryDirectory[str] | None = None + temporary_home = not bool(args.hermes_home) if args.hermes_home: hermes_home = Path(args.hermes_home).expanduser().resolve() hermes_home.mkdir(parents=True, exist_ok=True) else: - managed_tmp = tempfile.TemporaryDirectory(prefix="hermes-ava-smoke-") - hermes_home = Path(managed_tmp.name).resolve() + hermes_home = Path(tempfile.mkdtemp(prefix="hermes-ava-smoke-")).resolve() if args.workspace: workspace = Path(args.workspace).expanduser().resolve() @@ -238,11 +238,12 @@ def main(argv: list[str] | None = None) -> int: ) _emit(result, json_output=args.json_output) - if managed_tmp is not None and args.keep_temporary_home: - managed_tmp.cleanup = lambda: None # type: ignore[method-assign] + if temporary_home and args.keep_temporary_home: print(f"temporary HERMES_HOME retained at {hermes_home}", file=sys.stderr) - elif managed_tmp is not None: - managed_tmp.cleanup() + elif temporary_home: + shutil.rmtree(hermes_home, ignore_errors=True) + if not args.workspace: + shutil.rmtree(workspace, ignore_errors=True) return 0 if failure is None else 1 From 8f95335be7a053a886ac727e874ffe2820839b09 Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 11:46:54 +0200 Subject: [PATCH 09/50] feat(ava): create managed runtime package --- hermes_cli/ava_runtime/__init__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 hermes_cli/ava_runtime/__init__.py diff --git a/hermes_cli/ava_runtime/__init__.py b/hermes_cli/ava_runtime/__init__.py new file mode 100644 index 000000000000..7b8196676c86 --- /dev/null +++ b/hermes_cli/ava_runtime/__init__.py @@ -0,0 +1,13 @@ +"""Controlled runtime overlay for AVA, AEON, and AVAEON Codex. + +The overlay is deliberately narrow. It protects identity-bearing execution +without turning private entity material into Hermes source code. +""" + +from .session_context import ResumeRequest, ResolvedSessionContext, resolve_session_context + +__all__ = [ + "ResumeRequest", + "ResolvedSessionContext", + "resolve_session_context", +] From 9c11c50825de55ac2d3334c285faf1c8c9edd018 Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 11:47:31 +0200 Subject: [PATCH 10/50] feat(ava): centralize fail-closed session context --- hermes_cli/ava_runtime/session_context.py | 175 ++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 hermes_cli/ava_runtime/session_context.py diff --git a/hermes_cli/ava_runtime/session_context.py b/hermes_cli/ava_runtime/session_context.py new file mode 100644 index 000000000000..bd24d9fb2f77 --- /dev/null +++ b/hermes_cli/ava_runtime/session_context.py @@ -0,0 +1,175 @@ +"""Fail-closed durable session resolution for managed Hermes runtimes.""" + +from __future__ import annotations + +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +@dataclass(frozen=True) +class ResumeRequest: + """Identity-bearing request for one durable session. + + Managed runtimes forbid a cross-workspace global-most-recent fallback by + default. Callers may opt in only when their HERMES_HOME is already isolated + and the broader selection is intentional. + """ + + resume_session_id: str | None = None + continue_last: bool | str | None = None + restore_cwd: bool = True + require_recorded_cwd: bool = True + source: str = "cli" + workspace_key: str | None = None + allow_global_fallback: bool = False + + @property + def requested(self) -> bool: + return bool(self.resume_session_id or self.continue_last) + + +@dataclass(frozen=True) +class ResolvedSessionContext: + session_id: str + conversation_history: list[dict[str, Any]] + recorded_cwd: str | None + selection: str + + +def resolve_workspace_key(cwd: str | os.PathLike[str] | None = None) -> str: + """Return the current Git root, or the absolute working directory.""" + + base = Path(cwd or os.getcwd()).expanduser().resolve() + try: + result = subprocess.run( + ["git", "-C", str(base), "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=5, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return str(base) + if result.returncode == 0 and result.stdout.strip(): + return str(Path(result.stdout.strip()).expanduser().resolve()) + return str(base) + + +def _resolve_target(session_db: Any, request: ResumeRequest) -> tuple[str, str]: + explicit = str(request.resume_session_id or "").strip() + if explicit: + return explicit, "explicit-resume" + + if isinstance(request.continue_last, str): + title_or_id = request.continue_last.strip() + if title_or_id: + return title_or_id, "named-continue" + + if not request.continue_last: + raise ValueError("No resume or continue target was requested.") + + workspace_key = request.workspace_key or resolve_workspace_key() + recent = session_db.search_sessions( + source=request.source, + limit=1, + workspace_key=workspace_key, + ) + if recent: + return str(recent[0]["id"]), "workspace-latest" + + if request.allow_global_fallback: + recent = session_db.search_sessions(source=request.source, limit=1) + if recent: + return str(recent[0]["id"]), "entity-global-latest" + + raise ValueError( + "No previous session exists in the selected workspace; managed runtime " + "refuses a global-most-recent fallback. Pass an explicit session ID/title." + ) + + +def _resolve_existing_session(session_db: Any, target: str) -> tuple[str, dict[str, Any]]: + session_meta = session_db.get_session(target) + resolved_target = target + + if not session_meta: + title_match = session_db.resolve_session_by_title(target) + if title_match: + resolved_target = str(title_match) + session_meta = session_db.get_session(resolved_target) + + if not session_meta: + raise ValueError(f"Session not found: {target}") + + canonical_id = session_db.resolve_resume_session_id(resolved_target) or resolved_target + canonical_id = str(canonical_id) + if canonical_id != resolved_target: + session_meta = session_db.get_session(canonical_id) + if not session_meta: + raise ValueError(f"Canonical session not found: {canonical_id}") + + return canonical_id, session_meta + + +def _restore_recorded_cwd(session_meta: dict[str, Any], request: ResumeRequest) -> str | None: + saved_cwd = str(session_meta.get("cwd") or "").strip() + if not request.restore_cwd: + return saved_cwd or None + + if not saved_cwd: + if request.require_recorded_cwd: + raise RuntimeError("Resumed session has no recorded working directory.") + return None + + path = Path(saved_cwd).expanduser() + if not path.is_dir(): + raise FileNotFoundError(f"Recorded session working directory is unavailable: {path}") + try: + os.chdir(path) + except OSError as exc: + raise RuntimeError( + f"Failed to restore recorded session working directory: {path}" + ) from exc + return str(path.resolve()) + + +def resolve_session_context( + session_db: Any, + request: ResumeRequest, +) -> ResolvedSessionContext | None: + """Resolve exact durable identity, history, and workspace or fail visibly. + + The session is reopened only after history and workspace restoration have + succeeded. A failed transition therefore leaves the durable session state + untouched. + """ + + if not request.requested: + return None + if session_db is None: + raise RuntimeError("Session database unavailable; cannot resume managed runtime.") + + target, selection = _resolve_target(session_db, request) + session_id, session_meta = _resolve_existing_session(session_db, target) + + conversation_history, _display_history = session_db.get_resume_conversations(session_id) + history = [ + message + for message in conversation_history + if isinstance(message, dict) and message.get("role") != "session_meta" + ] + + recorded_cwd = _restore_recorded_cwd(session_meta, request) + session_db.reopen_session(session_id) + + return ResolvedSessionContext( + session_id=session_id, + conversation_history=history, + recorded_cwd=recorded_cwd, + selection=selection, + ) From 2a7928a6893a0e552f563f2bb4b34479e3c40a31 Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 11:48:05 +0200 Subject: [PATCH 11/50] test(ava): cover fail-closed session context --- tests/ava_runtime/test_session_context.py | 176 ++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 tests/ava_runtime/test_session_context.py diff --git a/tests/ava_runtime/test_session_context.py b/tests/ava_runtime/test_session_context.py new file mode 100644 index 000000000000..feaf9332036c --- /dev/null +++ b/tests/ava_runtime/test_session_context.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from hermes_cli.ava_runtime.session_context import ResumeRequest, resolve_session_context + + +class FakeSessionDB: + def __init__(self, sessions=None, histories=None, workspace_recent=None, global_recent=None): + self.sessions = sessions or {} + self.histories = histories or {} + self.workspace_recent = workspace_recent or [] + self.global_recent = global_recent or [] + self.title_matches = {} + self.canonical = {} + self.events = [] + self.searches = [] + + def get_session(self, session_id): + self.events.append(("get", session_id)) + return self.sessions.get(session_id) + + def resolve_session_by_title(self, title): + self.events.append(("title", title)) + return self.title_matches.get(title) + + def resolve_resume_session_id(self, session_id): + self.events.append(("canonical", session_id)) + return self.canonical.get(session_id, session_id) + + def get_resume_conversations(self, session_id): + self.events.append(("history", session_id)) + return self.histories.get(session_id, ([], [])) + + def reopen_session(self, session_id): + self.events.append(("reopen", session_id)) + + def search_sessions(self, *, source, limit, workspace_key=None): + self.searches.append((source, limit, workspace_key)) + if workspace_key is not None: + return self.workspace_recent + return self.global_recent + + +def test_explicit_resume_uses_canonical_tip_and_restores_cwd(monkeypatch, tmp_path): + saved_cwd = tmp_path / "workspace" + saved_cwd.mkdir() + caller_cwd = tmp_path / "caller" + caller_cwd.mkdir() + monkeypatch.chdir(caller_cwd) + + db = FakeSessionDB( + sessions={ + "root": {"id": "root", "cwd": str(saved_cwd)}, + "tip": {"id": "tip", "cwd": str(saved_cwd)}, + }, + histories={ + "tip": ( + [ + {"role": "session_meta", "content": "internal"}, + {"role": "user", "content": "prior"}, + ], + [], + ) + }, + ) + db.canonical["root"] = "tip" + + context = resolve_session_context( + db, + ResumeRequest(resume_session_id="root"), + ) + + assert context is not None + assert context.session_id == "tip" + assert context.conversation_history == [{"role": "user", "content": "prior"}] + assert context.recorded_cwd == str(saved_cwd.resolve()) + assert context.selection == "explicit-resume" + assert Path.cwd() == saved_cwd + assert db.events[-1] == ("reopen", "tip") + + +def test_named_resume_resolves_title(tmp_path): + saved_cwd = tmp_path / "workspace" + saved_cwd.mkdir() + db = FakeSessionDB( + sessions={"session-1": {"id": "session-1", "cwd": str(saved_cwd)}}, + histories={"session-1": ([{"role": "user", "content": "prior"}], [])}, + ) + db.title_matches["AVA planning"] = "session-1" + + context = resolve_session_context( + db, + ResumeRequest(resume_session_id="AVA planning", restore_cwd=False), + ) + + assert context is not None + assert context.session_id == "session-1" + assert context.recorded_cwd == str(saved_cwd) + + +def test_bare_continue_refuses_cross_workspace_global_fallback(tmp_path): + db = FakeSessionDB(global_recent=[{"id": "other-project"}]) + + with pytest.raises(ValueError, match="refuses a global-most-recent fallback"): + resolve_session_context( + db, + ResumeRequest(continue_last=True, workspace_key=str(tmp_path)), + ) + + assert db.searches == [("cli", 1, str(tmp_path))] + assert ("reopen", "other-project") not in db.events + + +def test_bare_continue_can_use_explicit_entity_global_fallback(tmp_path): + saved_cwd = tmp_path / "workspace" + saved_cwd.mkdir() + db = FakeSessionDB( + sessions={"entity-latest": {"id": "entity-latest", "cwd": str(saved_cwd)}}, + histories={"entity-latest": ([], [])}, + global_recent=[{"id": "entity-latest"}], + ) + + context = resolve_session_context( + db, + ResumeRequest( + continue_last=True, + workspace_key=str(tmp_path / "missing-workspace"), + allow_global_fallback=True, + restore_cwd=False, + ), + ) + + assert context is not None + assert context.session_id == "entity-latest" + assert context.selection == "entity-global-latest" + assert len(db.searches) == 2 + + +def test_missing_recorded_cwd_fails_before_reopen(): + db = FakeSessionDB( + sessions={"session-1": {"id": "session-1", "cwd": ""}}, + histories={"session-1": ([], [])}, + ) + + with pytest.raises(RuntimeError, match="no recorded working directory"): + resolve_session_context(db, ResumeRequest(resume_session_id="session-1")) + + assert ("reopen", "session-1") not in db.events + + +def test_restore_opt_out_allows_missing_recorded_cwd(): + db = FakeSessionDB( + sessions={"session-1": {"id": "session-1", "cwd": ""}}, + histories={"session-1": ([{"role": "user", "content": "prior"}], [])}, + ) + + context = resolve_session_context( + db, + ResumeRequest( + resume_session_id="session-1", + restore_cwd=False, + require_recorded_cwd=False, + ), + ) + + assert context is not None + assert context.recorded_cwd is None + assert db.events[-1] == ("reopen", "session-1") + + +def test_missing_database_fails_closed(): + with pytest.raises(RuntimeError, match="Session database unavailable"): + resolve_session_context(None, ResumeRequest(resume_session_id="session-1")) From c168d69b973bcd02a9a3b94fb0ad3bfd3d87ec5a Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 11:50:17 +0200 Subject: [PATCH 12/50] feat(ava): validate managed entity identity --- hermes_cli/ava_runtime/identity.py | 78 ++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 hermes_cli/ava_runtime/identity.py diff --git a/hermes_cli/ava_runtime/identity.py b/hermes_cli/ava_runtime/identity.py new file mode 100644 index 000000000000..ef3de8ac0f2c --- /dev/null +++ b/hermes_cli/ava_runtime/identity.py @@ -0,0 +1,78 @@ +"""Entity and filesystem identity for managed AVA runtimes.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +ENTITY_ALIASES = { + "ava": {"ava"}, + "aeon": {"aeon"}, + "avaeon-codex": {"avaeon-codex", "avaeon_codex", "avaeoncodex"}, +} + + +def _normalized_parts(path: Path) -> set[str]: + return {part.lower().replace("_", "-") for part in path.parts} + + +def _is_entity_scoped(path: Path, entity: str) -> bool: + aliases = {alias.lower().replace("_", "-") for alias in ENTITY_ALIASES[entity]} + return bool(_normalized_parts(path) & aliases) + + +@dataclass(frozen=True) +class ManagedIdentity: + entity: str + hermes_home: Path + workspace: Path + + @classmethod + def from_env(cls) -> "ManagedIdentity": + entity = os.environ.get("AVA_ENTITY", "").strip().lower() + if entity not in ENTITY_ALIASES: + raise RuntimeError( + "AVA_ENTITY must be one of: " + ", ".join(sorted(ENTITY_ALIASES)) + ) + + home_raw = os.environ.get("HERMES_HOME", "").strip() + if not home_raw: + raise RuntimeError("HERMES_HOME must be exported explicitly.") + workspace_raw = os.environ.get("AVA_WORKSPACE", "").strip() + if not workspace_raw: + raise RuntimeError("AVA_WORKSPACE must be exported explicitly.") + + hermes_home = Path(home_raw).expanduser() + workspace = Path(workspace_raw).expanduser() + if not hermes_home.is_absolute() or not workspace.is_absolute(): + raise RuntimeError("HERMES_HOME and AVA_WORKSPACE must be absolute paths.") + + hermes_home = hermes_home.resolve() + workspace = workspace.resolve() + for name, path in (("HERMES_HOME", hermes_home), ("AVA_WORKSPACE", workspace)): + if not path.is_dir(): + raise RuntimeError(f"{name} directory does not exist: {path}") + if not os.access(path, os.W_OK | os.X_OK): + raise RuntimeError(f"{name} directory is not writable/searchable: {path}") + + if not _is_entity_scoped(hermes_home, entity): + raise RuntimeError( + f"HERMES_HOME {hermes_home} is not visibly scoped to entity {entity}." + ) + + try: + workspace.relative_to(hermes_home) + except ValueError: + pass + else: + raise RuntimeError("AVA_WORKSPACE must not live inside HERMES_HOME.") + + return cls(entity=entity, hermes_home=hermes_home, workspace=workspace) + + def activate_workspace(self) -> None: + try: + os.chdir(self.workspace) + except OSError as exc: + raise RuntimeError(f"Cannot enter AVA_WORKSPACE: {self.workspace}") from exc From df8cb470af5da89f94ac8e07f6080b7179a54cf6 Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 11:50:32 +0200 Subject: [PATCH 13/50] test(ava): cover managed entity identity --- tests/ava_runtime/test_identity.py | 65 ++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/ava_runtime/test_identity.py diff --git a/tests/ava_runtime/test_identity.py b/tests/ava_runtime/test_identity.py new file mode 100644 index 000000000000..38208d14d9b5 --- /dev/null +++ b/tests/ava_runtime/test_identity.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from hermes_cli.ava_runtime.identity import ManagedIdentity + + +def test_identity_loads_isolated_paths(monkeypatch, tmp_path): + hermes_home = tmp_path / "state" / "ava" + workspace = tmp_path / "workspaces" / "ava" + hermes_home.mkdir(parents=True) + workspace.mkdir(parents=True) + monkeypatch.setenv("AVA_ENTITY", "ava") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("AVA_WORKSPACE", str(workspace)) + + identity = ManagedIdentity.from_env() + + assert identity.entity == "ava" + assert identity.hermes_home == hermes_home.resolve() + assert identity.workspace == workspace.resolve() + + +def test_identity_rejects_shared_home(monkeypatch, tmp_path): + hermes_home = tmp_path / "state" / "shared" + workspace = tmp_path / "workspaces" / "aeon" + hermes_home.mkdir(parents=True) + workspace.mkdir(parents=True) + monkeypatch.setenv("AVA_ENTITY", "aeon") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("AVA_WORKSPACE", str(workspace)) + + with pytest.raises(RuntimeError, match="not visibly scoped"): + ManagedIdentity.from_env() + + +def test_identity_rejects_workspace_inside_state(monkeypatch, tmp_path): + hermes_home = tmp_path / "state" / "avaeon-codex" + workspace = hermes_home / "workspace" + workspace.mkdir(parents=True) + monkeypatch.setenv("AVA_ENTITY", "avaeon-codex") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("AVA_WORKSPACE", str(workspace)) + + with pytest.raises(RuntimeError, match="must not live inside"): + ManagedIdentity.from_env() + + +def test_activate_workspace_changes_directory(monkeypatch, tmp_path): + hermes_home = tmp_path / "state" / "ava" + workspace = tmp_path / "workspaces" / "ava" + caller = tmp_path / "caller" + hermes_home.mkdir(parents=True) + workspace.mkdir(parents=True) + caller.mkdir() + monkeypatch.chdir(caller) + monkeypatch.setenv("AVA_ENTITY", "ava") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("AVA_WORKSPACE", str(workspace)) + + ManagedIdentity.from_env().activate_workspace() + + assert Path.cwd() == workspace From 531b23310cdbe7217a04c046ea5ed09aa5ba40b4 Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 11:51:36 +0200 Subject: [PATCH 14/50] feat(ava): add managed fail-closed oneshot launcher --- hermes_cli/ava_runtime/managed_oneshot.py | 294 ++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 hermes_cli/ava_runtime/managed_oneshot.py diff --git a/hermes_cli/ava_runtime/managed_oneshot.py b/hermes_cli/ava_runtime/managed_oneshot.py new file mode 100644 index 000000000000..ed42998facac --- /dev/null +++ b/hermes_cli/ava_runtime/managed_oneshot.py @@ -0,0 +1,294 @@ +"""Managed one-shot launcher with durable identity and workspace closure. + +Usage: + + AVA_ENTITY=avaeon-codex \ + HERMES_HOME=/var/lib/ava/hermes/avaeon-codex \ + AVA_WORKSPACE=/srv/ava/workspaces/avaeon-codex \ + python -m hermes_cli.ava_runtime.managed_oneshot \ + --resume SESSION_ID "Continue the work" + +This overlay intentionally accepts a narrow option surface. Unknown options are +rejected by argparse instead of being silently discarded. +""" + +from __future__ import annotations + +import argparse +import inspect +import logging +import os +import sys +from typing import Any + +from hermes_cli.ava_runtime.identity import ManagedIdentity +from hermes_cli.ava_runtime.session_context import ResumeRequest, resolve_session_context + + +_EXPECTED_UPSTREAM_RUN_ONESHOT_PARAMS = { + "prompt", + "model", + "provider", + "toolsets", + "usage_file", +} +_EXPECTED_UPSTREAM_RUN_AGENT_PARAMS = { + "prompt", + "model", + "provider", + "toolsets", + "use_config_toolsets", +} + + +def _assert_upstream_compatibility(upstream: Any) -> None: + run_oneshot_params = set(inspect.signature(upstream.run_oneshot).parameters) + if run_oneshot_params != _EXPECTED_UPSTREAM_RUN_ONESHOT_PARAMS: + raise RuntimeError( + "Upstream run_oneshot signature changed; managed overlay requires review. " + f"Expected {sorted(_EXPECTED_UPSTREAM_RUN_ONESHOT_PARAMS)}, " + f"found {sorted(run_oneshot_params)}." + ) + + run_agent_params = set(inspect.signature(upstream._run_agent).parameters) + if run_agent_params != _EXPECTED_UPSTREAM_RUN_AGENT_PARAMS: + raise RuntimeError( + "Upstream _run_agent signature changed; managed overlay requires review. " + f"Expected {sorted(_EXPECTED_UPSTREAM_RUN_AGENT_PARAMS)}, " + f"found {sorted(run_agent_params)}." + ) + + +def _build_managed_run_agent(upstream: Any, request: ResumeRequest): + def _managed_run_agent( + prompt: str, + model: str | None = None, + provider: str | None = None, + toolsets: object = None, + use_config_toolsets: bool = True, + ) -> tuple[str, dict]: + from hermes_cli.config import load_config + from hermes_cli.fallback_config import get_fallback_chain + from hermes_cli.models import detect_provider_for_model + from hermes_cli.runtime_provider import resolve_runtime_provider + from hermes_cli.tools_config import _get_platform_tools + from run_agent import AIAgent + + cfg = load_config() + model_cfg = cfg.get("model") or {} + if isinstance(model_cfg, str): + cfg_model = model_cfg + else: + cfg_model = model_cfg.get("default") or model_cfg.get("model") or "" + + env_model = os.getenv("HERMES_INFERENCE_MODEL", "").strip() + effective_model = (model or "").strip() or env_model or cfg_model + effective_provider = (provider or "").strip() or None + explicit_base_url_from_alias: str | None = None + + if effective_provider is None and (model or env_model): + explicit_model = (model or "").strip() or env_model + if explicit_model: + try: + from hermes_cli import model_switch as _ms + + _ms._ensure_direct_aliases() + direct = _ms.DIRECT_ALIASES.get(explicit_model.strip().lower()) + except Exception: + direct = None + if direct is not None: + effective_model = direct.model + effective_provider = direct.provider + if direct.base_url: + explicit_base_url_from_alias = direct.base_url.rstrip("/") + else: + cfg_provider = "" + if isinstance(model_cfg, dict): + cfg_provider = str(model_cfg.get("provider") or "").strip().lower() + current_provider = ( + cfg_provider + or os.getenv("HERMES_INFERENCE_PROVIDER", "").strip().lower() + or "auto" + ) + detected = detect_provider_for_model(explicit_model, current_provider) + if detected: + effective_provider, effective_model = detected + + runtime = resolve_runtime_provider( + requested=effective_provider, + target_model=effective_model or None, + explicit_base_url=explicit_base_url_from_alias, + ) + + toolsets_list = upstream._normalize_toolsets(toolsets) + if toolsets_list is None and use_config_toolsets: + toolsets_list = sorted(_get_platform_tools(cfg, "cli")) + + session_db = upstream._create_session_db_for_oneshot() + agent = None + try: + context = resolve_session_context(session_db, request) + session_id = context.session_id if context else None + history = context.conversation_history if context else None + fallback_chain = get_fallback_chain(cfg) + + agent = AIAgent( + api_key=runtime.get("api_key"), + base_url=runtime.get("base_url"), + provider=runtime.get("provider"), + requested_provider=runtime.get("requested_provider"), + api_mode=runtime.get("api_mode"), + model=effective_model, + enabled_toolsets=toolsets_list, + quiet_mode=True, + platform="cli", + session_db=session_db, + session_id=session_id, + credential_pool=runtime.get("credential_pool"), + fallback_model=fallback_chain or None, + clarify_callback=upstream._oneshot_clarify_callback, + ) + agent.suppress_status_output = True + agent.stream_delta_callback = None + agent.tool_gen_callback = None + + result = agent.run_conversation(prompt, conversation_history=history) + return result.get("final_response") or "", result + finally: + if agent is not None: + try: + session_messages = getattr(agent, "_session_messages", None) + if isinstance(session_messages, list): + agent.shutdown_memory_provider(session_messages) + else: + agent.shutdown_memory_provider() + except Exception: + logging.debug("managed oneshot memory cleanup failed", exc_info=True) + try: + agent.close() + except Exception: + logging.debug("managed oneshot agent cleanup failed", exc_info=True) + if session_db is not None: + try: + session_db.close() + except Exception: + logging.debug("managed oneshot session store cleanup failed", exc_info=True) + + return _managed_run_agent + + +def run_managed_oneshot( + prompt: str, + *, + resume_session_id: str | None = None, + continue_last: bool | str | None = None, + restore_cwd: bool = True, + require_recorded_cwd: bool = True, + allow_global_fallback: bool = False, + model: str | None = None, + provider: str | None = None, + toolsets: object = None, + usage_file: str | None = None, +) -> int: + identity = ManagedIdentity.from_env() + identity.activate_workspace() + + request = ResumeRequest( + resume_session_id=resume_session_id, + continue_last=continue_last, + restore_cwd=restore_cwd, + require_recorded_cwd=require_recorded_cwd, + workspace_key=str(identity.workspace), + allow_global_fallback=allow_global_fallback, + ) + + from hermes_cli import oneshot as upstream + + _assert_upstream_compatibility(upstream) + original_run_agent = upstream._run_agent + upstream._run_agent = _build_managed_run_agent(upstream, request) + try: + return upstream.run_oneshot( + prompt, + model=model, + provider=provider, + toolsets=toolsets, + usage_file=usage_file, + ) + finally: + upstream._run_agent = original_run_agent + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("prompt") + target = parser.add_mutually_exclusive_group() + target.add_argument("--resume", "-r", dest="resume_session_id") + target.add_argument( + "--continue", + "-c", + dest="continue_last", + nargs="?", + const=True, + default=None, + metavar="ID_OR_TITLE", + ) + parser.add_argument("--no-restore-cwd", action="store_false", dest="restore_cwd") + parser.add_argument( + "--allow-missing-recorded-cwd", + action="store_false", + dest="require_recorded_cwd", + ) + parser.add_argument( + "--allow-global-fallback", + action="store_true", + help="Allow bare --continue to leave the current workspace within this entity home.", + ) + parser.add_argument("--model") + parser.add_argument("--provider") + parser.add_argument("--toolsets") + parser.add_argument("--usage-file") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + return run_managed_oneshot( + args.prompt, + resume_session_id=args.resume_session_id, + continue_last=args.continue_last, + restore_cwd=args.restore_cwd, + require_recorded_cwd=args.require_recorded_cwd, + allow_global_fallback=args.allow_global_fallback, + model=args.model, + provider=args.provider, + toolsets=args.toolsets, + usage_file=args.usage_file, + ) + + +def _entrypoint() -> None: + try: + rc = main() + except KeyboardInterrupt: + rc = 130 + except BaseException as exc: # noqa: BLE001 + print(f"managed Hermes oneshot failed: {exc}", file=sys.stderr) + rc = 1 + + try: + from hermes_cli.main import _cleanup_oneshot_runtime + + _cleanup_oneshot_runtime() + except Exception: + pass + for stream in (sys.stdout, sys.stderr): + try: + stream.flush() + except Exception: + pass + os._exit(rc if isinstance(rc, int) else 1) + + +if __name__ == "__main__": + _entrypoint() From 04a1c7df34c60d12efe7c12c8c94139d06c7a5af Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 11:52:12 +0200 Subject: [PATCH 15/50] test(ava): cover managed oneshot overlay gates --- tests/ava_runtime/test_managed_oneshot.py | 125 ++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 tests/ava_runtime/test_managed_oneshot.py diff --git a/tests/ava_runtime/test_managed_oneshot.py b/tests/ava_runtime/test_managed_oneshot.py new file mode 100644 index 000000000000..e780bfbb963c --- /dev/null +++ b/tests/ava_runtime/test_managed_oneshot.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from hermes_cli.ava_runtime import managed_oneshot + + +def _compatible_run_oneshot( + prompt, + model=None, + provider=None, + toolsets=None, + usage_file=None, +): + return 0 + + +def _compatible_run_agent( + prompt, + model=None, + provider=None, + toolsets=None, + use_config_toolsets=True, +): + return "", {} + + +def test_upstream_compatibility_accepts_expected_surface(): + upstream = SimpleNamespace( + run_oneshot=_compatible_run_oneshot, + _run_agent=_compatible_run_agent, + ) + + managed_oneshot._assert_upstream_compatibility(upstream) + + +def test_upstream_compatibility_fails_closed_on_signature_drift(): + def changed_run_oneshot(prompt, new_option=None): + return 0 + + upstream = SimpleNamespace( + run_oneshot=changed_run_oneshot, + _run_agent=_compatible_run_agent, + ) + + with pytest.raises(RuntimeError, match="signature changed"): + managed_oneshot._assert_upstream_compatibility(upstream) + + +def test_parser_uses_narrow_explicit_surface(): + args = managed_oneshot.build_parser().parse_args( + [ + "--resume", + "session-1", + "--no-restore-cwd", + "--model", + "model-1", + "continue work", + ] + ) + + assert args.resume_session_id == "session-1" + assert args.continue_last is None + assert args.restore_cwd is False + assert args.model == "model-1" + assert args.prompt == "continue work" + + +def test_parser_rejects_unknown_options(): + with pytest.raises(SystemExit): + managed_oneshot.build_parser().parse_args(["--skills", "x", "prompt"]) + + +def test_overlay_restores_upstream_run_agent(monkeypatch, tmp_path): + import hermes_cli.oneshot as upstream + + events = [] + + class FakeIdentity: + workspace = tmp_path + + def activate_workspace(self): + events.append("activate") + + monkeypatch.setattr( + managed_oneshot.ManagedIdentity, + "from_env", + classmethod(lambda cls: FakeIdentity()), + ) + + original = _compatible_run_agent + monkeypatch.setattr(upstream, "_run_agent", original) + + def fake_run_oneshot( + prompt, + model=None, + provider=None, + toolsets=None, + usage_file=None, + ): + events.append(("run", prompt, model, provider, toolsets, usage_file)) + assert upstream._run_agent is not original + return 17 + + monkeypatch.setattr(upstream, "run_oneshot", fake_run_oneshot) + + def fake_builder(_upstream, request): + events.append(("request", request.resume_session_id, request.workspace_key)) + return _compatible_run_agent + + monkeypatch.setattr(managed_oneshot, "_build_managed_run_agent", fake_builder) + + rc = managed_oneshot.run_managed_oneshot( + "prompt", + resume_session_id="session-1", + model="model-1", + usage_file="usage.json", + ) + + assert rc == 17 + assert events[0] == "activate" + assert ("request", "session-1", str(tmp_path)) in events + assert upstream._run_agent is original From d4aa7d112eae4af4acc905cb7ebddfc0f31ac35f Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 11:53:20 +0200 Subject: [PATCH 16/50] fix(ava): make continue selection unambiguous --- hermes_cli/ava_runtime/managed_oneshot.py | 26 ++++++++++++++--------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/hermes_cli/ava_runtime/managed_oneshot.py b/hermes_cli/ava_runtime/managed_oneshot.py index ed42998facac..3b85ec290a7d 100644 --- a/hermes_cli/ava_runtime/managed_oneshot.py +++ b/hermes_cli/ava_runtime/managed_oneshot.py @@ -190,9 +190,12 @@ def run_managed_oneshot( toolsets: object = None, usage_file: str | None = None, ) -> int: + from hermes_cli import oneshot as upstream + + _assert_upstream_compatibility(upstream) + identity = ManagedIdentity.from_env() identity.activate_workspace() - request = ResumeRequest( resume_session_id=resume_session_id, continue_last=continue_last, @@ -202,9 +205,6 @@ def run_managed_oneshot( allow_global_fallback=allow_global_fallback, ) - from hermes_cli import oneshot as upstream - - _assert_upstream_compatibility(upstream) original_run_agent = upstream._run_agent upstream._run_agent = _build_managed_run_agent(upstream, request) try: @@ -227,11 +227,14 @@ def build_parser() -> argparse.ArgumentParser: target.add_argument( "--continue", "-c", - dest="continue_last", - nargs="?", - const=True, - default=None, + dest="continue_named", metavar="ID_OR_TITLE", + help="Continue an explicit session ID or title.", + ) + target.add_argument( + "--continue-last", + action="store_true", + help="Continue the latest session in AVA_WORKSPACE only.", ) parser.add_argument("--no-restore-cwd", action="store_false", dest="restore_cwd") parser.add_argument( @@ -242,7 +245,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument( "--allow-global-fallback", action="store_true", - help="Allow bare --continue to leave the current workspace within this entity home.", + help="Allow --continue-last to leave the current workspace within this entity home.", ) parser.add_argument("--model") parser.add_argument("--provider") @@ -253,10 +256,13 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) + continue_last: bool | str | None = args.continue_named + if args.continue_last: + continue_last = True return run_managed_oneshot( args.prompt, resume_session_id=args.resume_session_id, - continue_last=args.continue_last, + continue_last=continue_last, restore_cwd=args.restore_cwd, require_recorded_cwd=args.require_recorded_cwd, allow_global_fallback=args.allow_global_fallback, From ee38308cfe5544a531c7abbcd3b166a6e81cb48a Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 11:53:47 +0200 Subject: [PATCH 17/50] test(ava): cover unambiguous continue modes --- tests/ava_runtime/test_managed_oneshot.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/ava_runtime/test_managed_oneshot.py b/tests/ava_runtime/test_managed_oneshot.py index e780bfbb963c..68013b65c797 100644 --- a/tests/ava_runtime/test_managed_oneshot.py +++ b/tests/ava_runtime/test_managed_oneshot.py @@ -62,12 +62,27 @@ def test_parser_uses_narrow_explicit_surface(): ) assert args.resume_session_id == "session-1" - assert args.continue_last is None + assert args.continue_named is None + assert args.continue_last is False assert args.restore_cwd is False assert args.model == "model-1" assert args.prompt == "continue work" +def test_parser_separates_named_and_latest_continue(): + named = managed_oneshot.build_parser().parse_args( + ["--continue", "AVA planning", "continue work"] + ) + latest = managed_oneshot.build_parser().parse_args( + ["--continue-last", "continue work"] + ) + + assert named.continue_named == "AVA planning" + assert named.continue_last is False + assert latest.continue_named is None + assert latest.continue_last is True + + def test_parser_rejects_unknown_options(): with pytest.raises(SystemExit): managed_oneshot.build_parser().parse_args(["--skills", "x", "prompt"]) From da71f6515a7f519b795ec906997be5016f21fa5c Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 11:55:02 +0200 Subject: [PATCH 18/50] fix(ava): test managed launcher by default --- scripts/ava_runtime/smoke_session_identity.py | 114 ++++++++++++++---- 1 file changed, 89 insertions(+), 25 deletions(-) diff --git a/scripts/ava_runtime/smoke_session_identity.py b/scripts/ava_runtime/smoke_session_identity.py index 14a87337aa96..ba3ccf701866 100644 --- a/scripts/ava_runtime/smoke_session_identity.py +++ b/scripts/ava_runtime/smoke_session_identity.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 """Black-box smoke test for durable one-shot session identity. -The test performs two real Hermes invocations under an isolated HERMES_HOME. -It requires a working model/provider configuration. No credential values are -read or printed by this script. +The default mode exercises the managed AVA launcher. Use ``--mode upstream`` +only to compare the unmodified Hermes ``-z`` path. The test requires a working +model/provider configuration. It never reads or prints credential values. """ from __future__ import annotations @@ -22,6 +22,9 @@ from pathlib import Path +ENTITIES = ("ava", "aeon", "avaeon-codex") + + @dataclass(frozen=True) class Invocation: command: list[str] @@ -33,6 +36,8 @@ class Invocation: @dataclass(frozen=True) class SmokeResult: status: str + mode: str + entity: str session_id: str | None first_session_id: str | None second_session_id: str | None @@ -100,9 +105,57 @@ def _base_command(args: argparse.Namespace) -> list[str]: return command +def _first_command( + args: argparse.Namespace, + command: list[str], + usage_file: Path, + prompt: str, +) -> list[str]: + if args.mode == "managed": + return [*command, "--usage-file", str(usage_file), prompt] + return [*command, "--usage-file", str(usage_file), "-z", prompt] + + +def _resume_command( + args: argparse.Namespace, + command: list[str], + session_id: str, + usage_file: Path, + prompt: str, +) -> list[str]: + if args.mode == "managed": + return [ + *command, + "--resume", + session_id, + "--usage-file", + str(usage_file), + prompt, + ] + return [ + *command, + "--resume", + session_id, + "--usage-file", + str(usage_file), + "-z", + prompt, + ] + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--hermes-command", default="hermes") + parser.add_argument("--mode", choices=("managed", "upstream"), default="managed") + parser.add_argument( + "--entity", + choices=ENTITIES, + default=os.environ.get("AVA_ENTITY", "").strip().lower() or "avaeon-codex", + ) + parser.add_argument( + "--hermes-command", + default=f"{shlex.quote(sys.executable)} -m hermes_cli.ava_runtime.managed_oneshot", + help="Command prefix. For --mode upstream, pass the normal Hermes command.", + ) parser.add_argument("--model") parser.add_argument("--provider") parser.add_argument("--toolsets") @@ -119,6 +172,8 @@ def _emit(result: SmokeResult, *, json_output: bool) -> None: print(json.dumps(asdict(result), indent=2, sort_keys=True)) return print(f"STATUS_CLOSURE={result.status.upper()}") + print(f"mode={result.mode}") + print(f"entity={result.entity}") print(f"session_id={result.session_id or ''}") print(f"context_restored={str(result.context_restored).lower()}") print(f"stable_session_id={str(result.stable_session_id).lower()}") @@ -138,18 +193,20 @@ def main(argv: list[str] | None = None) -> int: hermes_home = Path(args.hermes_home).expanduser().resolve() hermes_home.mkdir(parents=True, exist_ok=True) else: - hermes_home = Path(tempfile.mkdtemp(prefix="hermes-ava-smoke-")).resolve() + hermes_home = Path( + tempfile.mkdtemp(prefix=f"hermes-{args.entity}-smoke-") + ).resolve() if args.workspace: workspace = Path(args.workspace).expanduser().resolve() workspace.mkdir(parents=True, exist_ok=True) else: - workspace = hermes_home.parent / f"{hermes_home.name}-workspace" + workspace = hermes_home.parent / f"workspace-{args.entity}-{hermes_home.name}" workspace.mkdir(parents=True, exist_ok=True) env = os.environ.copy() env["HERMES_HOME"] = str(hermes_home) - env["AVA_ENTITY"] = "avaeon-codex" + env["AVA_ENTITY"] = args.entity env["AVA_WORKSPACE"] = str(workspace) codeword = f"AVA-{secrets.token_hex(8).upper()}" @@ -157,14 +214,17 @@ def main(argv: list[str] | None = None) -> int: usage_two = hermes_home / "smoke-usage-2.json" command = _base_command(args) - first_command = [ - *command, - "--usage-file", - str(usage_one), - "-z", - f"Remember the exact codeword {codeword}. Reply with exactly STORED.", - ] - first = _run(first_command, env=env, cwd=workspace, timeout=args.timeout) + first = _run( + _first_command( + args, + command, + usage_one, + f"Remember the exact codeword {codeword}. Reply with exactly STORED.", + ), + env=env, + cwd=workspace, + timeout=args.timeout, + ) failure: str | None = None first_id: str | None = None @@ -187,16 +247,18 @@ def main(argv: list[str] | None = None) -> int: failure = "first invocation did not report a durable session_id" if failure is None and first_id: - second_command = [ - *command, - "--resume", - first_id, - "--usage-file", - str(usage_two), - "-z", - "Return only the exact codeword I asked you to remember in this session.", - ] - second = _run(second_command, env=env, cwd=workspace, timeout=args.timeout) + second = _run( + _resume_command( + args, + command, + first_id, + usage_two, + "Return only the exact codeword I asked you to remember in this session.", + ), + env=env, + cwd=workspace, + timeout=args.timeout, + ) count_after = _session_count(hermes_home / "state.db") if second.returncode != 0: failure = f"resume invocation failed ({second.returncode}): {second.stderr.strip()}" @@ -224,6 +286,8 @@ def main(argv: list[str] | None = None) -> int: result = SmokeResult( status="pass" if failure is None else "fail", + mode=args.mode, + entity=args.entity, session_id=first_id, first_session_id=first_id, second_session_id=second_id, From be689d34c57939733e7d6961f336112da8ee29a9 Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 11:56:19 +0200 Subject: [PATCH 19/50] test(ava): cover managed identity smoke commands --- .../test_smoke_session_identity.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/ava_runtime/test_smoke_session_identity.py diff --git a/tests/ava_runtime/test_smoke_session_identity.py b/tests/ava_runtime/test_smoke_session_identity.py new file mode 100644 index 000000000000..435894e7a700 --- /dev/null +++ b/tests/ava_runtime/test_smoke_session_identity.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import SimpleNamespace + + +ROOT = Path(__file__).resolve().parents[2] +SMOKE_PATH = ROOT / "scripts" / "ava_runtime" / "smoke_session_identity.py" + + +def _load_smoke_module(): + module_name = "ava_runtime_smoke_test" + spec = importlib.util.spec_from_file_location(module_name, SMOKE_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +def test_managed_commands_do_not_use_upstream_z_surface(tmp_path): + mod = _load_smoke_module() + args = SimpleNamespace(mode="managed") + command = ["python", "-m", "hermes_cli.ava_runtime.managed_oneshot"] + usage = tmp_path / "usage.json" + + first = mod._first_command(args, command, usage, "prompt") + second = mod._resume_command(args, command, "session-1", usage, "prompt") + + assert "-z" not in first + assert "-z" not in second + assert first[-1] == "prompt" + assert second[-1] == "prompt" + assert second[3:5] == ["--resume", "session-1"] + + +def test_upstream_comparison_commands_use_z_surface(tmp_path): + mod = _load_smoke_module() + args = SimpleNamespace(mode="upstream") + command = ["hermes"] + usage = tmp_path / "usage.json" + + first = mod._first_command(args, command, usage, "prompt") + second = mod._resume_command(args, command, "session-1", usage, "prompt") + + assert "-z" in first + assert "-z" in second + assert ["--resume", "session-1"] == second[1:3] + + +def test_default_command_targets_managed_launcher(): + mod = _load_smoke_module() + args = mod.build_parser().parse_args([]) + + assert args.mode == "managed" + assert "hermes_cli.ava_runtime.managed_oneshot" in args.hermes_command + assert args.entity == "avaeon-codex" From 5a69b028795207d67c1dcbf04b569e920c68468e Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 11:56:58 +0200 Subject: [PATCH 20/50] docs(ava): align handoff with managed launcher --- docs/ava-runtime/AVAEON_CODEX_HANDOFF.md | 49 ++++++++++++++++++++---- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/docs/ava-runtime/AVAEON_CODEX_HANDOFF.md b/docs/ava-runtime/AVAEON_CODEX_HANDOFF.md index d7f6b7f6a903..b45445c603b9 100644 --- a/docs/ava-runtime/AVAEON_CODEX_HANDOFF.md +++ b/docs/ava-runtime/AVAEON_CODEX_HANDOFF.md @@ -56,7 +56,7 @@ git fetch --all --tags --prune git checkout --detach ``` -Use detached HEAD or an exact stable tag for validation. Do not run production from a moving remote branch. +Use detached HEAD or an exact stable tag for validation. Do not run production from a moving remote branch. The existence of the `ava/stable` branch alone is not approval; only a recorded, tested promotion commit is deployable. ## 4. Build the controlled environment @@ -72,7 +72,7 @@ If the lockfile or dependencies cannot be reproduced, stop. Do not repair the li ## 5. Run static and focused tests ```bash -uv run pytest -q tests/ava_runtime/test_doctor.py +uv run pytest -q tests/ava_runtime uv run pytest -q tests/hermes_cli ``` @@ -98,18 +98,29 @@ Repeat with the corresponding paths for AVA and AEON. ## 7. Run the real session-identity smoke test -Use the actual configured local/provider runtime, preferably on a disposable state root first: +Use the actual configured local/provider runtime, preferably on a disposable state root first. The default mode exercises the managed overlay rather than the defective upstream `hermes -z` dispatch: ```bash uv run python scripts/ava_runtime/smoke_session_identity.py \ + --entity avaeon-codex \ + --workspace /srv/ava/workspaces/avaeon-codex +``` + +For a deliberate comparison against upstream: + +```bash +uv run python scripts/ava_runtime/smoke_session_identity.py \ + --mode upstream \ --hermes-command "uv run hermes" \ + --entity avaeon-codex \ --workspace /srv/ava/workspaces/avaeon-codex ``` -Required result: +Required managed result: ```text STATUS_CLOSURE=PASS +mode=managed context_restored=true stable_session_id=true no_session_fork=true @@ -117,7 +128,31 @@ no_session_fork=true A failure must not be bypassed by manually copying session data or selecting the newest global session. -## 8. Shadow launch +## 8. Managed oneshot launch command + +Until the upstream dispatch carries the same tested contract, identity-bearing one-shot services use: + +```bash +uv run python -m hermes_cli.ava_runtime.managed_oneshot \ + --resume \ + "" +``` + +Other supported selections are explicit: + +```bash +# Continue a session by exact ID or title +uv run python -m hermes_cli.ava_runtime.managed_oneshot \ + --continue "" "" + +# Continue latest session in AVA_WORKSPACE only +uv run python -m hermes_cli.ava_runtime.managed_oneshot \ + --continue-last "" +``` + +The launcher rejects unknown options and refuses upstream signature drift. It must not be silently replaced with `hermes -z` in a service unit. + +## 9. Shadow launch Launch one disposable/shadow instance with separate ports, logs, and `HERMES_HOME`. Validate: @@ -131,7 +166,7 @@ Launch one disposable/shadow instance with separate ports, logs, and `HERMES_HOM - restart persistence - clean shutdown and session closure -## 9. Promote and deploy +## 10. Promote and deploy Only after all evidence passes: @@ -145,7 +180,7 @@ Only after all evidence passes: AVA, AEON, and AVAEON Codex must not be migrated simultaneously on the first deployment. -## 10. Rollback +## 11. Rollback Before deployment, record: From e62694c5472df1327fe22d941db58056451bab50 Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 11:57:37 +0200 Subject: [PATCH 21/50] test(ava): isolate smoke defaults from host env --- tests/ava_runtime/test_smoke_session_identity.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ava_runtime/test_smoke_session_identity.py b/tests/ava_runtime/test_smoke_session_identity.py index 435894e7a700..bc065b0d9335 100644 --- a/tests/ava_runtime/test_smoke_session_identity.py +++ b/tests/ava_runtime/test_smoke_session_identity.py @@ -50,7 +50,8 @@ def test_upstream_comparison_commands_use_z_surface(tmp_path): assert ["--resume", "session-1"] == second[1:3] -def test_default_command_targets_managed_launcher(): +def test_default_command_targets_managed_launcher(monkeypatch): + monkeypatch.delenv("AVA_ENTITY", raising=False) mod = _load_smoke_module() args = mod.build_parser().parse_args([]) From afe5b29abe570f8a786a84b4149dcd1720182622 Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 11:58:07 +0200 Subject: [PATCH 22/50] ci(ava): add manual runtime validation workflow --- .github/workflows/ava-runtime.yml | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/ava-runtime.yml diff --git a/.github/workflows/ava-runtime.yml b/.github/workflows/ava-runtime.yml new file mode 100644 index 000000000000..ec559a03bd20 --- /dev/null +++ b/.github/workflows/ava-runtime.yml @@ -0,0 +1,44 @@ +name: AVA Runtime Validation + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ava-runtime-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout reviewed revision + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0 + with: + version: "0.9.28" + enable-cache: true + cache-dependency-glob: | + pyproject.toml + uv.lock + + - name: Install Python 3.11 + run: uv python install 3.11 + + - name: Install locked core and test dependencies + uses: ./.github/actions/retry + with: + command: uv sync --locked --python 3.11 --extra dev + + - name: Run AVA runtime tests + run: uv run pytest -q tests/ava_runtime + env: + OPENROUTER_API_KEY: "" + OPENAI_API_KEY: "" + NOUS_API_KEY: "" + ANTHROPIC_API_KEY: "" From f80072e6ff57e0880a0fca0e378aa4e9e01a82ee Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 12:15:44 +0200 Subject: [PATCH 23/50] feat(ava): add strict fleet configuration --- hermes_cli/ava_runtime/fleet_config.py | 456 +++++++++++++++++++++++++ 1 file changed, 456 insertions(+) create mode 100644 hermes_cli/ava_runtime/fleet_config.py diff --git a/hermes_cli/ava_runtime/fleet_config.py b/hermes_cli/ava_runtime/fleet_config.py new file mode 100644 index 000000000000..4521c03ba435 --- /dev/null +++ b/hermes_cli/ava_runtime/fleet_config.py @@ -0,0 +1,456 @@ +"""Validated fleet configuration for AVA managed Hermes runtimes. + +The configuration is intentionally non-secret. It binds each synthetic entity to +an explicit Hermes state root, workspace, and reviewed source checkout. Secrets +remain in the corresponding ``HERMES_HOME`` or external secret stores. +""" + +from __future__ import annotations + +import hashlib +import json +import re +import shlex +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping + +import yaml + +from hermes_cli.ava_runtime.identity import ENTITY_ALIASES + + +SCHEMA_VERSION = 1 +REQUIRED_ENTITIES = frozenset(ENTITY_ALIASES) +_COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") +REQUIRED_POLICY = { + "explicit_hermes_home": "required", + "resume_missing_session": "fail", + "resume_missing_workspace": "fail", + "resume_ambiguous_lineage": "fail", + "restore_recorded_workspace": True, + "allow_restore_workspace_opt_out": True, + "global_most_recent_session": "forbidden_for_managed_entities", + "terminal_backend_failure": "fail", + "deployment_ref": "pinned_commit_or_stable_tag", + "secrets_in_repository": "forbidden", +} + + +class FleetConfigError(ValueError): + """Raised when the fleet configuration violates an operational invariant.""" + + +def _normalized_entity(value: str) -> str: + normalized = value.strip().lower().replace("_", "-") + for entity, aliases in ENTITY_ALIASES.items(): + normalized_aliases = {alias.lower().replace("_", "-") for alias in aliases} + if normalized in normalized_aliases: + return entity + raise FleetConfigError( + f"unknown entity {value!r}; expected one of: {', '.join(sorted(REQUIRED_ENTITIES))}" + ) + + +def _absolute_path(raw: object, *, field: str) -> Path: + if not isinstance(raw, str) or not raw.strip(): + raise FleetConfigError(f"{field} must be a non-empty absolute path") + path = Path(raw).expanduser() + if not path.is_absolute(): + raise FleetConfigError(f"{field} must be absolute: {path}") + return path.resolve(strict=False) + + +def _is_within(path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + except ValueError: + return False + return True + + +def _path_scoped_to_entity(path: Path, entity: str) -> bool: + aliases = {alias.lower().replace("_", "-") for alias in ENTITY_ALIASES[entity]} + parts = {part.lower().replace("_", "-") for part in path.parts} + return bool(parts & aliases) + + +def _require_mapping(value: object, *, field: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise FleetConfigError(f"{field} must be a mapping") + return value + + +def _require_text(value: object, *, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise FleetConfigError(f"{field} must be a non-empty string") + return value.strip() + + +def _require_bool(value: object, *, field: str) -> bool: + if not isinstance(value, bool): + raise FleetConfigError(f"{field} must be a boolean") + return value + + +def _reject_unknown(mapping: Mapping[str, Any], *, field: str, allowed: set[str]) -> None: + unknown = set(mapping) - allowed + if unknown: + raise FleetConfigError( + f"{field} contains unknown fields: " + ", ".join(sorted(str(item) for item in unknown)) + ) + + +@dataclass(frozen=True) +class SourceConfig: + repository: Path + expected_ref: str + require_clean_checkout: bool + auto_update: bool + + +@dataclass(frozen=True) +class PromotionConfig: + upstream_snapshot: str + staging: str + stable: str + require_shadow_validation: bool + require_rollback_ref: bool + + +@dataclass(frozen=True) +class EntityConfig: + name: str + hermes_home: Path + workspace: Path + profile: str + session_scope: str + + def environment(self, source: SourceConfig) -> dict[str, str]: + return { + "AVA_ENTITY": self.name, + "HERMES_HOME": str(self.hermes_home), + "AVA_WORKSPACE": str(self.workspace), + "AVA_HERMES_REPO": str(source.repository), + "AVA_HERMES_EXPECTED_REF": source.expected_ref, + "AVA_PROFILE": self.profile, + "AVA_SESSION_SCOPE": self.session_scope, + } + + +@dataclass(frozen=True) +class FleetConfig: + path: Path + source: SourceConfig + promotion: PromotionConfig + policy: Mapping[str, object] + entities: Mapping[str, EntityConfig] + snapshot_root: Path + raw_sha256: str + + def entity(self, name: str) -> EntityConfig: + canonical = _normalized_entity(name) + try: + return self.entities[canonical] + except KeyError as exc: + raise FleetConfigError(f"entity {canonical!r} is not configured") from exc + + def environment(self, name: str) -> dict[str, str]: + return self.entity(name).environment(self.source) + + def render_shell_environment(self, name: str) -> str: + environment = self.environment(name) + return "\n".join( + f"export {key}={shlex.quote(value)}" for key, value in sorted(environment.items()) + ) + + def public_summary(self) -> dict[str, object]: + return { + "schema_version": SCHEMA_VERSION, + "config_path": str(self.path), + "config_sha256": self.raw_sha256, + "source": { + "repository": str(self.source.repository), + "expected_ref": self.source.expected_ref, + "require_clean_checkout": self.source.require_clean_checkout, + "auto_update": self.source.auto_update, + }, + "promotion": { + "upstream_snapshot": self.promotion.upstream_snapshot, + "staging": self.promotion.staging, + "stable": self.promotion.stable, + "require_shadow_validation": self.promotion.require_shadow_validation, + "require_rollback_ref": self.promotion.require_rollback_ref, + }, + "policy": dict(self.policy), + "snapshots": {"root": str(self.snapshot_root)}, + "entities": { + name: { + "hermes_home": str(entity.hermes_home), + "workspace": str(entity.workspace), + "profile": entity.profile, + "session_scope": entity.session_scope, + } + for name, entity in sorted(self.entities.items()) + }, + } + + def summary_json(self) -> str: + return json.dumps(self.public_summary(), indent=2, sort_keys=True) + + +def _parse_source(raw: Mapping[str, Any]) -> SourceConfig: + source_raw = _require_mapping(raw.get("source"), field="source") + _reject_unknown( + source_raw, + field="source", + allowed={"repository", "expected_ref", "require_clean_checkout", "auto_update"}, + ) + expected_ref = _require_text(source_raw.get("expected_ref"), field="source.expected_ref").lower() + if not _COMMIT_RE.fullmatch(expected_ref): + raise FleetConfigError( + "source.expected_ref must be an exact 40-character lowercase commit SHA" + ) + require_clean = _require_bool( + source_raw.get("require_clean_checkout"), field="source.require_clean_checkout" + ) + auto_update = _require_bool(source_raw.get("auto_update"), field="source.auto_update") + if not require_clean: + raise FleetConfigError("source.require_clean_checkout cannot be disabled") + if auto_update: + raise FleetConfigError("source.auto_update must remain false for managed runtimes") + return SourceConfig( + repository=_absolute_path(source_raw.get("repository"), field="source.repository"), + expected_ref=expected_ref, + require_clean_checkout=require_clean, + auto_update=auto_update, + ) + + +def _parse_promotion(raw: Mapping[str, Any]) -> PromotionConfig: + promotion_raw = _require_mapping(raw.get("promotion"), field="promotion") + _reject_unknown( + promotion_raw, + field="promotion", + allowed={ + "upstream_snapshot", + "staging", + "stable", + "require_shadow_validation", + "require_rollback_ref", + }, + ) + require_shadow = _require_bool( + promotion_raw.get("require_shadow_validation"), + field="promotion.require_shadow_validation", + ) + require_rollback = _require_bool( + promotion_raw.get("require_rollback_ref"), + field="promotion.require_rollback_ref", + ) + if not require_shadow or not require_rollback: + raise FleetConfigError("promotion shadow validation and rollback gates cannot be disabled") + return PromotionConfig( + upstream_snapshot=_require_text( + promotion_raw.get("upstream_snapshot"), field="promotion.upstream_snapshot" + ), + staging=_require_text(promotion_raw.get("staging"), field="promotion.staging"), + stable=_require_text(promotion_raw.get("stable"), field="promotion.stable"), + require_shadow_validation=require_shadow, + require_rollback_ref=require_rollback, + ) + + +def _parse_policy(raw: Mapping[str, Any]) -> dict[str, object]: + policy_raw = _require_mapping(raw.get("policy"), field="policy") + _reject_unknown(policy_raw, field="policy", allowed=set(REQUIRED_POLICY)) + missing = set(REQUIRED_POLICY) - set(policy_raw) + if missing: + raise FleetConfigError("policy is missing fields: " + ", ".join(sorted(missing))) + for name, expected in REQUIRED_POLICY.items(): + if policy_raw.get(name) != expected: + raise FleetConfigError( + f"policy.{name} cannot be lowered: expected {expected!r}, " + f"found {policy_raw.get(name)!r}" + ) + return dict(policy_raw) + + +def _parse_snapshot_root(raw: Mapping[str, Any]) -> Path: + snapshots_raw = _require_mapping(raw.get("snapshots"), field="snapshots") + _reject_unknown(snapshots_raw, field="snapshots", allowed={"root"}) + return _absolute_path(snapshots_raw.get("root"), field="snapshots.root") + + +def _parse_entities(raw: Mapping[str, Any]) -> dict[str, EntityConfig]: + entities_raw = _require_mapping(raw.get("entities"), field="entities") + canonical_keys: dict[str, str] = {} + for supplied_name in entities_raw: + if not isinstance(supplied_name, str): + raise FleetConfigError("entity names must be strings") + canonical = _normalized_entity(supplied_name) + if canonical in canonical_keys: + raise FleetConfigError( + f"duplicate aliases for entity {canonical!r}: " + f"{canonical_keys[canonical]!r} and {supplied_name!r}" + ) + canonical_keys[canonical] = supplied_name + + missing = REQUIRED_ENTITIES - set(canonical_keys) + extra = set(canonical_keys) - REQUIRED_ENTITIES + if missing or extra: + details = [] + if missing: + details.append("missing=" + ",".join(sorted(missing))) + if extra: + details.append("extra=" + ",".join(sorted(extra))) + raise FleetConfigError( + "entities must define the complete managed fleet (" + "; ".join(details) + ")" + ) + + entities: dict[str, EntityConfig] = {} + for canonical, supplied_name in canonical_keys.items(): + entity_raw = _require_mapping(entities_raw[supplied_name], field=f"entities.{supplied_name}") + _reject_unknown( + entity_raw, + field=f"entities.{supplied_name}", + allowed={"hermes_home", "workspace", "profile", "session_scope"}, + ) + hermes_home = _absolute_path( + entity_raw.get("hermes_home"), field=f"entities.{supplied_name}.hermes_home" + ) + workspace = _absolute_path( + entity_raw.get("workspace"), field=f"entities.{supplied_name}.workspace" + ) + profile = _require_text(entity_raw.get("profile"), field=f"entities.{supplied_name}.profile") + session_scope = _require_text( + entity_raw.get("session_scope"), field=f"entities.{supplied_name}.session_scope" + ) + if not _path_scoped_to_entity(hermes_home, canonical): + raise FleetConfigError( + f"entities.{supplied_name}.hermes_home is not visibly scoped to {canonical}: {hermes_home}" + ) + if _is_within(workspace, hermes_home): + raise FleetConfigError( + f"entities.{supplied_name}.workspace must not live inside its HERMES_HOME" + ) + entities[canonical] = EntityConfig( + name=canonical, + hermes_home=hermes_home, + workspace=workspace, + profile=profile, + session_scope=session_scope, + ) + return entities + + +def _validate_cross_entity_isolation( + source: SourceConfig, entities: Mapping[str, EntityConfig], snapshot_root: Path +) -> None: + homes: dict[Path, str] = {} + workspaces: dict[Path, str] = {} + profiles: dict[str, str] = {} + scopes: dict[str, str] = {} + + for name, entity in entities.items(): + if entity.hermes_home in homes: + raise FleetConfigError( + f"HERMES_HOME collision: {name} and {homes[entity.hermes_home]} use {entity.hermes_home}" + ) + homes[entity.hermes_home] = name + if entity.workspace in workspaces: + raise FleetConfigError( + f"workspace collision: {name} and {workspaces[entity.workspace]} use {entity.workspace}" + ) + workspaces[entity.workspace] = name + profile_key = entity.profile.casefold() + if profile_key in profiles: + raise FleetConfigError( + f"profile collision: {name} and {profiles[profile_key]} use {entity.profile!r}" + ) + profiles[profile_key] = name + scope_key = entity.session_scope.casefold() + if scope_key in scopes: + raise FleetConfigError( + f"session_scope collision: {name} and {scopes[scope_key]} use {entity.session_scope!r}" + ) + scopes[scope_key] = name + + ordered = list(entities.values()) + for index, left in enumerate(ordered): + for right in ordered[index + 1 :]: + if _is_within(left.hermes_home, right.hermes_home) or _is_within( + right.hermes_home, left.hermes_home + ): + raise FleetConfigError( + f"nested HERMES_HOME roots are forbidden: {left.name}={left.hermes_home}, " + f"{right.name}={right.hermes_home}" + ) + if _is_within(left.workspace, right.workspace) or _is_within( + right.workspace, left.workspace + ): + raise FleetConfigError( + f"nested workspaces are forbidden: {left.name}={left.workspace}, " + f"{right.name}={right.workspace}" + ) + + for workspace, workspace_owner in workspaces.items(): + for home, home_owner in homes.items(): + if _is_within(workspace, home): + raise FleetConfigError( + f"{workspace_owner}'s workspace {workspace} is inside " + f"{home_owner}'s HERMES_HOME {home}" + ) + + protected_paths: list[tuple[str, Path]] = [ + ("source.repository", source.repository), + ("snapshots.root", snapshot_root), + ] + protected_paths.extend((f"entities.{owner}.hermes_home", path) for path, owner in homes.items()) + protected_paths.extend((f"entities.{owner}.workspace", path) for path, owner in workspaces.items()) + for index, (left_name, left_path) in enumerate(protected_paths): + for right_name, right_path in protected_paths[index + 1 :]: + if _is_within(left_path, right_path) or _is_within(right_path, left_path): + raise FleetConfigError( + f"managed roots must be disjoint: {left_name}={left_path}, " + f"{right_name}={right_path}" + ) + + +def load_fleet_config(path: str | Path) -> FleetConfig: + config_path = Path(path).expanduser().resolve(strict=False) + try: + raw_bytes = config_path.read_bytes() + except OSError as exc: + raise FleetConfigError(f"cannot read fleet config {config_path}: {exc}") from exc + try: + parsed = yaml.safe_load(raw_bytes) or {} + except yaml.YAMLError as exc: + raise FleetConfigError(f"invalid YAML in {config_path}: {exc}") from exc + raw = _require_mapping(parsed, field="root") + _reject_unknown( + raw, + field="root", + allowed={"version", "source", "promotion", "policy", "snapshots", "entities"}, + ) + version = raw.get("version") + if version != SCHEMA_VERSION: + raise FleetConfigError( + f"unsupported fleet config version {version!r}; expected {SCHEMA_VERSION}" + ) + + source = _parse_source(raw) + promotion = _parse_promotion(raw) + policy = _parse_policy(raw) + snapshot_root = _parse_snapshot_root(raw) + entities = _parse_entities(raw) + _validate_cross_entity_isolation(source, entities, snapshot_root) + return FleetConfig( + path=config_path, + source=source, + promotion=promotion, + policy=policy, + entities=entities, + snapshot_root=snapshot_root, + raw_sha256=hashlib.sha256(raw_bytes).hexdigest(), + ) From f4c1b399231c3f13171b26b520453f8be745aed3 Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 12:18:06 +0200 Subject: [PATCH 24/50] feat(ava): add managed fleet control plane --- scripts/ava_runtime/fleet.py | 160 +++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 scripts/ava_runtime/fleet.py diff --git a/scripts/ava_runtime/fleet.py b/scripts/ava_runtime/fleet.py new file mode 100644 index 000000000000..0d69797b49e0 --- /dev/null +++ b/scripts/ava_runtime/fleet.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Control-plane CLI for the managed AVA Hermes fleet. + +This command never stores credentials. It validates the non-secret fleet map, +materializes one entity's environment, and invokes the existing doctor, smoke, +or managed one-shot entry points without a shell. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Iterable + +from hermes_cli.ava_runtime.fleet_config import ( + FleetConfig, + FleetConfigError, + REQUIRED_ENTITIES, + load_fleet_config, +) + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_CONFIG = ROOT / "config" / "ava-runtime" / "entities.yaml" + + +def _merged_environment(config: FleetConfig, entity: str) -> dict[str, str]: + env = os.environ.copy() + env.update(config.environment(entity)) + return env + + +def _run_child(command: list[str], *, env: dict[str, str], cwd: Path | None) -> int: + try: + result = subprocess.run(command, env=env, cwd=cwd, check=False) + except OSError as exc: + print(f"cannot execute managed command: {exc}", file=sys.stderr) + return 127 + return int(result.returncode) + + +def _doctor_command(config: FleetConfig, entity_name: str, *, expected_ref: str | None, require_state_db: bool, json_output: bool) -> list[str]: + entity = config.entity(entity_name) + command = [sys.executable, str(ROOT / "scripts" / "ava_runtime" / "doctor.py"), "--entity", entity.name, "--repo", str(config.source.repository), "--workspace", str(entity.workspace), "--hermes-home", str(entity.hermes_home), "--expected-ref", expected_ref or config.source.expected_ref] + if config.source.require_clean_checkout: + command.append("--require-clean") + if require_state_db: + command.append("--require-state-db") + if json_output: + command.append("--json") + return command + + +def _smoke_command(args: argparse.Namespace, config: FleetConfig) -> list[str]: + entity = config.entity(args.entity) + command = [sys.executable, str(ROOT / "scripts" / "ava_runtime" / "smoke_session_identity.py"), "--entity", entity.name, "--workspace", str(entity.workspace), "--mode", args.mode, "--timeout", str(args.timeout)] + if args.live_state: + command.extend(["--hermes-home", str(entity.hermes_home)]) + if args.model: + command.extend(["--model", args.model]) + if args.provider: + command.extend(["--provider", args.provider]) + if args.toolsets: + command.extend(["--toolsets", args.toolsets]) + if args.keep_temporary_home: + command.append("--keep-temporary-home") + if args.json_output: + command.append("--json") + return command + + +def _normalize_remainder(values: Iterable[str]) -> list[str]: + result = list(values) + if result and result[0] == "--": + result = result[1:] + return result + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", default=os.environ.get("AVA_FLEET_CONFIG", "").strip() or str(DEFAULT_CONFIG), help="Non-secret fleet YAML. Defaults to AVA_FLEET_CONFIG or config/ava-runtime/entities.yaml.") + subparsers = parser.add_subparsers(dest="command", required=True) + validate = subparsers.add_parser("validate", help="Validate the complete fleet map") + validate.add_argument("--json", action="store_true", dest="json_output") + env_parser = subparsers.add_parser("env", help="Render one entity's non-secret environment") + env_parser.add_argument("entity", choices=sorted(REQUIRED_ENTITIES)) + env_parser.add_argument("--format", choices=("shell", "json"), default="shell") + doctor = subparsers.add_parser("doctor", help="Run the preflight doctor from fleet configuration") + doctor.add_argument("entity", choices=[*sorted(REQUIRED_ENTITIES), "all"]) + doctor.add_argument("--expected-ref") + doctor.add_argument("--require-state-db", action="store_true") + doctor.add_argument("--json", action="store_true", dest="json_output") + smoke = subparsers.add_parser("smoke", help="Run the two-turn durable identity smoke") + smoke.add_argument("entity", choices=sorted(REQUIRED_ENTITIES)) + smoke.add_argument("--mode", choices=("managed", "upstream"), default="managed") + smoke.add_argument("--live-state", action="store_true") + smoke.add_argument("--model") + smoke.add_argument("--provider") + smoke.add_argument("--toolsets") + smoke.add_argument("--timeout", type=int, default=300) + smoke.add_argument("--keep-temporary-home", action="store_true") + smoke.add_argument("--json", action="store_true", dest="json_output") + snapshot = subparsers.add_parser("snapshot", help="Create verified SQLite state snapshots") + snapshot.add_argument("entity", choices=[*sorted(REQUIRED_ENTITIES), "all"]) + oneshot = subparsers.add_parser("oneshot", help="Launch the fail-closed managed one-shot surface") + oneshot.add_argument("entity", choices=sorted(REQUIRED_ENTITIES)) + oneshot.add_argument("managed_args", nargs=argparse.REMAINDER) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + config = load_fleet_config(args.config) + except FleetConfigError as exc: + print(f"AVA fleet configuration rejected: {exc}", file=sys.stderr) + return 2 + if args.command == "validate": + if args.json_output: + print(config.summary_json()) + else: + print("STATUS_CLOSURE=PASS") + print(f"config_sha256={config.raw_sha256}") + print("entities=" + ",".join(sorted(config.entities))) + return 0 + if args.command == "env": + print(json.dumps(config.environment(args.entity), indent=2, sort_keys=True) if args.format == "json" else config.render_shell_environment(args.entity)) + return 0 + if args.command == "doctor": + names = sorted(config.entities) if args.entity == "all" else [args.entity] + exit_code = 0 + for name in names: + command = _doctor_command(config, name, expected_ref=args.expected_ref, require_state_db=args.require_state_db, json_output=args.json_output) + exit_code = max(exit_code, _run_child(command, env=_merged_environment(config, name), cwd=config.source.repository if config.source.repository.is_dir() else None)) + return exit_code + if args.command == "smoke": + return _run_child(_smoke_command(args, config), env=_merged_environment(config, args.entity), cwd=config.source.repository if config.source.repository.is_dir() else None) + if args.command == "snapshot": + names = sorted(config.entities) if args.entity == "all" else [args.entity] + exit_code = 0 + for name in names: + entity = config.entity(name) + command = [sys.executable, str(ROOT / "scripts" / "ava_runtime" / "state_snapshot.py"), "create", "--entity", entity.name, "--hermes-home", str(entity.hermes_home), "--output-root", str(config.snapshot_root)] + exit_code = max(exit_code, _run_child(command, env=_merged_environment(config, name), cwd=config.source.repository if config.source.repository.is_dir() else None)) + return exit_code + if args.command == "oneshot": + managed_args = _normalize_remainder(args.managed_args) + if not managed_args: + print("oneshot requires managed arguments and a final prompt", file=sys.stderr) + return 2 + entity = config.entity(args.entity) + return _run_child([sys.executable, "-m", "hermes_cli.ava_runtime.managed_oneshot", *managed_args], env=_merged_environment(config, args.entity), cwd=entity.workspace) + raise AssertionError(f"unhandled fleet command: {args.command}") + + +if __name__ == "__main__": + sys.exit(main()) From fc63bc6b9e70a1a589c31c43bb2161468ccf73b3 Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 12:18:54 +0200 Subject: [PATCH 25/50] feat(ava): add promotion evidence gate --- scripts/ava_runtime/promotion.py | 226 +++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 scripts/ava_runtime/promotion.py diff --git a/scripts/ava_runtime/promotion.py b/scripts/ava_runtime/promotion.py new file mode 100644 index 000000000000..cb6b9911d99b --- /dev/null +++ b/scripts/ava_runtime/promotion.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Create or verify a promotion manifest for the managed AVA Hermes fleet. + +A manifest is generated only after the candidate revision, rollback target, +configuration, test evidence, and per-entity shadow validation all close. The +manifest contains hashes and public status metadata only; never include logs, +transcripts, credentials, or private prompts in the validation report. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import subprocess +import sys +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping + +from hermes_cli.ava_runtime.fleet_config import FleetConfig, FleetConfigError, REQUIRED_ENTITIES, load_fleet_config + +SCHEMA_VERSION = 1 +REQUIRED_TESTS = frozenset({"ava_runtime", "hermes_cli", "upstream_relevant"}) +REQUIRED_ENTITY_GATES = frozenset({"doctor", "identity_smoke", "shadow_runtime"}) + + +class PromotionError(ValueError): + """Raised when promotion evidence does not close.""" + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + try: + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + except OSError as exc: + raise PromotionError(f"cannot hash {path}: {exc}") from exc + return digest.hexdigest() + + +def _load_json(path: Path, *, label: str) -> Mapping[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise PromotionError(f"cannot read {label} {path}: {exc}") from exc + if not isinstance(value, Mapping): + raise PromotionError(f"{label} must be a JSON object: {path}") + return value + + +def _status(value: object, *, field: str) -> str: + if isinstance(value, str): + result = value.strip().lower() + elif isinstance(value, Mapping): + raw = value.get("status") + result = raw.strip().lower() if isinstance(raw, str) else "" + else: + result = "" + if result != "pass": + raise PromotionError(f"{field} must have status 'pass', found {result or value!r}") + return result + + +def _require_text(value: object, *, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise PromotionError(f"{field} must be a non-empty string") + return value.strip() + + +def _validate_report(report: Mapping[str, Any]) -> dict[str, Any]: + if report.get("schema_version") != SCHEMA_VERSION: + raise PromotionError(f"unsupported validation report schema {report.get('schema_version')!r}; expected {SCHEMA_VERSION}") + _status(report.get("status_closure"), field="status_closure") + candidate_commit = _require_text(report.get("candidate_commit"), field="candidate_commit") + rollback_ref = _require_text(report.get("rollback_ref"), field="rollback_ref") + tests_raw = report.get("tests") + if not isinstance(tests_raw, Mapping): + raise PromotionError("tests must be a JSON object") + missing_tests = REQUIRED_TESTS - set(tests_raw) + if missing_tests: + raise PromotionError("missing required test gates: " + ", ".join(sorted(missing_tests))) + tests: dict[str, Any] = {} + for name in sorted(REQUIRED_TESTS): + _status(tests_raw[name], field=f"tests.{name}") + tests[name] = tests_raw[name] + entities_raw = report.get("entities") + if not isinstance(entities_raw, Mapping): + raise PromotionError("entities must be a JSON object") + if set(entities_raw) != set(REQUIRED_ENTITIES): + raise PromotionError("validation report must contain exactly: " + ", ".join(sorted(REQUIRED_ENTITIES))) + entities: dict[str, Any] = {} + for entity_name in sorted(REQUIRED_ENTITIES): + entity_raw = entities_raw[entity_name] + if not isinstance(entity_raw, Mapping): + raise PromotionError(f"entities.{entity_name} must be a JSON object") + missing_gates = REQUIRED_ENTITY_GATES - set(entity_raw) + if missing_gates: + raise PromotionError(f"entities.{entity_name} missing gates: " + ", ".join(sorted(missing_gates))) + for gate in sorted(REQUIRED_ENTITY_GATES): + _status(entity_raw[gate], field=f"entities.{entity_name}.{gate}") + entities[entity_name] = dict(entity_raw) + remaining_gaps = report.get("remaining_gaps", []) + if remaining_gaps not in (None, []): + raise PromotionError("remaining_gaps must be empty before promotion") + return {"candidate_commit": candidate_commit, "rollback_ref": rollback_ref, "tests": tests, "entities": entities} + + +def _git(repo: Path, *args: str) -> str: + try: + result = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, check=False) + except (OSError, subprocess.TimeoutExpired) as exc: + raise PromotionError(f"git {' '.join(args)} failed: {exc}") from exc + if result.returncode != 0: + raise PromotionError(f"git {' '.join(args)} failed: {(result.stderr or result.stdout).strip()}") + return result.stdout.strip() + + +def _git_state(repo: Path, rollback_ref: str) -> dict[str, str]: + head = _git(repo, "rev-parse", "HEAD") + tree = _git(repo, "rev-parse", "HEAD^{tree}") + rollback_commit = _git(repo, "rev-parse", "--verify", f"{rollback_ref}^{{commit}}") + status = _git(repo, "status", "--porcelain=v1", "--untracked-files=normal") + if status: + raise PromotionError("working tree must be clean before promotion") + if rollback_commit == head: + raise PromotionError("rollback target must differ from the candidate commit") + return {"head": head, "tree": tree, "rollback_commit": rollback_commit} + + +def _atomic_write_json(path: Path, payload: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + encoded = json.dumps(payload, indent=2, sort_keys=True) + "\n" + fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(encoded) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temporary, 0o644) + os.replace(temporary, path) + except BaseException: + try: + os.unlink(temporary) + except OSError: + pass + raise + + +def build_manifest(*, config: FleetConfig, report_path: Path, report: Mapping[str, Any], repo: Path) -> dict[str, Any]: + validated = _validate_report(report) + git_state = _git_state(repo, validated["rollback_ref"]) + if validated["candidate_commit"] != git_state["head"]: + raise PromotionError("validation report candidate_commit does not match repository HEAD: " f"report={validated['candidate_commit']}, head={git_state['head']}") + return { + "schema_version": SCHEMA_VERSION, + "status_closure": "pass", + "generated_at": datetime.now(timezone.utc).isoformat(), + "source": {"repository": str(repo.resolve()), "candidate_commit": git_state["head"], "candidate_tree": git_state["tree"], "rollback_ref": validated["rollback_ref"], "rollback_commit": git_state["rollback_commit"]}, + "evidence": {"fleet_config": str(config.path), "fleet_config_sha256": config.raw_sha256, "validation_report": str(report_path.resolve()), "validation_report_sha256": _sha256(report_path), "tests": validated["tests"], "entities": validated["entities"]}, + } + + +def verify_manifest(*, manifest: Mapping[str, Any], config: FleetConfig, report_path: Path, report: Mapping[str, Any], repo: Path) -> None: + if manifest.get("schema_version") != SCHEMA_VERSION: + raise PromotionError("unsupported promotion manifest schema") + _status(manifest.get("status_closure"), field="manifest.status_closure") + validated = _validate_report(report) + source = manifest.get("source") + evidence = manifest.get("evidence") + if not isinstance(source, Mapping) or not isinstance(evidence, Mapping): + raise PromotionError("manifest source/evidence blocks are required") + git_state = _git_state(repo, validated["rollback_ref"]) + expected_pairs = { + "source.repository": (source.get("repository"), str(repo.resolve())), + "source.candidate_commit": (source.get("candidate_commit"), git_state["head"]), + "source.candidate_tree": (source.get("candidate_tree"), git_state["tree"]), + "source.rollback_ref": (source.get("rollback_ref"), validated["rollback_ref"]), + "source.rollback_commit": (source.get("rollback_commit"), git_state["rollback_commit"]), + "evidence.fleet_config_sha256": (evidence.get("fleet_config_sha256"), config.raw_sha256), + "evidence.validation_report_sha256": (evidence.get("validation_report_sha256"), _sha256(report_path)), + } + for field, (actual, expected) in expected_pairs.items(): + if actual != expected: + raise PromotionError(f"manifest drift at {field}: {actual!r} != {expected!r}") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + for name in ("prepare", "verify"): + command = subparsers.add_parser(name) + command.add_argument("--config", required=True) + command.add_argument("--report", required=True) + command.add_argument("--repo") + command.add_argument("--manifest", required=True) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + config = load_fleet_config(args.config) + repo = Path(args.repo).expanduser().resolve() if args.repo else config.source.repository + report_path = Path(args.report).expanduser().resolve() + report = _load_json(report_path, label="validation report") + manifest_path = Path(args.manifest).expanduser().resolve() + if args.command == "prepare": + _atomic_write_json(manifest_path, build_manifest(config=config, report_path=report_path, report=report, repo=repo)) + else: + verify_manifest(manifest=_load_json(manifest_path, label="promotion manifest"), config=config, report_path=report_path, report=report, repo=repo) + print("STATUS_CLOSURE=PASS") + print(f"candidate_commit={_git(repo, 'rev-parse', 'HEAD')}") + print(f"manifest={manifest_path}") + return 0 + except (FleetConfigError, PromotionError) as exc: + print(f"promotion rejected: {exc}", file=sys.stderr) + print("STATUS_CLOSURE=FAIL", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) From 21b557d12be303ecf57cc4936a412054cb80ed17 Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 12:19:30 +0200 Subject: [PATCH 26/50] feat(ava): add verified state snapshots --- scripts/ava_runtime/state_snapshot.py | 212 ++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 scripts/ava_runtime/state_snapshot.py diff --git a/scripts/ava_runtime/state_snapshot.py b/scripts/ava_runtime/state_snapshot.py new file mode 100644 index 000000000000..d8945585d277 --- /dev/null +++ b/scripts/ava_runtime/state_snapshot.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Create or verify an atomic SQLite state snapshot for one managed entity. + +The snapshot contains only ``state.db`` and a checksum manifest. It does not copy +configuration files, credentials, logs, caches, prompts, or other HERMES_HOME +content. The destination must be local protected storage outside HERMES_HOME. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import sqlite3 +import sys +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping + +from hermes_cli.ava_runtime.identity import ENTITY_ALIASES + +SCHEMA_VERSION = 1 + + +class SnapshotError(ValueError): + """Raised when a snapshot cannot be proven safe or valid.""" + + +def _normalize_entity(value: str) -> str: + normalized = value.strip().lower().replace("_", "-") + for entity, aliases in ENTITY_ALIASES.items(): + candidates = {alias.lower().replace("_", "-") for alias in aliases} + if normalized in candidates: + return entity + raise SnapshotError(f"unknown entity {value!r}; expected one of: {', '.join(sorted(ENTITY_ALIASES))}") + + +def _is_entity_scoped(path: Path, entity: str) -> bool: + aliases = {alias.lower().replace("_", "-") for alias in ENTITY_ALIASES[entity]} + parts = {part.lower().replace("_", "-") for part in path.parts} + return bool(parts & aliases) + + +def _within(path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + except ValueError: + return False + return True + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + try: + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + except OSError as exc: + raise SnapshotError(f"cannot hash {path}: {exc}") from exc + return digest.hexdigest() + + +def _sqlite_metadata(path: Path) -> dict[str, Any]: + try: + with sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=30) as conn: + integrity = conn.execute("PRAGMA integrity_check").fetchone() + if not integrity or str(integrity[0]).lower() != "ok": + raise SnapshotError(f"SQLite integrity_check failed for {path}: {integrity}") + page_count = int(conn.execute("PRAGMA page_count").fetchone()[0]) + page_size = int(conn.execute("PRAGMA page_size").fetchone()[0]) + user_version = int(conn.execute("PRAGMA user_version").fetchone()[0]) + schema_version = int(conn.execute("PRAGMA schema_version").fetchone()[0]) + except sqlite3.Error as exc: + raise SnapshotError(f"cannot inspect SQLite database {path}: {exc}") from exc + return {"integrity_check": "ok", "page_count": page_count, "page_size": page_size, "user_version": user_version, "schema_version": schema_version} + + +def _backup_sqlite(source: Path, destination: Path) -> None: + try: + with sqlite3.connect(f"file:{source}?mode=ro", uri=True, timeout=30) as source_conn: + with sqlite3.connect(destination, timeout=30) as destination_conn: + source_conn.backup(destination_conn) + except sqlite3.Error as exc: + raise SnapshotError(f"SQLite backup failed: {exc}") from exc + os.chmod(destination, 0o600) + + +def _write_manifest(path: Path, payload: Mapping[str, Any]) -> None: + encoded = json.dumps(payload, indent=2, sort_keys=True) + "\n" + try: + with path.open("x", encoding="utf-8") as handle: + handle.write(encoded) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(path, 0o600) + except OSError as exc: + raise SnapshotError(f"cannot write snapshot manifest {path}: {exc}") from exc + + +def _load_manifest(path: Path) -> Mapping[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise SnapshotError(f"cannot read snapshot manifest {path}: {exc}") from exc + if not isinstance(payload, Mapping): + raise SnapshotError("snapshot manifest must be a JSON object") + return payload + + +def create_snapshot(*, entity: str, hermes_home: Path, output_root: Path) -> Path: + entity = _normalize_entity(entity) + hermes_home = hermes_home.expanduser().resolve() + output_root = output_root.expanduser().resolve() + if not hermes_home.is_dir(): + raise SnapshotError(f"HERMES_HOME does not exist: {hermes_home}") + if not _is_entity_scoped(hermes_home, entity): + raise SnapshotError(f"HERMES_HOME is not visibly scoped to {entity}: {hermes_home}") + if _within(output_root, hermes_home): + raise SnapshotError("snapshot output root must be outside HERMES_HOME") + source = hermes_home / "state.db" + if source.is_symlink(): + raise SnapshotError(f"refusing symlinked state database: {source}") + if not source.is_file(): + raise SnapshotError(f"state database does not exist: {source}") + output_root.mkdir(parents=True, exist_ok=True, mode=0o700) + os.chmod(output_root, 0o700) + temporary = Path(tempfile.mkdtemp(prefix=f".{entity}-snapshot-", dir=output_root)) + os.chmod(temporary, 0o700) + try: + snapshot_db = temporary / "state.db" + _backup_sqlite(source, snapshot_db) + metadata = _sqlite_metadata(snapshot_db) + checksum = _sha256(snapshot_db) + timestamp = datetime.now(timezone.utc) + _write_manifest(temporary / "manifest.json", {"schema_version": SCHEMA_VERSION, "status_closure": "pass", "entity": entity, "created_at": timestamp.isoformat(), "source_state_db": str(source), "snapshot_file": "state.db", "state_db_sha256": checksum, "sqlite": metadata}) + destination = output_root / f"{entity}-{timestamp.strftime('%Y%m%dT%H%M%SZ')}-{checksum[:12]}" + if destination.exists(): + raise SnapshotError(f"snapshot destination already exists: {destination}") + temporary.replace(destination) + return destination + except BaseException: + shutil.rmtree(temporary, ignore_errors=True) + raise + + +def verify_snapshot(snapshot_dir: Path) -> Mapping[str, Any]: + snapshot_dir = snapshot_dir.expanduser().resolve() + if not snapshot_dir.is_dir(): + raise SnapshotError(f"snapshot directory does not exist: {snapshot_dir}") + manifest = _load_manifest(snapshot_dir / "manifest.json") + if manifest.get("schema_version") != SCHEMA_VERSION: + raise SnapshotError("unsupported snapshot manifest schema") + if manifest.get("status_closure") != "pass": + raise SnapshotError("snapshot manifest is not closed") + entity = _normalize_entity(str(manifest.get("entity") or "")) + snapshot_name = manifest.get("snapshot_file") + if snapshot_name != "state.db": + raise SnapshotError(f"unexpected snapshot_file: {snapshot_name!r}") + snapshot_db = snapshot_dir / snapshot_name + if snapshot_db.is_symlink() or not snapshot_db.is_file(): + raise SnapshotError(f"snapshot database is missing or symlinked: {snapshot_db}") + actual_hash = _sha256(snapshot_db) + expected_hash = manifest.get("state_db_sha256") + if actual_hash != expected_hash: + raise SnapshotError(f"snapshot checksum mismatch: expected {expected_hash}, found {actual_hash}") + metadata = _sqlite_metadata(snapshot_db) + recorded_metadata = manifest.get("sqlite") + if not isinstance(recorded_metadata, Mapping): + raise SnapshotError("snapshot manifest lacks SQLite metadata") + for key in ("page_count", "page_size", "user_version", "schema_version"): + if recorded_metadata.get(key) != metadata.get(key): + raise SnapshotError(f"snapshot SQLite metadata drift at {key}: {recorded_metadata.get(key)!r} != {metadata.get(key)!r}") + return {"entity": entity, "state_db_sha256": actual_hash, "sqlite": metadata} + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + create = subparsers.add_parser("create") + create.add_argument("--entity", required=True) + create.add_argument("--hermes-home", required=True) + create.add_argument("--output-root", required=True) + verify = subparsers.add_parser("verify") + verify.add_argument("snapshot_dir") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + if args.command == "create": + snapshot = create_snapshot(entity=args.entity, hermes_home=Path(args.hermes_home), output_root=Path(args.output_root)) + result = verify_snapshot(snapshot) + print("STATUS_CLOSURE=PASS") + print(f"snapshot={snapshot}") + else: + result = verify_snapshot(Path(args.snapshot_dir)) + print("STATUS_CLOSURE=PASS") + print(f"entity={result['entity']}") + print(f"state_db_sha256={result['state_db_sha256']}") + return 0 + except SnapshotError as exc: + print(f"snapshot rejected: {exc}", file=sys.stderr) + print("STATUS_CLOSURE=FAIL", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) From 8c9dd6608c5d55500d28d0483d5cad795165821b Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 12:19:59 +0200 Subject: [PATCH 27/50] docs(ava): harden fleet configuration example --- config/ava-runtime/entities.example.yaml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/config/ava-runtime/entities.example.yaml b/config/ava-runtime/entities.example.yaml index 58f054d10c29..79ae044923b4 100644 --- a/config/ava-runtime/entities.example.yaml +++ b/config/ava-runtime/entities.example.yaml @@ -1,9 +1,11 @@ -# Public example only. Keep credentials and private OmniPulse material outside Git. +# Public example only. Copy to a protected local path such as +# /etc/ava/hermes/entities.yaml, then replace the commit placeholder. +# Keep credentials and private OmniPulse material outside Git. version: 1 source: repository: /opt/ava/hermes/source - expected_ref: ava/stable + expected_ref: REPLACE_WITH_EXACT_40_CHARACTER_COMMIT_SHA require_clean_checkout: true auto_update: false @@ -14,6 +16,9 @@ promotion: require_shadow_validation: true require_rollback_ref: true +snapshots: + root: /var/backups/ava/hermes-state + entities: ava: hermes_home: /var/lib/ava/hermes/ava From f9a9a906a0b92dbde9205e1c3a7e1fc938abf916 Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 12:20:14 +0200 Subject: [PATCH 28/50] docs(ava): add failing promotion report template --- .../validation-report.example.json | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 config/ava-runtime/validation-report.example.json diff --git a/config/ava-runtime/validation-report.example.json b/config/ava-runtime/validation-report.example.json new file mode 100644 index 000000000000..02dc9ce9ef95 --- /dev/null +++ b/config/ava-runtime/validation-report.example.json @@ -0,0 +1,40 @@ +{ + "schema_version": 1, + "status_closure": "not-ready", + "candidate_commit": "REPLACE_WITH_EXACT_40_CHARACTER_COMMIT_SHA", + "rollback_ref": "REPLACE_WITH_PREVIOUS_STABLE_TAG_OR_COMMIT", + "tests": { + "ava_runtime": { + "status": "not-run", + "command": "uv run pytest -q tests/ava_runtime" + }, + "hermes_cli": { + "status": "not-run", + "command": "uv run pytest -q tests/hermes_cli" + }, + "upstream_relevant": { + "status": "not-run", + "command": "REPLACE_WITH_RELEVANT_UPSTREAM_TEST_COMMANDS" + } + }, + "entities": { + "ava": { + "doctor": {"status": "not-run"}, + "identity_smoke": {"status": "not-run"}, + "shadow_runtime": {"status": "not-run"} + }, + "aeon": { + "doctor": {"status": "not-run"}, + "identity_smoke": {"status": "not-run"}, + "shadow_runtime": {"status": "not-run"} + }, + "avaeon-codex": { + "doctor": {"status": "not-run"}, + "identity_smoke": {"status": "not-run"}, + "shadow_runtime": {"status": "not-run"} + } + }, + "remaining_gaps": [ + "Replace every placeholder and run every gate before promotion." + ] +} From ca9d90965ae7136046996ccfae872b9303cd2722 Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 12:20:36 +0200 Subject: [PATCH 29/50] docs(ava): add phase 2 control-plane runbook --- docs/ava-runtime/PHASE2_CONTROL_PLANE.md | 133 +++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 docs/ava-runtime/PHASE2_CONTROL_PLANE.md diff --git a/docs/ava-runtime/PHASE2_CONTROL_PLANE.md b/docs/ava-runtime/PHASE2_CONTROL_PLANE.md new file mode 100644 index 000000000000..bb16555250c0 --- /dev/null +++ b/docs/ava-runtime/PHASE2_CONTROL_PLANE.md @@ -0,0 +1,133 @@ +# AVA Hermes Phase 2 Control Plane + +Phase 2 turns the foundation into a fleet-level operating surface. It remains an overlay: no upstream Hermes file is modified, and no production branch is advanced automatically. + +## 1. Install the non-secret fleet map + +Copy the public example outside the source checkout so the reviewed tree remains clean: + +```bash +sudo install -d -m 0750 -o "$USER" -g "$USER" /etc/ava/hermes +sudo install -m 0640 -o "$USER" -g "$USER" \ + config/ava-runtime/entities.example.yaml \ + /etc/ava/hermes/entities.yaml +export AVA_FLEET_CONFIG=/etc/ava/hermes/entities.yaml +``` + +Replace `source.expected_ref` with the exact 40-character commit under test. Moving branches such as `ava/stable` are deliberately rejected by the validator. + +## 2. Validate the entire fleet before launching anything + +```bash +uv run python scripts/ava_runtime/fleet.py validate +``` + +The validator refuses: + +- an incomplete AVA/AEON/AVAEON fleet; +- shared or nested `HERMES_HOME` roots; +- shared or nested workspaces; +- a workspace inside any entity's state root; +- duplicate profiles or session scopes; +- source, snapshot, workspace, or state roots that overlap; +- unknown YAML fields or schema versions; +- `auto_update: true`; +- a moving or abbreviated deployment reference; +- any lowering of the canonical fail-closed policy. + +## 3. Render or consume one entity environment + +For inspection: + +```bash +uv run python scripts/ava_runtime/fleet.py env avaeon-codex +``` + +For a managed one-shot invocation: + +```bash +uv run python scripts/ava_runtime/fleet.py oneshot avaeon-codex -- \ + --resume \ + "Continue the work" +``` + +The fleet launcher sets `AVA_ENTITY`, `HERMES_HOME`, `AVA_WORKSPACE`, the reviewed repository and commit, then executes the narrow managed one-shot surface without a shell. + +## 4. Run fleet preflight and smoke tests + +```bash +uv run python scripts/ava_runtime/fleet.py doctor all --require-state-db + +uv run python scripts/ava_runtime/fleet.py smoke ava +uv run python scripts/ava_runtime/fleet.py smoke aeon +uv run python scripts/ava_runtime/fleet.py smoke avaeon-codex +``` + +Smoke tests use disposable state by default. `--live-state` is an explicit, visible opt-in and must not be used for the first validation pass. + +## 5. Create verified state snapshots + +Before any migration or deployment: + +```bash +uv run python scripts/ava_runtime/fleet.py snapshot all +``` + +Each snapshot uses SQLite's online backup API, performs `PRAGMA integrity_check`, stores a SHA-256 manifest, and sets directory/file modes to `0700/0600`. It contains only `state.db`; credentials, configuration files, logs, caches, prompts, and other `HERMES_HOME` content are not copied. + +Verify a snapshot independently: + +```bash +uv run python scripts/ava_runtime/state_snapshot.py verify \ + /var/backups/ava/hermes-state/ +``` + +Snapshot storage must itself be protected and included in the vessel's encrypted backup policy. + +## 6. Produce the promotion evidence + +Copy the deliberately failing template outside the source checkout: + +```bash +cp config/ava-runtime/validation-report.example.json \ + /var/lib/ava/promotion/validation-report.json +``` + +Fill it only with public command/status metadata. Never paste transcripts, prompts, API keys, tokens, or private OmniPulse material. + +Promotion requires all of the following to be `pass`: + +- `tests.ava_runtime`; +- `tests.hermes_cli`; +- `tests.upstream_relevant`; +- doctor, identity smoke, and shadow runtime for AVA; +- doctor, identity smoke, and shadow runtime for AEON; +- doctor, identity smoke, and shadow runtime for AVAEON Codex; +- an empty `remaining_gaps` array; +- a candidate commit exactly equal to checkout `HEAD`; +- a clean working tree; +- a resolvable rollback target different from the candidate. + +Generate the evidence manifest: + +```bash +uv run python scripts/ava_runtime/promotion.py prepare \ + --config "$AVA_FLEET_CONFIG" \ + --report /var/lib/ava/promotion/validation-report.json \ + --manifest /var/lib/ava/promotion/promotion-manifest.json +``` + +Re-verify immediately before promotion or deployment: + +```bash +uv run python scripts/ava_runtime/promotion.py verify \ + --config "$AVA_FLEET_CONFIG" \ + --report /var/lib/ava/promotion/validation-report.json \ + --manifest /var/lib/ava/promotion/promotion-manifest.json +``` + +Any later change to the checkout, fleet YAML, validation report, candidate tree, or rollback target invalidates the manifest. + +## 7. Promotion boundary + +The control plane never moves `ava/stable`, creates a stable tag, or restarts a service. Those remain explicit operator actions after the manifest verifies. The first deployment proceeds one entity at a time, with doctor and smoke rerun after each restart. From b233810ca5a82981b4748a1d746c8d4171deae29 Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 12:21:12 +0200 Subject: [PATCH 30/50] test(ava): cover strict fleet configuration --- tests/ava_runtime/test_fleet_config.py | 159 +++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 tests/ava_runtime/test_fleet_config.py diff --git a/tests/ava_runtime/test_fleet_config.py b/tests/ava_runtime/test_fleet_config.py new file mode 100644 index 000000000000..8ff8db71d609 --- /dev/null +++ b/tests/ava_runtime/test_fleet_config.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path + +import pytest +import yaml + +from hermes_cli.ava_runtime.fleet_config import FleetConfigError, load_fleet_config + + +def _raw(tmp_path: Path) -> dict: + return { + "version": 1, + "snapshots": {"root": str(tmp_path / "backups")}, + "promotion": { + "upstream_snapshot": "ava/upstream-2026-07-30", + "staging": "ava/staging", + "stable": "ava/stable", + "require_shadow_validation": True, + "require_rollback_ref": True, + }, + "policy": { + "explicit_hermes_home": "required", + "resume_missing_session": "fail", + "resume_missing_workspace": "fail", + "resume_ambiguous_lineage": "fail", + "restore_recorded_workspace": True, + "allow_restore_workspace_opt_out": True, + "global_most_recent_session": "forbidden_for_managed_entities", + "terminal_backend_failure": "fail", + "deployment_ref": "pinned_commit_or_stable_tag", + "secrets_in_repository": "forbidden", + }, + "source": { + "repository": str(tmp_path / "source"), + "expected_ref": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "require_clean_checkout": True, + "auto_update": False, + }, + "entities": { + "ava": {"hermes_home": str(tmp_path / "state" / "ava"), "workspace": str(tmp_path / "workspaces" / "ava"), "profile": "ava", "session_scope": "ava"}, + "aeon": {"hermes_home": str(tmp_path / "state" / "aeon"), "workspace": str(tmp_path / "workspaces" / "aeon"), "profile": "aeon", "session_scope": "aeon"}, + "avaeon-codex": {"hermes_home": str(tmp_path / "state" / "avaeon-codex"), "workspace": str(tmp_path / "workspaces" / "avaeon-codex"), "profile": "avaeon-codex", "session_scope": "avaeon-codex"}, + }, + } + + +def _write(tmp_path: Path, raw: dict) -> Path: + path = tmp_path / "fleet.yaml" + path.write_text(yaml.safe_dump(raw, sort_keys=False), encoding="utf-8") + return path + + +def test_valid_fleet_loads_and_renders_environment(tmp_path): + path = _write(tmp_path, _raw(tmp_path)) + config = load_fleet_config(path) + assert set(config.entities) == {"ava", "aeon", "avaeon-codex"} + assert config.entity("avaeon_codex").name == "avaeon-codex" + env = config.environment("ava") + assert env["AVA_ENTITY"] == "ava" + assert env["HERMES_HOME"].endswith("/state/ava") + assert env["AVA_HERMES_EXPECTED_REF"] == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + assert config.raw_sha256 == hashlib.sha256(path.read_bytes()).hexdigest() + assert "export AVA_ENTITY=aeon" in config.render_shell_environment("aeon") + assert "OPENAI_API_KEY" not in config.render_shell_environment("aeon") + + +def test_requires_complete_three_entity_fleet(tmp_path): + raw = _raw(tmp_path) + del raw["entities"]["aeon"] + with pytest.raises(FleetConfigError, match="complete managed fleet"): + load_fleet_config(_write(tmp_path, raw)) + + +def test_rejects_shared_hermes_home(tmp_path): + raw = _raw(tmp_path) + shared = str(tmp_path / "state" / "ava" / "aeon") + raw["entities"]["ava"]["hermes_home"] = shared + raw["entities"]["aeon"]["hermes_home"] = shared + with pytest.raises(FleetConfigError, match="HERMES_HOME collision"): + load_fleet_config(_write(tmp_path, raw)) + + +def test_rejects_nested_hermes_homes(tmp_path): + raw = _raw(tmp_path) + raw["entities"]["aeon"]["hermes_home"] = str(tmp_path / "state" / "ava" / "aeon") + with pytest.raises(FleetConfigError, match="nested HERMES_HOME"): + load_fleet_config(_write(tmp_path, raw)) + + +def test_rejects_workspace_inside_any_entity_home(tmp_path): + raw = _raw(tmp_path) + raw["entities"]["aeon"]["workspace"] = str(tmp_path / "state" / "ava" / "aeon-workspace") + with pytest.raises(FleetConfigError, match="workspace .* is inside"): + load_fleet_config(_write(tmp_path, raw)) + + +def test_rejects_duplicate_session_scope(tmp_path): + raw = _raw(tmp_path) + raw["entities"]["aeon"]["session_scope"] = "ava" + with pytest.raises(FleetConfigError, match="session_scope collision"): + load_fleet_config(_write(tmp_path, raw)) + + +def test_rejects_source_repository_inside_state_root(tmp_path): + raw = _raw(tmp_path) + raw["source"]["repository"] = str(tmp_path / "state" / "ava" / "source") + with pytest.raises(FleetConfigError, match="must be disjoint"): + load_fleet_config(_write(tmp_path, raw)) + + +def test_rejects_relative_paths(tmp_path): + raw = _raw(tmp_path) + raw["entities"]["ava"]["workspace"] = "relative/ava" + with pytest.raises(FleetConfigError, match="must be absolute"): + load_fleet_config(_write(tmp_path, raw)) + + +def test_rejects_config_version_drift(tmp_path): + raw = _raw(tmp_path) + raw["version"] = 2 + with pytest.raises(FleetConfigError, match="unsupported fleet config version"): + load_fleet_config(_write(tmp_path, raw)) + + +def test_rejects_snapshot_root_nested_with_entity_state(tmp_path): + raw = _raw(tmp_path) + raw["snapshots"]["root"] = str(tmp_path / "state" / "ava" / "snapshots") + with pytest.raises(FleetConfigError, match="managed roots must be disjoint"): + load_fleet_config(_write(tmp_path, raw)) + + +def test_rejects_unknown_root_field(tmp_path): + raw = _raw(tmp_path) + raw["typo_policy"] = {} + with pytest.raises(FleetConfigError, match="unknown fields"): + load_fleet_config(_write(tmp_path, raw)) + + +def test_rejects_moving_expected_ref(tmp_path): + raw = _raw(tmp_path) + raw["source"]["expected_ref"] = "ava/stable" + with pytest.raises(FleetConfigError, match="exact 40-character"): + load_fleet_config(_write(tmp_path, raw)) + + +def test_rejects_lowered_fail_closed_policy(tmp_path): + raw = _raw(tmp_path) + raw["policy"]["resume_missing_session"] = "fallback" + with pytest.raises(FleetConfigError, match="cannot be lowered"): + load_fleet_config(_write(tmp_path, raw)) + + +def test_rejects_auto_update(tmp_path): + raw = _raw(tmp_path) + raw["source"]["auto_update"] = True + with pytest.raises(FleetConfigError, match="must remain false"): + load_fleet_config(_write(tmp_path, raw)) From b2de6006003cd23c25f7c4bdb45c1101809ad18b Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 12:21:49 +0200 Subject: [PATCH 31/50] test(ava): cover fleet control plane --- tests/ava_runtime/test_fleet_cli.py | 131 ++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 tests/ava_runtime/test_fleet_cli.py diff --git a/tests/ava_runtime/test_fleet_cli.py b/tests/ava_runtime/test_fleet_cli.py new file mode 100644 index 000000000000..ed1330947b8a --- /dev/null +++ b/tests/ava_runtime/test_fleet_cli.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +FLEET_PATH = ROOT / "scripts" / "ava_runtime" / "fleet.py" + + +def _load_module(): + name = "ava_runtime_fleet_cli_test" + spec = importlib.util.spec_from_file_location(name, FLEET_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def _write_config(tmp_path: Path) -> Path: + raw = { + "version": 1, + "snapshots": {"root": str(tmp_path / "backups")}, + "promotion": {"upstream_snapshot": "ava/upstream-2026-07-30", "staging": "ava/staging", "stable": "ava/stable", "require_shadow_validation": True, "require_rollback_ref": True}, + "policy": {"explicit_hermes_home": "required", "resume_missing_session": "fail", "resume_missing_workspace": "fail", "resume_ambiguous_lineage": "fail", "restore_recorded_workspace": True, "allow_restore_workspace_opt_out": True, "global_most_recent_session": "forbidden_for_managed_entities", "terminal_backend_failure": "fail", "deployment_ref": "pinned_commit_or_stable_tag", "secrets_in_repository": "forbidden"}, + "source": {"repository": str(tmp_path / "source"), "expected_ref": "cccccccccccccccccccccccccccccccccccccccc", "require_clean_checkout": True, "auto_update": False}, + "entities": {}, + } + for entity in ("ava", "aeon", "avaeon-codex"): + raw["entities"][entity] = {"hermes_home": str(tmp_path / "state" / entity), "workspace": str(tmp_path / "workspaces" / entity), "profile": entity, "session_scope": entity} + path = tmp_path / "fleet.yaml" + path.write_text(yaml.safe_dump(raw, sort_keys=False), encoding="utf-8") + return path + + +def test_validate_outputs_hash_and_entities(tmp_path, capsys): + module = _load_module() + rc = module.main(["--config", str(_write_config(tmp_path)), "validate"]) + output = capsys.readouterr().out + assert rc == 0 + assert "STATUS_CLOSURE=PASS" in output + assert "entities=aeon,ava,avaeon-codex" in output + + +def test_env_json_contains_only_non_secret_runtime_bindings(tmp_path, capsys): + module = _load_module() + rc = module.main(["--config", str(_write_config(tmp_path)), "env", "ava", "--format", "json"]) + payload = json.loads(capsys.readouterr().out) + assert rc == 0 + assert payload["AVA_ENTITY"] == "ava" + assert payload["AVA_HERMES_EXPECTED_REF"] == "cccccccccccccccccccccccccccccccccccccccc" + assert not any("KEY" in key or "TOKEN" in key for key in payload) + + +def test_doctor_all_runs_each_entity_with_isolated_environment(tmp_path, monkeypatch): + module = _load_module() + config = _write_config(tmp_path) + events = [] + monkeypatch.setattr(module, "_run_child", lambda command, *, env, cwd: events.append((command, env.copy(), cwd)) or 0) + rc = module.main(["--config", str(config), "doctor", "all", "--require-state-db"]) + assert rc == 0 + assert [event[1]["AVA_ENTITY"] for event in events] == ["aeon", "ava", "avaeon-codex"] + assert len({event[1]["HERMES_HOME"] for event in events}) == 3 + assert all("--require-state-db" in event[0] and "--require-clean" in event[0] for event in events) + + +def test_smoke_defaults_to_disposable_state(tmp_path, monkeypatch): + module = _load_module() + captured = {} + monkeypatch.setattr(module, "_run_child", lambda command, *, env, cwd: captured.update(command=command, env=env, cwd=cwd) or 0) + rc = module.main(["--config", str(_write_config(tmp_path)), "smoke", "avaeon-codex"]) + assert rc == 0 + assert "--hermes-home" not in captured["command"] + assert captured["env"]["AVA_ENTITY"] == "avaeon-codex" + + +def test_smoke_live_state_is_explicit(tmp_path, monkeypatch): + module = _load_module() + captured = {} + monkeypatch.setattr(module, "_run_child", lambda command, *, env, cwd: captured.update(command=command) or 0) + rc = module.main(["--config", str(_write_config(tmp_path)), "smoke", "ava", "--live-state"]) + assert rc == 0 + index = captured["command"].index("--hermes-home") + assert captured["command"][index + 1].endswith("/state/ava") + + +def test_oneshot_uses_managed_launcher_and_entity_workspace(tmp_path, monkeypatch): + module = _load_module() + config = _write_config(tmp_path) + workspace = tmp_path / "workspaces" / "aeon" + workspace.mkdir(parents=True) + captured = {} + monkeypatch.setattr(module, "_run_child", lambda command, *, env, cwd: captured.update(command=command, env=env, cwd=cwd) or 0) + rc = module.main(["--config", str(config), "oneshot", "aeon", "--", "--resume", "session-1", "continue the work"]) + assert rc == 0 + assert captured["command"][1:4] == ["-m", "hermes_cli.ava_runtime.managed_oneshot", "--resume"] + assert captured["env"]["AVA_ENTITY"] == "aeon" + assert captured["cwd"] == workspace.resolve() + + +def test_rejected_config_never_launches_child(tmp_path, monkeypatch, capsys): + module = _load_module() + config = _write_config(tmp_path) + raw = yaml.safe_load(config.read_text(encoding="utf-8")) + raw["entities"]["aeon"]["session_scope"] = "ava" + config.write_text(yaml.safe_dump(raw, sort_keys=False), encoding="utf-8") + launched = False + def fake_run(*args, **kwargs): + nonlocal launched + launched = True + return 0 + monkeypatch.setattr(module, "_run_child", fake_run) + assert module.main(["--config", str(config), "doctor", "all"]) == 2 + assert launched is False + assert "configuration rejected" in capsys.readouterr().err + + +def test_snapshot_all_uses_configured_backup_root(tmp_path, monkeypatch): + module = _load_module() + config = _write_config(tmp_path) + events = [] + monkeypatch.setattr(module, "_run_child", lambda command, *, env, cwd: events.append((command, env.copy())) or 0) + assert module.main(["--config", str(config), "snapshot", "all"]) == 0 + assert [event[1]["AVA_ENTITY"] for event in events] == ["aeon", "ava", "avaeon-codex"] + for command, _env in events: + root_index = command.index("--output-root") + assert command[root_index + 1] == str((tmp_path / "backups").resolve()) From c3ae2af90e216c0e0af50d8e050ec2e534fbae9d Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 12:23:13 +0200 Subject: [PATCH 32/50] test(ava): cover promotion evidence gate --- tests/ava_runtime/test_promotion.py | 149 ++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 tests/ava_runtime/test_promotion.py diff --git a/tests/ava_runtime/test_promotion.py b/tests/ava_runtime/test_promotion.py new file mode 100644 index 000000000000..d0d565654d45 --- /dev/null +++ b/tests/ava_runtime/test_promotion.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest +import yaml + +from hermes_cli.ava_runtime.fleet_config import load_fleet_config + +ROOT = Path(__file__).resolve().parents[2] +PROMOTION_PATH = ROOT / "scripts" / "ava_runtime" / "promotion.py" + + +def _load_module(): + name = "ava_runtime_promotion_test" + spec = importlib.util.spec_from_file_location(name, PROMOTION_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def _config(tmp_path: Path): + raw = { + "version": 1, + "snapshots": {"root": str(tmp_path / "backups")}, + "promotion": {"upstream_snapshot": "ava/upstream-2026-07-30", "staging": "ava/staging", "stable": "ava/stable", "require_shadow_validation": True, "require_rollback_ref": True}, + "policy": {"explicit_hermes_home": "required", "resume_missing_session": "fail", "resume_missing_workspace": "fail", "resume_ambiguous_lineage": "fail", "restore_recorded_workspace": True, "allow_restore_workspace_opt_out": True, "global_most_recent_session": "forbidden_for_managed_entities", "terminal_backend_failure": "fail", "deployment_ref": "pinned_commit_or_stable_tag", "secrets_in_repository": "forbidden"}, + "source": {"repository": str(tmp_path / "source"), "expected_ref": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "require_clean_checkout": True, "auto_update": False}, + "entities": {}, + } + for entity in ("ava", "aeon", "avaeon-codex"): + raw["entities"][entity] = {"hermes_home": str(tmp_path / "state" / entity), "workspace": str(tmp_path / "workspaces" / entity), "profile": entity, "session_scope": entity} + path = tmp_path / "fleet.yaml" + path.write_text(yaml.safe_dump(raw, sort_keys=False), encoding="utf-8") + return load_fleet_config(path) + + +def _report() -> dict: + entity_gate = {"doctor": {"status": "pass"}, "identity_smoke": {"status": "pass"}, "shadow_runtime": {"status": "pass"}} + return { + "schema_version": 1, + "status_closure": "pass", + "candidate_commit": "dddddddddddddddddddddddddddddddddddddddd", + "rollback_ref": "stable-previous", + "tests": {"ava_runtime": {"status": "pass", "command": "pytest tests/ava_runtime"}, "hermes_cli": {"status": "pass", "command": "pytest tests/hermes_cli"}, "upstream_relevant": {"status": "pass", "command": "pytest relevant"}}, + "entities": {"ava": dict(entity_gate), "aeon": dict(entity_gate), "avaeon-codex": dict(entity_gate)}, + "remaining_gaps": [], + } + + +def _fake_git(repo: Path, *args: str) -> str: + if args == ("rev-parse", "HEAD"): + return "dddddddddddddddddddddddddddddddddddddddd" + if args == ("rev-parse", "HEAD^{tree}"): + return "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + if args == ("rev-parse", "--verify", "stable-previous^{commit}"): + return "ffffffffffffffffffffffffffffffffffffffff" + if args == ("status", "--porcelain=v1", "--untracked-files=normal"): + return "" + raise AssertionError(args) + + +def test_build_manifest_closes_candidate_and_rollback(monkeypatch, tmp_path): + module = _load_module() + config = _config(tmp_path) + report_path = tmp_path / "report.json" + report = _report() + report_path.write_text(json.dumps(report), encoding="utf-8") + monkeypatch.setattr(module, "_git", _fake_git) + manifest = module.build_manifest(config=config, report_path=report_path, report=report, repo=config.source.repository) + assert manifest["status_closure"] == "pass" + assert manifest["source"]["candidate_commit"] == "dddddddddddddddddddddddddddddddddddddddd" + assert manifest["source"]["rollback_commit"] == "ffffffffffffffffffffffffffffffffffffffff" + assert manifest["evidence"]["fleet_config_sha256"] == config.raw_sha256 + assert set(manifest["evidence"]["entities"]) == {"ava", "aeon", "avaeon-codex"} + + +def test_report_rejects_any_failed_entity_gate(): + module = _load_module() + report = _report() + report["entities"]["aeon"]["shadow_runtime"] = {"status": "fail"} + with pytest.raises(module.PromotionError, match="shadow_runtime"): + module._validate_report(report) + + +def test_report_requires_all_three_entities(): + module = _load_module() + report = _report() + del report["entities"]["ava"] + with pytest.raises(module.PromotionError, match="exactly"): + module._validate_report(report) + + +def test_report_rejects_remaining_gaps(): + module = _load_module() + report = _report() + report["remaining_gaps"] = ["real model smoke not run"] + with pytest.raises(module.PromotionError, match="remaining_gaps"): + module._validate_report(report) + + +def test_candidate_must_match_checked_out_head(monkeypatch, tmp_path): + module = _load_module() + config = _config(tmp_path) + report = _report() + report["candidate_commit"] = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + report_path = tmp_path / "report.json" + report_path.write_text(json.dumps(report), encoding="utf-8") + monkeypatch.setattr(module, "_git", _fake_git) + with pytest.raises(module.PromotionError, match="does not match"): + module.build_manifest(config=config, report_path=report_path, report=report, repo=config.source.repository) + + +def test_dirty_tree_blocks_promotion(monkeypatch, tmp_path): + module = _load_module() + def dirty_git(repo: Path, *args: str) -> str: + if args == ("status", "--porcelain=v1", "--untracked-files=normal"): + return " M modified.py" + return _fake_git(repo, *args) + monkeypatch.setattr(module, "_git", dirty_git) + with pytest.raises(module.PromotionError, match="working tree must be clean"): + module._git_state(tmp_path, "stable-previous") + + +def test_verify_detects_report_hash_drift(monkeypatch, tmp_path): + module = _load_module() + config = _config(tmp_path) + report = _report() + report_path = tmp_path / "report.json" + report_path.write_text(json.dumps(report), encoding="utf-8") + monkeypatch.setattr(module, "_git", _fake_git) + manifest = module.build_manifest(config=config, report_path=report_path, report=report, repo=config.source.repository) + report_path.write_text(json.dumps({**report, "note": "changed"}), encoding="utf-8") + with pytest.raises(module.PromotionError, match="validation_report_sha256"): + module.verify_manifest(manifest=manifest, config=config, report_path=report_path, report=report, repo=config.source.repository) + + +def test_atomic_write_replaces_manifest(tmp_path): + module = _load_module() + path = tmp_path / "manifest.json" + module._atomic_write_json(path, {"value": 1}) + module._atomic_write_json(path, {"value": 2}) + assert json.loads(path.read_text(encoding="utf-8")) == {"value": 2} + assert path.stat().st_mode & 0o777 == 0o644 From f08d5bf8f35e72944b1d3ebefb44fb198c0e5894 Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 12:23:33 +0200 Subject: [PATCH 33/50] test(ava): cover verified state snapshots --- tests/ava_runtime/test_state_snapshot.py | 107 +++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/ava_runtime/test_state_snapshot.py diff --git a/tests/ava_runtime/test_state_snapshot.py b/tests/ava_runtime/test_state_snapshot.py new file mode 100644 index 000000000000..cb5d3825f00a --- /dev/null +++ b/tests/ava_runtime/test_state_snapshot.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import importlib.util +import json +import sqlite3 +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SNAPSHOT_PATH = ROOT / "scripts" / "ava_runtime" / "state_snapshot.py" + + +def _load_module(): + name = "ava_runtime_state_snapshot_test" + spec = importlib.util.spec_from_file_location(name, SNAPSHOT_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def _home(tmp_path: Path, entity: str = "ava") -> Path: + home = tmp_path / "state" / entity + home.mkdir(parents=True) + with sqlite3.connect(home / "state.db") as conn: + conn.execute("CREATE TABLE sessions (id TEXT PRIMARY KEY, payload TEXT)") + conn.execute("INSERT INTO sessions VALUES (?, ?)", ("s1", "private transcript marker")) + conn.execute("PRAGMA user_version=7") + conn.commit() + return home + + +def test_create_and_verify_atomic_snapshot(tmp_path): + module = _load_module() + home = _home(tmp_path, "ava") + output = tmp_path / "backups" + snapshot = module.create_snapshot(entity="ava", hermes_home=home, output_root=output) + result = module.verify_snapshot(snapshot) + assert snapshot.parent == output.resolve() + assert result["entity"] == "ava" + assert len(result["state_db_sha256"]) == 64 + assert snapshot.stat().st_mode & 0o777 == 0o700 + assert (snapshot / "state.db").stat().st_mode & 0o777 == 0o600 + assert (snapshot / "manifest.json").stat().st_mode & 0o777 == 0o600 + manifest = json.loads((snapshot / "manifest.json").read_text(encoding="utf-8")) + assert manifest["sqlite"]["integrity_check"] == "ok" + assert manifest["sqlite"]["user_version"] == 7 + + +def test_online_backup_is_independent_from_later_source_changes(tmp_path): + module = _load_module() + home = _home(tmp_path, "aeon") + snapshot = module.create_snapshot(entity="aeon", hermes_home=home, output_root=tmp_path / "backups") + with sqlite3.connect(home / "state.db") as conn: + conn.execute("INSERT INTO sessions VALUES (?, ?)", ("s2", "later")) + conn.commit() + with sqlite3.connect(snapshot / "state.db") as conn: + count = conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] + assert count == 1 + module.verify_snapshot(snapshot) + + +def test_tampered_snapshot_is_rejected(tmp_path): + module = _load_module() + home = _home(tmp_path, "avaeon-codex") + snapshot = module.create_snapshot(entity="avaeon-codex", hermes_home=home, output_root=tmp_path / "backups") + with (snapshot / "state.db").open("ab") as handle: + handle.write(b"tamper") + with pytest.raises(module.SnapshotError, match="checksum mismatch"): + module.verify_snapshot(snapshot) + + +def test_output_inside_hermes_home_is_rejected(tmp_path): + module = _load_module() + home = _home(tmp_path, "ava") + with pytest.raises(module.SnapshotError, match="outside HERMES_HOME"): + module.create_snapshot(entity="ava", hermes_home=home, output_root=home / "backup") + + +def test_wrong_entity_scope_is_rejected(tmp_path): + module = _load_module() + home = _home(tmp_path, "ava") + with pytest.raises(module.SnapshotError, match="not visibly scoped"): + module.create_snapshot(entity="aeon", hermes_home=home, output_root=tmp_path / "backup") + + +def test_symlinked_source_db_is_rejected(tmp_path): + module = _load_module() + real = tmp_path / "real.db" + with sqlite3.connect(real) as conn: + conn.execute("CREATE TABLE x (id INTEGER)") + home = tmp_path / "state" / "ava" + home.mkdir(parents=True) + (home / "state.db").symlink_to(real) + with pytest.raises(module.SnapshotError, match="symlinked"): + module.create_snapshot(entity="ava", hermes_home=home, output_root=tmp_path / "backup") + + +def test_missing_state_db_fails_visibly(tmp_path): + module = _load_module() + home = tmp_path / "state" / "aeon" + home.mkdir(parents=True) + with pytest.raises(module.SnapshotError, match="does not exist"): + module.create_snapshot(entity="aeon", hermes_home=home, output_root=tmp_path / "backup") From cde8089b86dc2b8a0a08a117c13772559cd69db1 Mon Sep 17 00:00:00 2001 From: SE87H Date: Fri, 31 Jul 2026 11:15:28 +0200 Subject: [PATCH 34/50] fix(ava): synchronize managed workspace context --- hermes_cli/ava_runtime/identity.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/hermes_cli/ava_runtime/identity.py b/hermes_cli/ava_runtime/identity.py index ef3de8ac0f2c..781a4da209a8 100644 --- a/hermes_cli/ava_runtime/identity.py +++ b/hermes_cli/ava_runtime/identity.py @@ -76,3 +76,7 @@ def activate_workspace(self) -> None: os.chdir(self.workspace) except OSError as exc: raise RuntimeError(f"Cannot enter AVA_WORKSPACE: {self.workspace}") from exc + # Hermes prompt, file, and terminal resolution prefer TERMINAL_CWD. + # Publish it only after chdir succeeds so a failed transition leaves the + # previous runtime context untouched. + os.environ["TERMINAL_CWD"] = str(self.workspace) From ba7bd546d15d80b52c2984fd73f47b72a6af5f44 Mon Sep 17 00:00:00 2001 From: SE87H Date: Fri, 31 Jul 2026 11:16:01 +0200 Subject: [PATCH 35/50] fix(ava): close resumed runtime workspace identity --- hermes_cli/ava_runtime/session_context.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/hermes_cli/ava_runtime/session_context.py b/hermes_cli/ava_runtime/session_context.py index bd24d9fb2f77..454beb75b276 100644 --- a/hermes_cli/ava_runtime/session_context.py +++ b/hermes_cli/ava_runtime/session_context.py @@ -135,7 +135,13 @@ def _restore_recorded_cwd(session_meta: dict[str, Any], request: ResumeRequest) raise RuntimeError( f"Failed to restore recorded session working directory: {path}" ) from exc - return str(path.resolve()) + + # Runtime prompt construction and file/terminal tools prefer TERMINAL_CWD. + # Publish the resolved workspace only after chdir succeeds. A failed resume + # therefore leaves both the environment and the durable session untouched. + resolved_path = str(path.resolve()) + os.environ["TERMINAL_CWD"] = resolved_path + return resolved_path def resolve_session_context( From a6efa13a32fdd4992aa2c1f56e37d77a2ce7acc9 Mon Sep 17 00:00:00 2001 From: SE87H Date: Fri, 31 Jul 2026 11:16:25 +0200 Subject: [PATCH 36/50] test(ava): cover terminal workspace synchronization --- .../test_runtime_workspace_identity.py | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 tests/ava_runtime/test_runtime_workspace_identity.py diff --git a/tests/ava_runtime/test_runtime_workspace_identity.py b/tests/ava_runtime/test_runtime_workspace_identity.py new file mode 100644 index 000000000000..eeb3203b7742 --- /dev/null +++ b/tests/ava_runtime/test_runtime_workspace_identity.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from hermes_cli.ava_runtime.identity import ManagedIdentity +from hermes_cli.ava_runtime.session_context import ResumeRequest, _restore_recorded_cwd + + +def test_managed_identity_synchronizes_process_and_terminal_workspace( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + workspace = tmp_path / "workspace" + home = tmp_path / "ava" / "state" + workspace.mkdir() + home.mkdir(parents=True) + identity = ManagedIdentity(entity="ava", hermes_home=home, workspace=workspace.resolve()) + + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "stale")) + identity.activate_workspace() + + assert Path.cwd() == workspace.resolve() + assert os.environ["TERMINAL_CWD"] == str(workspace.resolve()) + + +def test_managed_identity_does_not_publish_terminal_workspace_when_chdir_fails( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + workspace = tmp_path / "workspace" + home = tmp_path / "ava" / "state" + workspace.mkdir() + home.mkdir(parents=True) + identity = ManagedIdentity(entity="ava", hermes_home=home, workspace=workspace.resolve()) + monkeypatch.setenv("TERMINAL_CWD", "/previous") + + def fail_chdir(_path: object) -> None: + raise OSError("blocked") + + monkeypatch.setattr(os, "chdir", fail_chdir) + + with pytest.raises(RuntimeError, match="Cannot enter AVA_WORKSPACE"): + identity.activate_workspace() + + assert os.environ["TERMINAL_CWD"] == "/previous" + + +def test_resume_synchronizes_terminal_workspace_after_successful_chdir( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + workspace = tmp_path / "session-workspace" + workspace.mkdir() + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "stale")) + + restored = _restore_recorded_cwd( + {"cwd": str(workspace)}, + ResumeRequest(resume_session_id="session-1"), + ) + + assert restored == str(workspace.resolve()) + assert Path.cwd() == workspace.resolve() + assert os.environ["TERMINAL_CWD"] == str(workspace.resolve()) + + +def test_resume_failure_keeps_previous_terminal_workspace( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + missing = tmp_path / "missing" + monkeypatch.setenv("TERMINAL_CWD", "/previous") + + with pytest.raises(FileNotFoundError, match="working directory is unavailable"): + _restore_recorded_cwd( + {"cwd": str(missing)}, + ResumeRequest(resume_session_id="session-1"), + ) + + assert os.environ["TERMINAL_CWD"] == "/previous" + + +def test_resume_opt_out_preserves_existing_runtime_workspace( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + workspace = tmp_path / "recorded" + workspace.mkdir() + original_cwd = Path.cwd() + monkeypatch.setenv("TERMINAL_CWD", "/intentional-current-workspace") + + restored = _restore_recorded_cwd( + {"cwd": str(workspace)}, + ResumeRequest(resume_session_id="session-1", restore_cwd=False), + ) + + assert restored == str(workspace) + assert Path.cwd() == original_cwd + assert os.environ["TERMINAL_CWD"] == "/intentional-current-workspace" From 77cdf1faf729e52cadec04f11ae1fc7719d84641 Mon Sep 17 00:00:00 2001 From: SE87H Date: Fri, 31 Jul 2026 11:25:29 +0200 Subject: [PATCH 37/50] test(ava): use a distinct managed runner replacement --- tests/ava_runtime/test_managed_oneshot.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/ava_runtime/test_managed_oneshot.py b/tests/ava_runtime/test_managed_oneshot.py index 68013b65c797..ddca414543ca 100644 --- a/tests/ava_runtime/test_managed_oneshot.py +++ b/tests/ava_runtime/test_managed_oneshot.py @@ -108,6 +108,21 @@ def activate_workspace(self): original = _compatible_run_agent monkeypatch.setattr(upstream, "_run_agent", original) + def replacement_run_agent( + prompt, + model=None, + provider=None, + toolsets=None, + use_config_toolsets=True, + ): + return _compatible_run_agent( + prompt, + model=model, + provider=provider, + toolsets=toolsets, + use_config_toolsets=use_config_toolsets, + ) + def fake_run_oneshot( prompt, model=None, @@ -116,14 +131,14 @@ def fake_run_oneshot( usage_file=None, ): events.append(("run", prompt, model, provider, toolsets, usage_file)) - assert upstream._run_agent is not original + assert upstream._run_agent is replacement_run_agent return 17 monkeypatch.setattr(upstream, "run_oneshot", fake_run_oneshot) def fake_builder(_upstream, request): events.append(("request", request.resume_session_id, request.workspace_key)) - return _compatible_run_agent + return replacement_run_agent monkeypatch.setattr(managed_oneshot, "_build_managed_run_agent", fake_builder) From 21e571a2acf25f1ddb300ff7cee22dc3e05ced4e Mon Sep 17 00:00:00 2001 From: SE87H Date: Fri, 31 Jul 2026 11:25:36 +0200 Subject: [PATCH 38/50] chore(contributors): map SE87H commit attribution --- contributors/emails/julienyezniguian@gmail.com | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 contributors/emails/julienyezniguian@gmail.com diff --git a/contributors/emails/julienyezniguian@gmail.com b/contributors/emails/julienyezniguian@gmail.com new file mode 100644 index 000000000000..7d1bf30bfcba --- /dev/null +++ b/contributors/emails/julienyezniguian@gmail.com @@ -0,0 +1,2 @@ +SE87H +# AVA managed runtime PR #1 From e4a63b47c37bd766eab21d596cfc55d6cb2666b9 Mon Sep 17 00:00:00 2001 From: SE87H Date: Fri, 31 Jul 2026 13:00:50 +0200 Subject: [PATCH 39/50] feat(ava): centralize atomic workspace transitions --- hermes_cli/ava_runtime/workspace.py | 53 +++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 hermes_cli/ava_runtime/workspace.py diff --git a/hermes_cli/ava_runtime/workspace.py b/hermes_cli/ava_runtime/workspace.py new file mode 100644 index 000000000000..cf39801cf0ae --- /dev/null +++ b/hermes_cli/ava_runtime/workspace.py @@ -0,0 +1,53 @@ +"""Atomic process-local workspace transitions for managed AVA runtimes. + +Finite CLI/one-shot processes may intentionally move their process working +directory. Hermes also exposes the same workspace through ``TERMINAL_CWD``. +This module is the single write-side seam that keeps those two representations +coherent and publishes neither when the transition fails. + +Concurrent gateway/cron hosts must use Hermes' task-local session cwd +ContextVar instead of this process-global operator. +""" + +from __future__ import annotations + +import os +from pathlib import Path + + +def activate_process_workspace( + workspace: str | os.PathLike[str], + *, + missing_message: str | None = None, + enter_message: str | None = None, +) -> Path: + """Enter *workspace* and publish its canonical path to ``TERMINAL_CWD``. + + The environment is updated only after ``chdir`` succeeds. If canonical + resolution fails after entry, the prior process directory and environment + value are restored before the error is re-raised. + """ + + path = Path(workspace).expanduser() + if not path.is_absolute(): + raise RuntimeError(f"Managed workspace path must be absolute: {path}") + if not path.is_dir(): + raise FileNotFoundError(missing_message or f"Workspace is unavailable: {path}") + + previous_cwd = Path.cwd() + previous_terminal_cwd = os.environ.get("TERMINAL_CWD") + try: + os.chdir(path) + active = Path.cwd().resolve() + os.environ["TERMINAL_CWD"] = str(active) + return active + except OSError as exc: + try: + os.chdir(previous_cwd) + except OSError: + pass + if previous_terminal_cwd is None: + os.environ.pop("TERMINAL_CWD", None) + else: + os.environ["TERMINAL_CWD"] = previous_terminal_cwd + raise RuntimeError(enter_message or f"Failed to enter workspace: {path}") from exc From 91188df98bca012256fb4134b73c6c4b78ddeeaf Mon Sep 17 00:00:00 2001 From: SE87H Date: Fri, 31 Jul 2026 13:01:15 +0200 Subject: [PATCH 40/50] refactor(ava): route identity activation through workspace seam --- hermes_cli/ava_runtime/identity.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/hermes_cli/ava_runtime/identity.py b/hermes_cli/ava_runtime/identity.py index 781a4da209a8..10058bc074b2 100644 --- a/hermes_cli/ava_runtime/identity.py +++ b/hermes_cli/ava_runtime/identity.py @@ -6,6 +6,8 @@ from dataclasses import dataclass from pathlib import Path +from hermes_cli.ava_runtime.workspace import activate_process_workspace + ENTITY_ALIASES = { "ava": {"ava"}, @@ -72,11 +74,8 @@ def from_env(cls) -> "ManagedIdentity": return cls(entity=entity, hermes_home=hermes_home, workspace=workspace) def activate_workspace(self) -> None: - try: - os.chdir(self.workspace) - except OSError as exc: - raise RuntimeError(f"Cannot enter AVA_WORKSPACE: {self.workspace}") from exc - # Hermes prompt, file, and terminal resolution prefer TERMINAL_CWD. - # Publish it only after chdir succeeds so a failed transition leaves the - # previous runtime context untouched. - os.environ["TERMINAL_CWD"] = str(self.workspace) + activate_process_workspace( + self.workspace, + missing_message=f"AVA_WORKSPACE directory does not exist: {self.workspace}", + enter_message=f"Cannot enter AVA_WORKSPACE: {self.workspace}", + ) From 15bbeec0bd5a7f3d847c463ab856b764491f8411 Mon Sep 17 00:00:00 2001 From: SE87H Date: Fri, 31 Jul 2026 13:01:47 +0200 Subject: [PATCH 41/50] refactor(ava): reuse atomic workspace transition on resume --- hermes_cli/ava_runtime/session_context.py | 28 ++++++++++------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/hermes_cli/ava_runtime/session_context.py b/hermes_cli/ava_runtime/session_context.py index 454beb75b276..3f3fb23a6d7c 100644 --- a/hermes_cli/ava_runtime/session_context.py +++ b/hermes_cli/ava_runtime/session_context.py @@ -8,6 +8,8 @@ from pathlib import Path from typing import Any +from hermes_cli.ava_runtime.workspace import activate_process_workspace + @dataclass(frozen=True) class ResumeRequest: @@ -116,7 +118,10 @@ def _resolve_existing_session(session_db: Any, target: str) -> tuple[str, dict[s return canonical_id, session_meta -def _restore_recorded_cwd(session_meta: dict[str, Any], request: ResumeRequest) -> str | None: +def _restore_recorded_cwd( + session_meta: dict[str, Any], + request: ResumeRequest, +) -> str | None: saved_cwd = str(session_meta.get("cwd") or "").strip() if not request.restore_cwd: return saved_cwd or None @@ -127,21 +132,12 @@ def _restore_recorded_cwd(session_meta: dict[str, Any], request: ResumeRequest) return None path = Path(saved_cwd).expanduser() - if not path.is_dir(): - raise FileNotFoundError(f"Recorded session working directory is unavailable: {path}") - try: - os.chdir(path) - except OSError as exc: - raise RuntimeError( - f"Failed to restore recorded session working directory: {path}" - ) from exc - - # Runtime prompt construction and file/terminal tools prefer TERMINAL_CWD. - # Publish the resolved workspace only after chdir succeeds. A failed resume - # therefore leaves both the environment and the durable session untouched. - resolved_path = str(path.resolve()) - os.environ["TERMINAL_CWD"] = resolved_path - return resolved_path + active = activate_process_workspace( + path, + missing_message=f"Recorded session working directory is unavailable: {path}", + enter_message=f"Failed to restore recorded session working directory: {path}", + ) + return str(active) def resolve_session_context( From 6672da3987d4b2187e8ea5228f1ebf3b3cf90c22 Mon Sep 17 00:00:00 2001 From: SE87H Date: Fri, 31 Jul 2026 13:02:20 +0200 Subject: [PATCH 42/50] test(ava): prove atomic workspace transition contract --- tests/ava_runtime/test_workspace.py | 102 ++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 tests/ava_runtime/test_workspace.py diff --git a/tests/ava_runtime/test_workspace.py b/tests/ava_runtime/test_workspace.py new file mode 100644 index 000000000000..4cb9539822fc --- /dev/null +++ b/tests/ava_runtime/test_workspace.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from hermes_cli.ava_runtime import workspace as workspace_mod +from hermes_cli.ava_runtime.workspace import activate_process_workspace + + +@pytest.fixture(autouse=True) +def _restore_process_cwd(monkeypatch: pytest.MonkeyPatch) -> None: + """Production transitions must not leak between tests.""" + monkeypatch.chdir(Path.cwd()) + + +def test_activate_process_workspace_synchronizes_both_carriers( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + caller = tmp_path / "caller" + target = tmp_path / "target" + caller.mkdir() + target.mkdir() + monkeypatch.chdir(caller) + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "stale")) + + active = activate_process_workspace(target) + + assert active == target.resolve() + assert Path.cwd() == target.resolve() + assert os.environ["TERMINAL_CWD"] == str(target.resolve()) + + +def test_missing_workspace_leaves_process_context_untouched( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + caller = tmp_path / "caller" + caller.mkdir() + monkeypatch.chdir(caller) + monkeypatch.setenv("TERMINAL_CWD", "/previous") + + with pytest.raises(FileNotFoundError, match="Workspace is unavailable"): + activate_process_workspace(tmp_path / "missing") + + assert Path.cwd() == caller.resolve() + assert os.environ["TERMINAL_CWD"] == "/previous" + + +def test_failed_chdir_leaves_process_context_untouched( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + caller = tmp_path / "caller" + target = tmp_path / "target" + caller.mkdir() + target.mkdir() + monkeypatch.chdir(caller) + monkeypatch.setenv("TERMINAL_CWD", "/previous") + + def fail_chdir(_path: object) -> None: + raise OSError("blocked") + + monkeypatch.setattr(workspace_mod.os, "chdir", fail_chdir) + + with pytest.raises(RuntimeError, match="Failed to enter workspace"): + activate_process_workspace(target) + + assert Path.cwd() == caller.resolve() + assert os.environ["TERMINAL_CWD"] == "/previous" + + +def test_post_entry_failure_rolls_back_cwd_and_environment( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + caller = tmp_path / "caller" + target = tmp_path / "target" + caller.mkdir() + target.mkdir() + monkeypatch.chdir(caller) + monkeypatch.setenv("TERMINAL_CWD", "/previous") + + real_cwd = Path.cwd + calls = 0 + + def flaky_cwd(_cls) -> Path: + nonlocal calls + calls += 1 + if calls == 1: + return real_cwd() + raise OSError("cwd vanished") + + monkeypatch.setattr(workspace_mod.Path, "cwd", classmethod(flaky_cwd)) + + with pytest.raises(RuntimeError, match="Failed to enter workspace"): + activate_process_workspace(target) + + assert Path(os.getcwd()).resolve() == caller.resolve() + assert os.environ["TERMINAL_CWD"] == "/previous" From d0ed572f866de23cb48cf9758c88a7cf95e2634d Mon Sep 17 00:00:00 2001 From: SE87H Date: Fri, 31 Jul 2026 13:03:52 +0200 Subject: [PATCH 43/50] fix(ava): rollback every failed workspace publication --- hermes_cli/ava_runtime/workspace.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/hermes_cli/ava_runtime/workspace.py b/hermes_cli/ava_runtime/workspace.py index cf39801cf0ae..5b886dad9b5b 100644 --- a/hermes_cli/ava_runtime/workspace.py +++ b/hermes_cli/ava_runtime/workspace.py @@ -1,7 +1,7 @@ """Atomic process-local workspace transitions for managed AVA runtimes. Finite CLI/one-shot processes may intentionally move their process working -directory. Hermes also exposes the same workspace through ``TERMINAL_CWD``. +directory. Hermes also exposes the same workspace through ``TERMINAL_CWD``. This module is the single write-side seam that keeps those two representations coherent and publishes neither when the transition fails. @@ -23,9 +23,9 @@ def activate_process_workspace( ) -> Path: """Enter *workspace* and publish its canonical path to ``TERMINAL_CWD``. - The environment is updated only after ``chdir`` succeeds. If canonical - resolution fails after entry, the prior process directory and environment - value are restored before the error is re-raised. + The environment is updated only after ``chdir`` succeeds. If any later + canonicalization/publication step fails, the prior process directory and + environment value are restored before the error is re-raised. """ path = Path(workspace).expanduser() @@ -41,7 +41,7 @@ def activate_process_workspace( active = Path.cwd().resolve() os.environ["TERMINAL_CWD"] = str(active) return active - except OSError as exc: + except Exception as exc: try: os.chdir(previous_cwd) except OSError: From d78c41441c6a38f1e366eb1e8979b0baf39cf2b1 Mon Sep 17 00:00:00 2001 From: SE87H Date: Fri, 31 Jul 2026 13:07:00 +0200 Subject: [PATCH 44/50] fix(ava): preserve workspace coherence when rollback fails --- hermes_cli/ava_runtime/workspace.py | 32 +++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/hermes_cli/ava_runtime/workspace.py b/hermes_cli/ava_runtime/workspace.py index 5b886dad9b5b..25c0a9dcd23c 100644 --- a/hermes_cli/ava_runtime/workspace.py +++ b/hermes_cli/ava_runtime/workspace.py @@ -15,6 +15,13 @@ from pathlib import Path +def _restore_terminal_cwd(previous: str | None) -> None: + if previous is None: + os.environ.pop("TERMINAL_CWD", None) + else: + os.environ["TERMINAL_CWD"] = previous + + def activate_process_workspace( workspace: str | os.PathLike[str], *, @@ -25,7 +32,8 @@ def activate_process_workspace( The environment is updated only after ``chdir`` succeeds. If any later canonicalization/publication step fails, the prior process directory and - environment value are restored before the error is re-raised. + environment value are restored. If that rollback itself is impossible, the + environment is aligned to the surviving process cwd before failing visibly. """ path = Path(workspace).expanduser() @@ -36,6 +44,7 @@ def activate_process_workspace( previous_cwd = Path.cwd() previous_terminal_cwd = os.environ.get("TERMINAL_CWD") + failure_message = enter_message or f"Failed to enter workspace: {path}" try: os.chdir(path) active = Path.cwd().resolve() @@ -44,10 +53,17 @@ def activate_process_workspace( except Exception as exc: try: os.chdir(previous_cwd) - except OSError: - pass - if previous_terminal_cwd is None: - os.environ.pop("TERMINAL_CWD", None) - else: - os.environ["TERMINAL_CWD"] = previous_terminal_cwd - raise RuntimeError(enter_message or f"Failed to enter workspace: {path}") from exc + except OSError as rollback_exc: + try: + surviving_cwd = str(Path.cwd().resolve()) + except Exception: + os.environ.pop("TERMINAL_CWD", None) + else: + os.environ["TERMINAL_CWD"] = surviving_cwd + raise RuntimeError( + f"{failure_message}; rollback to {previous_cwd} also failed: " + f"{rollback_exc}" + ) from exc + + _restore_terminal_cwd(previous_terminal_cwd) + raise RuntimeError(failure_message) from exc From 91566e1854a68cb0091ab0b12e21ecee433e6c41 Mon Sep 17 00:00:00 2001 From: SE87H Date: Fri, 31 Jul 2026 13:07:32 +0200 Subject: [PATCH 45/50] test(ava): cover failed workspace rollback coherence --- tests/ava_runtime/test_workspace.py | 48 +++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/ava_runtime/test_workspace.py b/tests/ava_runtime/test_workspace.py index 4cb9539822fc..67a3fca94570 100644 --- a/tests/ava_runtime/test_workspace.py +++ b/tests/ava_runtime/test_workspace.py @@ -100,3 +100,51 @@ def flaky_cwd(_cls) -> Path: assert Path(os.getcwd()).resolve() == caller.resolve() assert os.environ["TERMINAL_CWD"] == "/previous" + + +def test_failed_rollback_keeps_environment_aligned_to_surviving_cwd( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + caller = tmp_path / "caller" + target = tmp_path / "target" + caller.mkdir() + target.mkdir() + monkeypatch.chdir(caller) + monkeypatch.setenv("TERMINAL_CWD", "/previous") + + real_chdir = os.chdir + chdir_calls = 0 + + def enter_then_block_rollback(path: object) -> None: + nonlocal chdir_calls + chdir_calls += 1 + if chdir_calls == 1: + real_chdir(path) + return + raise OSError("rollback blocked") + + real_cwd = Path.cwd + cwd_calls = 0 + + def fail_canonicalization_once(_cls) -> Path: + nonlocal cwd_calls + cwd_calls += 1 + if cwd_calls == 1: + return real_cwd() + if cwd_calls == 2: + raise OSError("canonicalization failed") + return real_cwd() + + monkeypatch.setattr(workspace_mod.os, "chdir", enter_then_block_rollback) + monkeypatch.setattr( + workspace_mod.Path, + "cwd", + classmethod(fail_canonicalization_once), + ) + + with pytest.raises(RuntimeError, match="rollback .* also failed"): + activate_process_workspace(target) + + assert Path(os.getcwd()).resolve() == target.resolve() + assert os.environ["TERMINAL_CWD"] == str(target.resolve()) From 010e0db37ab5249b247965f80af64dd4397eeab6 Mon Sep 17 00:00:00 2001 From: SE87H Date: Fri, 31 Jul 2026 13:14:42 +0200 Subject: [PATCH 46/50] test(ava): isolate workspace identity transitions --- .../test_runtime_workspace_identity.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/tests/ava_runtime/test_runtime_workspace_identity.py b/tests/ava_runtime/test_runtime_workspace_identity.py index eeb3203b7742..767577999ef2 100644 --- a/tests/ava_runtime/test_runtime_workspace_identity.py +++ b/tests/ava_runtime/test_runtime_workspace_identity.py @@ -9,6 +9,12 @@ from hermes_cli.ava_runtime.session_context import ResumeRequest, _restore_recorded_cwd +@pytest.fixture(autouse=True) +def _restore_process_cwd(monkeypatch: pytest.MonkeyPatch) -> None: + """Production workspace transitions must not leak between tests.""" + monkeypatch.chdir(Path.cwd()) + + def test_managed_identity_synchronizes_process_and_terminal_workspace( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -37,10 +43,17 @@ def test_managed_identity_does_not_publish_terminal_workspace_when_chdir_fails( identity = ManagedIdentity(entity="ava", hermes_home=home, workspace=workspace.resolve()) monkeypatch.setenv("TERMINAL_CWD", "/previous") - def fail_chdir(_path: object) -> None: - raise OSError("blocked") + real_chdir = os.chdir + calls = 0 + + def fail_initial_chdir_then_allow_rollback(path: object) -> None: + nonlocal calls + calls += 1 + if calls == 1: + raise OSError("blocked") + real_chdir(path) - monkeypatch.setattr(os, "chdir", fail_chdir) + monkeypatch.setattr(os, "chdir", fail_initial_chdir_then_allow_rollback) with pytest.raises(RuntimeError, match="Cannot enter AVA_WORKSPACE"): identity.activate_workspace() @@ -98,4 +111,4 @@ def test_resume_opt_out_preserves_existing_runtime_workspace( assert restored == str(workspace) assert Path.cwd() == original_cwd - assert os.environ["TERMINAL_CWD"] == "/intentional-current-workspace" + assert os.environ["TERMINAL_CWD"] == "/intentional-current-workspace" \ No newline at end of file From 2485d81251f1a4621cfd8fefd9c709883c8f4754 Mon Sep 17 00:00:00 2001 From: SE87H Date: Fri, 31 Jul 2026 13:15:06 +0200 Subject: [PATCH 47/50] test(ava): separate entry failure from rollback failure --- tests/ava_runtime/test_workspace.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/ava_runtime/test_workspace.py b/tests/ava_runtime/test_workspace.py index 67a3fca94570..a9b0f64f9e1d 100644 --- a/tests/ava_runtime/test_workspace.py +++ b/tests/ava_runtime/test_workspace.py @@ -60,10 +60,21 @@ def test_failed_chdir_leaves_process_context_untouched( monkeypatch.chdir(caller) monkeypatch.setenv("TERMINAL_CWD", "/previous") - def fail_chdir(_path: object) -> None: - raise OSError("blocked") + real_chdir = os.chdir + calls = 0 - monkeypatch.setattr(workspace_mod.os, "chdir", fail_chdir) + def fail_initial_chdir_then_allow_rollback(path: object) -> None: + nonlocal calls + calls += 1 + if calls == 1: + raise OSError("blocked") + real_chdir(path) + + monkeypatch.setattr( + workspace_mod.os, + "chdir", + fail_initial_chdir_then_allow_rollback, + ) with pytest.raises(RuntimeError, match="Failed to enter workspace"): activate_process_workspace(target) @@ -147,4 +158,4 @@ def fail_canonicalization_once(_cls) -> Path: activate_process_workspace(target) assert Path(os.getcwd()).resolve() == target.resolve() - assert os.environ["TERMINAL_CWD"] == str(target.resolve()) + assert os.environ["TERMINAL_CWD"] == str(target.resolve()) \ No newline at end of file From d39c853c9538e64662d23f5cff170ac8bba7f79a Mon Sep 17 00:00:00 2001 From: SE87H Date: Sat, 1 Aug 2026 00:51:12 +0200 Subject: [PATCH 48/50] fix(ava): preserve durable session in usage proof --- hermes_cli/ava_runtime/managed_oneshot.py | 9 +++++++++ tests/ava_runtime/test_managed_oneshot.py | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/hermes_cli/ava_runtime/managed_oneshot.py b/hermes_cli/ava_runtime/managed_oneshot.py index 3b85ec290a7d..07584af9aaec 100644 --- a/hermes_cli/ava_runtime/managed_oneshot.py +++ b/hermes_cli/ava_runtime/managed_oneshot.py @@ -59,6 +59,14 @@ def _assert_upstream_compatibility(upstream: Any) -> None: ) +def _attach_durable_run_metadata(result: dict[str, Any], agent: Any) -> dict[str, Any]: + """Ensure one-shot usage evidence carries the durable agent identity.""" + result.setdefault("session_id", agent.session_id) + result.setdefault("model", agent.model) + result.setdefault("provider", agent.provider) + return result + + def _build_managed_run_agent(upstream: Any, request: ResumeRequest): def _managed_run_agent( prompt: str, @@ -153,6 +161,7 @@ def _managed_run_agent( agent.tool_gen_callback = None result = agent.run_conversation(prompt, conversation_history=history) + _attach_durable_run_metadata(result, agent) return result.get("final_response") or "", result finally: if agent is not None: diff --git a/tests/ava_runtime/test_managed_oneshot.py b/tests/ava_runtime/test_managed_oneshot.py index ddca414543ca..fa6ac7be0581 100644 --- a/tests/ava_runtime/test_managed_oneshot.py +++ b/tests/ava_runtime/test_managed_oneshot.py @@ -49,6 +49,21 @@ def changed_run_oneshot(prompt, new_option=None): managed_oneshot._assert_upstream_compatibility(upstream) +def test_usage_metadata_inherits_durable_agent_identity(): + result = {"final_response": "stored"} + agent = SimpleNamespace( + session_id="session-42", + model="gpt-5.6-sol", + provider="openai-codex", + ) + + attached = managed_oneshot._attach_durable_run_metadata(result, agent) + + assert attached["session_id"] == "session-42" + assert attached["model"] == "gpt-5.6-sol" + assert attached["provider"] == "openai-codex" + + def test_parser_uses_narrow_explicit_surface(): args = managed_oneshot.build_parser().parse_args( [ From dcc9538145a4d58438bbc7137a859009166fba9b Mon Sep 17 00:00:00 2001 From: SE87H Date: Tue, 4 Aug 2026 23:47:30 +0200 Subject: [PATCH 49/50] feat(ava): split operator and runtime identity policy --- config/ava-runtime/entities.example.yaml | 6 --- .../validation-report.example.json | 5 --- docs/ava-runtime/ARCHITECTURE.md | 40 +++++++++++++------ docs/ava-runtime/AVAEON_CODEX_HANDOFF.md | 25 ++++++------ docs/ava-runtime/PHASE2_CONTROL_PLANE.md | 10 ++--- hermes_cli/ava_runtime/__init__.py | 2 +- hermes_cli/ava_runtime/fleet_config.py | 16 ++++++-- hermes_cli/ava_runtime/identity.py | 34 +++++++++++++--- hermes_cli/ava_runtime/managed_oneshot.py | 7 ++-- hermes_cli/ava_runtime/update_policy.py | 25 ++++++++++++ scripts/ava_runtime/doctor.py | 8 ++-- scripts/ava_runtime/smoke_session_identity.py | 4 +- scripts/ava_runtime/state_snapshot.py | 4 +- tests/ava_runtime/test_doctor.py | 36 +++++++---------- tests/ava_runtime/test_fleet_cli.py | 14 +++---- tests/ava_runtime/test_fleet_config.py | 14 +++++-- tests/ava_runtime/test_identity.py | 24 +++++++++-- tests/ava_runtime/test_promotion.py | 8 ++-- .../test_smoke_session_identity.py | 2 +- tests/ava_runtime/test_state_snapshot.py | 4 +- tests/ava_runtime/test_update_policy.py | 25 ++++++++++++ 21 files changed, 209 insertions(+), 104 deletions(-) create mode 100644 hermes_cli/ava_runtime/update_policy.py create mode 100644 tests/ava_runtime/test_update_policy.py diff --git a/config/ava-runtime/entities.example.yaml b/config/ava-runtime/entities.example.yaml index 79ae044923b4..a12145f766f9 100644 --- a/config/ava-runtime/entities.example.yaml +++ b/config/ava-runtime/entities.example.yaml @@ -32,12 +32,6 @@ entities: profile: aeon session_scope: aeon - avaeon-codex: - hermes_home: /var/lib/ava/hermes/avaeon-codex - workspace: /srv/ava/workspaces/avaeon-codex - profile: avaeon-codex - session_scope: avaeon-codex - policy: explicit_hermes_home: required resume_missing_session: fail diff --git a/config/ava-runtime/validation-report.example.json b/config/ava-runtime/validation-report.example.json index 02dc9ce9ef95..5827261d35ef 100644 --- a/config/ava-runtime/validation-report.example.json +++ b/config/ava-runtime/validation-report.example.json @@ -28,11 +28,6 @@ "identity_smoke": {"status": "not-run"}, "shadow_runtime": {"status": "not-run"} }, - "avaeon-codex": { - "doctor": {"status": "not-run"}, - "identity_smoke": {"status": "not-run"}, - "shadow_runtime": {"status": "not-run"} - } }, "remaining_gaps": [ "Replace every placeholder and run every gate before promotion." diff --git a/docs/ava-runtime/ARCHITECTURE.md b/docs/ava-runtime/ARCHITECTURE.md index c71ef4d17e8f..7150ad64ea57 100644 --- a/docs/ava-runtime/ARCHITECTURE.md +++ b/docs/ava-runtime/ARCHITECTURE.md @@ -1,6 +1,6 @@ # AVA Hermes Runtime Architecture -This document defines the controlled Hermes distribution used by AVA, AEON, and AVAEON Codex. It is an operational contract, not a replacement for upstream Hermes. +This document defines the controlled Hermes distribution for the AVA and AEON runtimes. AVAEON Codex is the portable operator, not a runtime. It is an operational contract, not a replacement for upstream Hermes. ## Purpose @@ -27,7 +27,20 @@ Rollback is the inverse trace: ### Distinction -AVA, AEON, and AVAEON Codex are separate runtime identities. Each must have its own: +Runtime entities are only `ava` and `aeon`. The canonical identity types are: + +- `RuntimeEntity := ava | aeon` +- `OperatorIdentity := avaeon-codex` +- `HostIdentity := avaorus | minisforum` +- `InstanceIdentity := live | shadow-* | test-*` + +AVAEON Codex carries operator metadata, isolated worktrees, harnesses, reports, +manifests, and disposable shadow roots. It is excluded from runtime quorum, +promotion gates, gateway/Telegram/service inventory, `state.db`, and permanent +snapshot obligations. A runtime launch always sets `AVA_ENTITY` to `ava` or +`aeon`; `operator_id` is separate metadata. + +Each runtime must have its own: - `HERMES_HOME` - durable session namespace @@ -63,7 +76,7 @@ A valid deployment must prove: - recorded workspace is restored, or the run fails visibly - an explicit workspace opt-out remains possible - skills, rules, memory policy, provider, and terminal backend match the requested runtime -- all three entities remain isolated +- both runtime entities remain isolated; operator work is disposable and separate - rollback to the previous stable revision is executable ## Runtime policy @@ -78,10 +91,8 @@ Recommended layout on the Minisforum: /opt/ava/hermes/releases/ # optional immutable release views /var/lib/ava/hermes/ava # AVA HERMES_HOME /var/lib/ava/hermes/aeon # AEON HERMES_HOME -/var/lib/ava/hermes/avaeon-codex # AVAEON Codex HERMES_HOME /srv/ava/workspaces/ava /srv/ava/workspaces/aeon -/srv/ava/workspaces/avaeon-codex ``` Paths may differ, but isolation and explicit launch configuration are mandatory. @@ -102,17 +113,22 @@ The following conditions must produce a visible non-zero failure rather than a s ## Update workflow -1. Create a dated upstream snapshot at a reviewed upstream commit. +1. Fetch an upstream snapshot at a reviewed upstream commit. 2. Compare that snapshot with the currently deployed stable revision. -3. Integrate into `ava/staging`. +3. Integrate into `ava/staging` in an isolated candidate. 4. Reapply or retire AVA patches deliberately; never assume they still apply. -5. Run unit, integration, and AVA runtime smoke tests. -6. Deploy staging only to a disposable or shadow runtime. -7. Promote the exact tested commit to `ava/stable`. -8. Deploy the pinned stable commit to the Minisforum. -9. Run post-deployment identity and workspace checks. +5. Run unit, integration, and runtime smoke tests for `ava`/`aeon`. +6. Validate only a disposable or shadow runtime. +7. Snapshot and prove rollback independently. +8. Obtain explicit operator approval, then promote the exact tested commit. +9. Run post-promotion identity and workspace checks. 10. Record the rollback revision. +`auto_update` is permanently false. `hermes update`, self-update, silent update, +and moving branches as live sources are forbidden. AEON Core may provide +read-only diagnostics and smokes, but cannot deploy, approve, or mutate its own +live runtime. + ## Scope boundary This foundation does not place OmniPulse canon, private memories, credentials, or entity prompts into the public repository. It provides the stable vessel in which those materials can operate without being flattened by Hermes entry-point drift. diff --git a/docs/ava-runtime/AVAEON_CODEX_HANDOFF.md b/docs/ava-runtime/AVAEON_CODEX_HANDOFF.md index b45445c603b9..edd5ae130562 100644 --- a/docs/ava-runtime/AVAEON_CODEX_HANDOFF.md +++ b/docs/ava-runtime/AVAEON_CODEX_HANDOFF.md @@ -38,10 +38,8 @@ Example: sudo install -d -m 0750 -o "$USER" -g "$USER" \ /var/lib/ava/hermes/ava \ /var/lib/ava/hermes/aeon \ - /var/lib/ava/hermes/avaeon-codex \ /srv/ava/workspaces/ava \ - /srv/ava/workspaces/aeon \ - /srv/ava/workspaces/avaeon-codex + /srv/ava/workspaces/aeon ``` Existing state must be backed up before migration. Never point two managed entities at the same `HERMES_HOME`. @@ -78,15 +76,16 @@ uv run pytest -q tests/hermes_cli Then run any upstream suite required by the changed files. Record commands, commit, and results. -## 6. Run the AVA doctor for each entity +## 6. Run the AVA doctor for each runtime entity -Example for AVAEON Codex: +The operator is metadata, not a runtime. Example for AEON: ```bash -export AVA_ENTITY=avaeon-codex +export AVA_ENTITY=aeon +export AVA_OPERATOR_ID=avaeon-codex export AVA_HERMES_REPO=/opt/ava/hermes/source -export AVA_WORKSPACE=/srv/ava/workspaces/avaeon-codex -export HERMES_HOME=/var/lib/ava/hermes/avaeon-codex +export AVA_WORKSPACE=/srv/ava/workspaces/aeon +export HERMES_HOME=/var/lib/ava/hermes/aeon export AVA_HERMES_EXPECTED_REF= uv run python scripts/ava_runtime/doctor.py \ @@ -102,8 +101,8 @@ Use the actual configured local/provider runtime, preferably on a disposable sta ```bash uv run python scripts/ava_runtime/smoke_session_identity.py \ - --entity avaeon-codex \ - --workspace /srv/ava/workspaces/avaeon-codex + --entity aeon \ + --workspace /srv/ava/workspaces/aeon ``` For a deliberate comparison against upstream: @@ -112,8 +111,8 @@ For a deliberate comparison against upstream: uv run python scripts/ava_runtime/smoke_session_identity.py \ --mode upstream \ --hermes-command "uv run hermes" \ - --entity avaeon-codex \ - --workspace /srv/ava/workspaces/avaeon-codex + --entity aeon \ + --workspace /srv/ava/workspaces/aeon ``` Required managed result: @@ -178,7 +177,7 @@ Only after all evidence passes: 6. switch the service to the pinned stable commit 7. restart and rerun the doctor and smoke checks -AVA, AEON, and AVAEON Codex must not be migrated simultaneously on the first deployment. +AVA and AEON must not be migrated simultaneously on the first deployment. AVAEON Codex remains the portable operator and has no live runtime to migrate. ## 11. Rollback diff --git a/docs/ava-runtime/PHASE2_CONTROL_PLANE.md b/docs/ava-runtime/PHASE2_CONTROL_PLANE.md index bb16555250c0..4128f79561ae 100644 --- a/docs/ava-runtime/PHASE2_CONTROL_PLANE.md +++ b/docs/ava-runtime/PHASE2_CONTROL_PLANE.md @@ -24,7 +24,7 @@ uv run python scripts/ava_runtime/fleet.py validate The validator refuses: -- an incomplete AVA/AEON/AVAEON fleet; +- an incomplete AVA/AEON runtime fleet; - shared or nested `HERMES_HOME` roots; - shared or nested workspaces; - a workspace inside any entity's state root; @@ -40,13 +40,13 @@ The validator refuses: For inspection: ```bash -uv run python scripts/ava_runtime/fleet.py env avaeon-codex +uv run python scripts/ava_runtime/fleet.py env aeon ``` For a managed one-shot invocation: ```bash -uv run python scripts/ava_runtime/fleet.py oneshot avaeon-codex -- \ +AVA_OPERATOR_ID=avaeon-codex uv run python scripts/ava_runtime/fleet.py oneshot aeon -- \ --resume \ "Continue the work" ``` @@ -60,7 +60,7 @@ uv run python scripts/ava_runtime/fleet.py doctor all --require-state-db uv run python scripts/ava_runtime/fleet.py smoke ava uv run python scripts/ava_runtime/fleet.py smoke aeon -uv run python scripts/ava_runtime/fleet.py smoke avaeon-codex +uv run python scripts/ava_runtime/fleet.py smoke aeon ``` Smoke tests use disposable state by default. `--live-state` is an explicit, visible opt-in and must not be used for the first validation pass. @@ -102,7 +102,7 @@ Promotion requires all of the following to be `pass`: - `tests.upstream_relevant`; - doctor, identity smoke, and shadow runtime for AVA; - doctor, identity smoke, and shadow runtime for AEON; -- doctor, identity smoke, and shadow runtime for AVAEON Codex; +- operator metadata is recorded separately and is not a runtime gate; - an empty `remaining_gaps` array; - a candidate commit exactly equal to checkout `HEAD`; - a clean working tree; diff --git a/hermes_cli/ava_runtime/__init__.py b/hermes_cli/ava_runtime/__init__.py index 7b8196676c86..e310918965ef 100644 --- a/hermes_cli/ava_runtime/__init__.py +++ b/hermes_cli/ava_runtime/__init__.py @@ -1,4 +1,4 @@ -"""Controlled runtime overlay for AVA, AEON, and AVAEON Codex. +"""Controlled runtime overlay for AVA and AEON, operated by AVAEON Codex. The overlay is deliberately narrow. It protects identity-bearing execution without turning private entity material into Hermes source code. diff --git a/hermes_cli/ava_runtime/fleet_config.py b/hermes_cli/ava_runtime/fleet_config.py index 4521c03ba435..be13d0251e1c 100644 --- a/hermes_cli/ava_runtime/fleet_config.py +++ b/hermes_cli/ava_runtime/fleet_config.py @@ -17,11 +17,12 @@ import yaml -from hermes_cli.ava_runtime.identity import ENTITY_ALIASES +from hermes_cli.ava_runtime.identity import RUNTIME_ENTITY_ALIASES, RUNTIME_ENTITIES SCHEMA_VERSION = 1 -REQUIRED_ENTITIES = frozenset(ENTITY_ALIASES) +REQUIRED_ENTITIES = RUNTIME_ENTITIES +OPERATOR_ID = "avaeon-codex" _COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") REQUIRED_POLICY = { "explicit_hermes_home": "required", @@ -43,7 +44,7 @@ class FleetConfigError(ValueError): def _normalized_entity(value: str) -> str: normalized = value.strip().lower().replace("_", "-") - for entity, aliases in ENTITY_ALIASES.items(): + for entity, aliases in RUNTIME_ENTITY_ALIASES.items(): normalized_aliases = {alias.lower().replace("_", "-") for alias in aliases} if normalized in normalized_aliases: return entity @@ -70,7 +71,7 @@ def _is_within(path: Path, parent: Path) -> bool: def _path_scoped_to_entity(path: Path, entity: str) -> bool: - aliases = {alias.lower().replace("_", "-") for alias in ENTITY_ALIASES[entity]} + aliases = {alias.lower().replace("_", "-") for alias in RUNTIME_ENTITY_ALIASES[entity]} parts = {part.lower().replace("_", "-") for part in path.parts} return bool(parts & aliases) @@ -129,6 +130,7 @@ class EntityConfig: def environment(self, source: SourceConfig) -> dict[str, str]: return { "AVA_ENTITY": self.name, + "AVA_OPERATOR_ID": OPERATOR_ID, "HERMES_HOME": str(self.hermes_home), "AVA_WORKSPACE": str(self.workspace), "AVA_HERMES_REPO": str(source.repository), @@ -183,6 +185,12 @@ def public_summary(self) -> dict[str, object]: "require_rollback_ref": self.promotion.require_rollback_ref, }, "policy": dict(self.policy), + "identity_types": { + "runtime_entities": sorted(REQUIRED_ENTITIES), + "operator_id": OPERATOR_ID, + "host_ids": ["avaorus", "minisforum"], + "instance_pattern": "live|shadow-*|test-*", + }, "snapshots": {"root": str(self.snapshot_root)}, "entities": { name: { diff --git a/hermes_cli/ava_runtime/identity.py b/hermes_cli/ava_runtime/identity.py index 10058bc074b2..8f7d38f2b21d 100644 --- a/hermes_cli/ava_runtime/identity.py +++ b/hermes_cli/ava_runtime/identity.py @@ -3,17 +3,41 @@ from __future__ import annotations import os +import re from dataclasses import dataclass from pathlib import Path from hermes_cli.ava_runtime.workspace import activate_process_workspace -ENTITY_ALIASES = { +RUNTIME_ENTITY_ALIASES = { "ava": {"ava"}, "aeon": {"aeon"}, - "avaeon-codex": {"avaeon-codex", "avaeon_codex", "avaeoncodex"}, } +RUNTIME_ENTITIES = frozenset(RUNTIME_ENTITY_ALIASES) +OPERATOR_IDENTITIES = frozenset({"avaeon-codex"}) +HOST_IDENTITIES = frozenset({"avaorus", "minisforum"}) +_INSTANCE_RE = re.compile(r"^(?:live|shadow-[a-z0-9][a-z0-9-]*|test-[a-z0-9][a-z0-9-]*)$") + + +def validate_operator_identity(value: str) -> str: + if value.strip().lower() not in OPERATOR_IDENTITIES: + raise ValueError("operator_id must be exactly avaeon-codex") + return "avaeon-codex" + + +def validate_host_identity(value: str) -> str: + normalized = value.strip().lower() + if normalized not in HOST_IDENTITIES: + raise ValueError("host_id must be avaorus or minisforum") + return normalized + + +def validate_instance_identity(value: str) -> str: + normalized = value.strip().lower() + if not _INSTANCE_RE.fullmatch(normalized): + raise ValueError("instance_id must be live, shadow-*, or test-*") + return normalized def _normalized_parts(path: Path) -> set[str]: @@ -21,7 +45,7 @@ def _normalized_parts(path: Path) -> set[str]: def _is_entity_scoped(path: Path, entity: str) -> bool: - aliases = {alias.lower().replace("_", "-") for alias in ENTITY_ALIASES[entity]} + aliases = {alias.lower().replace("_", "-") for alias in RUNTIME_ENTITY_ALIASES[entity]} return bool(_normalized_parts(path) & aliases) @@ -34,9 +58,9 @@ class ManagedIdentity: @classmethod def from_env(cls) -> "ManagedIdentity": entity = os.environ.get("AVA_ENTITY", "").strip().lower() - if entity not in ENTITY_ALIASES: + if entity not in RUNTIME_ENTITY_ALIASES: raise RuntimeError( - "AVA_ENTITY must be one of: " + ", ".join(sorted(ENTITY_ALIASES)) + "AVA_ENTITY must be a runtime entity: " + ", ".join(sorted(RUNTIME_ENTITIES)) ) home_raw = os.environ.get("HERMES_HOME", "").strip() diff --git a/hermes_cli/ava_runtime/managed_oneshot.py b/hermes_cli/ava_runtime/managed_oneshot.py index 07584af9aaec..8b9c5a2a8ad1 100644 --- a/hermes_cli/ava_runtime/managed_oneshot.py +++ b/hermes_cli/ava_runtime/managed_oneshot.py @@ -2,9 +2,10 @@ Usage: - AVA_ENTITY=avaeon-codex \ - HERMES_HOME=/var/lib/ava/hermes/avaeon-codex \ - AVA_WORKSPACE=/srv/ava/workspaces/avaeon-codex \ + AVA_ENTITY=aeon \ + AVA_OPERATOR_ID=avaeon-codex \ + HERMES_HOME=/var/lib/ava/hermes/aeon \ + AVA_WORKSPACE=/srv/ava/workspaces/aeon \ python -m hermes_cli.ava_runtime.managed_oneshot \ --resume SESSION_ID "Continue the work" diff --git a/hermes_cli/ava_runtime/update_policy.py b/hermes_cli/ava_runtime/update_policy.py new file mode 100644 index 000000000000..0a9d44b049a7 --- /dev/null +++ b/hermes_cli/ava_runtime/update_policy.py @@ -0,0 +1,25 @@ +"""Fail-closed policy for live Hermes updates. + +The operator may prepare and validate pinned candidates, but no live process may +update itself or resolve a moving branch. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +AUTO_UPDATE = False +FORBIDDEN_LIVE_COMMANDS = frozenset({"hermes update", "update"}) + + +def assert_live_update_forbidden(command: Sequence[str]) -> None: + normalized = " ".join(str(part).strip().lower() for part in command if str(part).strip()) + if normalized in FORBIDDEN_LIVE_COMMANDS or normalized.endswith(" hermes update"): + raise RuntimeError("live self-update is forbidden; use an isolated pinned candidate") + + +def assert_pinned_source(ref: str) -> str: + value = ref.strip() + if len(value) != 40 or any(char not in "0123456789abcdefABCDEF" for char in value): + raise ValueError("live source must be an exact commit SHA, never a moving branch") + return value.lower() diff --git a/scripts/ava_runtime/doctor.py b/scripts/ava_runtime/doctor.py index 9566dc996318..a2e8f8eff81d 100644 --- a/scripts/ava_runtime/doctor.py +++ b/scripts/ava_runtime/doctor.py @@ -17,11 +17,9 @@ from typing import Iterable -ENTITY_ALIASES = { - "ava": {"ava"}, - "aeon": {"aeon"}, - "avaeon-codex": {"avaeon-codex", "avaeon_codex", "avaeoncodex"}, -} +from hermes_cli.ava_runtime.identity import RUNTIME_ENTITY_ALIASES + +ENTITY_ALIASES = RUNTIME_ENTITY_ALIASES @dataclass(frozen=True) diff --git a/scripts/ava_runtime/smoke_session_identity.py b/scripts/ava_runtime/smoke_session_identity.py index ba3ccf701866..edb41bdf6f3d 100644 --- a/scripts/ava_runtime/smoke_session_identity.py +++ b/scripts/ava_runtime/smoke_session_identity.py @@ -22,7 +22,7 @@ from pathlib import Path -ENTITIES = ("ava", "aeon", "avaeon-codex") +ENTITIES = ("ava", "aeon") @dataclass(frozen=True) @@ -149,7 +149,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument( "--entity", choices=ENTITIES, - default=os.environ.get("AVA_ENTITY", "").strip().lower() or "avaeon-codex", + default=os.environ.get("AVA_ENTITY", "").strip().lower() or "aeon", ) parser.add_argument( "--hermes-command", diff --git a/scripts/ava_runtime/state_snapshot.py b/scripts/ava_runtime/state_snapshot.py index d8945585d277..2c9177183758 100644 --- a/scripts/ava_runtime/state_snapshot.py +++ b/scripts/ava_runtime/state_snapshot.py @@ -20,7 +20,9 @@ from pathlib import Path from typing import Any, Mapping -from hermes_cli.ava_runtime.identity import ENTITY_ALIASES +from hermes_cli.ava_runtime.identity import RUNTIME_ENTITY_ALIASES + +ENTITY_ALIASES = RUNTIME_ENTITY_ALIASES SCHEMA_VERSION = 1 diff --git a/tests/ava_runtime/test_doctor.py b/tests/ava_runtime/test_doctor.py index bfb4b6e90bde..365b7345684f 100644 --- a/tests/ava_runtime/test_doctor.py +++ b/tests/ava_runtime/test_doctor.py @@ -107,7 +107,7 @@ def test_unscoped_hermes_home_fails(monkeypatch, tmp_path, capsys, doctor_mod): ) -def test_workspace_inside_state_root_fails(monkeypatch, tmp_path, capsys, doctor_mod): +def test_operator_identity_is_not_a_runtime(monkeypatch, tmp_path, capsys, doctor_mod): repo = tmp_path / "repo" hermes_home = tmp_path / "state" / "avaeon-codex" workspace = hermes_home / "workspace" @@ -116,26 +116,20 @@ def test_workspace_inside_state_root_fails(monkeypatch, tmp_path, capsys, doctor monkeypatch.setattr(doctor_mod, "_run_git", _fake_clean_git) - rc = doctor_mod.main( - [ - "--entity", - "avaeon-codex", - "--repo", - str(repo), - "--workspace", - str(workspace), - "--hermes-home", - str(hermes_home), - "--json", - ] - ) - - payload = json.loads(capsys.readouterr().out) - assert rc == 1 - assert any( - check["name"] == "isolation.workspace" and check["status"] == "error" - for check in payload["checks"] - ) + with pytest.raises(SystemExit): + doctor_mod.main( + [ + "--entity", + "avaeon-codex", + "--repo", + str(repo), + "--workspace", + str(workspace), + "--hermes-home", + str(hermes_home), + "--json", + ] + ) def test_missing_entity_fails(monkeypatch, tmp_path, capsys, doctor_mod): diff --git a/tests/ava_runtime/test_fleet_cli.py b/tests/ava_runtime/test_fleet_cli.py index ed1330947b8a..e381bdc81264 100644 --- a/tests/ava_runtime/test_fleet_cli.py +++ b/tests/ava_runtime/test_fleet_cli.py @@ -30,7 +30,7 @@ def _write_config(tmp_path: Path) -> Path: "source": {"repository": str(tmp_path / "source"), "expected_ref": "cccccccccccccccccccccccccccccccccccccccc", "require_clean_checkout": True, "auto_update": False}, "entities": {}, } - for entity in ("ava", "aeon", "avaeon-codex"): + for entity in ("ava", "aeon"): raw["entities"][entity] = {"hermes_home": str(tmp_path / "state" / entity), "workspace": str(tmp_path / "workspaces" / entity), "profile": entity, "session_scope": entity} path = tmp_path / "fleet.yaml" path.write_text(yaml.safe_dump(raw, sort_keys=False), encoding="utf-8") @@ -43,7 +43,7 @@ def test_validate_outputs_hash_and_entities(tmp_path, capsys): output = capsys.readouterr().out assert rc == 0 assert "STATUS_CLOSURE=PASS" in output - assert "entities=aeon,ava,avaeon-codex" in output + assert "entities=aeon,ava" in output def test_env_json_contains_only_non_secret_runtime_bindings(tmp_path, capsys): @@ -63,8 +63,8 @@ def test_doctor_all_runs_each_entity_with_isolated_environment(tmp_path, monkeyp monkeypatch.setattr(module, "_run_child", lambda command, *, env, cwd: events.append((command, env.copy(), cwd)) or 0) rc = module.main(["--config", str(config), "doctor", "all", "--require-state-db"]) assert rc == 0 - assert [event[1]["AVA_ENTITY"] for event in events] == ["aeon", "ava", "avaeon-codex"] - assert len({event[1]["HERMES_HOME"] for event in events}) == 3 + assert [event[1]["AVA_ENTITY"] for event in events] == ["aeon", "ava"] + assert len({event[1]["HERMES_HOME"] for event in events}) == 2 assert all("--require-state-db" in event[0] and "--require-clean" in event[0] for event in events) @@ -72,10 +72,10 @@ def test_smoke_defaults_to_disposable_state(tmp_path, monkeypatch): module = _load_module() captured = {} monkeypatch.setattr(module, "_run_child", lambda command, *, env, cwd: captured.update(command=command, env=env, cwd=cwd) or 0) - rc = module.main(["--config", str(_write_config(tmp_path)), "smoke", "avaeon-codex"]) + rc = module.main(["--config", str(_write_config(tmp_path)), "smoke", "aeon"]) assert rc == 0 assert "--hermes-home" not in captured["command"] - assert captured["env"]["AVA_ENTITY"] == "avaeon-codex" + assert captured["env"]["AVA_ENTITY"] == "aeon" def test_smoke_live_state_is_explicit(tmp_path, monkeypatch): @@ -125,7 +125,7 @@ def test_snapshot_all_uses_configured_backup_root(tmp_path, monkeypatch): events = [] monkeypatch.setattr(module, "_run_child", lambda command, *, env, cwd: events.append((command, env.copy())) or 0) assert module.main(["--config", str(config), "snapshot", "all"]) == 0 - assert [event[1]["AVA_ENTITY"] for event in events] == ["aeon", "ava", "avaeon-codex"] + assert [event[1]["AVA_ENTITY"] for event in events] == ["aeon", "ava"] for command, _env in events: root_index = command.index("--output-root") assert command[root_index + 1] == str((tmp_path / "backups").resolve()) diff --git a/tests/ava_runtime/test_fleet_config.py b/tests/ava_runtime/test_fleet_config.py index 8ff8db71d609..fc4911f31f65 100644 --- a/tests/ava_runtime/test_fleet_config.py +++ b/tests/ava_runtime/test_fleet_config.py @@ -41,7 +41,6 @@ def _raw(tmp_path: Path) -> dict: "entities": { "ava": {"hermes_home": str(tmp_path / "state" / "ava"), "workspace": str(tmp_path / "workspaces" / "ava"), "profile": "ava", "session_scope": "ava"}, "aeon": {"hermes_home": str(tmp_path / "state" / "aeon"), "workspace": str(tmp_path / "workspaces" / "aeon"), "profile": "aeon", "session_scope": "aeon"}, - "avaeon-codex": {"hermes_home": str(tmp_path / "state" / "avaeon-codex"), "workspace": str(tmp_path / "workspaces" / "avaeon-codex"), "profile": "avaeon-codex", "session_scope": "avaeon-codex"}, }, } @@ -55,10 +54,10 @@ def _write(tmp_path: Path, raw: dict) -> Path: def test_valid_fleet_loads_and_renders_environment(tmp_path): path = _write(tmp_path, _raw(tmp_path)) config = load_fleet_config(path) - assert set(config.entities) == {"ava", "aeon", "avaeon-codex"} - assert config.entity("avaeon_codex").name == "avaeon-codex" + assert set(config.entities) == {"ava", "aeon"} env = config.environment("ava") assert env["AVA_ENTITY"] == "ava" + assert env["AVA_OPERATOR_ID"] == "avaeon-codex" assert env["HERMES_HOME"].endswith("/state/ava") assert env["AVA_HERMES_EXPECTED_REF"] == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" assert config.raw_sha256 == hashlib.sha256(path.read_bytes()).hexdigest() @@ -66,7 +65,7 @@ def test_valid_fleet_loads_and_renders_environment(tmp_path): assert "OPENAI_API_KEY" not in config.render_shell_environment("aeon") -def test_requires_complete_three_entity_fleet(tmp_path): +def test_requires_complete_runtime_fleet(tmp_path): raw = _raw(tmp_path) del raw["entities"]["aeon"] with pytest.raises(FleetConfigError, match="complete managed fleet"): @@ -157,3 +156,10 @@ def test_rejects_auto_update(tmp_path): raw["source"]["auto_update"] = True with pytest.raises(FleetConfigError, match="must remain false"): load_fleet_config(_write(tmp_path, raw)) + + +def test_operator_is_metadata_not_a_runtime_entity(tmp_path): + raw = _raw(tmp_path) + raw["entities"]["avaeon-codex"] = {"hermes_home": str(tmp_path / "state" / "avaeon-codex"), "workspace": str(tmp_path / "workspaces" / "avaeon-codex"), "profile": "avaeon-codex", "session_scope": "avaeon-codex"} + with pytest.raises(FleetConfigError, match="unknown entity"): + load_fleet_config(_write(tmp_path, raw)) diff --git a/tests/ava_runtime/test_identity.py b/tests/ava_runtime/test_identity.py index 38208d14d9b5..804cd161c5a6 100644 --- a/tests/ava_runtime/test_identity.py +++ b/tests/ava_runtime/test_identity.py @@ -4,7 +4,25 @@ import pytest -from hermes_cli.ava_runtime.identity import ManagedIdentity +from hermes_cli.ava_runtime.identity import ( + HOST_IDENTITIES, + OPERATOR_IDENTITIES, + RUNTIME_ENTITIES, + ManagedIdentity, + validate_host_identity, + validate_instance_identity, + validate_operator_identity, +) + + +def test_identity_types_are_disjoint_and_canonical(): + assert RUNTIME_ENTITIES == {"ava", "aeon"} + assert OPERATOR_IDENTITIES == {"avaeon-codex"} + assert validate_operator_identity("avaeon-codex") == "avaeon-codex" + assert validate_host_identity("minisforum") in HOST_IDENTITIES + assert validate_instance_identity("shadow-m7") == "shadow-m7" + with pytest.raises(ValueError): + validate_instance_identity("aeon-shadow-live") def test_identity_loads_isolated_paths(monkeypatch, tmp_path): @@ -36,7 +54,7 @@ def test_identity_rejects_shared_home(monkeypatch, tmp_path): ManagedIdentity.from_env() -def test_identity_rejects_workspace_inside_state(monkeypatch, tmp_path): +def test_identity_rejects_operator_as_runtime(monkeypatch, tmp_path): hermes_home = tmp_path / "state" / "avaeon-codex" workspace = hermes_home / "workspace" workspace.mkdir(parents=True) @@ -44,7 +62,7 @@ def test_identity_rejects_workspace_inside_state(monkeypatch, tmp_path): monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.setenv("AVA_WORKSPACE", str(workspace)) - with pytest.raises(RuntimeError, match="must not live inside"): + with pytest.raises(RuntimeError, match="runtime entity"): ManagedIdentity.from_env() diff --git a/tests/ava_runtime/test_promotion.py b/tests/ava_runtime/test_promotion.py index d0d565654d45..7d6a8efe8cab 100644 --- a/tests/ava_runtime/test_promotion.py +++ b/tests/ava_runtime/test_promotion.py @@ -33,7 +33,7 @@ def _config(tmp_path: Path): "source": {"repository": str(tmp_path / "source"), "expected_ref": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "require_clean_checkout": True, "auto_update": False}, "entities": {}, } - for entity in ("ava", "aeon", "avaeon-codex"): + for entity in ("ava", "aeon"): raw["entities"][entity] = {"hermes_home": str(tmp_path / "state" / entity), "workspace": str(tmp_path / "workspaces" / entity), "profile": entity, "session_scope": entity} path = tmp_path / "fleet.yaml" path.write_text(yaml.safe_dump(raw, sort_keys=False), encoding="utf-8") @@ -48,7 +48,7 @@ def _report() -> dict: "candidate_commit": "dddddddddddddddddddddddddddddddddddddddd", "rollback_ref": "stable-previous", "tests": {"ava_runtime": {"status": "pass", "command": "pytest tests/ava_runtime"}, "hermes_cli": {"status": "pass", "command": "pytest tests/hermes_cli"}, "upstream_relevant": {"status": "pass", "command": "pytest relevant"}}, - "entities": {"ava": dict(entity_gate), "aeon": dict(entity_gate), "avaeon-codex": dict(entity_gate)}, + "entities": {"ava": dict(entity_gate), "aeon": dict(entity_gate)}, "remaining_gaps": [], } @@ -77,7 +77,7 @@ def test_build_manifest_closes_candidate_and_rollback(monkeypatch, tmp_path): assert manifest["source"]["candidate_commit"] == "dddddddddddddddddddddddddddddddddddddddd" assert manifest["source"]["rollback_commit"] == "ffffffffffffffffffffffffffffffffffffffff" assert manifest["evidence"]["fleet_config_sha256"] == config.raw_sha256 - assert set(manifest["evidence"]["entities"]) == {"ava", "aeon", "avaeon-codex"} + assert set(manifest["evidence"]["entities"]) == {"ava", "aeon"} def test_report_rejects_any_failed_entity_gate(): @@ -88,7 +88,7 @@ def test_report_rejects_any_failed_entity_gate(): module._validate_report(report) -def test_report_requires_all_three_entities(): +def test_report_requires_all_runtime_entities(): module = _load_module() report = _report() del report["entities"]["ava"] diff --git a/tests/ava_runtime/test_smoke_session_identity.py b/tests/ava_runtime/test_smoke_session_identity.py index bc065b0d9335..bb775aa6cd58 100644 --- a/tests/ava_runtime/test_smoke_session_identity.py +++ b/tests/ava_runtime/test_smoke_session_identity.py @@ -57,4 +57,4 @@ def test_default_command_targets_managed_launcher(monkeypatch): assert args.mode == "managed" assert "hermes_cli.ava_runtime.managed_oneshot" in args.hermes_command - assert args.entity == "avaeon-codex" + assert args.entity == "aeon" diff --git a/tests/ava_runtime/test_state_snapshot.py b/tests/ava_runtime/test_state_snapshot.py index cb5d3825f00a..76dd68e9074f 100644 --- a/tests/ava_runtime/test_state_snapshot.py +++ b/tests/ava_runtime/test_state_snapshot.py @@ -65,8 +65,8 @@ def test_online_backup_is_independent_from_later_source_changes(tmp_path): def test_tampered_snapshot_is_rejected(tmp_path): module = _load_module() - home = _home(tmp_path, "avaeon-codex") - snapshot = module.create_snapshot(entity="avaeon-codex", hermes_home=home, output_root=tmp_path / "backups") + home = _home(tmp_path, "aeon") + snapshot = module.create_snapshot(entity="aeon", hermes_home=home, output_root=tmp_path / "backups") with (snapshot / "state.db").open("ab") as handle: handle.write(b"tamper") with pytest.raises(module.SnapshotError, match="checksum mismatch"): diff --git a/tests/ava_runtime/test_update_policy.py b/tests/ava_runtime/test_update_policy.py new file mode 100644 index 000000000000..22e426006d12 --- /dev/null +++ b/tests/ava_runtime/test_update_policy.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import pytest + +from hermes_cli.ava_runtime.update_policy import ( + AUTO_UPDATE, + assert_live_update_forbidden, + assert_pinned_source, +) + + +def test_live_update_is_disabled_and_self_update_fails_closed(): + assert AUTO_UPDATE is False + with pytest.raises(RuntimeError, match="self-update is forbidden"): + assert_live_update_forbidden(["hermes", "update"]) + + +def test_moving_source_is_not_a_live_source(): + with pytest.raises(ValueError, match="exact commit SHA"): + assert_pinned_source("ava/stable") + assert assert_pinned_source("A" * 40) == "a" * 40 + + +def test_other_commands_are_not_update_commands(): + assert_live_update_forbidden(["hermes", "--resume", "session"]) From 06b98566ae85d36a479003a9b6da344963c48207 Mon Sep 17 00:00:00 2001 From: SE87H Date: Tue, 4 Aug 2026 23:57:41 +0200 Subject: [PATCH 50/50] docs(ava): close M7 postflight policy wording --- docs/ava-runtime/ARCHITECTURE.md | 10 ++++++---- docs/ava-runtime/PHASE2_CONTROL_PLANE.md | 1 - 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/ava-runtime/ARCHITECTURE.md b/docs/ava-runtime/ARCHITECTURE.md index 7150ad64ea57..1ff7fed07f25 100644 --- a/docs/ava-runtime/ARCHITECTURE.md +++ b/docs/ava-runtime/ARCHITECTURE.md @@ -124,10 +124,12 @@ The following conditions must produce a visible non-zero failure rather than a s 9. Run post-promotion identity and workspace checks. 10. Record the rollback revision. -`auto_update` is permanently false. `hermes update`, self-update, silent update, -and moving branches as live sources are forbidden. AEON Core may provide -read-only diagnostics and smokes, but cannot deploy, approve, or mutate its own -live runtime. +`auto_update` is permanently false. The managed fleet control plane rejects +`auto_update: true` and moving references; `hermes update` is forbidden +operationally. The upstream binary is not globally intercepted outside that +control plane, so it must not be invoked directly for live updates. AEON Core +may provide read-only diagnostics and smokes, but cannot deploy, approve, or +mutate its own live runtime. ## Scope boundary diff --git a/docs/ava-runtime/PHASE2_CONTROL_PLANE.md b/docs/ava-runtime/PHASE2_CONTROL_PLANE.md index 4128f79561ae..68471e8efa34 100644 --- a/docs/ava-runtime/PHASE2_CONTROL_PLANE.md +++ b/docs/ava-runtime/PHASE2_CONTROL_PLANE.md @@ -60,7 +60,6 @@ uv run python scripts/ava_runtime/fleet.py doctor all --require-state-db uv run python scripts/ava_runtime/fleet.py smoke ava uv run python scripts/ava_runtime/fleet.py smoke aeon -uv run python scripts/ava_runtime/fleet.py smoke aeon ``` Smoke tests use disposable state by default. `--live-state` is an explicit, visible opt-in and must not be used for the first validation pass.