#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.14"
# dependencies = [
#   "typer>=0.12,<1",
#   "questionary>=2.0,<3",
#   "rich>=13,<15",
#   "pyyaml>=6,<7",
# ]
# ///
#
# agent-container — interactive wizard + CLI for agent-container containers.
#
# ============================================================================
# This script is the SINGLE SOURCE OF TRUTH for the agent-container on-disk contract.
# Everything below — container naming (agent-container-<name>), the port hash
# (2200 + sum-of-char-codes mod 100), the per-name state files
# ($XDG_STATE_HOME/agent-container/<name>.port), the env-file resolution order
# (./.env -> ~/.config/agent-container/<name>.env -> ~/.config/agent-container/.env), and
# the hosts.conf KEY=VALUE format (FOO_HOST / FOO_PORT) — is defined here and
# only here. The shell completions read the same state files directly; keep
# them in step with the constants in this file.
# ============================================================================
#
# hosts.conf handling: this tool parses hosts.conf line-by-line and NEVER
# executes/sources it. Quoting and unquoted trailing `# comment`s are handled
# like bash `source`, but values containing `$` or backticks are taken
# literally (no expansion), with a one-time warning.

import atexit
import base64
import calendar
import contextlib
import fcntl
import hashlib
import inspect
import io
import itertools
import json
import operator
import os
import re
import secrets
import shlex
import shutil
import signal
import socket
import subprocess
import sys
import tarfile
import tempfile
import termios
import time
import urllib.error
import urllib.request
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import NoReturn

import questionary
import typer
from rich.console import Console
from rich.table import Table

# --- constants ---------------------------------------------------------------

IMAGE_NAME = "localhost/agent-container:latest"
CONTAINER_PREFIX = "agent-container-"
PORT_BASE = 2200
PORT_RANGE = 100  # 2200..2299

STATE_DIR = (
    Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") / "agent-container"
)
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "agent-container"
# Feature 016 R1: the SIXTH layout location, and it is DATA_HOME rather than a
# corner of STATE_DIR because docs/layout.md documents the state dir as "computed;
# safe to delete". A durable record kept somewhere safe to delete is a
# contradiction, and it would quietly make Feature 011's own map false.
DATA_DIR = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share") / "agent-container"
HOSTS_CONF = CONFIG_DIR / "hosts.conf"
# Feature 001: the host registry (hosts.json) supersedes the flat hosts.conf
# address book. DEFAULT_HOST is the implicit local host name; per-host runtime
# state lives under STATE_DIR/<host>/. See specs/001-multi-host-deployment/.
HOSTS_JSON = CONFIG_DIR / "hosts.json"
REGISTRY_VERSION = 1
DEFAULT_HOST = "local"


def _is_repo_checkout(base: Path) -> bool:
    """Best-effort test that `base` is an agent-container checkout.

    Keyed on `completions/agent-container.bash` (plus `image/Dockerfile` for the
    build context): a repo-specific sentinel rather than the generic `Dockerfile`
    + `completions/` dir pair, which is common to unrelated trees. Feature 011
    moved the image sources into `image/`, which makes the marker MORE specific,
    not less. Note this runs at IMPORT time, before Fatal/die exist — a wrong
    marker cannot report itself, it just degrades to "no checkout reachable".
    Requiring the
    exact file _completion_script reads (bin/agent-container: REPO_ROOT/completions/
    agent-container.<shell>) also makes the marker and that consumed path the same
    invariant — a recognized checkout can never then serve stale package data.
    This is a heuristic, not a guaranteed false-positive-free identifier.
    """
    return (base / "image" / "Dockerfile").is_file() and (
        base / "completions" / "agent-container.bash"
    ).is_file()


def _looks_like_pre011_checkout(base: Path) -> bool:
    """A checkout from BEFORE Feature 011 moved the image sources into `image/`.

    Feature 011 changed the checkout marker from `Dockerfile` to `image/Dockerfile`.
    Without this, an operator standing inside their own (stale) checkout is told
    "no repo checkout ... run from a checkout" — which research R1 predicted
    verbatim and which is useless advice to someone already in one. Detecting the
    old shape lets the message name the actual problem.
    """
    return (base / "Dockerfile").is_file() and (
        base / "completions" / "agent-container.bash"
    ).is_file()


def _pre011_checkout_near(start: Path) -> Path | None:
    """The nearest ancestor of `start` that is a pre-011 checkout, if any."""
    for base in (start, *start.parents):
        if _looks_like_pre011_checkout(base):
            return base
    return None


def _no_checkout_message(extra: str = "") -> str:
    """The 'no checkout' diagnostic, upgraded when a STALE one is what we found."""
    stale = _pre011_checkout_near(Path.cwd())
    if stale is not None:
        return (
            f"{stale} looks like an agent-container checkout from before v0.18.0: the image "
            "sources moved to image/ (Dockerfile, entrypoint.sh, .dockerignore). "
            "Pull the latest revision, or move them yourself: git mv Dockerfile entrypoint.sh "
            ".dockerignore image/"
        )
    return (
        "no repo checkout for the docker build context (expected image/Dockerfile); "
        "run from a checkout, set AGENT_CONTAINER_REPO=<checkout>, or pass --context <dir>" + extra
    )


def _find_repo_root() -> Path | None:
    """Locate the repo checkout in a way that survives a non-editable install.

    A wheel copies this module into site-packages, so Path(__file__) no longer
    points inside the repo. Resolution order:
      1. AGENT_CONTAINER_REPO (explicit operator override): trusted only when it
         satisfies the checkout marker (see _is_repo_checkout); a wrong/typo'd
         path yields None rather than a bogus root (build then dies actionably).
      2. Walk up from this file, then from cwd, looking for a dir that matches
         the marker.
    Returns None when no checkout is reachable — client subcommands still work
    (completions falls back to bundled package data; build fails actionably).
    Note: this cannot die here — it runs at import, before Fatal/die exist.
    """
    env = os.environ.get("AGENT_CONTAINER_REPO")
    if env:
        base = Path(env).expanduser().resolve()
        return base if _is_repo_checkout(base) else None
    here = Path(__file__).resolve()
    for base in here.parents:
        if _is_repo_checkout(base):
            return base
    for base in (Path.cwd(), *Path.cwd().parents):
        if _is_repo_checkout(base):
            return base
    return None


REPO_ROOT = _find_repo_root()

NAME_RE = re.compile(r"[a-z0-9][a-z0-9_-]*")
# ssh argv injection guard: a user/host that begins with '-' would be parsed
# by ssh as an option (e.g. -oProxyCommand=...). Conservative charsets that
# can never start with '-'.
SSH_USER_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9._-]*")
SSH_HOST_RE = re.compile(r"[A-Za-z0-9_\[][A-Za-z0-9.:_\]-]*")
# tmux window name: embedded in the ssh remote command (attach --window), so it
# is charset-validated. Matches the entrypoint / agent-container window charset.
WINDOW_RE = re.compile(r"[A-Za-z0-9._-]+")

console = Console()


def eprint(msg: str) -> None:
    # Plain stderr writer; rich would mangle "[agent-container]" as markup and wrap lines.
    print(msg, file=sys.stderr)


# --- Feature 009: the agent-facing machine-readable surface ------------------
# An AI agent drives this CLI, so alongside the human surface every command can
# emit ONE versioned JSON envelope on stdout. Two disciplines are load-bearing:
#   * the envelope carries a SCHEMA version an agent can check, so the contract
#     can evolve without silently breaking callers (FR-006);
#   * NO secret value may ever appear in a payload (Constitution III) — state is
#     described by locators (a variable name, a path, a manager reference).
# Human prose keeps going to stderr, unchanged, so interactive use is untouched
# (FR-019). JSON mode is opt-in PER INVOCATION via each command's --json flag.

SCHEMA_VERSION = "agent-container/v1"
FAILURE_CODE_UNSPECIFIED = "unspecified"  # documented default for un-annotated die() sites
_JSON_MODE = False

# Commands that deliberately do NOT take --json, and why. Each one's stdout is
# already a contract that JSON would break:
#   host env / completions — their stdout is EVAL'd by a shell; wrapping it in an
#     envelope would make `eval $(…)` execute JSON (Feature 005 contract, R3).
#   attach                 — hands the terminal to ssh/tmux; there is no payload,
#     and its --print/--ssh-config forms are eval surfaces too.
#   menu                   — the interactive wizard; a TUI has no machine output.
# The test suite asserts this set is exactly these four, so a new command cannot
# quietly opt out of the machine-readable surface.
NO_JSON_COMMANDS = frozenset({"host env", "completions", "attach", "menu"})

# One shared Option object, reused as the default for every command's `as_json`
# parameter — the flag is per-command (clarified), but its declaration is not
# copy-pasted 20 times.
JSON_OPT = typer.Option(False, "--json", help="Emit machine-readable JSON (agent-facing).")
SKIP_UNKNOWN_OPT = typer.Option(
    False,
    "--skip-unknown-files",
    help="Ignore unrecognised YAML in .agent-container/ with a warning instead of refusing.",
)


def run_child(argv: list[str]) -> subprocess.CompletedProcess:
    """Run a child process with INHERITED stdio, except that in JSON mode its
    stdout is redirected to stderr.

    Build/compose output must keep streaming so the operator sees progress, but in
    machine-readable mode our stdout belongs to the envelope alone (FR-002) — a
    BuildKit log printed there makes the payload unparseable. Progress is
    diagnostic output, and diagnostics go to stderr by contract.
    """
    if json_mode():
        return subprocess.run(argv, stdout=sys.stderr)
    return subprocess.run(argv)


def emit_action(command: str, **fields: object) -> None:
    """Emit an action command's result — a no-op unless --json was passed, so the
    human path is byte-for-byte unchanged (FR-019). Fields are LOCATORS and status
    only; never a secret value (Constitution III)."""
    if json_mode():
        emit_json({"command": command} | fields)


def set_json_mode(on: bool) -> None:
    """Enable machine-readable output for this invocation (each command's --json)."""
    global _JSON_MODE
    _JSON_MODE = on


def json_mode() -> bool:
    return _JSON_MODE


def emit_json(data: object = None, error: dict | None = None) -> None:
    """Write EXACTLY ONE envelope to stdout — the single emitter every command
    routes through, so 23 commands cannot drift into 23 payload shapes (R1).

    >>> import io, contextlib
    >>> buf = io.StringIO()
    >>> with contextlib.redirect_stdout(buf):
    ...     emit_json({"hello": "world"})
    >>> json.loads(buf.getvalue()) == {
    ...     "schema": "agent-container/v1", "ok": True, "data": {"hello": "world"}}
    True
    """
    env: dict = {"schema": SCHEMA_VERSION, "ok": error is None}
    if error is None:
        env["data"] = data if data is not None else {}
    else:
        env["error"] = error
    print(json.dumps(env, indent=2, default=str))


class Fatal(Exception):
    """Fatal error; CLI exits 1, wizard prints and returns to the menu.

    Carries the optional structured metadata an agent branches on (FR-003/004/005):
    a STABLE `code` naming the failure class independently of the message wording,
    the affected `entity`, and a `remedy`. All optional so the ~100 existing die()
    sites keep working and are annotated incrementally (research R4).
    """

    def __init__(
        self,
        msg: str,
        *,
        code: str = FAILURE_CODE_UNSPECIFIED,
        entity: str | None = None,
        remedy: str | None = None,
    ) -> None:
        super().__init__(msg)
        self.code = code
        self.entity = entity
        self.remedy = remedy

    def descriptor(self) -> dict:
        """The FailureDescriptor an agent parses. `code` is the parsing surface;
        `message` is for humans and is never required for a decision."""
        return {
            "code": self.code,
            "entity": self.entity,
            "message": str(self),
            "remedy": self.remedy,
        }


def die(
    msg: str,
    *,
    code: str = FAILURE_CODE_UNSPECIFIED,
    entity: str | None = None,
    remedy: str | None = None,
) -> NoReturn:  # cli() turns Fatal into exit 1
    raise Fatal(msg, code=code, entity=entity, remedy=remedy)


# --- pure helpers ------------------------------------------------------------


def validate_name(name: str) -> str:
    """Validate the container short-name charset: ^[a-z0-9][a-z0-9_-]*$.

    >>> validate_name("acme")
    'acme'
    >>> validate_name("my-box")
    'my-box'
    >>> validate_name("Bad")
    Traceback (most recent call last):
    ...
    Fatal: invalid <name> 'Bad'; must match [a-z0-9][a-z0-9_-]*
    """
    if not name:
        die("missing required <name> argument")
    if not NAME_RE.fullmatch(name):
        die(f"invalid <name> '{name}'; must match [a-z0-9][a-z0-9_-]*")
    return name


def container_name(name: str) -> str:
    """
    >>> container_name("acme")
    'agent-container-acme'
    """
    return f"{CONTAINER_PREFIX}{name}"


def volume_name(name: str) -> str:
    """
    >>> volume_name("acme")
    'agent-container-acme-workspace'
    """
    return f"{CONTAINER_PREFIX}{name}-workspace"


def claude_volume_name(name: str) -> str:
    """
    >>> claude_volume_name("acme")
    'agent-container-acme-claude'
    """
    return f"{CONTAINER_PREFIX}{name}-claude"


def codex_volume_name(name: str) -> str:
    """
    >>> codex_volume_name("acme")
    'agent-container-acme-codex'
    """
    return f"{CONTAINER_PREFIX}{name}-codex"


def pi_volume_name(name: str) -> str:
    """
    >>> pi_volume_name("acme")
    'agent-container-acme-pi'
    """
    return f"{CONTAINER_PREFIX}{name}-pi"


def opencode_volume_name(name: str) -> str:
    """opencode's CONFIG volume (~/.config/opencode).

    >>> opencode_volume_name("acme")
    'agent-container-acme-opencode'
    """
    return f"{CONTAINER_PREFIX}{name}-opencode"


def opencode_data_volume_name(name: str) -> str:
    """opencode's DATA volume (~/.local/share/opencode) — auth.json + session db.

    opencode is the only agent that splits config from credentials (it follows
    XDG), so it is the only one with two volumes. See docs/execution.md.

    >>> opencode_data_volume_name("acme")
    'agent-container-acme-opencode-data'
    """
    return f"{CONTAINER_PREFIX}{name}-opencode-data"


def shellenv_volume_name(name: str) -> str:
    """
    >>> shellenv_volume_name("acme")
    'agent-container-acme-shellenv'
    """
    return f"{CONTAINER_PREFIX}{name}-shellenv"


def tmux_volume_name(name: str) -> str:
    """Persists ~/.config/tmux (tmux.conf + tpm plugins) across down/up.

    >>> tmux_volume_name("acme")
    'agent-container-acme-tmux'
    """
    return f"{CONTAINER_PREFIX}{name}-tmux"


def ssh_volume_name(name: str) -> str:
    """Persists ~/.ssh across down/up: authorized_keys, known_hosts, and the
    dev-owned SSH host key under hostkeys/. Because the container is rootless
    (sshd runs as dev), the host key lives here rather than in root-owned
    /etc/ssh, so a container keeps a STABLE SSH identity across recreation.

    >>> ssh_volume_name("acme")
    'agent-container-acme-ssh'
    """
    return f"{CONTAINER_PREFIX}{name}-ssh"


# Where the container writes a pending run record, and the mount point of the
# runs volume. Outside /home/dev on purpose: everything under the home directory
# is writable by whatever the operator or the agent runs, and the account of a
# run must not live where the subject of the account can edit it (research R2).
RUNS_MOUNT_PATH = "/var/lib/agent-container/runs"


def runs_volume_name(name: str) -> str:
    """Carries the PENDING run record (Feature 016) from the container to the
    next CLI contact with the host.

    Its own volume rather than a corner of an existing one: the workspace volume
    does not exist in `bind`/`ephemeral` mode — which is where a disposable
    headless run lives — and shellenv is operator-writable by design (R2).

    >>> runs_volume_name("acme")
    'agent-container-acme-runs'
    """
    return f"{CONTAINER_PREFIX}{name}-runs"


def all_volume_mounts(name: str) -> list[str]:
    """The ten per-container '-v' volume args, in the canonical fixed order
    (workspace, claude, codex, pi, shellenv, tmux, ssh, runs). pi-coding-agent's
    config/auth dir is ~/.pi (verified from
    the package: piConfig.configDir='.pi', getAgentDir()->~/.pi/agent).

    The doctest below MOVED when Feature 016 added the runs volume, and moving it
    is the point: it is an exact-equality pin on the identity contract, so a tenth
    volume cannot be added without a deliberate edit here. Every pre-016 entry is
    byte-identical and in its original position — the set grew, nothing was
    renamed or reordered.

    >>> all_volume_mounts("acme")
    ['agent-container-acme-workspace:/workspace', 'agent-container-acme-claude:/home/dev/.claude', 'agent-container-acme-codex:/home/dev/.codex', 'agent-container-acme-pi:/home/dev/.pi', 'agent-container-acme-opencode:/home/dev/.config/opencode', 'agent-container-acme-opencode-data:/home/dev/.local/share/opencode', 'agent-container-acme-shellenv:/home/dev/.agent-env', 'agent-container-acme-tmux:/home/dev/.config/tmux', 'agent-container-acme-ssh:/home/dev/.ssh', 'agent-container-acme-runs:/var/lib/agent-container/runs']
    """
    return [
        f"{volume_name(name)}:/workspace",
        f"{claude_volume_name(name)}:/home/dev/.claude",
        f"{codex_volume_name(name)}:/home/dev/.codex",
        f"{pi_volume_name(name)}:/home/dev/.pi",
        f"{opencode_volume_name(name)}:/home/dev/.config/opencode",
        f"{opencode_data_volume_name(name)}:/home/dev/.local/share/opencode",
        f"{shellenv_volume_name(name)}:/home/dev/.agent-env",
        f"{tmux_volume_name(name)}:/home/dev/.config/tmux",
        f"{ssh_volume_name(name)}:/home/dev/.ssh",
        f"{runs_volume_name(name)}:{RUNS_MOUNT_PATH}",
    ]


def per_container_volumes(name: str) -> list[str]:
    """All per-container volume NAMES, canonical order; used by --purge.

    The exact-equality doctest MOVED when Feature 016 appended the runs volume.
    That failure was the identity contract noticing a shape change (Constitution
    IV) — the nine pre-016 names are unchanged and still in their original order,
    so `--purge`, `wipe` and the completions keep addressing the same storage.

    >>> per_container_volumes("acme")
    ['agent-container-acme-workspace', 'agent-container-acme-claude', 'agent-container-acme-codex', 'agent-container-acme-pi', 'agent-container-acme-opencode', 'agent-container-acme-opencode-data', 'agent-container-acme-shellenv', 'agent-container-acme-tmux', 'agent-container-acme-ssh', 'agent-container-acme-runs']
    """
    return [
        volume_name(name),
        claude_volume_name(name),
        codex_volume_name(name),
        pi_volume_name(name),
        opencode_volume_name(name),
        opencode_data_volume_name(name),
        shellenv_volume_name(name),
        tmux_volume_name(name),
        ssh_volume_name(name),
        runs_volume_name(name),
    ]


def other_container_volumes(name: str) -> list[str]:
    """The nine NON-workspace per-container volume names (Feature 004). The
    workspace volume is mode-dependent (persistent only), so bind/ephemeral
    deployments declare only these nine in the compose model's `volumes:`.

    The runs volume is in here, which is the whole reason Feature 016 gave it one:
    a `bind` or `ephemeral` run declares no workspace volume, and a run record
    that only survived in `persistent` mode would be absent for precisely the
    disposable runs the record is most needed for.

    >>> other_container_volumes("acme")
    ['agent-container-acme-claude', 'agent-container-acme-codex', 'agent-container-acme-pi', 'agent-container-acme-opencode', 'agent-container-acme-opencode-data', 'agent-container-acme-shellenv', 'agent-container-acme-tmux', 'agent-container-acme-ssh', 'agent-container-acme-runs']
    """
    ws = volume_name(name)
    return [v for v in per_container_volumes(name) if v != ws]


def other_volume_mounts(name: str) -> list[str]:
    """The nine non-workspace '-v' mounts (Feature 004): all_volume_mounts minus
    the leading workspace mount, which the workspace mode selects separately."""
    ws_prefix = f"{volume_name(name)}:"
    return [m for m in all_volume_mounts(name) if not m.startswith(ws_prefix)]


def resolve_bind_mount(spec: str) -> str:
    """Resolve a '--mount HOSTDIR[:CONTAINERPATH]' spec into an absolute,
    read-write '-v' value.

    HOSTDIR is resolved to an absolute real path (Path.resolve()) and must be
    an existing directory. CONTAINERPATH defaults to /workspace/<basename>; if
    given explicitly it must be absolute. No secret/env value ever reaches argv.

    Lima prerequisite: the host dir must sit under a path the Lima VM exposes
    WRITABLE (set `writable: true` on the relevant mount and restart Lima),
    otherwise the bind is read-only inside the VM.
    """
    host, sep, container = spec.partition(":")
    if not host:
        die(f"--mount: empty host directory in '{spec}'")
    p = Path(host)
    if not p.is_dir():
        die(f"--mount: host path '{host}' does not exist or is not a directory")
    abs_host = str(p.resolve())
    if sep:  # an explicit ':' was present
        if not container:
            die(f"--mount: empty container path in '{spec}'")
        if not container.startswith("/"):
            die(f"--mount: container path '{container}' must be absolute")
    else:
        container = f"/workspace/{Path(abs_host).name}"
    return f"{abs_host}:{container}"


# Container-side paths the entrypoint reads injected SSH material from (bind
# mounts). Kept in sync with INJECT_DIR in entrypoint.sh.
INJECT_AUTHORIZED_KEYS_PATH = "/run/agent-container/authorized_keys"
# Feature 003 — outbound push credential + model/API keys + canonical config,
# delivered EPHEMERALLY: these targets live under /run (a compose config, not a
# named volume) so the material vanishes with the container (FR-012) — the
# deliberate opposite of the inbound host key above, which persists for identity.
# The entrypoint reads them here; kept in sync with INJECT_DIR in entrypoint.sh.
INJECT_PUSH_KEY_PATH = "/run/agent-container/push_ed25519_key"
INJECT_KNOWN_HOSTS_PATH = "/run/agent-container/known_hosts"
INJECT_APIKEY_DIR = "/run/agent-container/apikeys"
INJECT_CONFIG_DIR = "/run/agent-container/config"
# Feature 004 — the initial/headless task rides the same ephemeral compose-config
# channel (never argv/env, no size cap); the entrypoint reads it here.
INJECT_TASK_PATH = "/run/agent-container/task"

# Feature 004 — execution surface. The primary agent per deployment, the
# execution mode, and the workspace mode. These are non-secret and travel as
# compose environment (mode/agent/repo) — the task travels as an injected file.
EXEC_MODES = ("interactive", "headless")
AGENTS = ("claude", "codex", "pi", "opencode")
WORKSPACE_MODES = ("persistent", "bind", "ephemeral")

# --- Feature 012: egress and provider control --------------------------------
# An operator declares a PROVIDER NAME; the proxy needs HOSTNAMES. The mapping is
# ours and will drift as vendors change endpoints, so it is versioned with the
# tool and exposed via --json (FR-005) rather than discovered when a request is
# refused. An operator reaching a provider INDIRECTLY (a gateway, a self-hosted
# or vendor-compatible endpoint) overrides the hosts per entry (FR-001a) — and
# that override REPLACES this mapping rather than extending it (FR-001b).
PROVIDERS: dict[str, tuple[str, ...]] = {
    "anthropic": ("api.anthropic.com",),
    "openai": ("api.openai.com",),
    "google": ("generativelanguage.googleapis.com", "aiplatform.googleapis.com"),
    "openrouter": ("openrouter.ai",),
    "big-pickle": ("api.big-pickle.com",),
}

# Which agents reach a provider with NO operator credential at all. This is the
# defect Feature 012 exists to surface: Feature 010's probe ran opencode with no
# credential and it answered, over the network, via a provider nobody declared.
# A FIXTURE, NOT A COMMENT — a test pins it to AGENTS exactly, so a fifth agent
# added without probing FAILS rather than silently inheriting "no default".
AGENT_BUILTIN_DEFAULT: dict[str, str | None] = {
    "claude": None,
    "codex": None,
    "pi": None,
    "opencode": "big-pickle",
}

# Which agents honour HTTPS_PROXY/HTTP_PROXY — the fact that decides whether a
# declaration is ENFORCEABLE (FR-008) and whether `strict` may deploy. Every
# entry here was established by RUNNING the agent against a black-holed proxy
# (research R1), never read from documentation. Also a pinned fixture: an agent
# absent from this table is treated as NOT known to honour, so `strict` refuses
# it until someone probes it — the safe default, and the opposite of what a
# hand-maintained comment would give.
AGENT_HONOURS_PROXY: dict[str, bool] = {
    "claude": True,
    "codex": True,
    "pi": True,
    "opencode": True,
}

ENFORCEMENT_MODES = ("advisory", "strict")

# The proxy's compose service key — also its DNS name on the project network, which
# is how the agent reaches it. Its container name is set EXPLICITLY because compose
# would otherwise call it `<project>-egress-1`, i.e. `agent-container-<name>-egress-1`,
# which begins with CONTAINER_PREFIX — and six separate sites treat any
# `agent-container-*` container as an environment to list, pick or tear down.
EGRESS_SERVICE_KEY = "egress"
# squid's NON-INTERCEPT forward-proxy port. Phase A used 8888 (tinyproxy); this is
# a different daemon on a different port, and the constant moved with it rather
# than being kept for continuity — a stale port here points the diagnostic layer
# at nothing, and the symptom is an unreachable DECLARED destination, which reads
# as the allowlist being wrong.
EGRESS_PORT = 3127
EGRESS_ACL_PATH = "/etc/squid/allowed_sni.acl"
EGRESS_UNBOUND_PATH = "/etc/unbound/allowed.conf"
# A shell fragment the entrypoint SOURCES before flipping the policy to DROP,
# so a declared {host, port} is permitted by the time anything can use it.
EGRESS_PORTS_PATH = "/etc/egress/ports.rules"
# The minimum for in-container traffic. The tool owns this value; an operator one is
# refused rather than compared (contract C6) — deciding whether one NO_PROXY is
# "wider" than another means comparing *, .suffix, IP, CIDR and port forms, and a
# comparison erring permissively reproduces the exact bypass the rule prevents.
EGRESS_NO_PROXY = "localhost,127.0.0.1,::1"
# FR-020c: where the sidecar resolver forwards DECLARED names. Defaulting to the
# host's own resolver would leak the declared destination set to whatever the
# VPS provider runs, so the tool picks rather than inherits.
EGRESS_UPSTREAM_DNS = "1.1.1.1"


def egress_container_name(name: str) -> str:
    """The proxy's container name — deliberately OUTSIDE the `agent-container-*`
    namespace that identifies deployable environments (Constitution IV).

    >>> egress_container_name("acme")
    'agent-egress-acme'
    """
    return f"agent-egress-{name}"


def validate_private_key(path: Path) -> None:
    """Fail fast if `path` is not a readable, unencrypted OpenSSH private key.
    stdin is closed so an encrypted key fails cleanly instead of prompting."""
    r = subprocess.run(
        ["ssh-keygen", "-y", "-f", str(path)],
        capture_output=True,
        text=True,
        stdin=subprocess.DEVNULL,
    )
    if r.returncode != 0:
        die(
            f"{path} is not a valid, unencrypted OpenSSH private key "
            f"({r.stderr.strip() or 'ssh-keygen validation failed'})"
        )


def port_for_name(name: str) -> int:
    """Deterministic port: PORT_BASE + (sum of char codes mod PORT_RANGE).

    Name charset is ASCII-only (enforced by NAME_RE), so ord() matches
    bash's printf '%d' "'c" exactly.

    >>> port_for_name("acme")
    2206
    >>> port_for_name("my-box")
    2204
    """
    return PORT_BASE + (sum(ord(c) for c in name) % PORT_RANGE)


def name_to_key(name: str) -> str:
    """hosts.conf key prefix: hyphens -> underscores, uppercased.

    >>> name_to_key("my-box")
    'MY_BOX'
    >>> name_to_key("acme")
    'ACME'
    """
    return name.replace("-", "_").upper()


def resolve_ssh_user(override: str | None = None) -> str:
    """SSH user from --user / AGENT_CONTAINER_USER / 'dev', charset-checked so it can
    never be parsed by ssh as an option.

    >>> resolve_ssh_user("dev")
    'dev'
    >>> resolve_ssh_user("-oProxyCommand=x")
    Traceback (most recent call last):
    ...
    Fatal: invalid ssh user '-oProxyCommand=x'
    """
    user = override or os.environ.get("AGENT_CONTAINER_USER") or "dev"
    if not SSH_USER_RE.fullmatch(user):
        die(f"invalid ssh user '{user}'")
    return user


def validate_window(window: str) -> str:
    """tmux window name guard: it is embedded in the ssh remote shell command,
    so reject anything outside the safe charset before building it.

    >>> validate_window("agents")
    'agents'
    >>> validate_window("a; rm -rf ~")
    Traceback (most recent call last):
    ...
    Fatal: invalid tmux window 'a; rm -rf ~'; must match [A-Za-z0-9._-]+
    """
    if not WINDOW_RE.fullmatch(window):
        die(f"invalid tmux window '{window}'; must match [A-Za-z0-9._-]+")
    return window


def parse_kv_config(text: str) -> dict[str, str]:
    """Literal KEY=VALUE parser for hosts.conf. Never executes the file.

    Skips blank lines and # comments, strips an optional leading 'export ',
    splits on the first '=', honours one pair of matching quotes, and — like
    bash `source` — drops unquoted trailing ' # comment's (a '#' only starts
    a comment when preceded by whitespace; 'A=#lit' keeps the '#').

    >>> parse_kv_config("# c\\n\\nA=1\\nexport B='two'\\nC=\\"three\\"\\nD=a=b\\n")
    {'A': '1', 'B': 'two', 'C': 'three', 'D': 'a=b'}
    >>> parse_kv_config('H=vps.example.com # primary box\\nQ="a # b" # c\\nR=#lit\\n')
    {'H': 'vps.example.com', 'Q': 'a # b', 'R': '#lit'}
    """
    out: dict[str, str] = {}
    for line in text.splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        if line.startswith("export "):
            line = line[len("export ") :].lstrip()
        key, _, value = line.partition("=")
        key = key.strip()
        value = value.strip()
        if value[:1] in ("'", '"'):
            # Quoted: take up to the matching close quote; anything after it
            # (e.g. a trailing comment) is ignored, as bash would.
            closing = value.find(value[0], 1)
            if closing != -1:
                value = value[1:closing]
        else:
            m = re.search(r"\s#", value)
            if m:
                value = value[: m.start()].rstrip()
        if key:
            out[key] = value
    return out


def env_file_candidates(name: str, cwd: Path) -> list[Path]:
    """Env-file resolution order (Feature 011, FR-001b): project level then user
    level, each with a per-environment file and a shared default.

        <root>/.agent-container/<name>.env
        <root>/.agent-container/.env
        ~/.config/agent-container/<name>.env
        ~/.config/agent-container/.env

    Symmetric by design — the same filename means the same thing at both levels,
    which is what makes the two levels legible as ONE layered configuration.

    The bare `./.env` is deliberately NOT here. It is a shared ecosystem
    convention (Compose, direnv, dotenv libraries all read it) and belongs to
    whoever put it there; an agent-container env file goes in an agent-container
    location, or is named explicitly with `-e`.

    >>> [p.name for p in env_file_candidates("acme", Path("/nonexistent-xyz"))]
    ['acme.env', '.env']
    """
    out: list[Path] = []
    pcd = project_config_dir(cwd)
    if pcd is not None:
        out += [pcd / f"{name}.env", pcd / ".env"]
    return [*out, CONFIG_DIR / f"{name}.env", CONFIG_DIR / ".env"]


# Feature 011 FR-004/FR-005: the hard cut REFUSES, it does not ignore.
#
# Deleting the old lookup is only half the work. A deleted lookup is
# indistinguishable, from the operator's side, from silently ignoring their file
# — and the superseded set includes CREDENTIALS, so an ignored key means an agent
# running unauthenticated while the operator believes one was injected
# (Constitution III). Each superseded name is paired with where it now belongs.
#
# `<name>.<provider>.key` deliberately has NO project-local destination (FR-001f):
# `.agent-container/` travels with the repository and Feature 008 settled that the
# repo holds a locator, never a value.
SUPERSEDED_SUFFIXES: tuple[tuple[str, str], ...] = (
    (".env", ".agent-container/{name}.env"),
    (".services.yaml", ".agent-container/{name}.services.yaml"),
    (".config", ".agent-container/{name}.config/"),
)


def refuse_superseded_layout(name: str, root: Path | None = None) -> None:
    """Refuse a project still using the pre-011 layout, naming every offender.

    Silent when there is nothing to report — an operator with a clean project
    must never see migration chatter.
    """
    root = Path.cwd() if root is None else root
    offenders: list[str] = []
    prefix = f"agent-container.{name}"

    for suffix, dest in SUPERSEDED_SUFFIXES:
        p = root / f"{prefix}{suffix}"
        if p.exists():
            offenders.append(f"  {p.name}  ->  {dest.format(name=name)}")
    for p in sorted(root.glob(f"{prefix}.*.key")):
        offenders.append(
            f"  {p.name}  ->  {CONFIG_DIR}/{p.name.removeprefix('agent-container.')}"
            "  (user level; or reference it with a credential locator)"
        )

    # The CONDITIONAL case (FR-001c). A bare ./.env belongs to whoever put it
    # there — Compose, direnv, a framework — so refusing on it unconditionally
    # would make the tool hostile to the directory it shares. But ignoring it
    # when NOTHING else resolves would silently strand GH_TOKEN and provider
    # keys. Refuse only in that second case.
    bare = root / ".env"
    resolves = any(c.is_file() for c in env_file_candidates(name, root))
    if bare.is_file() and not resolves:
        offenders.append(
            f"  .env  ->  .agent-container/{name}.env  (or .agent-container/.env,"
            " or name it explicitly with -e/--env-file)"
        )

    if offenders:
        die(
            "this project uses the pre-011 layout, which is no longer read:\n"
            + "\n".join(offenders),
            entity=name,
            remedy="move each file to the path shown, then re-run",
        )


def _resolve_env_files(name: str, override: list[Path] | None) -> list[Path]:
    """The env files for this deployment (Feature 011 FR-001d).

    Explicit `-e` files REPLACE the discovery chain and are applied in the order
    given, later winning — naming files is a statement that the operator is in
    control, so merging discovered files underneath would make the effective
    environment depend on directory contents they were bypassing.
    """
    if override:
        return list(override)
    found = resolve_env_file(name)
    if found is None:
        cands = ", ".join(str(c) for c in env_file_candidates(name, Path.cwd()))
        die(
            f"no env file found. Looked in: {cands}",
            remedy="create one of those paths, or name a file explicitly with -e/--env-file",
        )
    return [found]


def resolve_env_file(name: str) -> Path | None:
    # Secret hygiene: RESOLUTION does only existence checks and never reads contents.
    # Feature 012's C6 check (env_file_keys) is the one place that opens an env file,
    # and it extracts variable NAMES only — never values, which are never returned or
    # logged. Amended here rather than left saying "contents are never read", because
    # a stale hygiene claim is worse than none: it is exactly what a reviewer trusts.
    for candidate in env_file_candidates(name, Path.cwd()):
        if candidate.is_file():
            return candidate
    return None


# Model/API key FILE discovery (Feature 003, US2). A per-provider key file is
# discovered by convention — project-local first, then the per-name user config —
# exactly like `.env`/sidecar resolution, so the common `up <name>` path needs no
# new flags. `<provider>` is lower-cased and restricted to a safe charset so it is
# a valid compose config source and a clean /run target segment.
APIKEY_PROVIDER_RE = re.compile(r"^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$")


def discover_apikey_files(name: str, cwd: Path | None = None) -> dict[str, Path]:
    """Discover per-provider model/API key FILES by convention (US2, T012).

    Globs `<name>.<provider>.key` under the USER config dir — one file per provider,
    <provider> lower-cased (e.g. anthropic, openai). Returns {provider: source
    path}, providers sorted for a deterministic order. Secret hygiene: existence
    only — the file's CONTENTS are never read here. Absent → {} so the env/`.env`
    delivery remains the layered fallback (`up <name>` with no key file unchanged).
    """
    # Feature 011 FR-001f: plaintext credentials are USER-LEVEL ONLY. There is
    # deliberately no project-local lookup and no `.agent-container/` equivalent:
    # that directory travels with the repository, and Feature 008 settled that the
    # repo holds a LOCATOR, never a value. Putting keys there would mean `git add
    # .agent-container/` — the natural action, since it holds the spec — stages an
    # API key, with _refuse_git_tracked_plaintext catching it only at deploy time,
    # after the secret is already in history. A project that wants its credential
    # referenced from the repo uses a locator source instead.
    _ = cwd  # accepted for signature compatibility; project level is not consulted
    found: dict[str, Path] = {}
    for base, prefix in ((CONFIG_DIR, f"{name}."),):
        if not base.is_dir():
            continue
        for f in sorted(base.glob(f"{prefix}*.key")):
            if not f.is_file():
                continue
            provider = f.name[len(prefix) : -len(".key")].lower()
            if APIKEY_PROVIDER_RE.fullmatch(provider) and provider not in found:
                found[provider] = f
    return dict(sorted(found.items()))


# Canonical agent configuration (Feature 003, US3). Per data-model.md, one agent
# home is split at FILE granularity: a small set of operator-owned CANONICAL
# files is delivered FRESH each deploy (edits propagate — FR-007), while every
# OTHER file under the home is the agent's mutable runtime state and persists on
# the per-agent volume (FR-008). This manifest is the authoritative canonical set;
# it is single-sourced HERE (the entrypoint stays manifest-agnostic — it just
# mirrors whatever the CLI staged). Keyed by agent LABEL (the operator's source
# subdir name); value = (home, config_globs). `home` is the container home-relative
# dir the files mirror onto; `config_globs` select the canonical files.
#
# Canonical config is NON-SECRET by definition (FR-007: "settings, project
# guidance, tool/MCP definitions WITHOUT embedded secrets"), so MCP definitions
# are canonical config here — delivered to the volume and consumed by the agent.
# The operator externalizes any real secret through the dedicated key-file channel
# (US2), which is ephemeral. FR-009 (a config file that DOES carry a token) can't
# be detected content-free without reading the file (a hygiene violation), so
# richer auto-classification of secret-bearing config is deferred to Feature 006
# (agent-as-code); this feature keeps canonical config and secrets on separate,
# well-defined channels.
CANONICAL_MANIFEST: dict[str, tuple[str, tuple[str, ...]]] = {
    "claude": (".claude", ("settings.json", "CLAUDE.md", "*.mcp.json", "mcp.json")),
    "codex": (".codex", ("config.toml", "AGENTS.md")),
    "pi": (".pi", ("config.json", "config.toml", "config.yaml", "config.yml")),
}


def canonical_config_dir(name: str, cwd: Path) -> Path | None:
    """Resolve the operator-canonical config SOURCE dir (Feature 011): project
    config directory first, then user level — mirrors the env/sidecar chain.

    >>> canonical_config_dir("acme", Path("/nonexistent-xyz")) is None
    True
    """
    pcd = project_config_dir(cwd)
    project = [pcd / f"{name}.config"] if pcd is not None else []
    for c in (*project, CONFIG_DIR / f"{name}.config"):
        if c.is_dir():
            return c
    return None


def discover_canonical_config(name: str, cwd: Path | None = None) -> list[tuple[str, Path]]:
    """Discover the per-agent CANONICAL config files (US3, T016) from the
    `agent-container.<name>.config/<agent>/…` convention.

    Returns (home_relative_target, source_path) for each source file that matches
    the per-agent manifest — `home_relative_target` e.g. ".claude/settings.json".
    A file matching NO manifest glob is the agent's runtime state and is NOT
    returned (never delivered, FR-008). Secret hygiene: existence/name checks only
    — CONTENTS are never read here. Absent source dir → [] so `up <name>` with no
    config dir is unchanged.
    """
    cwd = Path.cwd() if cwd is None else cwd
    src = canonical_config_dir(name, cwd)
    found: list[tuple[str, Path]] = []
    if src is None:
        return found
    for label, (home, config_globs) in CANONICAL_MANIFEST.items():
        adir = src / label
        if not adir.is_dir():
            continue
        for f in sorted(adir.iterdir()):
            if f.is_file() and any(f.match(g) for g in config_globs):
                found.append((f"{home}/{f.name}", f))
            # else: runtime state / unrecognized → NOT delivered (FR-008)
    return found


def state_file(name: str) -> Path:
    return STATE_DIR / f"{name}.port"


# --- per-host state (Feature 001) --------------------------------------------
# Runtime state is namespaced per host: STATE_DIR/<host>/<name>.{port,compose.yaml,
# host_key,authorized_keys}. Identity VALUES (container_name/port_for_name/volume
# names) are unchanged — only the state file LOCATION gains a host segment, so the
# same name may run on different hosts without collision (per-host daemons make
# project/volume collisions structurally impossible). The pre-per-host flat
# STATE_DIR/<name>.* files belong to the implicit 'local' host; migrate_flat_state()
# relocates them once, keeping the identity contract stable (Constitution IV).


def host_state_dir(host: str) -> Path:
    return STATE_DIR / host


def state_file_for(host: str, name: str) -> Path:
    return host_state_dir(host) / f"{name}.port"


def compose_file_path(host: str, name: str) -> Path:
    return host_state_dir(host) / f"{name}.compose.yaml"


# --- Feature 018: the tool-owned known_hosts ---------------------------------
# DERIVED HOST STATE, beside <name>.port, and that placement is a claim: "safe to
# delete" is TRUE of this file, because a deploy re-captures it from the running
# container. Deliberately NOT under DATA_DIR with the run records (016), the egress
# events (012) and the inventory (014) — those must OUTLIVE their host; this must
# die with it. Same shaped question, opposite lifetime, so opposite location.
#
# NEVER the operator's ~/.ssh/known_hosts (FR-006): the tool owns its own file and
# leaves theirs byte-identical (SC-007).


# `attach` resolves a local target to this address unless overridden, and a deploy
# pins under driver_reachable_address(), which is the same string for the implicit
# local host. Named once so the two sides cannot drift apart silently.
ADDRESS_FOR_LOCAL_ROWS = "localhost"


def known_hosts_path(host: str) -> Path:
    """The tool-owned known_hosts for one registered host. One file per host,
    holding one line per environment (data-model §2)."""
    return host_state_dir(host) / "known_hosts"


def known_hosts_files() -> list[Path]:
    """Every tool-owned known_hosts that exists, for ssh to VERIFY against.

    Attach must read across hosts while a deploy writes to exactly one. The
    asymmetry is not sloppiness: a deploy knows its registered host, but
    `resolve_attach_target` may resolve an endpoint through hosts.conf, which
    carries no registry host name. Handing ssh every file costs nothing, because
    the ENTRY KEY — `[address]:port` — is what discriminates (FR-005), not the
    directory. Two environments cannot collide unless they genuinely share an
    address and a port, in which case they are the same endpoint.

    ssh only ever READS these (StrictHostKeyChecking=yes never appends), so a
    multi-file list has no write-ordering surprise.
    """
    if not STATE_DIR.is_dir():
        return []
    return sorted(p for p in STATE_DIR.glob("*/known_hosts") if p.is_file())


def known_hosts_entry(address: str, port: str | int, pubkey: str) -> str:
    """One `known_hosts` line: `[<address>]:<port> <type> <key>` (data-model §1).

    THE BRACKET-PORT FORM IS LOAD-BEARING AND WAS MEASURED (research R3), because
    FR-005 rests on it: against a file holding `[127.0.0.1]:2222`, `ssh-keygen -F`
    finds `[127.0.0.1]:2222` and does NOT match `[127.0.0.1]:2223` or the bare
    `127.0.0.1`. Writing the bare-host form instead would let one container's key
    verify another container's connection — silently, on a tool whose whole premise
    is several containers on one host.

    >>> known_hosts_entry("localhost", 2206, "ssh-ed25519 AAAAC3 comment")
    '[localhost]:2206 ssh-ed25519 AAAAC3'
    """
    parts = pubkey.split()
    return f"[{address}]:{port} {parts[0]} {parts[1]}"


_PUBKEY_TYPES = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-", "sk-ssh-", "ssh-dss")


def valid_host_pubkey(text: str) -> str | None:
    """The single OpenSSH PUBLIC key line in `text`, or None.

    Refuses empty, whitespace-only, multi-line and malformed input — and refuses
    anything containing PRIVATE KEY. That last check is a tripwire rather than
    paranoia: private material is the one thing that must never reach this file
    (FR-001/FR-012), and a tripwire that never fires costs nothing.

    Returning None rather than raising is the point: "captured nothing" must be a
    VALUE the caller has to handle, not an exception it can forget to catch and not
    an empty string that formats into a blank entry. A blank line in a known_hosts
    file reads exactly like a successful pin (research R5).

    >>> k = "AAAAC3NzaC1lZDI1NTE5AAAAIExample0000000000000000000000000000000="
    >>> valid_host_pubkey(f"ssh-ed25519 {k} root@box")
    'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample0000000000000000000000000000000='
    >>> valid_host_pubkey("") is None
    True
    >>> valid_host_pubkey("-----BEGIN OPENSSH PRIVATE KEY-----") is None
    True
    >>> valid_host_pubkey("ssh-ed25519 tooshort") is None
    True
    >>> valid_host_pubkey(f"ssh-ed25519 {k}\\nssh-ed25519 {k}") is None
    True
    """
    if not text or "PRIVATE KEY" in text:
        return None
    lines = [ln.strip() for ln in text.strip().splitlines() if ln.strip()]
    if len(lines) != 1:
        return None
    parts = lines[0].split()
    if len(parts) < 2 or not parts[0].startswith(_PUBKEY_TYPES):
        return None
    # The key body is base64; reject a "type garbage" line that would otherwise
    # be pinned and then never match anything sshd presents.
    if not re.fullmatch(r"[A-Za-z0-9+/=]{32,}", parts[1]):
        return None
    return f"{parts[0]} {parts[1]}"


def pubkey_fingerprint(pubkey: str) -> str:
    """The SHA256 fingerprint of a public key line, for the operator to compare
    against another source (FR-016). A prompt with nothing to compare is theatre.

    Computed here rather than shelled out to `ssh-keygen -lf`: it is a hash of the
    base64 blob, needs no temp file, and cannot fail for a key we already
    validated.

    >>> k = "AAAAC3NzaC1lZDI1NTE5AAAAIExample0000000000000000000000000000000="
    >>> pubkey_fingerprint(f"ssh-ed25519 {k}")
    'SHA256:YX6ibcFhJB3k4cdmvU01qDxJxL1Za/DtGfVn63R0y/0'
    """
    blob = base64.b64decode(pubkey.split()[1] + "===")
    digest = base64.b64encode(hashlib.sha256(blob).digest()).decode().rstrip("=")
    return f"SHA256:{digest}"


@contextlib.contextmanager
def known_hosts_lock(host: str):
    """Serialise read-modify-write of ONE host's known_hosts (FR-018).

    `deployment_lock` is per (host, name), but this file is per HOST: two
    environments deploying concurrently on one host would each read, modify and
    replace it, and one entry would be lost. That is not a cosmetic race — the
    loser's next attach finds nothing pinned and falls through to the FR-013
    prompt, so a lost write silently DEGRADES VERIFICATION INTO A QUESTION the
    operator will answer yes to.

    Blocking, unlike `deployment_lock`: the contending writers here are two
    unrelated deploys that both legitimately need the file, and failing one of
    them over a sub-millisecond write would be worse than waiting for it.
    """
    d = host_state_dir(host)
    d.mkdir(parents=True, exist_ok=True)
    f = (d / "known_hosts.lock").open("w")
    try:
        fcntl.flock(f.fileno(), fcntl.LOCK_EX)
        yield
    finally:
        with contextlib.suppress(OSError):
            fcntl.flock(f.fileno(), fcntl.LOCK_UN)
        f.close()


def _entry_key(line: str) -> str:
    return line.split(None, 1)[0] if line.split() else ""


def pin_host_key(host: str, address: str, port: str | int, pubkey: str) -> Path:
    """Write this environment's entry, replacing any previous one for the same
    `[address]:port` and leaving every OTHER line byte-identical.

    Atomic (temp + os.replace in the same directory): a partial write here leaves
    the file ssh reads corrupt for EVERY environment on the host, not just the one
    being deployed.

    0644 rather than 0600: it holds public keys only, and ssh reads it as the
    invoking user anyway. Nothing here is a secret — that is the whole point of
    Feature 018.
    """
    entry = known_hosts_entry(address, port, pubkey)
    key = _entry_key(entry)
    path = known_hosts_path(host)
    # Own the directory rather than inheriting it from the lock's side effect: a
    # write whose target directory exists only because something else happened to
    # create it is a write that breaks the moment that something changes.
    path.parent.mkdir(parents=True, exist_ok=True)
    with known_hosts_lock(host):
        existing = path.read_text().splitlines() if path.is_file() else []
        kept = [ln for ln in existing if ln.strip() and _entry_key(ln) != key]
        body = "\n".join([*kept, entry]) + "\n"
        fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".known_hosts.", suffix=".tmp")
        tmp_path = Path(tmp)
        try:
            with os.fdopen(fd, "w") as fh:
                fh.write(body)
                fh.flush()
                os.fsync(fh.fileno())
            os.chmod(tmp_path, 0o644)
            os.replace(tmp_path, path)
        except BaseException:
            tmp_path.unlink(missing_ok=True)
            raise
    return path


def pinned_host_key(address: str, port: str | int) -> str | None:
    """The pinned public key for `[address]:port` across every tool-owned file,
    or None. Reads local state only — NEVER the daemon, so the answer survives an
    unreachable host, which is exactly when an operator needs it (FR-010)."""
    key = f"[{address}]:{port}"
    for path in known_hosts_files():
        try:
            lines = path.read_text().splitlines()
        except OSError:
            continue
        for ln in lines:
            if _entry_key(ln) == key:
                return ln.split(None, 1)[1].strip()
    return None


# The container's PUBLIC host key, which the entrypoint already writes beside the
# private one and chmods 0644 — so capture needs NO image change (research R1).
CONTAINER_HOSTKEY_PUB = "/home/dev/.ssh/hostkeys/ssh_host_ed25519_key.pub"
# The file does not exist the instant the container reports Up: Feature 016
# MEASURED the runtime publishing Up before the entrypoint executes a line, with
# its first write landing 0.27-0.57s later, and host-key generation later still.
# So capture POLLS. A fixed sleep is a bet that loses under load — widening it
# widens the race rather than closing it (research R5).
CAPTURE_TIMEOUT = 30.0
CAPTURE_POLL_INTERVAL = 0.5


def capture_host_pubkey(host_rec: dict, name: str, timeout: float = CAPTURE_TIMEOUT) -> str | None:
    """The container's host PUBLIC key, read THROUGH THE RUNTIME (FR-003).

    Never `ssh-keyscan`, and that is a requirement rather than a preference: asking
    the endpoint you are authenticating to state its own identity is
    trust-on-first-use wearing a hat. The runtime is the daemon that CREATED this
    container, so at deploy time it can vouch for provenance the endpoint cannot.

    Returns None on timeout, on an unreachable daemon, or on anything that is not a
    single well-formed public key. NONE IS A VALUE THE CALLER MUST HANDLE: an empty
    capture and a successful one are indistinguishable by exit code, and a blank
    line in a known_hosts file reads exactly like a pin that worked.
    """
    base = driver_runtime_argv(host_rec)
    cname = container_name(name)
    # POLL ONLY WHEN THERE IS SOMETHING TO WAIT FOR. "The key is not written yet" is
    # worth waiting out; "there is no container" and "the daemon does not answer" are
    # not, and retrying those for the full window makes every failed deploy — and
    # every attach against a stopped environment — pay the timeout for nothing.
    # Asked as a question rather than matched against the runtime's error text: the
    # wording differs between podman and docker, and a message that changes turns a
    # fast path back into a slow one silently.
    if not runtime_container_exists(base, cname):
        return None
    argv = base + ["exec", cname, "cat", CONTAINER_HOSTKEY_PUB]
    deadline = time.monotonic() + timeout
    while True:
        try:
            r = query(argv, timeout=RUNS_PROBE_TIMEOUT)
        except OSError, subprocess.SubprocessError:
            return None
        if r.returncode == 0 and (key := valid_host_pubkey(r.stdout)):
            return key
        if time.monotonic() >= deadline:
            return None
        time.sleep(CAPTURE_POLL_INTERVAL)


def refuse_removed_host_key(flag_source: str) -> None:
    """FR-002: every channel that supplied a PRIVATE host key is removed, and using
    one must SAY WHY rather than produce a bare "no such option".

    An operator who used `--host-key` had a reason — a stable SSH identity across
    recreations so `known_hosts` stopped complaining. That reason is now served
    without the cost: the container generates its own key on a persisted volume, the
    tool captures the PUBLIC half at every deploy, and attach verifies against it.
    Saying so is the difference between a removal and a regression.
    """
    die(
        f"{flag_source}: removed. A private host key is never supplied to, staged for "
        f"or written by this tool (Feature 018). The container generates its own on "
        f"its persisted ssh volume; the tool captures the PUBLIC key at every deploy "
        f"and `attach` verifies against it. Identity survives recreation as before — "
        f"and no private key sits on your disk. Drop the flag."
    )


def remove_stale_staged_host_key(host: str, name: str) -> None:
    """Delete a private host key staged by a pre-018 version, and SAY SO (FR-011).

    `--purge` never removed this file, so a release that merely stopped WRITING it
    would leave the exposure sitting on every machine that ever used the flag —
    which is the whole thing Feature 018 exists to remove.

    Loud rather than quiet: an operator should learn that a plaintext private key
    left their disk. A silent deletion is indistinguishable from never having had
    one, and they may have copies elsewhere worth revoking.
    """
    stale = host_state_dir(host) / f"{name}.host_key"
    if not stale.exists():
        return
    try:
        stale.unlink()
    except OSError as e:
        warn(f"could not remove the stale private host key at {stale} ({e}) — delete it yourself")
        return
    warn(
        f"removed a PRIVATE host key staged by an older version: {stale}. It was "
        f"mode 0644 and `--purge` never deleted it. The container now generates its "
        f"own key and keeps it; nothing private is stored here any more. If that key "
        f"exists anywhere else, treat it as exposed."
    )


def capture_and_pin(
    host_name: str, host_rec: dict, name: str, address: str, port: str | int
) -> str | None:
    """Capture the container's host public key and pin it. Returns the key, or None.

    A capture failure MUST NOT fail the deploy (FR-008): a container that is running
    perfectly should not be torn down because the tool could not read one file from
    it. But it must not be SILENT either, because "attach is unverified" is the one
    thing the operator cannot infer from a successful deploy — so the warning says
    exactly that, and names the recovery.

    Nothing is written on failure. Not a blank line, not an empty entry: a blank line
    in a known_hosts file reads exactly like a pin that worked, and the whole failure
    mode Feature 018 guards against is a check that passes while the thing it names
    is broken.
    """
    key = capture_host_pubkey(host_rec, name)
    if key is None:
        warn(
            f"{name}: could not read the container's host key within "
            f"{CAPTURE_TIMEOUT:g}s, so nothing was pinned — ATTACH WILL BE "
            f"UNVERIFIED and will ask before connecting. Re-run the deploy to pin it."
        )
        return None
    pin_host_key(host_name, address, port, key)
    log(f"pinned host key for {name} ({pubkey_fingerprint(key)})")
    return key


@contextlib.contextmanager
def deployment_lock(host: str, name: str):
    """Serialize mutating lifecycle ops on one deployment (FR-017): a non-blocking
    advisory flock on <state>/<host>/<name>.lock. A second concurrent op on the
    same (host,name) fails fast rather than interleaving; independent deployments
    never contend. Read-only ops (list/logs) do NOT take this lock."""
    lock_dir = host_state_dir(host)
    lock_dir.mkdir(parents=True, exist_ok=True)
    f = (lock_dir / f"{name}.lock").open("w")
    try:
        try:
            fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
        except OSError:
            die(f"another lifecycle operation is in progress for {name} on {host}")
        yield
    finally:
        with contextlib.suppress(OSError):
            fcntl.flock(f.fileno(), fcntl.LOCK_UN)
        f.close()


# Flat state artifacts written before per-host namespacing existed. Migrated into
# STATE_DIR/local/ on first host-aware access.
# `.host_key` was here until Feature 018 removed private-host-key injection:
# migrating a file we now DELETE would relocate the exposure rather than remove it.
_FLAT_STATE_SUFFIXES = (".port", ".authorized_keys")


def migrate_flat_state() -> None:
    """One-time: relocate flat STATE_DIR/<name>.{port,host_key,authorized_keys}
    into STATE_DIR/local/ (the implicit host). Idempotent: skips when the target
    already exists and never overwrites. Safe to call repeatedly."""
    if not STATE_DIR.is_dir():
        return
    local_dir = host_state_dir(DEFAULT_HOST)
    for f in sorted(STATE_DIR.iterdir()):
        if not f.is_file() or f.suffix not in _FLAT_STATE_SUFFIXES:
            continue
        # Skip .compose.yaml (never existed flat) and anything already migrated.
        target = local_dir / f.name
        if target.exists():
            continue
        local_dir.mkdir(parents=True, exist_ok=True)
        os.replace(f, target)


# --- durable record store (Feature 016) --------------------------------------
#
# One file per record, written to a temporary name in the target directory and
# atomically renamed into place (research R3). That single choice is what gives
# FR-009 without a lock: two runs cannot select the same name, and a partially
# written record is never visible because it is never AT its final name — no
# append, no fcntl, nothing that has to work across a remote daemon.
#
# The write and list helpers below take the DIRECTORY as a parameter and know
# nothing about run records. FR-011a says Feature 014's inventory adopts this
# machinery: shared placement and write-safety, separate schema and separate
# retention. A helper that reached for a run record's fields could not be shared,
# and 014 would grow a second, subtly different atomic write.


def runs_store_dir(host: str, environment: str) -> Path:
    """Where the DURABLE records for one environment on one host live.

    Namespaced by host because the same environment name can be deployed to
    several hosts (Constitution IV makes the identity per-host), and a record
    that lost which host it came from could not answer "which of last night's
    runs is which".

    >>> runs_store_dir("vps", "demo").parts[-4:]
    ('agent-container', 'runs', 'vps', 'demo')
    """
    return DATA_DIR / "runs" / host / environment


# --- Feature 014: the durable inventory --------------------------------------
# The THIRD tenant of the durable location, after 016's runs/ and 012's egress/,
# and the one that is shaped differently on purpose.


def inventory_store_dir() -> Path:
    """Where the durable record of everything the tool created lives.

    FLAT — deliberately unlike `runs/<host>/<environment>/` and `egress/<host>/…`.
    FR-003 requires an entry to OUTLIVE its host's removal, and a per-host
    directory is deleted with the host, destroying exactly the entries the
    requirement exists to keep. Host is an ATTRIBUTE here, never a path component.

    The two shapes differ because their lifetimes do: a run record answers "what
    happened on this host", and dies sensibly with it; this answers "what did we
    ever create", and must not.

    >>> inventory_store_dir().parts[-2:]
    ('agent-container', 'inventory')
    """
    return DATA_DIR / "inventory"


# Closed at construction (FR-004). `unknown` is deliberately absent: it is a
# RECONCILIATION RESULT, not a state, and storing it would make the record
# permanently lie about a host that later comes back.
INVENTORY_OUTCOMES = ("active", "removed", "vanished", "host-gone")
# COUNT ONLY, and there is deliberately NO age dimension at any level (FR-012).
# Feature 016 prunes runs by age AND count because a run's value decays once its
# commits are ordinary history. This is the opposite: the entry most worth having
# is the one forgotten six months ago, so a time criterion would delete the
# feature's whole value first. 5000 is roughly an order of magnitude past the
# spec's own estimate that years of heavy use is hundreds of rows — a backstop
# against runaway growth, not tidying. A test binds this number to the help text.
INVENTORY_MAX_ENTRIES = 5000
INVENTORY_SCHEMA = 1
# Pinned closed by a test. Unlike Feature 016 — which had to STATE its task-text
# exposure — FR-010's guarantee here is structural: every field is tool-generated,
# so there is nowhere for a credential to arrive. The field set IS the guarantee,
# which is why it is a constant and not a convention.
INVENTORY_FIELDS = (
    "schema",
    "entry_id",
    "name",
    "host",
    "host_provisioned",
    "created_at",
    "outcome",
    "outcome_at",
    "notes",
)


def inventory_entry_id(name: str, when: str) -> str:
    """A per-DEPLOYMENT id, sortable, and also the filename (FR-015).

    Keyed on the deployment rather than on (host, name): a reused name yields
    ANOTHER entry, so FR-015 holds by construction — there is no overwrite path to
    get wrong. That is the whole reason history survives a recreate.

    The random suffix separates two deployments of one name inside the same second,
    which `redeploy` in a loop reaches easily.

    >>> len(inventory_entry_id("acme", "2026-08-15T10:00:00Z").split("-"))
    3
    """
    stamp = when.replace("-", "").replace(":", "").replace("Z", "").replace("T", "")
    return f"{stamp}-{name}-{secrets.token_hex(3)}"


def build_inventory_entry(
    name: str, host: str, host_provisioned: bool, when: str | None = None
) -> dict:  # noqa: FBT001
    """A new `active` entry (data-model §1).

    Outcome is validated HERE rather than trusted at the call sites, the way
    Feature 016 enforces its kind/outcome pairing: a rule kept by convention
    becomes prose the first time someone adds a state, and then SC-003's "zero
    entries carrying `unknown`" cannot be measured at all.
    """
    # utc_now(), not a second format string: a listing sorts on this text, and two
    # writers that disagreed about the shape would order entries by which code path
    # created them (the reason Feature 016 made it one helper).
    when = when or utc_now()
    return {
        "schema": INVENTORY_SCHEMA,
        "entry_id": inventory_entry_id(name, when),
        "name": name,
        "host": host,
        "host_provisioned": host_provisioned,
        "created_at": when,
        "outcome": "active",
        "outcome_at": None,
        "notes": [],
    }


def validate_inventory_outcome(outcome: str) -> str:
    """Refuse anything outside the four, and refuse `unknown` by name (FR-004).

    `unknown` gets its own message because it is the one wrong answer somebody will
    reach for in good faith — a reconciliation that cannot reach a host has an
    obvious place to write it, and writing it there is exactly the lie SC-003
    forbids.
    """
    if outcome == "unknown":
        die(
            "inventory: 'unknown' is a reconciliation RESULT, never a stored outcome — "
            "an unreachable host is not a gone one, and storing it would make the "
            "record lie about a host that comes back."
        )
    if outcome not in INVENTORY_OUTCOMES:
        die(f"inventory: outcome={outcome!r} is not one of {{{', '.join(INVENTORY_OUTCOMES)}}}")
    return outcome


def prune_inventory() -> int:
    """Enforce the backstop cap. Returns how many entries were removed.

    COUNT ONLY, and there is deliberately no age criterion at any level (FR-012).
    Age-pruning would delete the oldest forgotten entries first — which are exactly
    the ones this feature exists to surface. A future "obvious improvement" that adds
    a max-age here would quietly invert the feature's purpose, so a test asserts no
    such constant exists.

    5000 is a backstop against runaway growth, not tidying: the spec's own estimate
    is that years of heavy use is hundreds of rows.
    """
    paths = list_stored_records(inventory_store_dir())  # newest first
    doomed = paths[INVENTORY_MAX_ENTRIES:]
    for p in doomed:
        with contextlib.suppress(OSError):
            p.unlink()
    if doomed:
        warn(
            f"inventory: pruned {len(doomed)} entr{'y' if len(doomed) == 1 else 'ies'} "
            f"over the {INVENTORY_MAX_ENTRIES} backstop. Said out loud rather than done "
            f"quietly — this store's whole value is the entry you forgot."
        )
    return len(doomed)


def write_inventory_entry(entry: dict) -> Path:
    """Persist one entry, using Feature 016's `atomic_write_json` (FR-012a).

    THIRD consumer of that path, after 012's egress events — not a second copy.
    A separate implementation would be a second thing to drift, and the property
    being relied on (temp-in-target-dir + fsync + rename) is subtle enough that
    two versions of it would not stay equivalent.

    The write is atomic per FILE, which is what gives FR-009 its concurrency
    guarantee for free: separate entries are separate files, and two writers to
    the SAME entry serialise on the rename.
    """
    validate_inventory_outcome(entry["outcome"])
    return atomic_write_json(inventory_store_dir(), f"{entry['entry_id']}.json", entry)


# Computed, never stored (data-model §3). `unknown` lives ONLY here: putting it in
# the file would make the record permanently lie about a host that comes back.
INVENTORY_CLASSIFICATIONS = ("agreeing", "missing", "unrecorded", "unknown")


def reconcile_inventory() -> list[dict]:
    """Compare the record against what each host reports (FR-005).

    Every entry lands in EXACTLY ONE of the four classifications, and one of them —
    `unknown` — exists because of Feature 002's fail-closed rule: an unreachable host
    must never yield `missing`. Invisible is indistinguishable from gone, and a
    reconciliation that guessed would send an operator hunting for a container that
    is sitting safely on a host they cannot currently reach.

    `unrecorded` is an OBSERVATION, not a claim (FR-007). The tool recognises its own
    containers by a naming convention an operator can imitate, so a prefix match is
    evidence of a NAME and nothing more. Every string this function produces is
    worded accordingly.
    """
    entries = [e for e in read_inventory_entries() if e.get("outcome") == "active"]
    hosts = registry_hosts(load_registry()) or {DEFAULT_HOST: implicit_local_host()}
    hosts.setdefault(DEFAULT_HOST, implicit_local_host())

    live: dict[str, set[str] | None] = {}  # host -> names, or None when unreachable
    # str() rather than the raw field: an entry whose `host` is missing or malformed
    # must still be classified (as `unknown`, since no such host can be reached)
    # rather than crash the reconciliation and take every other entry with it.
    named = {str(e.get("host") or "") for e in entries} | set(hosts)
    for host_name in sorted(named - {""}):
        rec = hosts.get(host_name)
        if rec is None:
            # The entry names a host the registry no longer has. Not reachable, and
            # not a lie either — FR-003 keeps the reference precisely so this reads
            # as "cannot check" rather than "never existed".
            live[host_name] = None
            continue
        try:
            live[host_name] = {r[0] for r in host_ps_rows(rec, include_stopped=True)}
        except Fatal, SystemExit, subprocess.SubprocessError, OSError:
            live[host_name] = None

    out: list[dict] = []
    for e in entries:
        names = live.get(e.get("host"))
        if names is None:
            state = "unknown"
        elif container_name(e.get("name", "")) in names:
            state = "agreeing"
        else:
            state = "missing"
        out.append({"classification": state, "entry": e})

    # Containers on a host that match the tool's naming but have no active entry.
    recorded = {(e.get("host"), container_name(e.get("name", ""))) for e in entries}
    for host_name, names in live.items():
        for cname in sorted(names or ()):
            if (host_name, cname) not in recorded:
                out.append(
                    {
                        "classification": "unrecorded",
                        "host": host_name,
                        "container": cname,
                        # Wording matters here and is asserted by a test: naming is a
                        # convention, so this is what we SAW, never what we own.
                        "note": (
                            "matches this tool's container naming convention but has no "
                            "inventory entry; the convention can be imitated, so this is "
                            "an observation and not a claim of ownership"
                        ),
                    }
                )
    return out


def do_inventory_reconcile(as_json: bool) -> None:
    """Report the differences (C6). Only THIS path may record `vanished`."""
    rows = reconcile_inventory()
    # `vanished` is written ONLY here (data-model §5): reconciliation is the one path
    # that has seen a REACHABLE host report the container absent. Anywhere else would
    # be recording an inference as a fact.
    for row in rows:
        if row["classification"] == "missing":
            e = row["entry"]
            set_inventory_outcome(e["name"], e["host"], "vanished")
    if as_json:
        emit_json({"reconciliation": rows})
        return
    if not rows:
        log("nothing to reconcile: no active entries recorded")
        return
    table = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
    for col in ("STATE", "NAME", "HOST", "DETAIL"):
        table.add_column(col)
    for row in rows:
        if row["classification"] == "unrecorded":
            table.add_row("unrecorded", row["container"], row["host"], row["note"])
        else:
            e = row["entry"]
            detail = {
                "agreeing": "present, as recorded",
                "missing": "recorded, host reachable, container absent — marked vanished",
                "unknown": "host unreachable — NOT reported missing; invisible is not gone",
            }[row["classification"]]
            table.add_row(row["classification"], e.get("name", "?"), e.get("host", "?"), detail)
    console.print(table)


def inventory_disagreement_hint() -> str | None:
    """One line for `list` when record and reality disagree (FR-005a).

    `list` already queries every host, so the comparison is nearly free — and a
    discrepancy an operator must already suspect in order to look for is one nobody
    finds. Deliberately does NOT print the classification: that is `reconcile`'s job,
    and duplicating it here would give two answers that can drift.
    """
    try:
        rows = [r for r in reconcile_inventory() if r["classification"] != "agreeing"]
    except Exception:  # noqa: BLE001 — a hint must never break `list` (FR-013)
        return None
    if not rows:
        return None
    return (
        f"{len(rows)} inventory discrepanc{'y' if len(rows) == 1 else 'ies'} "
        f"(run: agent-container inventory reconcile)"
    )


def inventory_age(created_at: str, now: float | None = None) -> str:
    """How long this environment has existed, in words (SC-009).

    Rendered rather than left as a timestamp because the question US3 asks is "what
    is this costing me to leave running", and a date the operator has to subtract
    from today is not the answer they asked for.

    Computed from the ENTRY's own `created_at`, never from the live host — the
    environments this matters most for are the ones whose host is gone, and a
    rendering that reached for the host would break exactly there.

    >>> inventory_age("2026-08-01T00:00:00Z", now=1785542490)
    '1m'
    >>> inventory_age("2026-08-01T00:00:00Z", now=1785549600)
    '2h'
    >>> inventory_age("2026-08-01T00:00:00Z", now=1785801600)
    '3d'
    >>> inventory_age("not a timestamp")
    'unknown'
    """
    try:
        started = calendar.timegm(time.strptime(created_at, TIME_FORMAT))
    except ValueError, TypeError:
        return "unknown"
    seconds = max(0, int((now if now is not None else time.time()) - started))
    if seconds < 3600:
        return f"{seconds // 60}m"
    if seconds < 86400:
        return f"{seconds // 3600}h"
    return f"{seconds // 86400}d"


def do_inventory_list(as_json: bool) -> None:
    """The inventory, newest first (C1, FR-011).

    Reads STORED ENTRIES ONLY — no host is contacted. That is what lets it answer
    months later, on a machine whose registry no longer lists the host, about an
    environment that no longer exists.
    """
    entries = read_inventory_entries()
    if as_json:
        emit_json({"entries": entries})
        return
    if not entries:
        # A plain sentence, not an empty screen: nothing recorded and a broken
        # store look identical when both print zero rows.
        log("no environments recorded yet (the inventory begins at install; it is not backfilled)")
        return
    table = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
    for col in ("NAME", "HOST", "AGE", "OUTCOME", "HOST-PROVISIONED"):
        table.add_column(col)
    for e in entries:
        table.add_row(
            str(e.get("name", "?")),
            str(e.get("host", "?")),
            inventory_age(str(e.get("created_at", ""))),
            str(e.get("outcome", "?")),
            # From the ENTRY, not the live host record: the host may be gone, and
            # that is exactly when an operator asks who created it.
            "yes" if e.get("host_provisioned") else "no",
        )
    console.print(table)


def read_inventory_entries() -> list[dict]:
    """Every stored entry, newest first. Missing store is not an error — a fresh
    install has simply never created anything (FR-013)."""
    out: list[dict] = []
    for path in list_stored_records(inventory_store_dir()):
        rec = read_stored_record(path, kind="inventory entry")
        if rec is not None:
            out.append(rec)
    return out


def set_inventory_outcome(name: str, host: str, outcome: str, when: str | None = None) -> int:
    """Move this host's `active` entries for `name` to `outcome`. Returns the count.

    Scoped to `active` on purpose: an entry already `removed` must not be rewritten
    when its host is later deleted, or the record would claim the host took
    something that was already gone — and FR-004's distinction between `removed`
    and `host-gone` is exactly what disappeared.
    """
    validate_inventory_outcome(outcome)
    changed = 0
    for entry in read_inventory_entries():
        if entry.get("name") != name or entry.get("host") != host:
            continue
        if entry.get("outcome") != "active":
            continue
        entry["outcome"] = outcome
        entry["outcome_at"] = when or utc_now()
        with contextlib.suppress(OSError):
            write_inventory_entry(entry)
            changed += 1
    return changed


def set_inventory_outcome_for_host(host: str, outcome: str, when: str | None = None) -> int:
    """Move EVERY `active` entry on `host` to `outcome`. Returns the count.

    Separate from the per-environment version because host removal does not know
    the environment names, and enumerating them from the registry would ask the
    thing being deleted what it used to contain.
    """
    validate_inventory_outcome(outcome)
    changed = 0
    for entry in read_inventory_entries():
        if entry.get("host") != host or entry.get("outcome") != "active":
            continue
        entry["outcome"] = outcome
        entry["outcome_at"] = when or utc_now()
        with contextlib.suppress(OSError):
            write_inventory_entry(entry)
            changed += 1
    return changed


def record_inventory_creation(name: str, host: str, host_provisioned: bool) -> None:
    """Record a deployment (FR-001), and NEVER fail the deploy for it (FR-008).

    A write failure is surfaced rather than swallowed: an unrecorded environment is
    precisely the blind spot this feature exists to remove, so silence here would
    reintroduce it while everything else looked healthy. But the container is
    already running and working — tearing it down over a bookkeeping failure would
    be a worse outcome than the missing row.
    """
    try:
        write_inventory_entry(build_inventory_entry(name, host, host_provisioned))
        prune_inventory()
    except (OSError, Fatal) as e:
        warn(
            f"{name}: could not record this deployment in the inventory ({e}). The "
            f"environment is running and unaffected, but it will not appear in "
            f"`agent-container inventory list` or in reconciliation."
        )


def atomic_write_json(directory: Path, filename: str, payload: object) -> Path:
    """Serialise `payload` to `directory/filename` so a reader never sees a
    partial file. Returns the final path.

    The temporary MUST be created in the target directory: os.replace is atomic
    only within a filesystem, and a temp file in $TMPDIR can land on a different
    one — where the "atomic" rename degrades to a copy that a reader can catch
    half-done. That is the failure this helper exists to prevent, so the temp
    directory is not a parameter.

    fsync before the rename: the rename orders the NAME, not the CONTENT. Without
    it a crash can leave a correctly-named, zero-length record — which reads as a
    corrupt record rather than as a missing one, and a corrupt record is the one
    outcome worse than no record at all.

    0600 (mkstemp's default, preserved through the rename): the record's task text
    is the one field that can carry a credential the operator typed (data-model
    §5), so the file is not world-readable.

    Knows nothing about what it is writing — see the section note (FR-011a).
    """
    directory.mkdir(parents=True, exist_ok=True)
    fd, tmp = tempfile.mkstemp(dir=directory, prefix=f".{filename}.", suffix=".tmp")
    tmp_path = Path(tmp)
    try:
        with os.fdopen(fd, "w") as fh:
            json.dump(payload, fh, indent=2)
            fh.write("\n")
            fh.flush()
            os.fsync(fh.fileno())
        target = directory / filename
        os.replace(tmp_path, target)
    except BaseException:
        # Leave no debris at a name a listing could later pick up. The suffix
        # already excludes it from list_stored_records, but a temp file that
        # accumulates on every failed write is a slow leak in a directory whose
        # whole retention story is "delete files".
        tmp_path.unlink(missing_ok=True)
        raise
    return target


def list_stored_records(directory: Path, suffix: str = ".json") -> list[Path]:
    """Records in `directory`, NEWEST FIRST. Empty list when the directory does
    not exist — an environment that has never run is not an error.

    Ordered by mtime and NOT by name, because this helper is shared (FR-011a) and
    only run records happen to have sortable ids; Feature 014's inventory need not.
    The name is the tie-break so equal mtimes still yield a stable order rather
    than the filesystem's arbitrary one — two records written in the same second
    must not reorder between two listings of the same directory.

    The suffix filter is what keeps a half-written record invisible: atomic_write_json
    stages under a dot-prefixed `.tmp` name, so an in-flight write can never be
    listed as a finished record.
    """
    try:
        entries = [p for p in directory.iterdir() if p.is_file() and p.name.endswith(suffix)]
    except FileNotFoundError, NotADirectoryError:
        return []
    return sorted(entries, key=lambda p: (p.stat().st_mtime, p.name), reverse=True)


# The record's one time shape (data-model §1), written by utc_now and read back by
# retention. Declared here rather than at either site because it is the contract
# BETWEEN them: a writer and a reader with their own copies would drift, and the
# only symptom would be every record silently ageing from the mtime fallback.
TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ"


# --- retention (FR-011, C14, research R8) ------------------------------------
#
# The defaults are DECIDED and named here, not "documented somewhere": 90 days and
# 500 records per environment, whichever prunes first (plan.md decision 8). 500
# records is roughly a year of four nightly runs; 90 days is past the point where a
# run's commits are ordinary history. A test binds these constants to the text that
# states them, because a documented number the code does not use is this project's
# recurring defect.
#
# Retention is the one part of this store machinery Feature 014 must NOT adopt
# (FR-011a): its inventory has to keep its oldest entries indefinitely, and one
# rule shared by both stores would necessarily be the wrong rule for one of them.
RETENTION_MAX_AGE_DAYS = 90
RETENTION_MAX_RECORDS = 500


def _record_epoch(path: Path) -> float:
    """When the RUN happened, in epoch seconds, for both retention bounds.

    Read from `started_at` and not from the file's mtime, because mtime is when the
    record was INGESTED — for a detached run that is whenever the operator next ran
    a command, and for a re-ingested one it is today. An age rule built on mtime
    would keep a year-old run alive because the tool touched its file this morning.

    CLAMPED TO THE MOMENT THIS STORE WROTE THE RECORD DOWN (`min` with mtime),
    because no run can have started after the tool recorded it. Without that clamp a
    `started_at` in the future — a skewed container clock, a bogus value — is
    `>= cutoff` for every cutoff the age rule can compute, so the age bound can NEVER
    remove it, and it sorts first so it holds a count slot forever. Measured before
    the clamp: 600 records stamped 2098 evicted all 30 real ones, and advancing the
    clock by ten years removed none of them. With it, such a record ages and buckets
    from the day the tool first saw it, which is a reading a real clock produced.

    An unreadable or unparseable `started_at` falls back to mtime, which is always
    LATER than the run it describes. So the fallback errs toward KEEPING a record
    under the age rule, and pruning is the one operation in this feature that cannot
    be undone.
    """
    try:
        custody = path.stat().st_mtime
    except OSError:
        # The file went away under us. Return the far future so the caller keeps it
        # rather than deleting a path it can no longer even read.
        return float("inf")
    try:
        rec = json.loads(path.read_text())
        started = float(calendar.timegm(time.strptime(str(rec["started_at"]), TIME_FORMAT)))
    except OSError, ValueError, TypeError, KeyError, json.JSONDecodeError:
        return custody
    return min(started, custody)


def _utc_day(epoch: float) -> str:
    """The UTC day `epoch` falls in, or "" for a value no clock can render.

    "" is a real bucket rather than an error: `_record_epoch` returns infinity for a
    record that vanished mid-prune, and a bucketing rule that raised on it would
    turn one missing file into a failure of the whole prune.
    """
    try:
        return time.strftime("%Y-%m-%d", time.gmtime(epoch))
    except OverflowError, OSError, ValueError:
        return ""


def _round_robin_keeps(rows: list[tuple[float, str, Path]], limit: int) -> set[Path]:
    """Which of `rows` — `(when, bucket, path)`, NEWEST FIRST — the count bound
    keeps: one pass per bucket, newest first, until `limit` is reached.

    ONE RULE, TWO AXES. The run store buckets by UTC DAY and the egress store by
    DESTINATION, because the burst each has to survive is different — a restart loop
    writes thousands of records over a few hours, an agent retrying a misconfigured
    provider produces thousands of events at one host — but the property wanted is
    the same, so the mechanism is shared rather than reimplemented per store.

    PLAIN NEWEST-FIRST IS WHAT THIS REPLACES, and it is defeated by the very failure
    that makes growth unbounded: the tool gives a headless run `restart: on-failure`
    with no retry limit, so an agent that cannot start is restarted for as long as
    the operator leaves it and each restart writes a record (measured: ~9 records in
    ~40s, i.e. thousands overnight). Newest-first lets that one burst evict EVERY
    older record — the store stays bounded and becomes worthless, the letter of
    FR-011 with none of its point.

    PARAMETER-FREE, which is the other half of the argument and the reason this is
    not a per-bucket share constant. A share has to be picked without knowing how
    many buckets there will be, and any share S with `limit/S` buckets at S apiece
    consumes the whole bound before an older bucket is ever examined. Measured on the
    share this replaces (250 of 500 per UTC day): a burst confined to ONE day left
    all 30 days of prior history intact, and the SAME burst split across two UTC days
    — an overnight loop, which is the motivating scenario — deleted every one of
    them with 500 records instead of 600. Round-robin allocates from the data, so K
    buckets get `limit/K` apiece with no number to drift and no midnight to cross.

    Nothing is deleted while the store is UNDER the bound: every bucket empties
    before `limit` is reached, so this is a priority order and not a second bound. A
    bound would delete today's 251st run from a store holding 251 records, which is
    data loss for no space.

    The global newest record is always kept: `rows` is newest-first, so its bucket is
    the first this walks and its own newest record is the first thing taken.

    The trade-off, stated because it is real: while a bucket is over its round-robin
    share the surviving set is no longer a contiguous "everything since <date>"
    window, so an answer from it — C16's `--changed` in particular — is thinner
    across that bucket than a newest-first store would have been. That is the
    incompleteness any prune creates, moved onto the bucket that produced redundant
    records and away from the ones that produced distinct records.
    """
    by_bucket: dict[str, list[Path]] = {}
    for _when, bucket, path in rows:
        by_bucket.setdefault(bucket, []).append(path)
    queues = [q for q in by_bucket.values() if q]
    keep: set[Path] = set()
    while queues and len(keep) < limit:
        for queue in queues:
            keep.add(queue.pop(0))
            if len(keep) >= limit:
                break
        queues = [q for q in queues if q]
    return keep


def prune_run_store(
    directory: Path, now: float | None = None, protect: frozenset[str] = frozenset()
) -> list[str]:
    """Delete the records past EITHER retention bound; return the names deleted.

    Both bounds are applied — "whichever prunes first" — because they bound two
    different failures that the other one misses entirely: an environment that runs
    every ten minutes blows the count long before anything reaches the age bound,
    and one that ran twice a year ago keeps two records forever under the count rule
    alone.

    Ordered by the run's own time (newest first) with the filename breaking ties, so
    two machines prune the same store identically. Which records the count bound
    then keeps is `_round_robin_keeps` over the UTC day, and the NEWEST record is
    always among them.

    `protect` NAMES THE RECORDS THIS COMMAND HAS JUST TAKEN CUSTODY OF, and they are
    exempt from the AGE bound alone. A host switched off for four months hands over
    records that are all past 90 days, and the drain removes the volume copy before
    this runs — so without the exemption the tool stored them, deleted their only
    other copy, and deleted the stored copy, all inside one command, and the operator
    never saw the listing they asked for. They are pruned on a LATER contact, by
    which time they have been readable at least once and the announcement names them.
    The COUNT bound still applies to them: being outnumbered by 500 newer records is
    a different and legitimate story, and exempting them from it would let one drain
    put the store over its documented bound.

    Reads only `directory`, which is the DURABLE store. A record still pending on a
    container volume is not a candidate and cannot be: retention must never delete
    an account of a run that has not been ingested yet, because that one leaves
    nothing behind to notice it is missing.

    A file that cannot be deleted is a warning and not a failure: retention is
    bookkeeping done on the way to the operator's actual command (R8), and a
    read-only store must not become a reason `runs list` stops working.
    """
    paths = list_stored_records(directory)
    if not paths:
        return []
    cutoff = (time.time() if now is None else now) - RETENTION_MAX_AGE_DAYS * 86400
    ordered = sorted(
        ((_record_epoch(p), p.name, p) for p in paths), key=operator.itemgetter(0, 1), reverse=True
    )
    keep = _round_robin_keeps(
        [
            (when, _utc_day(when), path)
            for when, name, path in ordered
            if when >= cutoff or name in protect
        ],
        RETENTION_MAX_RECORDS,
    )
    removed: list[str] = []
    for _epoch, name, path in ordered:
        if path in keep:
            continue
        try:
            path.unlink()
        except OSError as e:
            warn(f"could not prune run record {path} ({e})")
            continue
        removed.append(name)
    return removed


# --- the run record itself ----------------------------------------------------

RUN_SCHEMA = 1
# The outcome vocabulary is CLOSED and SCOPED TO THE KIND (data-model §2, C5).
# An interactive session has no completion semantics, so `finished`/`failed` are
# absent from its set rather than merely discouraged: FR-003 requires them to be
# unrepresentable, and a rule kept by convention becomes prose the first time a
# kind is added — after which SC-002 ("zero ambiguous endings") cannot be measured.
RUN_OUTCOMES: dict[str, tuple[str, ...]] = {
    "headless": ("finished", "failed", "stopped", "never-started"),
    "interactive": ("ended", "stopped"),
}
# The one outcome the TOOL authors (C6, research R5): the container never ran, so
# nothing inside it existed to report. Named here so the ingestion path cannot
# assume every record arrives from a volume.
RUN_OUTCOME_NEVER_STARTED = "never-started"
# The one outcome legal for BOTH kinds (data-model §2), which is why it is what a
# reconstructed pending record gets: ingestion cannot know the kind's own ending,
# and `stopped` is true of every container that went away under a run.
RUN_OUTCOME_STOPPED = "stopped"

# The repository effect's states are CLOSED too (data-model §3, C7), and every one
# of them is a RECORD rather than an error: research R4 measured no-upstream
# (`git rev-parse @{u}` → 128), detached HEAD (`git symbolic-ref -q HEAD` → 1) and
# no-repository (→ 128) as ordinary situations. An `ephemeral` workspace with no
# clone is the common case for a throwaway run, so a state outside this set means
# a caller invented one, not that a new situation appeared.
REPOSITORY_STATES = ("ok", "no-repository", "no-upstream", "detached", "unreadable")

# EVERY FIELD OF A RECORD AND WHERE ITS VALUE COMES FROM (data-model §1, §5).
#
# SC-005 claims no credential value appears in any record, 100% of runs. That is
# not the property of a filter — research R9 rejected pattern redaction, because a
# redactor that misses one value converts an operator's caution into misplaced
# confidence. It is the property of THIS TABLE HAVING EXACTLY ONE `operator` ROW.
# Widen the field set with a second free-text field and SC-005 becomes false while
# every existing test still passes, which is why T058 asserts the table rather than
# trusting the sentence in the data model.
#
#   tool     — this file or the entrypoint composed it from what it already knew
#   git      — read out of the workspace's repository, so it is SHAs and paths
#   agent    — the agent's own usage report, bounded to NUMBERS by validate_usage;
#              the bound is what keeps this row out of the `operator` column
#   operator — typed by a human. `task`, and nothing else, ever.
RECORD_FIELD_PROVENANCE: dict[str, str] = {
    "schema": "tool",
    "run_id": "tool",
    "environment": "tool",
    "host": "tool",
    "agent": "tool",
    "kind": "tool",
    "task": "operator",
    "started_at": "tool",
    "ended_at": "tool",
    "outcome": "tool",
    "exit_code": "tool",
    "repository": "git",
    "usage": "agent",
    "notes": "tool",
}

# `usage.units` is the one place an AGENT's vocabulary enters a record, and FR-015
# forbids normalising it — so the keys are whatever the agent calls them. What is
# NOT free is their shape: an identifier-shaped key and a number, because a string
# value here would open a second free-text field in the record and quietly falsify
# the closure RECORD_FIELD_PROVENANCE states above.
USAGE_UNIT_KEY_RE = re.compile(r"[A-Za-z][A-Za-z0-9_]{0,63}")

# A run id is generated INSIDE the container and arrives here as a tar member
# name, and it is also what `runs show <run-id>` takes from the command line. It
# therefore becomes a path component of a file on the operator's machine from two
# untrusted directions, so it is validated against a closed charset and REFUSED
# rather than sanitised: an extractor that "cleans" a hostile name is one silent
# bug away from writing outside the store, and there is no legitimate run id this
# pattern rejects.
RUN_ID_RE = re.compile(r"[0-9A-Za-z][0-9A-Za-z._-]{0,127}")


def utc_now() -> str:
    """The record's one time format: RFC 3339, UTC (data-model §1).

    One helper rather than a format string at each site, because a listing sorts
    on this text — two sites that disagreed about the shape would order records by
    which code path wrote them. Retention READS the same field back through
    TIME_FORMAT, so a writer and a parser that drifted apart would silently age
    every record from the mtime fallback instead.
    """
    return time.strftime(TIME_FORMAT, time.gmtime())


def new_run_id() -> str:
    """A run id for the one record the TOOL authors (C6).

    Sortable first so a listing is chronological without parsing timestamps, and
    suffixed with randomness because sortable alone is not unique: two
    environments that fail to start in the same second would otherwise choose the
    same name, and one-file-per-record holds FR-009 only while two runs cannot.

    >>> a, b = new_run_id(), new_run_id()
    >>> a != b and RUN_ID_RE.fullmatch(a) is not None
    True
    """
    return f"{time.strftime('%Y%m%dT%H%M%SZ', time.gmtime())}-{secrets.token_hex(4)}"


def validate_repository_effect(repo: dict) -> None:
    """Refuse a repository effect a reader could only misread (C7, C8).

    Both rules describe a record that would MISLEAD rather than one that is merely
    incomplete, which is why they are refusals and not warnings:

      - `state` comes from the closed set. R4 measured the awkward cases as
        ordinary situations with a word each, so an unrecognised state is an
        invented one — and it would render as itself, looking authoritative.
      - **`pushed` is null, never `false`, when no upstream was recorded.**
        `false` means "committed and did not push", the failure Constitution I
        exists to prevent and the loudest signal this feature has (FR-005). Spent
        once on "could not tell", every future one is unreliable. `true` is
        refused on the same footing: without an upstream there was nothing to
        compare against, so it would be a verification that never happened.

    This is where the TOOL's own records are made honest, and that is all it can
    be: a record written by the container is built in shell and arrives through
    ingestion without passing here. The read side (`push_status`) therefore treats
    the same contradiction defensively rather than assuming this guard held.
    """
    state = repo.get("state")
    if state not in REPOSITORY_STATES:
        die(
            f"run record: unknown repository state {state!r} "
            f"(legal: {', '.join(REPOSITORY_STATES)})"
        )
    if repo.get("upstream") is None and repo.get("pushed") is not None:
        die(
            "run record: pushed must be null when no upstream was recorded (C8) — "
            "false means 'committed and did not push', and conflating it with "
            "'could not tell' makes the loudest signal in the feature unreliable"
        )


# --- usage (data-model §4, FR-006/FR-015, C9/C10) ----------------------------
#
# WHAT IS EXTRACTED PER AGENT, AND WHY THAT IS CURRENTLY NOTHING (T034).
#
# The record is written inside the container, so usage could only be captured from
# what the agent's invocation emits. `run_headless_agent` in image/entrypoint.sh
# runs `claude -p`, `codex exec`, `pi -p` and `opencode run` — the prose forms. None
# of them is asked for a machine-readable report, so there is nothing to parse and
# every record written today carries `usage_unreported()`.
#
# NOTHING IS INVENTED FOR THEM. A figure this tool made up would be worse than the
# gap FR-006 already allows for, and a `0` is the exact failure SC-004 counts. What
# stops this from rotting into an assumption is a test that pins the entrypoint's
# record body to the unreported shape: the day an invocation starts reporting usage,
# that test fails and the extraction has to be written, rather than the tool
# continuing to file a real figure as unknown.


def usage_unreported() -> dict:
    """The usage of a run whose agent reported nothing (data-model §4).

    A constructor rather than a literal at each site, because the ONE thing this
    value must never become is `0` (FR-006, SC-004) and the one thing it must never
    become is absent (research R6: a consumer reads an absent key as zero). Both
    mistakes are edits to a literal somebody copied; neither is an edit to this.

    >>> usage_unreported()
    {'reported': False}
    """
    return {"reported": False}


def validate_usage(usage: dict) -> None:
    """Refuse a usage block that would misstate what the agent said (C9, C10).

    Three rules, each naming a way the record would MISLEAD rather than merely be
    incomplete:

      - **`reported` is a bool and always present.** It is the field that separates
        "nothing was reported" from "nothing was consumed", and SC-004 is the count
        of times those two got confused.
      - **An unreported usage carries nothing else.** `{"reported": false,
        "units": {...}}` says both at once, and a consumer would believe whichever
        key it looked at first.
      - **A reported usage names its agent and carries numeric units under
        identifier-shaped keys.** The agent is named because FR-015/C10 forbid
        normalising across agents — units are only meaningful next to whose they
        are. The values are numbers because a string would make `units` a second
        free-text field, and SC-005's closure (RECORD_FIELD_PROVENANCE) rests on
        `task` being the only one.

    Like validate_repository_effect, this can only police the records this TOOL
    builds: a container-written record is composed in shell and reaches the store
    through ingestion without passing here. So `render_usage` and `aggregate_usage`
    treat a malformed usage as UNKNOWN rather than trusting this guard held —
    unknown is the answer that cannot silently understate a total.
    """
    reported = usage.get("reported")
    if not isinstance(reported, bool):
        die("run record: usage.reported must be true or false (data-model §4)")
    if not reported:
        if set(usage) != {"reported"}:
            die(
                "run record: an unreported usage carries nothing but "
                "{'reported': False} — a units key beside it states both "
                "'nothing was reported' and a figure, and a consumer would "
                "believe whichever it read first"
            )
        return
    if usage.get("agent") not in AGENTS:
        die(
            f"run record: reported usage must name one of {', '.join(AGENTS)} "
            f"(FR-015: units are only meaningful next to whose they are)"
        )
    units = usage.get("units")
    if not isinstance(units, dict) or not units:
        die("run record: reported usage must carry a non-empty units object")
    for key, value in units.items():
        if not isinstance(key, str) or USAGE_UNIT_KEY_RE.fullmatch(key) is None:
            die(f"run record: usage unit key {key!r} is not an identifier")
        # `bool` before `int` on purpose: True is an int in Python and would sum
        # into a total as 1, turning a flag into a quantity nobody reported.
        if isinstance(value, bool) or not isinstance(value, (int, float)):
            die(
                f"run record: usage unit '{key}' must be a number, not "
                f"{type(value).__name__} — a free-text value here would widen the "
                f"record's one operator-authored field to two (SC-005)"
            )
    if set(usage) - {"reported", "agent", "units"}:
        die(f"run record: unknown usage keys {sorted(set(usage) - {'reported', 'agent', 'units'})}")


def build_run_record(
    *,
    run_id: str,
    environment: str,
    agent: str,
    kind: str,
    outcome: str,
    started_at: str,
    ended_at: str | None = None,
    task: str | None = None,
    exit_code: int | None = None,
    host: str | None = None,
    repository: dict | None = None,
    usage: dict | None = None,
    notes: list[str] | None = None,
) -> dict:
    """Build one record in the schema of data-model §1, REFUSING an illegal
    kind/outcome pair (C5) rather than emitting a record no consumer can read.

    Four invariants of §1 are enforced here rather than trusted, because this is
    the only place a record is constructed and therefore the only place they can
    be made unrepresentable:
      - the outcome must come from THIS kind's closed set (FR-003);
      - an interactive session carries no task (FR-002) — it was never given one,
        and a task on a session record would be an invented fact;
      - `exit_code` is headless-only and absent for `never-started` — a session
        has no exit status, and a container that never ran produced none. A `0`
        there would read as a clean run that never happened;
      - a repository effect, when present, satisfies C7 and C8
        (`validate_repository_effect`), and a usage block satisfies §4
        (`validate_usage`).

    `host` is None by default and stamped at INGESTION: the container does not
    reliably know what the operator calls its host.

    `usage` defaults to unreported rather than to zero. A false zero silently
    understates every total it enters (FR-006, SC-004), which is worse than a gap
    that says it is a gap.

    >>> r = build_run_record(run_id="20260809T101010Z-ab12", environment="demo",
    ...     agent="claude", kind="headless", outcome="finished",
    ...     started_at="2026-08-09T10:10:10Z", ended_at="2026-08-09T10:12:00Z",
    ...     exit_code=0, task="tidy the imports")
    >>> r["schema"], r["outcome"], r["exit_code"], r["host"]
    (1, 'finished', 0, None)
    >>> r["usage"]
    {'reported': False}
    >>> build_run_record(run_id="x", environment="demo", agent="claude",
    ...     kind="interactive", outcome="finished", started_at="t")
    Traceback (most recent call last):
    ...
    Fatal: run record: outcome 'finished' is not legal for kind 'interactive' (legal: ended, stopped)
    """
    if kind not in RUN_OUTCOMES:
        die(f"run record: unknown kind '{kind}' (legal: {', '.join(sorted(RUN_OUTCOMES))})")
    if outcome not in RUN_OUTCOMES[kind]:
        die(
            f"run record: outcome '{outcome}' is not legal for kind '{kind}' "
            f"(legal: {', '.join(RUN_OUTCOMES[kind])})"
        )
    if agent not in AGENTS:
        # The field set is closed (C13/SC-005) and every non-task field is
        # tool-generated; an agent name that is not one of ours means the caller
        # invented one, not that a new agent appeared.
        die(f"run record: unknown agent '{agent}' (legal: {', '.join(AGENTS)})")
    if kind == "interactive" and task is not None:
        die("run record: an interactive session has no task (data-model §1, FR-002)")
    if exit_code is not None and (kind != "headless" or outcome == RUN_OUTCOME_NEVER_STARTED):
        die(f"run record: exit_code is not meaningful for kind '{kind}' outcome '{outcome}'")
    if repository is not None:
        validate_repository_effect(repository)
    if usage is not None:
        validate_usage(usage)
    return {
        "schema": RUN_SCHEMA,
        "run_id": run_id,
        "environment": environment,
        "host": host,
        "agent": agent,
        "kind": kind,
        "task": task,
        "started_at": started_at,
        "ended_at": ended_at,
        "outcome": outcome,
        "exit_code": exit_code,
        "repository": repository,
        # ALWAYS present, and `reported: False` is a value — not an absence.
        # Omitting the key would be indistinguishable from a schema change, and a
        # consumer would read the absence as zero (research R6).
        "usage": usage if usage is not None else usage_unreported(),
        "notes": list(notes or []),
    }


# --- runtime -----------------------------------------------------------------

_hosts_warned = False

# Captured at startup so an abnormal ssh exit can't leave the terminal raw.
try:
    _ORIG_TERMIOS = termios.tcgetattr(sys.stdin.fileno()) if sys.stdin.isatty() else None
except Exception:
    _ORIG_TERMIOS = None


def detect_runtime() -> str:
    """Resolve the container runtime.

    AGENT_CONTAINER_RUNTIME (validated docker|podman, must be on PATH) always wins.
    Otherwise the default is platform-aware: on macOS the operator runs Lima +
    docker-cli, so prefer docker then podman; on Linux (the VPS) prefer podman
    then docker. Dies if neither is installed.
    """
    forced = os.environ.get("AGENT_CONTAINER_RUNTIME")
    if forced:
        if forced not in ("podman", "docker"):
            die(f"AGENT_CONTAINER_RUNTIME must be 'docker' or 'podman', got: {forced}")
        if not shutil.which(forced):
            die(f"AGENT_CONTAINER_RUNTIME={forced} but '{forced}' is not on PATH")
        return forced
    order = ("docker", "podman") if sys.platform.startswith("darwin") else ("podman", "docker")
    for rt in order:
        if shutil.which(rt):
            return rt
    die("neither 'podman' nor 'docker' on PATH (install one, or set AGENT_CONTAINER_RUNTIME)")


def query(argv: list[str], timeout: float | None = None) -> subprocess.CompletedProcess:
    return subprocess.run(argv, capture_output=True, text=True, timeout=timeout)


def container_running(rt: str, cname: str) -> bool:
    r = query([rt, "ps", "--filter", f"name=^{cname}$", "--format", "{{.Names}}"])
    return cname in r.stdout.splitlines()  # exact match, mirrors grep -qx


def container_exists(rt: str, cname: str) -> bool:
    return runtime_container_exists([rt], cname)


def runtime_container_exists(base_argv: list[str], cname: str) -> bool:
    """Does `cname` exist (running or not) on the runtime `base_argv` addresses?

    Takes the base argv rather than a runtime name so a HOST's runtime can be asked
    the same question (`driver_runtime_argv`), which is what the egress drain needs
    to tell "this environment was torn down" from "its boundary is broken". One
    implementation for both, because the filter is the load-bearing part: `ps -a`
    plus an anchored name, matched EXACTLY against the returned lines — a substring
    match here would report `agent-egress-acme-2` as `agent-egress-acme`.
    """
    r = query(base_argv + ["ps", "-a", "--filter", f"name=^{cname}$", "--format", "{{.Names}}"])
    return cname in r.stdout.splitlines()


def ps_agent_container(
    rt: str, include_stopped: bool = False, strict: bool = False
) -> list[tuple[str, str, str, str]]:
    """(cname, image, status, uptime) rows for agent-container-* containers.

    Go-template + tab split, not --format json: podman and docker JSON
    shapes are incompatible, the template output is not. With strict=True (the
    `list` reconcile path) the call is bounded by a timeout and DIES on a nonzero
    exit — a failed local `ps` must not read as 'zero containers' (fail-closed,
    001-US3 lesson). Default (best-effort) behavior is unchanged for other callers.
    """
    argv = [rt, "ps"] + (["-a"] if include_stopped else [])
    argv += ["--format", "{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.RunningFor}}"]
    r = query(argv, timeout=15 if strict else None)
    if strict and r.returncode != 0:
        die(f"could not list local containers: {r.stderr.strip() or f'exit {r.returncode}'}")
    rows = []
    for line in r.stdout.splitlines():
        parts = line.split("\t")
        if len(parts) == 4 and parts[0].startswith(CONTAINER_PREFIX):
            rows.append((parts[0], parts[1], parts[2], parts[3]))
    return rows


def image_exists(rt: str, tag: str) -> bool:
    # `image inspect` works on both podman and docker (podman's `image exists` does not).
    return query([rt, "image", "inspect", tag]).returncode == 0


def port_free(port: int) -> bool:
    """Can the DAEMON bind this port? (Not: can *we* bind it right now.)

    Two details make this match what the container runtime will actually do:

    * Wildcard bind — `-p {port}:2222` publishes on ALL interfaces, so probe what
      will really be bound, not just loopback.
    * SO_REUSEADDR — a server socket left in TIME_WAIT rejects a plain bind() but
      is bindable by a listener that sets SO_REUSEADDR, which daemons (including
      docker-proxy) do. Probing WITHOUT it reports "in use" for a port the daemon
      would take happily — a false negative that used to become a hard failure.
    """
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            s.bind(("", port))
        return True
    except OSError:
        return False


def wait_port_released(port: int, timeout: float = 30.0, *, quiet: bool = False) -> bool:
    """Block until `port` is bindable again; return whether it came free.

    `<rt> rm` returns before the daemon has finished tearing down the published-port
    forwarding, so an immediate `up` on the same name can see the port still held.
    Waiting here keeps a down+up (recreate) cycle on one name reliable. The ceiling
    is generous because a busy daemon — notably a loaded CI runner tearing down many
    containers at once — can be slow to release the forward.

    Best-effort by design: the caller proceeds either way (the daemon is the only
    authority on whether the bind succeeds). But a timeout is REPORTED rather than
    swallowed, so if the deploy then fails the operator can attribute it instead of
    seeing an unexplained 'port in use'.
    """
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        if port_free(port):
            return True
        time.sleep(0.25)
    if not quiet:
        warn(
            f"port {port} was still held after {timeout:g}s; continuing anyway — "
            f"if the deploy fails on the port, the previous container's teardown "
            f"had not finished (retry it) or something else holds the port "
            f"(agent-container list)"
        )
    return False


def write_state(host: str, name: str, port: int) -> None:
    host_state_dir(host).mkdir(parents=True, exist_ok=True)
    state_file_for(host, name).write_text(f"{port}\n")


def read_state_port(host: str, name: str) -> str | None:
    f = state_file_for(host, name)
    try:
        if not f.is_file():
            return None
        return f.read_text().strip() or None
    except OSError:  # unreadable / TOCTOU delete — degrade to unknown, never abort a listing
        return None


def clear_state(host: str, name: str) -> None:
    state_file_for(host, name).unlink(missing_ok=True)


def load_hosts_conf() -> dict[str, str]:
    global _hosts_warned
    if not HOSTS_CONF.is_file():
        return {}
    conf = parse_kv_config(HOSTS_CONF.read_text())
    if not _hosts_warned and any("$" in v or "`" in v for v in conf.values()):
        eprint(
            "[agent-container] WARNING: hosts.conf contains '$' or '`' values; "
            "agent-container does not perform shell expansion (agent-container does)."
        )
        _hosts_warned = True
    return conf


def hosts_entry(name: str) -> tuple[str, str] | None:
    """(host, port) from hosts.conf, or None unless BOTH keys are present."""
    conf = load_hosts_conf()
    key = name_to_key(name)
    host, port = conf.get(f"{key}_HOST"), conf.get(f"{key}_PORT")
    if host and port:
        return host, port
    return None


# --- host registry (Feature 001) ---------------------------------------------
# The registry (hosts.json) is the single source of truth for WHERE containers
# run, superseding the flat hosts.conf address book. Shape:
#   {"version": 1, "default": "<name>|null", "hosts": {"<name>": {Host}, ...}}
# A Host record: {driver, context, address, port?, provisioning, created_by_tool}.
# When hosts.json is absent, a legacy hosts.conf is read into read-only
# 'existing-ssh' (attach-only) hosts for a deprecation window. The registry is
# written atomically and its content is NEVER executed (parity with hosts.conf).


def _synthesize_legacy_registry() -> dict:
    """Read a legacy hosts.conf into attach-only 'existing-ssh' Host records.
    Not written back; removed after the deprecation window."""
    conf = load_hosts_conf()
    hosts: dict = {}
    for key in conf:
        if not key.endswith("_HOST"):
            continue
        base = key[: -len("_HOST")]
        host, port = conf[key], conf.get(f"{base}_PORT")
        if not (host and port):
            continue
        hosts[base.lower()] = {
            "driver": "existing-ssh",
            "context": "",
            "address": host,
            "port": port,
            "provisioning": None,
            "created_by_tool": False,
        }
    return {"version": REGISTRY_VERSION, "default": None, "hosts": hosts}


def load_registry() -> dict:
    """The host registry. Reads hosts.json when present, else synthesizes a
    read-only legacy registry from hosts.conf. Dies on a malformed file rather
    than silently losing host records."""
    if HOSTS_JSON.is_file():
        try:
            data = json.loads(HOSTS_JSON.read_text())
        except (json.JSONDecodeError, OSError) as e:
            die(f"invalid host registry at {HOSTS_JSON}: {e}")
        if not isinstance(data, dict) or not isinstance(data.get("hosts"), dict):
            die(f"invalid host registry at {HOSTS_JSON}: missing 'hosts' object")
        data.setdefault("version", REGISTRY_VERSION)
        data.setdefault("default", None)
        return data
    return _synthesize_legacy_registry()


def save_registry(reg: dict) -> None:
    """Persist the registry atomically (temp + os.replace) so a crash mid-write
    never truncates hosts.json."""
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    tmp = HOSTS_JSON.with_name(HOSTS_JSON.name + ".tmp")
    tmp.write_text(json.dumps(reg, indent=2) + "\n")
    os.replace(tmp, HOSTS_JSON)


def registry_hosts(reg: dict) -> dict:
    hosts = reg.get("hosts")
    return hosts if isinstance(hosts, dict) else {}


def get_host(reg: dict, name: str) -> dict | None:
    return registry_hosts(reg).get(name)


def default_host_name(reg: dict) -> str | None:
    """The registry's default deploy target, or None."""
    d = reg.get("default")
    return d if isinstance(d, str) else None


def address_from_context(context: str) -> str:
    """Attach address implied by a runtime context: the host of an ssh:// context,
    else localhost (a local context publishes to the local machine).

    >>> address_from_context("ssh://root@203.0.113.7")
    '203.0.113.7'
    >>> address_from_context("ssh://ops@vps.example.com:22")
    'vps.example.com'
    >>> address_from_context("lima-docker")
    'localhost'
    """
    m = re.match(r"ssh://(?:[^@/]+@)?([^:/]+)", context)
    return m.group(1) if m else "localhost"


def probe_host_runtime(host: dict) -> str | None:
    """Best-effort capability probe: returns an error string if the host's
    runtime is not usable (binary missing / context unreachable), else None.
    Non-blocking by design — a host may be registered before its context is
    reachable (e.g. just-provisioned); the deploy path fails hard later if still
    unusable. Bounded so a dead ssh:// context can't hang registration."""
    ensure_tunnel(host)
    try:
        argv = driver_runtime_argv(host) + ["version"]
    except Fatal:
        return "driver is attach-only"
    if not shutil.which(argv[0]):
        return f"'{argv[0]}' is not on PATH"
    try:
        r = subprocess.run(argv, capture_output=True, text=True, timeout=8)
    except (subprocess.TimeoutExpired, OSError) as e:
        return f"runtime probe failed: {e}"
    if r.returncode != 0:
        return (r.stderr.strip() or "runtime probe failed").splitlines()[0][:160]
    return None


def cli_host_add(
    name: str,
    driver: str,
    context: str | None,
    address: str | None,
    make_default: bool,
    *,
    provider: str | None = None,
    create: bool = False,
    reuse: bool = False,
    server_type: str | None = None,
    location: str | None = None,
    ssh_key: str | None = None,
    ssh_pubkey: Path | None = None,
) -> None:
    """Register a container-runtime host. Without --provider, a local/remote
    docker/podman context. With --provider, either allocate a cloud server
    (--create, billable) or register an existing one (--reuse)."""
    validate_name(name)
    if provider:
        if create == reuse:
            die("with --provider choose exactly one of --create (allocate) or --reuse (existing)")
        if create:
            record = provision_host(
                provider,
                name,
                server_type=server_type,
                location=location,
                ssh_key=ssh_key,
                ssh_pubkey=ssh_pubkey,
            )
        else:  # --reuse: register an operator-supplied server; never destroyable
            if not context:
                die("--reuse needs --docker-context (ssh://user@host or an existing context name)")
            if context.startswith("ssh://"):
                # A raw ssh:// URL is not a valid `docker --context` value; wrap it
                # in a named local context so the deploy path works unchanged.
                ctx_name = docker_context_name(name)
                docker_context_create(ctx_name, context)
                reuse_ctx, reuse_addr = ctx_name, address or address_from_context(context)
            else:
                reuse_ctx, reuse_addr = context, address or "localhost"
            record = {
                "driver": "docker",
                "context": reuse_ctx,
                "address": reuse_addr,
                "provisioning": {"provider": provider, "created": False},
                "created_by_tool": False,
            }
    else:
        if driver not in ("docker", "podman"):
            die(f"--driver must be 'docker' or 'podman' (got '{driver}')")
        if not context:
            opt = "--docker-context" if driver == "docker" else "--connection"
            die(f"host add: a {driver} host needs {opt} <context>")
        record = {
            "driver": driver,
            "context": context,
            "address": address or address_from_context(context),
            "provisioning": None,
            "created_by_tool": False,
        }
    reg = load_registry()
    hosts = registry_hosts(reg)
    existed = name in hosts
    hosts[name] = record
    reg["hosts"] = hosts
    if make_default or default_host_name(reg) is None:
        reg["default"] = name
    save_registry(reg)
    verb = "updated" if existed else "registered"
    is_default = " [default]" if default_host_name(reg) == name else ""
    addr = f", address={record['address']}" if record.get("address") else ""
    log(
        f"{verb} host '{name}' (driver={record['driver']}, "
        f"context={record['context']}{addr}){is_default}"
    )
    err = probe_host_runtime(record)
    if err is not None:
        warn(f"host '{name}' registered but its runtime is not usable yet: {err}")


def do_host_ls(as_json: bool) -> None:
    reg = load_registry()
    hosts = registry_hosts(reg)
    default = default_host_name(reg)
    if as_json:
        # Routed through the single emitter so this pre-009 payload also carries
        # the schema version — 3 commands must not speak a different dialect (FR-006).
        emit_json({"default": default, "hosts": hosts})
        return
    if not hosts:
        log("no hosts registered (add one: agent-container host add <name> --docker-context <ctx>)")
        return
    table = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
    for col in ("NAME", "DRIVER", "CONTEXT", "ADDRESS", "DEFAULT"):
        table.add_column(col)
    for hname in sorted(hosts):
        h = hosts[hname]
        table.add_row(
            hname,
            str(h.get("driver", "?")),
            str(h.get("context") or "-"),
            str(h.get("address") or "-"),
            "*" if hname == default else "",
        )
    console.print(table)


def do_host_show(name: str, as_json: bool) -> None:
    """Show one host's full record. Read-only: never saves, never fetches a token,
    never contacts a daemon (the record holds no secret — only ids)."""
    validate_name(name)
    reg = load_registry()
    h = get_host(reg, name)
    if h is None:
        die(
            f"no host named '{name}' (see: agent-container host ls)",
            code="host_not_registered",
            entity=name,
            remedy="agent-container host ls",
        )
    is_default = default_host_name(reg) == name
    if as_json:
        emit_json({"name": name, "default": is_default, **h})
        return
    prov = h.get("provisioning")
    table = Table(show_header=False, box=None, pad_edge=False)
    table.add_column(style="bold")
    table.add_column()
    table.add_row("name", name)
    table.add_row("driver", str(h.get("driver", "?")))
    table.add_row("context", str(h.get("context") or "-"))
    table.add_row("address", str(h.get("address") or "-"))
    table.add_row("default", "yes" if is_default else "no")
    table.add_row("created_by_tool", "yes" if h.get("created_by_tool") else "no")
    table.add_row("provisioning", json.dumps(prov) if prov else "-")
    console.print(table)


def cli_host_rm(name: str, destroy: bool, yes: bool) -> None:
    """Remove a host from the registry. Without --destroy, only the registration
    is removed — infrastructure is NEVER touched (FR-010). With --destroy, also
    deprovision the cloud server, but ONLY when: the tool created it, its provider
    has a deprovisioner, and no container remains on it (FR-008/009/010, SC-005).
    The registry entry is removed only AFTER a successful deprovision, so a partial
    teardown leaves the record for an idempotent retry."""
    validate_name(name)
    reg = load_registry()
    h = get_host(reg, name)
    if h is None:
        die(
            f"no host named '{name}' (see: agent-container host ls)",
            code="host_not_registered",
            entity=name,
            remedy="agent-container host ls",
        )
    if destroy:
        if not h.get("created_by_tool"):
            die(
                f"refusing --destroy on '{name}': this tool did not create its server "
                f"(FR-010). Remove just the registry entry with: agent-container host rm {name}"
            )
        prov = h.get("provisioning") or {}
        if prov.get("provider") != "hetzner":
            die(
                f"cannot --destroy '{name}': no deprovisioner for provider '{prov.get('provider')}'"
            )
        # FR-009 / SC-005: never destroy a server that still hosts containers.
        # Fail-CLOSED (assert_host_empty): only a container listing that provably
        # succeeded and is empty may proceed — a down tunnel or an unreachable
        # daemon refuses rather than being read as "empty".
        assert_host_empty(h)
        if not yes:
            if not is_tty():
                eprint(
                    f"[agent-container] refusing to destroy host '{name}' "
                    f"without -y/--yes on a non-TTY"
                )
                raise typer.Exit(2)
            if not questionary.confirm(
                f"destroy the cloud server for host '{name}' (irreversible)?", default=False
            ).ask():
                log("aborted")
                return
        token = _hcloud_token()  # only here, only after every refusal + the confirm
        provisioner_destroy(h, token)
    elif h.get("created_by_tool"):
        warn(
            f"host '{name}' was provisioned by this tool; its cloud server is left "
            f"running (and billable). Use 'host rm --destroy {name}' to deallocate it."
        )
    was_default = default_host_name(reg) == name
    hosts = registry_hosts(reg)
    hosts.pop(name, None)
    reg["hosts"] = hosts
    if was_default:  # never leave the default pointing at a removed host
        reg["default"] = next(iter(sorted(hosts)), None)
    save_registry(reg)
    # Feature 014 (FR-003/FR-004): the host's ACTIVE entries become `host-gone` —
    # with OR without --destroy, because the outcome records WHAT disappeared rather
    # than who caused it. The entries themselves stay: they outlive the registry
    # entry and the host's state directory, which is the whole point of the feature
    # (an operator asking "is something still billing me on a host I removed?" has
    # nowhere else to look).
    gone = set_inventory_outcome_for_host(name, "host-gone")
    if gone:
        log(f"marked {gone} inventory entr{'y' if gone == 1 else 'ies'} host-gone for '{name}'")
    log(f"removed host '{name}'" + (" and destroyed its server" if destroy else ""))


# --- Hetzner provisioner (Feature 001, US2) ----------------------------------
# A provisioner allocates a cloud server and yields a plain docker-driver host,
# so all provider specifics stay confined here (adding a provider is a new
# provisioner, not a change to build/run/attach). Stdlib urllib only — no hcloud
# SDK (Constitution VI). The API token is read from HCLOUD_TOKEN at call time and
# used ONLY in the Bearer header — never on argv, never baked, never persisted to
# hosts.json, never logged (Constitution III). Server allocation is billable and
# happens ONLY on an explicit `--create`.
#
# NOT LIVE-VALIDATED against real Hetzner in unit tests; the create/response
# shapes, cloud-init timing, and ssh-context behavior are exercised by the opt-in
# tokened acceptance test (bin/tests, -m acceptance) an operator runs manually.

HCLOUD_API = "https://api.hetzner.cloud/v1"
HETZNER_DEFAULT_SERVER_TYPE = "cax11"  # cheapest ARM shared vCPU; override with --server-type
HETZNER_DEFAULT_LOCATION = "nbg1"
HETZNER_IMAGE = "debian-12"
HETZNER_CODENAME = "bookworm"  # debian-12 suite on download.docker.com (pinned to HETZNER_IMAGE)
# Hetzner server names must be RFC-1123 labels (no underscore; <=63) — stricter than validate_name.
HETZNER_NAME_RE = re.compile(r"[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")


class _NoAuthRedirect(urllib.request.HTTPRedirectHandler):
    """Strip the Bearer header on any redirect so the token never follows a
    cross-host Location (defense in depth; Hetzner does not redirect)."""

    def redirect_request(self, req, fp, code, msg, headers, newurl):
        new = super().redirect_request(req, fp, code, msg, headers, newurl)
        if new is not None:
            new.remove_header("Authorization")
        return new


_HCLOUD_OPENER = urllib.request.build_opener(_NoAuthRedirect())
_HCLOUD_IDEMPOTENT = {"GET", "DELETE"}  # safe to retry; POST is not (double-create risk)


def _hcloud_token() -> str:
    """The Hetzner API token from HCLOUD_TOKEN. Dies (before any HTTP call) if
    unset. Never returned into a Host record, argv, or log."""
    tok = os.environ.get("HCLOUD_TOKEN")
    if not tok:
        die(
            "HCLOUD_TOKEN is not set — export it (e.g. from your secret store) before "
            "provisioning. It is read from the environment only, never stored or on argv."
        )
    return tok


def _retry_delay(exc: urllib.error.HTTPError | None, attempt: int) -> float:
    """Backoff: honor Retry-After on a 429/503 if present, else exponential (cap 15s)."""
    if exc is not None and exc.headers:
        ra = exc.headers.get("Retry-After")
        if ra and ra.strip().isdigit():
            return min(float(ra), 30.0)
    return min(2.0**attempt, 15.0)


def _hcloud_request(
    method: str,
    path: str,
    token: str,
    body: dict | None = None,
    *,
    timeout: float = 15.0,
    retries: int = 3,
) -> tuple[int, dict]:
    """One Hetzner REST call via stdlib urllib. Bearer header (stripped on redirect),
    JSON body, explicit timeout. Transient 429/5xx and transport errors are retried
    with backoff for idempotent methods (GET/DELETE) — never POST, to avoid double
    allocation. Non-2xx becomes Fatal with the API's error.code/message; the token
    never appears in the message."""
    url = f"{HCLOUD_API}{path}"
    if not url.startswith("https://"):  # defense: scheme is always the fixed HCLOUD_API host
        die(f"refusing non-https Hetzner API URL: {url}")
    data = json.dumps(body).encode() if body is not None else None
    retryable = method in _HCLOUD_IDEMPOTENT
    attempt = 0
    while True:
        attempt += 1
        req = urllib.request.Request(url, data=data, method=method)
        req.add_header("Authorization", f"Bearer {token}")
        req.add_header("Content-Type", "application/json")
        try:
            # opener.open (not urlopen); url is the fixed https HCLOUD_API base.
            with _HCLOUD_OPENER.open(req, timeout=timeout) as resp:
                raw = resp.read()
                return resp.status, (json.loads(raw) if raw else {})
        except urllib.error.HTTPError as e:
            if (e.code == 429 or e.code >= 500) and retryable and attempt <= retries:
                time.sleep(_retry_delay(e, attempt))
                continue
            raw = e.read()
            try:
                err = json.loads(raw).get("error", {})
                msg = f"{err.get('code', e.code)}: {err.get('message', e.reason)}"
            except ValueError, AttributeError:
                msg = f"{e.code}: {e.reason}"
            die(f"Hetzner API {method} {path} failed ({msg})")
        except (urllib.error.URLError, TimeoutError, OSError) as e:
            if retryable and attempt <= retries:
                time.sleep(_retry_delay(None, attempt))
                continue
            die(f"Hetzner API {method} {path} unreachable ({e})")


def resolve_operator_pubkey(override: Path | None = None) -> str:
    """The operator SSH PUBLIC key to authorize on the new server (so the operator's
    docker ssh:// context + attach can reach it). --ssh-pubkey wins, else the first
    of ~/.ssh/id_ed25519.pub / id_rsa.pub."""

    def _public(text: str, src: str) -> str:
        # Guard against embedding a PRIVATE key in user_data (it would land in the
        # server's cloud-init logs). Public keys start with a known type prefix.
        if "PRIVATE KEY" in text or not re.match(r"(ssh-|ecdsa-|sk-)", text):
            die(f"{src} is not an SSH PUBLIC key (expected ssh-ed25519/ssh-rsa/…)")
        return text

    if override is not None:
        p = override.expanduser()
        if not p.is_file():
            die(f"--ssh-pubkey: {override} does not exist")
        return _public(p.read_text().strip(), f"--ssh-pubkey {override}")
    for cand in ("id_ed25519.pub", "id_rsa.pub"):
        p = Path.home() / ".ssh" / cand
        if p.is_file():
            return _public(p.read_text().strip(), str(p))
    die(
        "no SSH public key found (~/.ssh/id_ed25519.pub or id_rsa.pub); pass "
        "--ssh-pubkey <path> so the server authorizes a key you hold"
    )


def hetzner_build_user_data() -> str:
    """cloud-init that installs Docker + the Compose v2 plugin on a fresh Debian 12
    server. Passed verbatim as user_data (plain YAML, no base64)."""
    # DOCKER INSTALL ONLY — key authorization is handled via Hetzner's ssh_keys
    # API (injected into root at provision time), NOT cloud-init: on the Hetzner
    # debian-12 image cloud-init's top-level `ssh_authorized_keys` does NOT
    # authorize root (verified live — root rejects the key), so `root@ip` auth
    # would fail and the docker-over-ssh poll would never succeed. The apt suite
    # is pinned to HETZNER_CODENAME (not a nested $(...) whose quoting broke).
    return (
        "#cloud-config\n"
        "package_update: true\n"
        "packages:\n"
        "  - ca-certificates\n"
        "  - curl\n"
        "runcmd:\n"
        "  - install -m 0755 -d /etc/apt/keyrings\n"
        "  - curl -fsSL --retry 5 --retry-connrefused https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc\n"
        "  - chmod a+r /etc/apt/keyrings/docker.asc\n"
        f'  - echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian {HETZNER_CODENAME} stable" > /etc/apt/sources.list.d/docker.list\n'
        "  - apt-get update -o DPkg::Lock::Timeout=600\n"
        "  - DEBIAN_FRONTEND=noninteractive apt-get install -y -o DPkg::Lock::Timeout=600 docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin\n"
        "  - systemctl enable --now docker\n"
    )


def hetzner_create_server(
    name: str,
    server_type: str,
    location: str,
    user_data: str,
    token: str,
    ssh_keys: list,
) -> dict:
    """POST /servers (pure allocation; the caller owns cleanup of a half-provisioned
    server). ssh_keys (ids/names) are injected into root by Hetzner. Returns the
    parsed create response (server.id, server.public_net.ipv4.ip)."""
    body: dict = {
        "name": name,
        "server_type": server_type,
        "location": location,
        "image": HETZNER_IMAGE,
        "start_after_create": True,
        "public_net": {"enable_ipv4": True, "enable_ipv6": True},
        "user_data": user_data,
        "ssh_keys": ssh_keys,
    }
    _status, resp = _hcloud_request("POST", "/servers", token, body, timeout=30.0)
    return resp


def hetzner_ensure_ssh_key(name: str, pubkey: str, token: str) -> tuple[int, bool]:
    """Ensure `pubkey` exists as a Hetzner project SSH key and return (id, created_by_us).
    Reuses an existing key with the same public key (so we never delete a key the
    operator uses elsewhere); otherwise uploads it under `name` (replacing a stale
    same-named key first). Injecting via the API is what actually authorizes root —
    cloud-init's ssh_authorized_keys does not on this image."""
    want = pubkey.split()[:2]  # match on type + base64, ignore the trailing comment
    _status, existing = _hcloud_request("GET", "/ssh_keys", token)
    keys = existing.get("ssh_keys", [])
    for k in keys:
        if k.get("public_key", "").split()[:2] == want:
            return int(k["id"]), False
    for k in keys:  # drop a stale key with our name (e.g. a prior failed run)
        if k.get("name") == name:
            _hcloud_request("DELETE", f"/ssh_keys/{k['id']}", token)
    _status, resp = _hcloud_request(
        "POST", "/ssh_keys", token, {"name": name, "public_key": pubkey}
    )
    return int(resp["ssh_key"]["id"]), True


def hetzner_delete_ssh_key(key_id: int, token: str) -> None:
    """DELETE /ssh_keys/<id>, idempotent + best-effort (only for keys we uploaded)."""
    try:
        _hcloud_request("DELETE", f"/ssh_keys/{key_id}", token)
    except Fatal as e:
        if "not_found" not in str(e):
            warn(f"could not delete Hetzner ssh key {key_id}: {e}")


def hetzner_get_server(server_id: int, token: str) -> dict:
    _status, resp = _hcloud_request("GET", f"/servers/{server_id}", token)
    return resp.get("server", {})


def hetzner_find_server_by_name(name: str, token: str) -> dict | None:
    """Best-effort lookup by exact name — used to reconcile a server that a POST
    created but whose response was lost, so we can still delete it (no orphan)."""
    try:
        _status, resp = _hcloud_request("GET", f"/servers?name={name}", token)
    except Fatal:
        return None
    servers = resp.get("servers") or []
    return servers[0] if servers else None


def hetzner_delete_server(server_id: int, token: str, *, strict: bool = False) -> None:
    """DELETE /servers/<id>, idempotent (404 => already gone). Best-effort by
    default so a failed *rollback* (cleanup_on_failure) never masks the original
    error; but strict=True on the deliberate `host rm --destroy` teardown RAISES on
    a non-404 failure, so the caller keeps the host registered for a retry rather
    than silently orphaning a live billable server (SC-009)."""
    try:
        _hcloud_request("DELETE", f"/servers/{server_id}", token, timeout=30.0)
    except Fatal as e:
        if "not_found" in str(e):
            return
        if strict:
            raise
        warn(f"could not delete Hetzner server {server_id}: {e}")


def remove_automation_key(name: str) -> None:
    """Delete the local file-based automation keypair for host `name`."""
    key = automation_key_path(name)
    key.unlink(missing_ok=True)
    (key.parent / f"{key.name}.pub").unlink(missing_ok=True)


def cleanup_on_failure(
    server_id: int | None,
    token: str,
    *,
    ssh_key_ids: tuple[int | None, ...] = (),
    name: str | None = None,
) -> None:
    """Destroy a half-provisioned server (and any keys we uploaded, plus the local
    automation key + its tunnel) so a failed `host add` leaves nothing billable or
    dangling (FR-011). No-op for anything not allocated yet."""
    if server_id is not None:
        warn(f"cleaning up half-provisioned Hetzner server {server_id}")
        hetzner_delete_server(server_id, token)
    for key_id in ssh_key_ids:
        if isinstance(key_id, int):
            hetzner_delete_ssh_key(key_id, token)
    if name:
        _close_tunnel(name)
        remove_automation_key(name)


def seed_known_hosts(ip: str) -> None:
    """Pin the new server's host key so `docker context` over ssh:// and `attach`
    connect non-interactively. First drop any stale key for this IP — Hetzner
    recycles public IPv4s, and a stale entry makes ssh refuse the new host
    (REMOTE HOST IDENTIFICATION HAS CHANGED), which would hang the docker poll."""
    kh = Path.home() / ".ssh" / "known_hosts"
    kh.parent.mkdir(mode=0o700, exist_ok=True)
    query(["ssh-keygen", "-R", ip])  # remove any recycled/stale key + dedup
    scan = query(["ssh-keyscan", "-t", "ed25519", "-T", "10", ip])
    if scan.returncode == 0 and scan.stdout:
        with kh.open("a") as f:
            f.write(scan.stdout)


def docker_context_name(name: str) -> str:
    return f"{CONTAINER_PREFIX}{name}"  # agent-container-<name>, mirrors the project name


def docker_context_create(ctx: str, endpoint: str) -> None:
    """Create (idempotently) a named local docker context pointing at an ssh://
    endpoint, so `docker --context <ctx> compose` (which honors --context) builds/
    runs on the server. `endpoint` is a full docker host, e.g. ssh://root@1.2.3.4."""
    query(["docker", "context", "rm", "-f", ctx])  # tolerate a stale one
    r = query(["docker", "context", "create", ctx, "--docker", f"host={endpoint}"])
    if r.returncode != 0:
        die(f"could not create docker context {ctx}: {r.stderr.strip()}")


def _docker_ctx_ready(ctx: str) -> tuple[bool, str]:
    """`docker --context <ctx> version` exits 0, with a HARD timeout so a first-
    connect ssh host-key prompt can never hang the poll forever (BatchMode is not
    guaranteed on docker's ssh helper; known_hosts is seeded first as the primary
    guard, this is the backstop). Returns (ready, why) — `why` carries the last
    failure reason (ssh host-key/auth vs. daemon-not-up) so a poll timeout can say
    what actually kept failing instead of a blind 'never became reachable'."""
    try:
        r = subprocess.run(
            ["docker", "--context", ctx, "version"],
            capture_output=True,
            text=True,
            stdin=subprocess.DEVNULL,
            timeout=20,
        )
        if r.returncode == 0:
            return True, ""
        return False, (r.stderr.strip() or r.stdout.strip() or f"exit {r.returncode}")
    except subprocess.TimeoutExpired:
        return False, "docker version timed out after 20s"
    except OSError as e:
        return False, f"docker invocation failed: {e}"


# --- provisioned-host automation identity + ssh socket-forward ---------------
# A provisioned host authorizes a dedicated, FILE-based automation key alongside
# the operator key. All of the tool's docker traffic runs over an ssh LOCAL-socket
# forward that presents that key with every ssh option passed as a command-line
# arg (-i / -o …) — so it signs unattended regardless of the operator's ~/.ssh/
# config or agent (e.g. a 1Password key that needs interactive approval per use).
# The operator key stays authorized for interactive `attach`. The docker context
# for such a host points at unix://<sock> (not ssh://), so every existing
# `docker --context` deploy/ps/down path is unchanged — only the sock must be live.

_TUNNELS: dict[str, subprocess.Popen] = {}


def host_name_from_context(ctx: str) -> str:
    return ctx.removeprefix(CONTAINER_PREFIX)


def automation_key_path(name: str) -> Path:
    """File-based ed25519 automation private key for host `name` (0600)."""
    return host_state_dir(name) / "automation_key"


def _socket_base() -> Path:
    """Short, user-private base dir for the docker forward sockets. NOT under
    STATE_DIR: a unix socket's sun_path is capped (~104 bytes on macOS) and a deep
    XDG_STATE_HOME (e.g. a pytest tmp dir) would overflow it. Prefer XDG_RUNTIME_DIR
    (the standard socket home on Linux); else a per-uid dir under the system tmp."""
    rt = os.environ.get("XDG_RUNTIME_DIR")
    base = Path(rt) if rt else Path(tempfile.gettempdir()) / f"agent-container-{os.getuid()}"
    base.mkdir(mode=0o700, parents=True, exist_ok=True)
    return base


def daemon_socket_path(name: str) -> Path:
    """Local unix socket the docker context connects to (hashed to a short name)."""
    h = hashlib.sha256(name.encode()).hexdigest()[:12]
    return _socket_base() / f"ac-{h}.sock"


def generate_automation_key(name: str) -> str:
    """Create (idempotently) the automation keypair and return its public key."""
    key = automation_key_path(name)
    key.parent.mkdir(parents=True, exist_ok=True)
    if not key.exists():
        query(
            ["ssh-keygen", "-t", "ed25519", "-N", "", "-q",
             "-C", f"agent-container-{name}-automation", "-f", str(key)]
        )  # fmt: skip
        key.chmod(0o600)
    return (key.parent / f"{key.name}.pub").read_text().strip()


def _forward_argv(ip: str, key: Path, sock: Path) -> list[str]:
    """ssh LOCAL-socket forward to the remote docker daemon — every option a literal
    CLI arg (no ~/.ssh/config, no agent): the automation key only, agent disabled,
    host key auto-pinned. `-N` (no remote shell); run foreground so we own the pid."""
    kh = Path.home() / ".ssh" / "known_hosts"
    return [
        shutil.which("ssh") or "ssh",
        "-i", str(key),
        "-o", "IdentitiesOnly=yes",
        "-o", "IdentityAgent=none",
        "-o", "StrictHostKeyChecking=accept-new",
        "-o", f"UserKnownHostsFile={kh}",
        "-o", "ExitOnForwardFailure=yes",
        "-o", "ServerAliveInterval=15",
        "-o", "BatchMode=yes",
        "-N", "-L", f"{sock}:/var/run/docker.sock", f"root@{ip}",
    ]  # fmt: skip


def _close_tunnel(name: str) -> None:
    proc = _TUNNELS.pop(name, None)
    if proc and proc.poll() is None:
        proc.terminate()
        try:
            proc.wait(timeout=5)
        except subprocess.TimeoutExpired:
            proc.kill()
    daemon_socket_path(name).unlink(missing_ok=True)


atexit.register(lambda: [_close_tunnel(n) for n in list(_TUNNELS)])


def _start_tunnel(name: str, ip: str) -> subprocess.Popen:
    """(Re)start the docker socket-forward for host `name`; idempotent — a running
    tunnel is reused. A dead one (auth not yet injected during cloud-init) is
    replaced so the readiness poll self-heals."""
    proc = _TUNNELS.get(name)
    if proc and proc.poll() is None:
        return proc
    sock = daemon_socket_path(name)
    sock.parent.mkdir(parents=True, exist_ok=True)
    sock.unlink(missing_ok=True)
    proc = subprocess.Popen(
        _forward_argv(ip, automation_key_path(name), sock),
        stdin=subprocess.DEVNULL,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    _TUNNELS[name] = proc
    return proc


def ensure_tunnel(host: dict, *, required: bool = False) -> None:
    """For a provisioned (ssh-forward) host, ensure its docker socket-forward is up
    for the life of this process, waiting briefly for the socket to bind. No-op for
    local / user-supplied-context / existing-ssh hosts. With required=True (the
    teardown safety path), DIE if the forward cannot be established within the
    deadline — a down tunnel must never be mistaken for a reachable-but-empty
    daemon (SC-005)."""
    prov = host.get("provisioning") or {}
    if prov.get("connection") != "ssh-forward":
        return
    name = host_name_from_context(host.get("context") or "")
    ip = host.get("address")
    if not name or not ip:
        return
    sock = daemon_socket_path(name)
    proc = _start_tunnel(name, ip)
    deadline = time.monotonic() + 15
    while time.monotonic() < deadline:
        if sock.exists():
            return
        if proc.poll() is not None:  # tunnel died — retry (idempotent)
            proc = _start_tunnel(name, ip)
        time.sleep(0.3)
    if required:
        die(
            f"could not establish the docker socket-forward to host '{name}' ({ip}); "
            f"refusing to proceed"
        )


def wait_until_reachable(
    ip: str,
    ctx: str,
    *,
    ssh_timeout: float = 150.0,
    docker_timeout: float = 420.0,
    poll_interval: float = 5.0,
) -> None:
    """Two independent budgets: SSH port open (cloud-init authorized the key), THEN
    — with its own fresh, larger budget — `docker version` succeeds over the socket-
    forward (docker install from cloud-init finishes, minutes on a small node). A
    shared deadline would starve the docker phase when boot is slow. Both budgets
    are env-tunable (AGENT_CONTAINER_HZ_SSH_TIMEOUT / _DOCKER_TIMEOUT). The forward
    is (re)established each poll: early on the automation key may not be injected
    yet, so the tunnel dies and is retried until auth succeeds."""
    ssh_timeout = float(os.environ.get("AGENT_CONTAINER_HZ_SSH_TIMEOUT", ssh_timeout))
    docker_timeout = float(os.environ.get("AGENT_CONTAINER_HZ_DOCKER_TIMEOUT", docker_timeout))
    name = host_name_from_context(ctx)
    ssh_deadline = time.monotonic() + ssh_timeout
    while time.monotonic() < ssh_deadline:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.settimeout(5)
            if s.connect_ex((ip, 22)) == 0:
                break
        time.sleep(poll_interval)
    else:
        die(f"server {ip}: SSH never became reachable within {ssh_timeout:.0f}s")
    seed_known_hosts(ip)
    docker_deadline = time.monotonic() + docker_timeout
    last_why = "(no probe completed)"
    while time.monotonic() < docker_deadline:
        _start_tunnel(name, ip)  # (re)establish; heals a tunnel that died pre-auth
        ready, last_why = _docker_ctx_ready(ctx)
        if ready:
            return
        time.sleep(poll_interval)
    die(
        f"server {ip}: docker never became reachable within {docker_timeout:.0f}s "
        f"(cloud-init?); last probe error: {last_why}"
    )


def provisioner_create(
    name: str,
    server_type: str,
    location: str,
    operator_pubkey: str,
    extra_ssh_key: str | None,
    token: str,
) -> dict:
    """Authorize the operator key via Hetzner's ssh_keys API (injected into root),
    allocate a server, wait until docker is up, register a local docker context, and
    return the Host record. On any post-allocation failure, destroys the server AND
    any key we uploaded (no orphaned billable server / dangling key) and dies."""
    user_data = hetzner_build_user_data()
    ctx = docker_context_name(name)
    sock = daemon_socket_path(name)
    automation_pub = generate_automation_key(name)
    server_id: int | None = None
    our_op_key: int | None = None  # operator key — only if WE uploaded it
    auto_key_id: int | None = None  # automation key — always ours, always cleaned up
    # ONE guard around key upload, allocation AND everything after. BaseException
    # (not just Fatal) so a Ctrl-C during the multi-minute wait, a missing docker/
    # ssh binary (OSError), or a decode error still tears everything down (FR-011).
    try:
        op_key_id, created = hetzner_ensure_ssh_key(ctx, operator_pubkey, token)
        our_op_key = op_key_id if created else None
        auto_key_id, _ = hetzner_ensure_ssh_key(f"{ctx}-automation", automation_pub, token)
        # Authorize BOTH on root: the automation key for the tool's unattended docker
        # socket-forward, the operator key for interactive `attach`.
        ssh_keys: list = [op_key_id, auto_key_id] + ([extra_ssh_key] if extra_ssh_key else [])
        resp = hetzner_create_server(name, server_type, location, user_data, token, ssh_keys)
        server = resp.get("server", {})
        sid = server.get("id")
        if not isinstance(sid, int):
            # Redacted: the create response can carry a generated root_password.
            die(f"Hetzner create returned no server id (response keys: {sorted(resp)})")
        server_id = sid
        ip = (server.get("public_net", {}).get("ipv4") or {}).get("ip")
        deadline = time.monotonic() + 120
        while not ip and time.monotonic() < deadline:
            time.sleep(3)
            srv = hetzner_get_server(server_id, token)  # transient GET failures retry internally
            ip = (srv.get("public_net", {}).get("ipv4") or {}).get("ip")
        if not ip:
            die(f"server {server_id} never reported a public IPv4")
        log(f"server {server_id} allocated at {ip}; waiting for docker (cloud-init)…")
        # Context targets the local forwarded socket, not ssh:// — the tool owns the
        # ssh invocation (automation key, no agent) via the _forward_argv tunnel.
        docker_context_create(ctx, f"unix://{sock}")
        wait_until_reachable(ip, ctx)
    except BaseException:
        # A POST may have created a server whose response we never saw — find it by
        # name so it is not orphaned, then delete server + the keys we uploaded, the
        # local automation key, the tunnel and the context.
        if server_id is None:
            found = hetzner_find_server_by_name(name, token)
            server_id = found.get("id") if isinstance(found, dict) else None
        cleanup_on_failure(server_id, token, ssh_key_ids=(our_op_key, auto_key_id), name=name)
        query(["docker", "context", "rm", "-f", ctx])
        raise
    return {
        "driver": "docker",
        "context": ctx,
        "address": ip,
        "provisioning": {
            "provider": "hetzner",
            "server_id": server_id,
            "server_type": server_type,
            "location": location,
            "connection": "ssh-forward",
            "ssh_key_id": our_op_key,  # operator key, only if WE uploaded it (destroy removes it)
            "automation_ssh_key_id": auto_key_id,  # always ours; destroy removes it
            "created": True,
        },
        "created_by_tool": True,
    }


def provisioner_destroy(host: dict, token: str) -> None:
    """Deprovision: delete the server and remove the local docker context. Refuses
    a host this tool did not create (defense in depth for the future US3 caller;
    FR-010 — never destroy infrastructure we did not allocate)."""
    if not host.get("created_by_tool"):
        die("refusing to deprovision a server this tool did not create")
    prov = host.get("provisioning") or {}
    server_id = prov.get("server_id")
    if isinstance(server_id, int):
        # strict: a failed server delete must propagate so the caller keeps the
        # host registered for a retry (never orphan a live billable server). The
        # key/context cleanup below is best-effort and re-runs idempotently.
        hetzner_delete_server(server_id, token, strict=True)
    # Both keys we uploaded; a reused operator key (ssh_key_id is None) is left alone.
    for key_id in (prov.get("ssh_key_id"), prov.get("automation_ssh_key_id")):
        if isinstance(key_id, int):
            hetzner_delete_ssh_key(key_id, token)
    ctx = host.get("context")
    name = host_name_from_context(ctx) if isinstance(ctx, str) else ""
    if name:
        _close_tunnel(name)
        remove_automation_key(name)
    if isinstance(ctx, str) and ctx.startswith(CONTAINER_PREFIX):
        query(["docker", "context", "rm", "-f", ctx])


def provision_host(
    provider: str,
    name: str,
    *,
    server_type: str | None,
    location: str | None,
    ssh_key: str | None,
    ssh_pubkey: Path | None,
) -> dict:
    """Dispatch provisioning to the provider (only 'hetzner' today) and return the
    Host record for host `name`. Token read once from env; billable allocation only."""
    if provider != "hetzner":
        die(f"unknown --provider '{provider}' (only 'hetzner' is supported)")
    if not HETZNER_NAME_RE.fullmatch(name):
        die(
            f"'{name}' is not a valid cloud server name (RFC-1123: lowercase letters, "
            "digits, hyphens; no underscore; <=63 chars)"
        )
    token = _hcloud_token()
    operator_pubkey = resolve_operator_pubkey(ssh_pubkey)
    return provisioner_create(
        name,
        server_type or HETZNER_DEFAULT_SERVER_TYPE,
        location or HETZNER_DEFAULT_LOCATION,
        operator_pubkey,
        ssh_key,
        token,
    )


# --- driver seam (Feature 001) -----------------------------------------------
# A driver abstracts build/run/connect on a host so local and remote share one
# path. Pure argv builders (no runtime needed) — the docker/podman asymmetry is
# absorbed here: docker targets `--context`, podman targets `--connection`. An
# 'existing-ssh' host is attach-only and rejects container operations.


def driver_runtime_argv(host: dict) -> list[str]:
    """Base argv targeting a host's runtime.

    >>> driver_runtime_argv({"driver": "docker", "context": "lima"})
    ['docker', '--context', 'lima']
    >>> driver_runtime_argv({"driver": "podman", "context": "vps"})
    ['podman', '--connection', 'vps']
    >>> driver_runtime_argv({"driver": "docker", "context": ""})
    ['docker']
    """
    driver = host.get("driver")
    ctx = host.get("context") or ""
    if driver == "docker":
        return ["docker", "--context", ctx] if ctx else ["docker"]
    if driver == "podman":
        return ["podman", "--connection", ctx] if ctx else ["podman"]
    die(f"host driver '{driver}' is attach-only; it cannot build or run containers")


# --- sidecar / helper services (Feature 002 US4, research R5) -----------------
# A deployment may declare helper services in an operator-supplied compose
# OVERRIDE file, discovered by convention next to the .env. When present it is
# merged as a SECOND `-f` into every compose invocation for that deployment, so
# the agent and its helpers share one project and one lifecycle (FR-004). This is
# a deliberately thin, file-based seam — the richer whole-directory model is
# Feature 006 (agent-as-code), which builds ON this primitive.

# The tool owns the agent service's identity: its service key, plus the top-level
# project name / named volumes / injected configs. An override MUST NOT redefine
# any of these — it may only ADD helper services (services-only fragment).
AGENT_SERVICE_KEY = "agent"
SIDECAR_ALLOWED_TOPLEVEL = {"services", "version"}  # version: tolerated (compose-deprecated)


def sidecar_override_candidates(name: str, cwd: Path) -> list[Path]:
    """Discovery order, mirroring the env chain: project config directory first,
    then the per-name user config (Feature 011).

    >>> [p.name for p in sidecar_override_candidates("acme", Path("/nonexistent-xyz"))]
    ['acme.services.yaml']
    """
    pcd = project_config_dir(cwd)
    project = [pcd / f"{name}.services.yaml"] if pcd is not None else []
    return [*project, CONFIG_DIR / f"{name}.services.yaml"]


def resolve_sidecar_override(name: str) -> Path | None:
    """The first existing sidecar override for <name>, validated, or None. A
    present-but-invalid override is fatal (FR-018) — never silently ignored."""
    for candidate in sidecar_override_candidates(name, Path.cwd()):
        if candidate.is_file():
            validate_sidecar_override(candidate)
            return candidate
    return None


def _yaml_document(text: str) -> dict:
    """Parse an override document with a REAL parser. `yaml.safe_load` only — never
    `yaml.load` (an untrusted `!!python/...` tag must never construct an object),
    and never a regex scan.

    This replaced two column-0 regex scanners that silently returned NOTHING for
    flow style (`services: {agent: {...}}`) and for quoted keys (`"agent":`). Both
    forms therefore passed validation, and since the override rides as the second
    `-f` it WINS the compose merge — so an operator could redefine the agent
    service, or set `agent.environment.NO_PROXY`, past a guard that reported no
    problem. A scanner only recognises the shape it was written for; the guard
    passed while the thing it named was broken.

    Returns {} for anything that is not a mapping, so callers can treat "no keys"
    and "not a mapping" alike — compose does the authoritative parse either way.

    >>> sorted(_yaml_document("services: {agent: {image: x}}"))
    ['services']
    >>> list(_yaml_document('services:\\n  "agent":\\n    image: x\\n')["services"])
    ['agent']
    >>> _yaml_document("- not a mapping")
    {}
    """
    import yaml

    try:
        obj = yaml.safe_load(text)
    except yaml.YAMLError:
        return {}
    return obj if isinstance(obj, dict) else {}


def _yaml_toplevel_keys(text: str) -> list[str]:
    """Top-level mapping keys of a YAML/JSON document. JSON is a YAML subset, so
    both parse through the same path.

    >>> _yaml_toplevel_keys("services:\\n  a: {}\\nvolumes: {}\\n")
    ['services', 'volumes']
    """
    return [str(k) for k in _yaml_document(text)]


def _yaml_service_keys(text: str) -> list[str]:
    """Service keys declared under a top-level `services:` mapping.

    >>> _yaml_service_keys("services: {agent: {image: x}}")     # flow style
    ['agent']
    >>> _yaml_service_keys('services:\\n  "agent": {}\\n')        # quoted key
    ['agent']
    >>> _yaml_service_keys("services:\\n  redis: {}\\n  db: {}\\n")
    ['redis', 'db']
    """
    svc = _yaml_document(text).get("services")
    return [str(k) for k in svc] if isinstance(svc, dict) else []


# Capabilities a sidecar must not hold once it shares the agent's network
# namespace. NET_ADMIN is the fatal one: a sidecar holding it can FLUSH THE
# IPTABLES RULES of the namespace it shares, and the agent does not need the
# capability itself — it only needs to ask something that has it. `privileged`
# grants it and more. `network_mode: host` leaves the namespace altogether, which
# is the boundary simply not applying.
EGRESS_FORBIDDEN_SIDECAR_CAPS = ("NET_ADMIN", "NET_RAW", "SYS_ADMIN", "ALL")


def check_sidecar_egress_posture(path: Path, egress: object) -> None:
    """Refuse a sidecar that could dismantle the boundary it sits inside (FR-023d).

    `validate_sidecar_override` checks SHAPE — services-only, does not redefine
    the agent. That was cosmetic before this feature and is security-relevant
    after: a helper service is now inside the enforcement namespace, so what it is
    permitted to hold is part of the security model.

    Only applies to sidecars INSIDE the boundary. One deliberately placed outside
    (`sidecars_outside`) is already declared as unconstrained and named as such —
    refusing its capabilities too would be theatre, since it is not in the
    namespace to dismantle.
    """
    if not is_egress_declared(egress):
        return
    try:
        doc = _yaml_document(path.read_text())
    except OSError:
        return
    services = doc.get("services")
    if not isinstance(services, dict):
        return
    outside = set(sidecars_outside_boundary(egress))
    for name, spec in services.items():
        if str(name) in outside or not isinstance(spec, dict):
            continue
        if spec.get("privileged"):
            die(
                f"sidecar override {path}: service '{name}' is `privileged` and would "
                f"share the agent's network namespace, so it could flush the egress "
                f"rules — and the agent only has to ask it. Declare it in "
                f"`egress.sidecars_outside` if it genuinely needs its own privileges; "
                f"it will then be named as outside the boundary."
            )
        caps = spec.get("cap_add")
        held = [str(c).upper().removeprefix("CAP_") for c in caps] if isinstance(caps, list) else []
        bad = sorted(set(held) & set(EGRESS_FORBIDDEN_SIDECAR_CAPS))
        if bad:
            die(
                f"sidecar override {path}: service '{name}' requests {bad}, which would "
                f"let it reprogram the network namespace it shares with the agent. The "
                f"agent needs no capability of its own to use it — only to ask. Move the "
                f"service to `egress.sidecars_outside` if that is intended."
            )
        if str(spec.get("network_mode") or "") == "host":
            die(
                f"sidecar override {path}: service '{name}' uses `network_mode: host`, "
                f"which leaves the enforcement namespace entirely — the boundary would "
                f"simply not apply to it while the declaration read as enforced."
            )


def validate_sidecar_override(path: Path) -> None:
    """Guardrail on an operator sidecar override (FR-004/FR-018): it must be a
    non-empty compose `services:`-only fragment and MUST NOT redefine the agent
    service (whose identity — name/port/the 7 volumes — the tool owns). Deeper
    malformations are left to compose's own parse at run time; here we reject the
    two footguns with an actionable message rather than silently merging them."""
    try:
        text = path.read_text()
    except OSError as e:
        die(f"sidecar override {path} is unreadable: {e}")
    if not text.strip():
        die(f"sidecar override {path} is empty (declare helper services under `services:`)")
    toplevel = _yaml_toplevel_keys(text)
    if "services" not in toplevel:
        die(f"sidecar override {path} has no `services:` block (it must add helper services)")
    extra = [k for k in toplevel if k not in SIDECAR_ALLOWED_TOPLEVEL]
    if extra:
        die(
            f"sidecar override {path} may only declare `services:` (a services-only "
            f"fragment); remove tool-owned/unsupported top-level key(s): {', '.join(sorted(extra))}"
        )
    if AGENT_SERVICE_KEY in _yaml_service_keys(text):
        die(
            f"sidecar override {path} must not redefine the '{AGENT_SERVICE_KEY}' service "
            f"(the tool owns its name, port, and volumes); name your helper differently"
        )


def driver_compose_argv(
    host: dict,
    project: str,
    file: Path,
    *args: str,
    override: Path | None = None,
    boundary: Path | None = None,
) -> list[str]:
    """`<runtime> compose -p <project> -f <file> [-f <override>] [-f <boundary>] …`.

    THREE LAYERS, AND THE ORDER IS THE POINT. The generated file first; the
    operator's sidecar override second, so their helpers fold into the same
    project (US4); and — when a declaration is enforced — the tool's BOUNDARY
    OVERLAY last, placing each sidecar in the egress namespace.

    The overlay must come after the override, or an operator's `network_mode`
    would win and their sidecar would sit outside the boundary while the
    declaration said otherwise. It is a separate file rather than an edit to
    theirs because the override is operator-owned: the tool reads it, never
    rewrites it.

    >>> from pathlib import Path
    >>> driver_compose_argv({"driver": "docker", "context": "lima"}, "agent-container-acme", Path("/s/acme.compose.yaml"), "up", "-d")
    ['docker', '--context', 'lima', 'compose', '-p', 'agent-container-acme', '-f', '/s/acme.compose.yaml', 'up', '-d']
    >>> driver_compose_argv({"driver": "docker", "context": "lima"}, "p", Path("/s/a.yaml"), "up", override=Path("/s/side.yaml"))
    ['docker', '--context', 'lima', 'compose', '-p', 'p', '-f', '/s/a.yaml', '-f', '/s/side.yaml', 'up']
    >>> driver_compose_argv({"driver": "docker", "context": ""}, "p", Path("/a.yaml"), "up", override=Path("/s.yaml"), boundary=Path("/b.yaml"))[-7:]
    ['-f', '/a.yaml', '-f', '/s.yaml', '-f', '/b.yaml', 'up']
    """
    files = ["-f", str(file)]
    if override:
        files += ["-f", str(override)]
    if boundary:
        files += ["-f", str(boundary)]
    return driver_runtime_argv(host) + ["compose", "-p", project, *files, *args]


def sidecars_inside_boundary(override: Path | None, egress: object) -> list[str]:
    """The operator sidecars that will share the egress network namespace.

    One place, because the overlay that does the PLACING and the checks that
    DESCRIBE it (T149) must answer identically. A second spelling would eventually
    disagree, and the disagreement an operator would notice is a service they
    believe is constrained.

    Empty when nothing is declared or there is no override — not "every service",
    which is what a caller reading the file directly would get for an undeclared
    environment where no sidecar is placed anywhere.
    """
    if override is None or not is_egress_declared(egress):
        return []
    try:
        services = _yaml_service_keys(override.read_text())
    except OSError:
        return []
    outside = set(sidecars_outside_boundary(egress))
    return [svc for svc in services if svc not in outside]


def build_sidecar_boundary_overlay(override: Path | None, egress: object) -> dict | None:
    """A compose fragment placing each operator sidecar in the egress namespace.

    ANY SIDECAR THE AGENT CAN REACH THAT HAS FREE EGRESS **IS** A BYPASS. The
    agent need not escape anything — it need only ask something that already has
    the access: `redis REPLICAOF <host> <port>`, `postgres COPY … FROM PROGRAM`,
    or any service that fetches a URL on request. Locking the agent down while
    leaving sidecars outside produces an environment reporting `enforced: true`
    while two lines of redis walk straight out.

    So the default is INSIDE, and `sidecars_outside` is the deliberate exception
    (FR-023a) — verified to name real services before we get here.

    Returns None when there is nothing to place, so no third `-f` is emitted and
    the argv for an environment without sidecars is unchanged.
    """
    inside = sidecars_inside_boundary(override, egress)
    if not inside:
        return None
    return {
        "services": {
            svc: {
                "network_mode": f"service:{EGRESS_SERVICE_KEY}",
                # `service_healthy` for the same reason as the agent: a sidecar
                # placed inside the boundary and started before it serves gets
                # bare connection refusals for everything, declared or not.
                "depends_on": {EGRESS_SERVICE_KEY: {"condition": "service_healthy"}},
            }
            for svc in inside
        }
    }


def driver_up_argv(
    host: dict,
    project: str,
    file: Path,
    override: Path | None = None,
    foreground: bool = False,
    boundary: Path | None = None,
) -> list[str]:
    """Bring the deployment up. Detached by default (`-d`). A headless FOREGROUND
    run (Feature 004) instead runs ATTACHED with `--abort-on-container-exit
    --exit-code-from agent` so `compose up`'s own exit status is the agent
    container's exit code (FR-002/SC-004) — without which compose returns 0
    regardless of the workload. Sidecar caveat (US4): --abort-on-container-exit
    stops every service when any one exits, so a headless-foreground deployment's
    sidecars must be long-lived, not one-shot."""
    if foreground:
        return driver_compose_argv(
            host,
            project,
            file,
            "up",
            "--build",
            "--abort-on-container-exit",
            "--exit-code-from",
            "agent",
            override=override,
            boundary=boundary,
        )
    return driver_compose_argv(
        host, project, file, "up", "-d", "--build", override=override, boundary=boundary
    )


def driver_redeploy_argv(
    host: dict,
    project: str,
    file: Path,
    override: Path | None = None,
    boundary: Path | None = None,
) -> list[str]:
    """Deliberately non-idempotent (FR-010): rebuild the image and recreate the
    container even with no change. Named volumes are external-by-name so they are
    re-attached, not recreated (FR-008)."""
    return driver_compose_argv(
        host,
        project,
        file,
        "up",
        "-d",
        "--build",
        "--force-recreate",
        override=override,
        boundary=boundary,
    )


def driver_stop_argv(
    host: dict, project: str, file: Path, override: Path | None = None
) -> list[str]:
    return driver_compose_argv(host, project, file, "stop", override=override)


def driver_start_argv(
    host: dict, project: str, file: Path, override: Path | None = None
) -> list[str]:
    return driver_compose_argv(host, project, file, "start", override=override)


def driver_down_argv(
    host: dict,
    project: str,
    file: Path,
    purge: bool = False,
    rmi_local: bool = False,
    override: Path | None = None,
) -> list[str]:
    # --remove-orphans is on DOWN ONLY, and it is load-bearing. Reproduced: drop an
    # `egress:` declaration, regenerate, `up --force-recreate` — the old proxy keeps
    # running, and a subsequent `down` with the new file leaves it Up, fails to remove
    # the project network, and STILL EXITS 0. The stranded container carries
    # `restart: unless-stopped` and is invisible to `list`, to every wizard picker and
    # to assert_host_empty, so nothing would ever mention it again.
    # NOT on up/redeploy: there it would destroy an operator's own helper services
    # from <name>.services.yaml, which are legitimately not in the generated file.
    args = ["down", "--remove-orphans"]
    if purge:
        args.append("--volumes")
    if rmi_local:  # wipe also removes the locally-built image (never a public one)
        args += ["--rmi", "local"]
    return driver_compose_argv(host, project, file, *args, override=override)


# --- Feature 016: pending records travel as a TARBALL, not over a filesystem --
# The operator's machine and the container host share no filesystem — the host is
# typically a VPS — and once a detached run has exited there is no container for
# `docker cp` to copy from. So ingestion goes through the RUNTIME: a throwaway
# container mounts the runs volume and streams its contents to our stdout as a tar
# (research R10, measured). Only bytes cross the boundary, which is what lets one
# code path serve a local daemon and a remote context alike — and the remote case
# is the entire reason the mechanism exists.
#
# These are argv builders and nothing else, so the shape can be asserted without a
# runtime, exactly like the rest of the driver seam.

RUNS_INGEST_MOUNT = "/mnt"


def driver_ingest_argv(host: dict, volume: str, image: str) -> list[str]:
    """Stream a runs volume's contents to OUR stdout as a tar.

    `--entrypoint tar` is LOAD-BEARING, not tidiness. The agent image sets
    ENTRYPOINT to entrypoint.sh, so without it `tar cf - -C /mnt .` arrives as
    ARGUMENTS to the entrypoint: stdout then carries the entrypoint's own output,
    which parses as an empty archive and makes the drain report success having
    ingested nothing. A wrong answer that looks right, for every record.

    Read-only mount and `--network none`: this container reads a directory and
    exits. Neither write access nor a network would buy anything, and on a host
    with an egress declaration (Feature 012) a networked helper the tool starts
    behind the operator's back is the last thing this project should add.

    >>> driver_ingest_argv({"driver": "docker", "context": "vps"}, "ac-acme-runs", "img")
    ['docker', '--context', 'vps', 'run', '--rm', '--network', 'none', '-v', 'ac-acme-runs:/mnt:ro', '--entrypoint', 'tar', 'img', 'cf', '-', '-C', '/mnt', '.']
    """
    return driver_runtime_argv(host) + [
        "run",
        "--rm",
        "--network",
        "none",
        "-v",
        f"{volume}:{RUNS_INGEST_MOUNT}:ro",
        "--entrypoint",
        "tar",
        image,
        "cf",
        "-",
        "-C",
        RUNS_INGEST_MOUNT,
        ".",
    ]


def driver_ingest_clear_argv(
    host: dict, volume: str, image: str, filenames: list[str]
) -> list[str]:
    """Remove the NAMED records from the volume, once they are durably stored.

    Names the files instead of emptying the directory: a run that wrote a record
    between the tar read and this call has not been ingested, and a wildcard would
    delete it unread. That record is exactly the one nobody would ever notice was
    missing.

    `--entrypoint rm` with the paths as argv — no shell anywhere in the chain, so
    a metacharacter in a filename has nothing to be interpreted by. The names are
    also already validated against RUN_ID_RE before they reach here; this is the
    second of the two, because either alone is one refactor from being the only one.

    >>> driver_ingest_clear_argv({"driver": "podman", "context": ""}, "v", "img", ["a.json"])
    ['podman', 'run', '--rm', '--network', 'none', '-v', 'v:/mnt', '--entrypoint', 'rm', 'img', '-f', '--', '/mnt/a.json']
    """
    return driver_runtime_argv(host) + [
        "run",
        "--rm",
        "--network",
        "none",
        "-v",
        f"{volume}:{RUNS_INGEST_MOUNT}",
        "--entrypoint",
        "rm",
        image,
        "-f",
        "--",
        *[f"{RUNS_INGEST_MOUNT}/{f}" for f in filenames],
    ]


def driver_reachable_address(host: dict) -> str:
    """Address used for attach: the host's own address (localhost for local)."""
    return host.get("address") or "localhost"


# --- compose generation (Feature 001) ----------------------------------------
# The per-container deployment is a generated, inspectable compose project,
# emitted as JSON (a valid YAML subset) so no YAML dependency is needed. It
# declares the nine named volumes and expresses injected SSH identity as compose
# `secrets` (private host key) / `configs` (public authorized_keys) referencing
# LOCAL files, so the material transfers over a remote context (a bind would
# resolve empty on the remote). No secret value is ever written inline — only
# `file:` references. Regenerated from parameters on every up (derived artifact).


def compose_project(name: str) -> str:
    """
    >>> compose_project("acme")
    'agent-container-acme'
    """
    return container_name(name)


_WORKSPACE_DEFAULT = object()  # sentinel: "use the default persistent workspace mount"


def build_compose_model(
    name: str,
    build_context: Path | str,
    authorized_keys_file: Path | None = None,
    env_file: Path | list[Path] | None = None,
    extra_mounts: list[str] | None = None,
    injected_configs: list[tuple[str, Path, str]] | None = None,
    *,
    restart: str = "unless-stopped",
    environment: dict[str, str] | None = None,
    workspace_mount: object = _WORKSPACE_DEFAULT,
    declare_workspace_volume: bool = True,
    egress_filter_body: str | None = None,
    egress_unbound_body: str | None = None,
    egress_ports_body: str | None = None,
) -> dict:
    """Build the compose model for container <name>. Pure: embeds paths as
    strings, reads nothing. authorized_keys_file is a LOCAL staged
    files referenced via compose secrets/configs (never inlined). env_file is
    read client-side by compose and merged into the service environment;
    extra_mounts are already-resolved '<abs-host>:<container>' bind specs.
    injected_configs (Feature 003) is the generic extension point: each
    (config-name, local staged file, in-container target) is emitted as a compose
    config the same portable way as the SSH material — the credentialing stories
    (push key + known_hosts, model/API keys, canonical config) all feed it, so no
    per-material param proliferates. Ephemeral-secret targets live under /run
    (never a persistent volume, FR-012); canonical config targets INJECT_CONFIG_DIR.

    NOTE: the exact in-container mount path for compose secrets vs configs (an
    absolute `target`) is validated in the acceptance tier (research R5); the
    entrypoint reads the INJECT_* paths.
    """
    port = port_for_name(name)
    # Feature 004: the /workspace mount is workspace-mode dependent. Default (and
    # every pre-004 caller) → the persistent named volume; ephemeral → nothing
    # mounted (None); bind → the caller's '<abs>:/workspace'. The other nine volumes
    # are always mounted.
    ws = (
        f"{volume_name(name)}:/workspace"
        if workspace_mount is _WORKSPACE_DEFAULT
        else workspace_mount
    )
    workspace_mounts = [ws] if ws is not None else []
    service: dict = {
        "container_name": container_name(name),
        "build": {"context": str(build_context)},
        "restart": restart,
        "ports": [f"{port}:2222"],
        "volumes": workspace_mounts + other_volume_mounts(name) + list(extra_mounts or []),
    }
    if egress_filter_body is not None:
        # Joining the egress namespace means the agent CANNOT publish ports — the
        # daemon refuses both together — so the binding moves there (above). The
        # agent gains no capability: `cap_add` is absent here deliberately, and
        # SC-011 asserts the set stays empty.
        service.pop("ports", None)
        service["network_mode"] = f"service:{EGRESS_SERVICE_KEY}"
        # `service_healthy`, not the list form: the list form waits only for the
        # container to be STARTED, which is true long before squid and unbound are
        # serving. This is what closes the window described on the healthcheck.
        service["depends_on"] = {EGRESS_SERVICE_KEY: {"condition": "service_healthy"}}
    env = dict(environment or {})  # Feature 004 non-secret settings (mode/agent/clone-url)
    if egress_filter_body is not None:
        # Feature 012. Lowercase variants too: curl, git and most HTTP clients read
        # `https_proxy`, not `HTTPS_PROXY`. Compose applies `environment:` OVER
        # `env_file:`, so these win over an operator env-file — which is what C6
        # requires of NO_PROXY precedence.
        # LOOPBACK, NOT THE SERVICE NAME. The agent shares this sidecar's network
        # namespace, so the proxy IS 127.0.0.1 — naming it `egress:` would make
        # the diagnostic layer depend on a DNS lookup that the allowlist itself
        # refuses (measured: curl exit 5, "couldn't resolve proxy"). A security
        # control whose own address needs permission from that control is a loop.
        target = f"http://127.0.0.1:{EGRESS_PORT}"
        env |= {
            "HTTPS_PROXY": target,
            "HTTP_PROXY": target,
            "https_proxy": target,
            "http_proxy": target,
            "NO_PROXY": EGRESS_NO_PROXY,
            "no_proxy": EGRESS_NO_PROXY,
        }
    if env:
        service["environment"] = env
    if env_file is not None:
        # Feature 011 FR-001d: one path or many. Compose applies `env_file:` in
        # order with LATER entries winning, which is exactly the stacking `-e`
        # promises — so ordering needs no logic here, only the list.
        files = [env_file] if isinstance(env_file, Path) else list(env_file)
        service["env_file"] = [str(f) for f in files]
    # The workspace named volume is declared ONLY in persistent mode (Constitution
    # IV refinement, FR-013): bind/ephemeral must NOT create it. The other nine are
    # always declared. `--purge`/`wipe` tolerate the workspace volume's absence.
    declared = (
        per_container_volumes(name) if declare_workspace_volume else other_container_volumes(name)
    )
    model: dict = {
        "name": compose_project(name),
        "services": {"agent": service},
        # Pin each volume's `name` so compose does NOT prefix it with the project
        # ("<project>_<vol>"); the volume names are the deterministic identity
        # contract (Constitution IV) that `--purge` and the completions rely on.
        "volumes": {vn: {"name": vn} for vn in declared},
    }
    # Injected SSH identity rides as compose `configs` (NOT `secrets`). Both the
    # host key and authorized_keys are delivered this way because a compose
    # `secret` with an ABSOLUTE target crash-loops the container on some docker
    # engines (observed on GitHub ubuntu runners; compose `configs` with an
    # absolute target are portable). The material is still handled as sensitive:
    # staged locally 0600, ephemeral (never persisted to a volume), never baked
    # into the image, never on argv (Constitution III — least exposure holds; the
    # secret-vs-config choice is a portability-forced compose mechanism detail).
    svc_configs: list[dict] = []
    model_configs: dict = {}
    if authorized_keys_file is not None:
        svc_configs.append({"source": "ssh_authorized_keys", "target": INJECT_AUTHORIZED_KEYS_PATH})
        model_configs["ssh_authorized_keys"] = {"file": str(authorized_keys_file)}
    # Feature 003 runtime-injected material — same portable compose-config channel.
    for cfg_name, local_file, target in injected_configs or []:
        svc_configs.append({"source": cfg_name, "target": target})
        model_configs[cfg_name] = {"file": str(local_file)}
    if egress_filter_body is not None:
        # A second service in the model the tool GENERATES — never the operator's
        # <name>.services.yaml, which is validated services-only and stays theirs.
        # No `ports:` (reachable only over the project network), no `volumes:` (the
        # per-container volume identity contract is untouched), no `env_file:` (an operator
        # env-file must never reach the security control itself).
        model["services"][EGRESS_SERVICE_KEY] = {
            "container_name": egress_container_name(name),
            "build": {"context": str(Path(build_context) / "egress")},
            "restart": restart,
            # THE ONLY PRIVILEGE IN THE DEPLOYMENT, and it is not on the agent.
            # Constitution II is per-container: this one runs no untrusted code.
            "cap_add": ["NET_ADMIN"],
            # THE PUBLISHED PORT LIVES HERE NOW. A shared network namespace has
            # exactly one port owner, and it is the container that owns the
            # namespace. The port NUMBER is unchanged, so `port_for_name` and every
            # consumer still agree — but which service publishes it is part of the
            # deployed shape, and every Phase A environment has it on `agent`.
            # That makes this a migration (T118), not an edit.
            "ports": [f"{port}:2222"],
            "configs": [
                {"source": "egress_acl", "target": EGRESS_ACL_PATH},
                {"source": "egress_unbound", "target": EGRESS_UNBOUND_PATH},
                {"source": "egress_ports", "target": EGRESS_PORTS_PATH},
            ],
            # READINESS IS PART OF THE CONTRACT, not a detail. `up` otherwise
            # returns while the boundary is starting: netfilter is already
            # installed (the entrypoint does that FIRST, deliberately), so the
            # agent's traffic is redirected at a port nothing is listening on yet
            # and every call fails with a bare connection refusal. Measured — a
            # declared destination gave curl exit 7 immediately after `up` and
            # exit 0 three seconds later.
            #
            # It fails CLOSED during that window, which is the right direction and
            # is by design. But an agent that starts working immediately sees a
            # refusal indistinguishable from "this destination is not declared",
            # so the window is a correctness problem for the DIAGNOSTIC layer even
            # though it is not a hole.
            #
            # BOTH daemons are probed, not just squid: a resolver that is not yet
            # answering fails every name lookup, and that failure also looks like
            # a policy refusal from inside the container.
            "healthcheck": {
                "test": [
                    "CMD-SHELL",
                    # OBSERVES THE LISTENING SOCKETS; DOES NOT CONNECT TO THEM.
                    # `nc -z` opened a request-less TCP connection to squid every
                    # few seconds, and squid logged each one as
                    # `NONE_NONE/000 error:transaction-end-before-headers` —
                    # measured at 53 of 67 access-log lines on a two-minute-old
                    # boundary, ~28,800/day in an always-on container. Enforcement
                    # was never affected; the FR-020d RECORD was, and `grep
                    # NONE_NONE` — the documented way to find a refused connection
                    # — returned mostly the probe.
                    #
                    # It cannot be filtered at the log: an `access_log` ACL is
                    # evaluated against a request, and these transactions have none,
                    # so `url_regex` and `method NONE` were both measured INEFFECTIVE
                    # (research R25). The probe is the right place to fix it.
                    #
                    # Readiness is unchanged in substance: a bound listening socket is
                    # what "the daemon is up" means here, and `nc -z` only ever proved
                    # the same thing one layer later.
                    # No `$` anchor: netstat prints Foreign Address and State AFTER
                    # the local address, so an end-of-line anchor never matches and
                    # the healthcheck would never pass — the agent would then never
                    # start, since it waits on `service_healthy`. Trailing whitespace
                    # is the correct delimiter and keeps `:53` from matching `:5353`.
                    "netstat -lnt | grep -qE '[:.]3127[[:space:]]'"
                    " && netstat -lnu | grep -qE '[:.]53[[:space:]]'",
                ],
                "interval": "2s",
                "timeout": "2s",
                "retries": 15,
                "start_period": "2s",
            },
        }
        # `content:`, never `file:` — a file: config is a read-only BIND of the
        # local path and cannot reach a daemon that does not share the filesystem
        # (research R10b, measured). All three surfaces ride the same channel.
        model_configs["egress_acl"] = {"content": egress_filter_body}
        model_configs["egress_unbound"] = {"content": egress_unbound_body or "\n"}
        model_configs["egress_ports"] = {"content": egress_ports_body or "\n"}
    if svc_configs:
        service["configs"] = svc_configs
    if model_configs:
        # Split from the guard above: the egress config can exist while the agent
        # service has no configs of its own.
        model["configs"] = model_configs
    return model


def write_compose_file(host: str, name: str, model: dict) -> Path:
    """Write the (regenerable) compose model as JSON to the per-host state dir."""
    host_state_dir(host).mkdir(parents=True, exist_ok=True)
    p = compose_file_path(host, name)
    p.write_text(json.dumps(model, indent=2) + "\n")
    return p


def deployed_volume_set(host: str, name: str) -> set[str] | None:
    """The per-container volumes the LAST GENERATED model declared, or None when
    there is no readable model (nothing this tool deployed — nothing to migrate).

    Reads the generated compose file rather than probing the runtime, for the same
    two reasons _previous_model_had_egress does: this is consulted on every deploy
    and on every reconcile, and the tests that call those paths are hermetic — they
    are documented never to require docker or podman, so a runtime probe here would
    make the migration untestable exactly where it needs proving.

    None on ANY read or parse failure. That is deliberately the same answer as "no
    drift": a failed read is not evidence of a stale shape, and inventing one would
    recreate a healthy environment on every deploy.
    """
    try:
        model = json.loads(compose_file_path(host, name).read_text())
    except OSError, json.JSONDecodeError, ValueError:
        return None
    vols = model.get("volumes") if isinstance(model, dict) else None
    if not isinstance(vols, dict):
        return None
    return set(vols)


def volume_set_migration(host: str, name: str, desired: list[str]) -> tuple[list[str], list[str]]:
    """(to_adopt, to_release): how the deployed volume set differs from the one
    about to be declared. Both empty when there is nothing to migrate.

    THE IDENTITY LOCK CANNOT SEE THIS (Constitution IV, and the T118/T129d lesson).
    Feature 016 added a tenth volume while container name, port and all nine
    existing volume names stayed byte-identical — so every identity check passes
    while the deployed shape differs, and an environment left alone keeps writing
    its run records into the container's own layer, where teardown destroys them.
    A missing record is silent by nature: nothing about it looks wrong.

    SYMMETRIC, because T129d proved the reverse path is the one that gets
    forgotten. It compares SETS rather than asking "does it have the runs volume",
    so `to_release` is populated by the same code that populates `to_adopt` — a
    volume the deployed model declares and the new one does not (a workspace mode
    moved to bind/ephemeral today; a withdrawn volume tomorrow). There is no
    one-directional branch that a rollback could slip past untested.

    No `compose down` is issued for this, unlike the port migration: compose
    recreates a container whose mount set changed, and the port case needed a
    teardown only because compose cannot bind a port the current owner still
    holds. The reason does not carry over, and a teardown that buys nothing is
    just another way to lose a running environment.
    """
    deployed = deployed_volume_set(host, name)
    if deployed is None:
        return [], []
    want = set(desired)
    return sorted(want - deployed), sorted(deployed - want)


def announce_volume_set_migration(host: str, name: str, desired: list[str]) -> bool:
    """Say out loud that the deployed volume set differs from the declared one, and
    return whether it does. Announced, not silent: an operator whose container is
    recreated deserves to know it was a migration and not a bug — and on the release
    side, that a volume they still pay for is no longer addressed by `--purge`.

    The wording states the FACT and not what happens next, because the three call
    sites differ: `up` on a running container recreates nothing, a deploy recreates
    immediately, and `apply` recreates via its own drift path. A message that
    promised a recreate would be false on the one path where the operator has to
    act.
    """
    adopt, release = volume_set_migration(host, name, desired)
    if adopt:
        warn(
            f"{name}: the deployed volume set is missing {', '.join(adopt)}. "
            f"Recreating the container attaches it; every existing volume is "
            f"re-attached, not recreated."
        )
    if release:
        warn(
            f"{name}: {', '.join(release)} is no longer part of this deployment. "
            f"The volume is NOT deleted — it is left on the host, and `down --purge` "
            f"no longer removes it. Remove it by hand if you want the space back."
        )
    return bool(adopt or release)


def quadlet_active(name: str) -> bool:
    # Best-effort probe; systemctl is absent on macOS — ignore all failures.
    if not shutil.which("systemctl"):
        return False
    try:
        r = query(["systemctl", "--user", "is-active", f"{container_name(name)}.service"])
        return r.returncode == 0 and r.stdout.strip() == "active"
    except Exception:
        return False


def is_tty() -> bool:
    return sys.stdin.isatty() and sys.stdout.isatty()


# --- commands ----------------------------------------------------------------


def log(msg: str) -> None:
    eprint(f"[agent-container] {msg}")


def warn(msg: str) -> None:
    eprint(f"[agent-container] WARNING: {msg}")


def hint(cmd: str) -> None:
    console.print(f"hint: {cmd}", style="dim")


def do_build(tag: str, context: Path | None = None) -> None:
    # `build` is the one subcommand that needs a repo checkout: a docker build
    # context IS a checkout. Resolve it explicitly (a PyPI install has REPO_ROOT
    # is None) and fail actionably rather than with a traceback.
    ctx = context
    if ctx is None:
        env = os.environ.get("AGENT_CONTAINER_REPO")
        if env:
            base = Path(env).expanduser().resolve()
            if not _is_repo_checkout(base):
                die(
                    f"AGENT_CONTAINER_REPO={env} is not an agent-container checkout "
                    "(missing image/Dockerfile or completions/agent-container.bash)"
                )
            ctx = base
        else:
            ctx = REPO_ROOT
    if ctx is None:
        die(_no_checkout_message(), remedy="see the path named above")
    # Feature 011 FR-006/FR-007: the context is `image/`, not the checkout root.
    # Narrow BY CONSTRUCTION — the directory boundary is the guarantee, rather
    # than a .dockerignore allowlist that must be maintained in step with the
    # Dockerfile. This matters most for a REMOTE host, where the context crosses
    # the network to another daemon.
    ctx = ctx / "image"
    rt = detect_runtime()
    log(f"building image '{tag}' with {rt} from {ctx}")
    # Inherited stdio: layer progress must stream, never capture.
    rc = run_child([rt, "build", "-t", tag, str(ctx)]).returncode
    if rc != 0:
        die(f"build failed (exit {rc})")


def resolve_build_context() -> Path:
    """The compose build context — `<checkout>/image` (Feature 011 FR-006/FR-007).

    The context is the image directory, not the checkout: narrow BY CONSTRUCTION
    rather than by a .dockerignore allowlist maintained in step with the
    Dockerfile. The whole context travels to the target daemon, which may be
    REMOTE, so the boundary is a security property and not tidiness.

    Same resolution as do_build (AGENT_CONTAINER_REPO -> REPO_ROOT); dies
    actionably when no checkout is reachable (e.g. a bare PyPI install).
    """
    env = os.environ.get("AGENT_CONTAINER_REPO")
    if env:
        base = Path(env).expanduser().resolve()
        if not _is_repo_checkout(base):
            die(f"AGENT_CONTAINER_REPO={env} is not an agent-container checkout")
        return base / "image"
    if REPO_ROOT is not None:
        return REPO_ROOT / "image"
    die(_no_checkout_message(), remedy="see the path named above")


def host_is_local(host: dict) -> bool:
    return driver_reachable_address(host) == "localhost"


def implicit_local_host(rt: str | None = None) -> dict:
    """A synthetic 'local' host using the default runtime context — the smooth
    upgrade path from the pre-registry single-host tool."""
    return {
        "driver": rt or detect_runtime(),
        "context": "",
        "address": "localhost",
        "provisioning": None,
        "created_by_tool": False,
    }


def resolve_deploy_host(host_name: str | None) -> tuple[str, dict]:
    """Resolve (host_name, host_record) for a deploy/lifecycle op. An explicit
    --host must exist; otherwise the registry default; and if the registry is
    empty, an implicit local host (behaves like the pre-multi-host tool)."""
    reg = load_registry()
    if host_name is not None:
        h = get_host(reg, host_name)
        if h is None:
            if host_name == DEFAULT_HOST:
                # 'local' is the reserved implicit-host name: a spec (or `--host local`)
                # naming it resolves to the implicit local host WITHOUT an explicit
                # registration — matching the no-`--host` behavior and keeping a
                # declarative `host: local` spec portable to a fresh checkout.
                return DEFAULT_HOST, implicit_local_host()
            die(
                f"no host named '{host_name}' (see: agent-container host ls)",
                code="host_not_registered",
                entity=host_name,
                remedy="agent-container host ls",
            )
        return host_name, h
    default = default_host_name(reg)
    if default is not None:
        h = get_host(reg, default)
        if h is not None:
            return default, h
    log(
        f"no hosts registered; using an implicit local host "
        f"(register one with: agent-container host add {DEFAULT_HOST} --docker-context <ctx>)"
    )
    return DEFAULT_HOST, implicit_local_host()


def host_ps_rows(host: dict, include_stopped: bool = False) -> list[tuple[str, str, str, str]]:
    """(cname, image, status, uptime) rows for agent-container-* on a host's daemon.
    Fail-CLOSED (001-US3 lesson): an unreachable/timed-out/errored `ps` RAISES
    (Fatal via ensure_tunnel(required)/returncode, or subprocess.TimeoutExpired)
    rather than returning [] — a down daemon must never read as 'no containers'.
    A reachable host returns 0, so lifecycle existence-checks are unaffected."""
    ensure_tunnel(host, required=True)
    argv = driver_runtime_argv(host) + ["ps"] + (["-a"] if include_stopped else [])
    argv += ["--format", "{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.RunningFor}}"]
    try:
        r = query(argv, timeout=20)  # bounded so a wedged forward can't hang the caller
    except (subprocess.SubprocessError, OSError) as e:
        # A wedged-but-bound forward times out here; a spawn failure raises OSError.
        # Convert to Fatal so lifecycle callers (do_up/down/redeploy) fail-closed
        # via the normal die() path instead of an uncaught TimeoutExpired traceback.
        die(
            f"could not list containers on the host: {e}",
            code="host_unreachable",
            remedy="agent-container host ls",
        )
    if r.returncode != 0:
        die(f"could not list containers on the host: {r.stderr.strip() or f'exit {r.returncode}'}")
    rows = []
    for line in r.stdout.splitlines():
        parts = line.split("\t")
        if len(parts) == 4 and parts[0].startswith(CONTAINER_PREFIX):
            rows.append((parts[0], parts[1], parts[2], parts[3]))
    return rows


def host_container_names(host: dict, include_stopped: bool = False) -> set[str]:
    return {row[0] for row in host_ps_rows(host, include_stopped)}


def assert_host_empty(host: dict) -> None:
    """Fail-CLOSED container check for `host rm --destroy` (FR-009 / SC-005):
    permit deprovisioning ONLY when a container listing that PROVABLY succeeded
    reports zero agent-container-* containers. `host_ps_rows`/`query` are
    best-effort (fine for `list`), but on the teardown path a failed/unreachable
    `docker ps` returns empty stdout that is indistinguishable from a genuinely
    empty daemon — so trusting it would let a loaded (or unreachable) server be
    destroyed. Here any enumeration failure is fatal. include_stopped: a stopped
    agent-container-* still occupies the server and holds its volumes."""
    ensure_tunnel(host, required=True)  # a down forward must refuse, not read empty
    argv = driver_runtime_argv(host) + ["ps", "-a", "--format", "{{.Names}}"]
    r = query(argv)
    if r.returncode != 0:
        die(
            "refusing to destroy: could not confirm the host is empty "
            f"(listing containers failed: {r.stderr.strip() or f'exit {r.returncode}'})"
        )
    names = sorted(n for n in r.stdout.splitlines() if n.startswith(CONTAINER_PREFIX))
    if names:
        hostname = host_name_from_context(host.get("context") or "")
        die(
            f"refusing to destroy: {len(names)} container(s) still present "
            f"({', '.join(names)}); remove them first "
            f"(e.g. agent-container down <name> --host {hostname})"
        )


# --- Feature 016: drain-on-contact (research R7 + R10) -----------------------
# A detached headless run is the DEFAULT, so at the moment a run ends there is no
# CLI attached to write anything down — only the entrypoint, which leaves the
# record on a volume. The tool therefore learns of it the next time it talks to
# that host, and that is the whole of ingestion: read the volume, write the record
# durably, then clear what was written.
#
# Never fatal and never silent. The operator's actual command must not fail
# because a record could not be read (FR-008), and a drain that failed quietly
# would look exactly like a host with nothing pending — which is the one thing an
# operator would never think to check.

# A record is a summary, not a log (the feature's first assumption), so a single
# one is a few kilobytes. The cap is here because the tar arrives from inside a
# container: a member that large is not a record, and reading it into memory to
# discover that is how a small helper becomes a way to exhaust the CLI.
MAX_RECORD_BYTES = 1 << 20


def host_environments(host_name: str) -> list[str]:
    """Environment names this tool has deployed to <host>, from its state dir.

    The state dir is what still remembers an environment whose container is gone —
    which is precisely the environment whose records are still pending and whose
    volume nothing else would think to look at.
    """
    d = host_state_dir(host_name)
    names = {p.stem for p in d.glob("*.port")}
    names |= {p.name.removesuffix(".compose.yaml") for p in d.glob("*.compose.yaml")}
    return sorted(n for n in names if NAME_RE.fullmatch(n))


def resolve_ingest_image(images: dict[str, str], name: str) -> str:
    """The image for the throwaway container — one that CERTAINLY exists on that host.

    Research R10 measured the mechanism with `alpine`, which assumes a registry
    pull. That assumption does not survive this project's own hosts: a declared
    egress boundary (Feature 012) can refuse the pull, and the tool never asks a
    host to fetch anything else. The environment's OWN image is present by
    construction — the records exist because that container ran there — and it is
    the image whose `dev` user wrote them, so the 0700 record directory is readable
    without widening anything.

    Falls back to the locally-built tag: right for a local host, and the only guess
    left once the container itself is gone. The override exists for a host that
    keeps neither.

    >>> resolve_ingest_image({"agent-container-acme": "acme-agent"}, "acme")
    'acme-agent'
    >>> resolve_ingest_image({}, "acme") == IMAGE_NAME
    True
    """
    return (
        os.environ.get("AGENT_CONTAINER_INGEST_IMAGE")
        or images.get(container_name(name))
        or IMAGE_NAME
    )


def host_drain_facts(host_rec: dict) -> tuple[dict[str, str], set[str]]:
    """({container name: image}, {container names still alive}) for one host.

    Both come from ONE `ps`, because the drain needs both and asking twice would
    let them disagree — a container that exits between the two calls would be
    reported as alive by one answer and imaged by the other.

    "Alive" deliberately counts `Restarting` as well as `Up`. It gates the
    reconstruction of pending records (`_reconstruct_pending`), and there the
    conservative direction is to leave a record pending: a record wrongly left
    pending is corrected by the next contact, while a record wrongly declared
    `stopped` is a false statement about a run that is still going.

    host_ps_rows is fail-CLOSED by design (a down daemon must never read as 'no
    containers'), which is right for lifecycle callers and wrong here: a drain is
    an errand on the way to the operator's real command, and it must not be the
    thing that kills it. The empty answer degrades to the IMAGE_NAME fallback,
    which either works or produces a loud failure from the run itself — and it
    cannot mislabel anything, because a host that cannot be asked for `ps` cannot
    be asked for a volume either, so no record is ingested at all.
    """
    try:
        rows = host_ps_rows(host_rec, include_stopped=True)
    except Fatal, OSError, subprocess.SubprocessError:
        return {}, set()
    images = {cname: image for cname, image, _status, _uptime in rows if image}
    live = {
        cname for cname, _image, status, _uptime in rows if status.startswith(("Up", "Restarting"))
    }
    return images, live


def pending_records_from_tar(blob: bytes) -> list[tuple[str, dict]]:
    """(filename, record) pairs from a drain tarball, skipping what is not a record.

    Member names come from inside the container and each one would become a
    filename in the operator's durable store, so they are checked and REFUSED, not
    repaired: `tar cf - -C /mnt .` emits `./<run-id>.json` for a record and nothing
    else, so a member carrying a directory component, a `..` or a name that is not
    a run id is not a record needing cleanup — it is something that should not be
    there, and saying so is more useful than quietly fixing it.

    A refused member is also never handed to the clear step, so nothing this
    function distrusts can reach an `rm`.
    """
    out: list[tuple[str, dict]] = []
    with tarfile.open(fileobj=io.BytesIO(blob), mode="r|*") as tf:
        for member in tf:
            if not member.isfile():
                continue
            filename = member.name.removeprefix("./")
            stem = filename.removesuffix(".json")
            if filename == stem or RUN_ID_RE.fullmatch(stem) is None:
                warn(f"ignoring '{member.name}' on the runs volume: not a run record")
                continue
            if member.size > MAX_RECORD_BYTES:
                warn(f"ignoring run record '{filename}': {member.size} bytes is not a summary")
                continue
            fh = tf.extractfile(member)
            if fh is None:
                continue
            rec = _decode_record(filename, fh.read())
            if rec is not None:
                out.append((filename, rec))
    return out


def _decode_record(filename: str, raw: bytes) -> dict | None:
    """Parse one record, refusing a schema this build does not understand.

    `schema` exists so a consumer can refuse a record rather than MISREAD it
    (data-model §1), which only means something if something actually refuses. A
    refused record is left on the volume rather than dropped: the tool that wrote
    it can still read it, and losing it here to make a warning go away would be the
    opposite of the feature.
    """
    try:
        rec = json.loads(raw)
    except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as e:
        warn(f"ignoring unreadable run record '{filename}' ({e})")
        return None
    if not isinstance(rec, dict):
        warn(f"ignoring run record '{filename}': not an object")
        return None
    if rec.get("schema") != RUN_SCHEMA:
        warn(
            f"leaving run record '{filename}' on the volume: schema {rec.get('schema')!r}, "
            f"this build reads {RUN_SCHEMA}"
        )
        return None
    undeclared = sorted(set(rec) - set(RECORD_FIELD_PROVENANCE))
    if undeclared:
        # SC-005 is a claim about the records IN THE STORE, and this is the only
        # door records enter it by that the tool did not compose itself. The
        # record is still stored — dropping a field would lose an operator's data
        # to make a warning go away, and C2 says `runs show --json` is verbatim —
        # but the claim is narrowed out loud, because "no credential value beyond
        # the task text" is only true of the field set this build declares.
        warn(
            f"run record '{filename}' carries undeclared field(s) {undeclared}: stored as-is, "
            f"but the tool's no-credentials statement covers only the fields it declares"
        )
    return rec


def _reconstruct_pending(rec: dict) -> dict:
    """Complete a record no exit path ever completed (data-model §7).

    A pending record — `outcome` and `ended_at` both null — is what SIGKILL leaves
    behind: no trap runs, so the start-side write is the only thing on the volume.
    Storing it as-is would list the run with a `?` outcome, which is the failure
    SC-008 names: the run IS recorded as having been killed, so leaving the
    strongest fact about it unstated turns a recorded kill into an unexplained gap.

    `stopped` is asserted; `ended_at` is NOT invented. The container's clock stopped
    at an instant nobody observed, and the ingestion time would be the time the
    operator next ran a command — which for a detached run can be the next morning.
    The note says the record was reconstructed so the null reads as "not known"
    rather than as a bug in the writer.

    >>> _reconstruct_pending({"outcome": None, "ended_at": None, "notes": []})
    {'outcome': 'stopped', 'ended_at': None, 'notes': ['reconstructed at ingestion: the container went away without completing this record (no exit path ran); ended_at is unknown']}
    >>> _reconstruct_pending({"outcome": "failed", "notes": []})  # already complete
    {'outcome': 'failed', 'notes': []}
    """
    if rec.get("outcome") is not None:
        return rec
    rec["outcome"] = RUN_OUTCOME_STOPPED
    notes = rec.get("notes")
    rec["notes"] = [*notes] if isinstance(notes, list) else []
    rec["notes"].append(
        "reconstructed at ingestion: the container went away without completing this "
        "record (no exit path ran); ended_at is unknown"
    )
    return rec


def warn_records_lost_with_the_volume(host_name: str, host_rec: dict, name: str) -> None:
    """Say when an environment's runs volume has gone missing under the tool (T057).

    A missing volume is usually nothing at all: an environment that has never been
    deployed has none, and one this tool tore down has none either — `down --purge`
    drains FIRST and then removes the volume together with the generated model, so
    it deliberately leaves no model behind for this to ask about.

    What is NOT nothing is a model that still DECLARES the runs volume while the
    host no longer has it. Something removed it outside the tool (`docker volume
    rm`, a pruned host, a rebuilt VPS), and every record still pending on it went
    with it. That loss is invisible by construction: an un-ingested record leaves
    nothing behind, so the store simply has fewer runs in it than the operator ran,
    and nothing anywhere looks wrong. T017's drain covers the tool's own teardown;
    this covers the case that goes around the tool.

    TWO conditions, and the second is not optional. The first is the MODEL rather
    than the absence, so a never-deployed or properly-purged environment never
    triggers it. The second is that the daemon ANSWERED: `volume inspect` fails
    identically for "no such volume" and for a host that cannot be reached at all,
    and without the second probe this would announce records permanently lost every
    time a VPS was merely asleep. A warning that cries wolf on a reachable-tomorrow
    host is one an operator learns to scroll past — which would cost exactly the
    case it exists for. So the claim is only made when `volume ls` succeeded and
    `volume inspect` did not: the daemon was asked, and it does not have it.
    """
    declared = deployed_volume_set(host_name, name)
    if declared is None or runs_volume_name(name) not in declared:
        return
    if query(driver_runtime_argv(host_rec) + ["volume", "ls", "-q"]).returncode != 0:
        return  # the host could not be asked, so nothing about it is known
    warn(
        f"{name}: {runs_volume_name(name)} is part of this deployment but no longer "
        f"exists on {host_name} — it was removed outside the tool. Any run records "
        f"still pending on it are GONE and cannot be recovered; records already "
        f"ingested are unaffected. `agent-container up {name}` recreates the volume."
    )


def ingest_records(
    host_name: str, host_rec: dict, name: str, image: str, live: bool = False
) -> list[str]:
    """Pull <name>'s pending records off its runs volume into the durable store.

    `live` says the container is still up, and it changes what may be concluded
    about a record no exit path completed: while the writer is alive that record is
    IN FLIGHT, and both declaring it `stopped` and clearing it from the volume
    would be wrong about a run that is still going. So a pending record from a live
    container is stored as pending and LEFT on the volume, and the next contact
    after the container is gone finalises it.

    Returns the run ids ingested. Stamps `host` AND `environment` HERE and nowhere
    else: the container writes both null on purpose. It is never told what the
    operator's registry calls its host, and telling it its own environment name
    would create a second copy that can drift from the volume the tool actually
    keys on — while the drain reads `agent-container-<name>-runs`, so it knows
    both with certainty. A record that lost them could not answer "which of last
    night's runs is which" (data-model §1).

    THE ORDER OF THE LAST TWO STEPS IS THE PROPERTY. Every record is written
    durably — mkstemp, fsync, rename — before a single one is cleared from the
    volume. Clearing first would trade a record that is merely un-ingested for one
    that is gone.

    Retention runs LAST and unconditionally (FR-011, C14), including on the paths
    that ingested nothing: the store is bounded per environment, and an environment
    whose volume has gone away still has a store that must stop growing — it just
    stopped growing on its own.

    `finally` is what makes "unconditionally" true rather than merely intended. It
    was not: the drain RAISES on a large backlog (the clear step's argv exceeded
    ARG_MAX — measured, see `_clear_ingested`), the exception left through this
    function to `drain_host_records`'s handler, and the prune below never ran. That
    is retention becoming unreachable exactly on the crash-looping environment that
    needs it, on every contact, forever — the store then grows without bound while
    a bounded-store rule sits three lines away.

    `rescued` is filled AS RECORDS ARE WRITTEN, not returned, so the `finally` holds
    the list even when the drain raises part-way through it. Those names are exempt
    from the age bound for this one prune (see `prune_run_store`): a host switched off
    for four months hands over records that are all past 90 days, and the clear step
    has already removed their only other copy, so pruning them here would destroy in
    one command the records this command existed to rescue.
    """
    rescued: list[str] = []
    try:
        return _ingest_from_volume(host_name, host_rec, name, image, live, rescued)
    finally:
        _prune_and_announce(host_name, name, frozenset(rescued))


def _prune_and_announce(host_name: str, name: str, rescued: frozenset[str] = frozenset()) -> None:
    """Prune <name>'s durable store and say so — see `ingest_records` (FR-011, C14).

    Deleting durable records is announced. The whole feature is that the account of
    a run outlives its container, so the tool removing one is the last thing that may
    happen quietly. The message names the rule, so an operator who wants a record
    back knows which bound took it, and names the ID RANGE it took, so they can tell
    whether the run they came looking for is among them — "pruned 4000 records" alone
    answers the question an operator does not have.

    NEVER RAISES, because `ingest_records` calls it from a `finally`: an exception
    escaping here would REPLACE the drain's own exception with a complaint about
    bookkeeping — the exact masking failure T038/C11 exists to prevent, and this is
    the one place where a bookkeeping error would be reported instead of the failure
    the operator is waiting to read.

    On stderr (`log`), so it is present in `--json` mode too: the prune is a side
    effect of a command whose stdout belongs to the caller's parser, and a deletion
    that only a human-readable mode reported would be silent for the agent that FR-012
    exists for.
    """
    try:
        pruned = prune_run_store(runs_store_dir(host_name, name), protect=rescued)
    except OSError as e:
        warn(f"{name}: could not apply run-record retention ({e})")
        return
    if not pruned:
        return
    span = min(pruned).removesuffix(".json")
    if len(pruned) > 1:
        span += f" .. {max(pruned).removesuffix('.json')}"
    log(
        f"{name}: pruned {len(pruned)} run record(s) past retention "
        f"({RETENTION_MAX_AGE_DAYS} days / {RETENTION_MAX_RECORDS} records per environment, "
        f"the count spent on distinct UTC days first): {span}"
    )


# How long a drain may block on a host before giving up and answering from the
# local store. Short on purpose: the drain is opportunistic and sits in front of
# read-only commands, so an unreachable host must cost a pause, not a hang.
RUNS_PROBE_TIMEOUT = 10.0


def _ingest_from_volume(
    host_name: str, host_rec: dict, name: str, image: str, live: bool, rescued: list[str]
) -> list[str]:
    """The drain itself — see `ingest_records`, which owns the contract and adds
    retention around this. `rescued` is appended to as each record becomes durable,
    so the caller's `finally` can protect them even if this raises."""
    volume = runs_volume_name(name)
    # BOUNDED, because a drain runs on the way to almost every command. Against an
    # unreachable host this probe blocks on the runtime's own connect timeout, so
    # `runs list` — which needs nothing but the LOCAL store to answer — hung instead
    # of printing records already ingested. The drain is an opportunistic refresh;
    # it must never be the reason a read-only query cannot complete.
    try:
        probe = query(
            driver_runtime_argv(host_rec) + ["volume", "inspect", volume],
            timeout=RUNS_PROBE_TIMEOUT,
        )
    except subprocess.SubprocessError:
        # Unreachable, not absent — and the two must not be conflated. Warning that
        # records "were lost with the volume" here would accuse an operator of
        # destroying evidence that is sitting safely on a host they cannot reach.
        warn(
            f"{name}: {host_name} did not answer within {RUNS_PROBE_TIMEOUT:g}s; pending records "
            f"stay on the host and will be ingested on a later contact. Showing what is stored."
        )
        return []
    if probe.returncode != 0:
        # No volume: nothing pending. Probed rather than assumed, because
        # `run -v <name>:…` CREATES a named volume as a side effect — a drain that
        # ran unconditionally would quietly re-create the volume it found missing,
        # erasing the very evidence that records were lost to an out-of-band
        # `volume rm` (the spec's own edge case).
        warn_records_lost_with_the_volume(host_name, host_rec, name)
        return []
    try:
        proc = subprocess.run(
            driver_ingest_argv(host_rec, volume, image), capture_output=True, timeout=120
        )
    except (OSError, subprocess.SubprocessError) as e:
        warn(f"{name}: could not read pending run records from {volume} on {host_name} ({e})")
        return []
    if proc.returncode != 0:
        detail = proc.stderr.decode("utf-8", "replace").strip() or f"exit {proc.returncode}"
        warn(
            f"{name}: could not read pending run records from {volume} on {host_name} "
            f"({detail}). They stay on the volume — `down --purge` would discard them."
        )
        return []
    try:
        pending = pending_records_from_tar(proc.stdout)
    except tarfile.TarError as e:
        warn(f"{name}: the drain of {volume} on {host_name} was not a readable archive ({e})")
        return []
    store = runs_store_dir(host_name, name)
    clearable: list[str] = []
    for filename, rec in pending:
        rec["host"] = host_name
        rec["environment"] = name
        if not live:
            _reconstruct_pending(rec)
        try:
            atomic_write_json(store, filename, rec)
        except OSError as e:
            warn(f"{name}: could not store run record '{filename}' ({e}); left on the volume")
            continue
        rescued.append(filename)
        # Only a COMPLETE record is cleared. An in-flight one is still owned by a
        # writer that will rewrite it at exit, and removing it now would mean a
        # SIGKILL between here and then leaves nothing on the volume to finalise —
        # the store would keep a pending record forever, which is the one shape
        # `runs list` cannot explain.
        if rec.get("outcome") is not None:
            clearable.append(filename)
    if clearable:
        _clear_ingested(host_name, host_rec, name, volume, image, clearable)
    return [f.removesuffix(".json") for f in rescued]


# Per-`rm`-container budget for the record paths on argv. The names are attacker-
# irrelevant but UNBOUNDED IN NUMBER: a headless run gets `restart: on-failure` with
# no retry limit, so one crash-looping environment writes a record every few seconds
# for as long as it is left alone, and a single drain then has to clear all of them.
# Measured on this project's own machine (ARG_MAX 1 MiB, macOS): `subprocess.run`
# raises OSError(E2BIG) somewhere between 10k and 20k paths of this shape — reached
# by roughly a day of the cadence above. That exception used to escape the drain and
# skip retention entirely (see `ingest_records`), so the fix is both: bound the argv
# HERE, and prune regardless THERE.
#
# 64 KiB rather than "some number of files", because a run id may be 128 characters
# (RUN_ID_RE) and a count that was safe for short ids would not be for long ones. It
# is two orders of magnitude below the smallest ARG_MAX this tool runs against, which
# leaves room for the runtime's own argv and the environment it inherits.
MAX_CLEAR_ARGV_BYTES = 64 << 10


def _clear_batches(filenames: list[str]) -> list[list[str]]:
    """`filenames` split into batches that each fit MAX_CLEAR_ARGV_BYTES.

    At least one name per batch even if that one name is over budget: dropping it
    would leave a stored record on the volume with nothing saying why, and the
    runtime is entitled to refuse it loudly instead.
    """
    batches: list[list[str]] = []
    used = MAX_CLEAR_ARGV_BYTES
    for f in filenames:
        cost = len(f.encode()) + len(RUNS_INGEST_MOUNT) + 2
        if used + cost > MAX_CLEAR_ARGV_BYTES:
            batches.append([])
            used = 0
        batches[-1].append(f)
        used += cost
    return batches


def _clear_ingested(
    host_name: str, host_rec: dict, name: str, volume: str, image: str, filenames: list[str]
) -> None:
    """Remove records from the volume AFTER they are durably stored.

    A drain that only ever read would leave every record on its volume forever, so
    the volume grows without bound and — worse — retention (FR-011) becomes
    theatre: a pruned record would be re-ingested by the next contact, and the
    prune would look like it worked.

    Batched (see MAX_CLEAR_ARGV_BYTES) so a backlog cannot exceed ARG_MAX, and every
    batch is attempted even after one fails: the failure that matters is a single
    unremovable record, and aborting on it would leave thousands of already-durable
    ones on the volume to be re-ingested on every contact from then on.

    A failed clear is a warning, not a failure: the records are already durable,
    and re-ingesting them is idempotent because a record's id IS its filename.
    """
    for batch in _clear_batches(filenames):
        r = query(driver_ingest_clear_argv(host_rec, volume, image, batch))
        if r.returncode != 0:
            warn(
                f"{name}: stored {len(batch)} run record(s) but could not clear them from "
                f"{volume} on {host_name} ({r.stderr.strip() or f'exit {r.returncode}'}). "
                f"They will be re-ingested (harmless) and the volume keeps growing."
            )


def drain_host_records(host_name: str, host_rec: dict, names: list[str] | None = None) -> list[str]:
    """Ingest pending records for <names> on <host>, defaulting to every
    environment the tool knows there. Returns the run ids ingested.

    SCOPED ON PURPOSE. Lifecycle commands pass the one environment they act on;
    `runs list` with no argument passes None and drains the host. Draining every
    environment on every `up` would start a throwaway container per environment on
    every deploy, and nothing is lost by not doing it — an undrained record sits on
    its volume until the next contact that names it, which is exactly where the
    design already puts it.
    """
    if host_rec.get("driver") not in ("docker", "podman"):
        return []  # attach-only host: reachable over ssh, cannot run a container
    envs = host_environments(host_name) if names is None else list(names)
    if not envs:
        return []
    try:
        ensure_tunnel(host_rec)
    except (Fatal, OSError, subprocess.SubprocessError) as e:
        # FR-008/C11, and the promise this section's own header makes: a drain is an
        # errand on the way to the operator's real command and must never be the
        # thing that kills it. The case that matters is `runs list` against a host
        # that has gone away — the durable store is the whole point of the feature,
        # and it is read from local disk, so failing here would deny an operator
        # records that survived precisely so they could be read now.
        warn(f"could not reach {host_name} to collect pending run records ({e})")
        return []
    images, live = host_drain_facts(host_rec)
    ingested: list[str] = []
    for env in envs:
        try:
            ingested += ingest_records(
                host_name,
                host_rec,
                env,
                resolve_ingest_image(images, env),
                live=container_name(env) in live,
            )
        except (Fatal, OSError, subprocess.SubprocessError) as e:
            warn(f"{env}: could not drain pending run records from {host_name} ({e})")
    # THE EGRESS EVENTS SHARE THESE CONTACT POINTS DELIBERATELY (Feature 012 FR-010).
    # The one that cannot be missed is `down_container`'s: it stops, drains, THEN
    # removes — and removing the boundary destroys its log, which is where the events
    # still are. A separate hook for egress would be a second list of contact points
    # to keep in step with this one, and the moment they diverged the drain that
    # matters would be the one left out.
    #
    # A SECOND LOOP, not the one above. An egress failure must not cost a run record
    # (or the reverse), and the two warnings must never be confusable: an operator
    # reading "could not drain" needs to know which store is short.
    for env in envs:
        try:
            ingest_egress_events(host_name, host_rec, env)
        except (Fatal, OSError, subprocess.SubprocessError) as e:
            warn(f"{env}: could not collect egress events from {host_name} ({e})")
    return ingested


# --- Feature 012 FR-010 / US3: the durable egress record ----------------------
#
# WHY THE BOUNDARY'S LOG IS THE SOURCE AND NOTHING NEW PRODUCES EVENTS. squid
# already writes one line per connection and unbound one per reply, both into the
# boundary container's own stream, and both by deliberate work: T130 found unbound's
# refusals going to a syslog nobody ran, T150 found squid's access log in a file no
# verb could read. `logs --egress` streams exactly that. A second producer — the
# sidecar writing records to a volume the tool then ingests — would be a mechanism
# that can DISAGREE with the one the operator reads, and the disagreement would
# present as "the tool says nothing was refused" beside a log that says it was. So
# the durable record is a NARROWING of that stream: same source, same facts, the
# interesting ones kept.
#
# What the choice buys beyond one truth:
#   * NO tenth volume. Research R9 deferred this story behind an identity migration;
#     reading the log means that migration is not paid at all. What the story
#     actually waited for is Feature 016's durable STORE and its write/list helpers
#     (FR-011a) — reused here verbatim, under this schema.
#   * NO container-authored filenames. Run-record ingestion has to refuse member
#     names that arrive from inside a container; every name here is composed by this
#     tool from fields it parsed, so that class of check has nothing to guard.
#
# What it costs, said out loud because it is real: the pending window is the
# RUNTIME's log retention rather than a volume this tool owns. Lines the log driver
# has already rotated away were never ingested and cannot be recovered, and each
# drain reads the last EGRESS_LOG_TAIL_LINES lines. Contact the host — any command
# that drains — and the window resets.

EGRESS_SCHEMA = 1

# EVERY FIELD OF AN EGRESS EVENT AND WHERE ITS VALUE COMES FROM (data-model §6).
# The closure is the point. §6's narrowness — no request bodies, no headers, no
# tokens, no model names, no prompt content — is a Constitution III claim about what
# this store can possibly hold, and a claim about a record's fields is worth exactly
# as much as the thing that enumerates them. A test asserts every event this build
# writes carries these keys and no others, so a field added without a line here
# fails rather than quietly widening the store.
#
# `host` IS THE DESTINATION, and `deployment_host` is the machine the environment
# runs on. The collision is inherited: §6 names the destination `host` and Feature
# 016's run record uses `host` for the deployment host. §6 wins here because the
# destination is what an egress event is ABOUT — and the other field is spelled out
# rather than shortened so no reader has to guess which "host" a value is.
EGRESS_FIELD_PROVENANCE: dict[str, str] = {
    "schema": "this build (EGRESS_SCHEMA)",
    "environment": "the tool — whose boundary logged it",
    "deployment_host": "the tool — which host that environment runs on",
    "timestamp": "the container runtime's own log timestamp (`logs --timestamps`)",
    "host": "the DESTINATION — the SNI squid matched, or the host of the request target",
    "provider": "the tool's provider table, reverse-looked-up from `host`",
    "declared": "the allowlist the deployed boundary was given, or the resolver's own verdict",
    "decision": "what the boundary did",
    "stage": "which daemon observed it — the resolver, or the proxy",
}

EGRESS_DECISION_REFUSED = "refused"
EGRESS_DECISION_PERMITTED = "permitted"
# WHICH DAEMON SAW IT, and this is not cosmetic: T131 went to trouble to keep a
# policy refusal distinguishable from a name that does not exist, and the same
# distinction one layer up is "the name was never resolved" versus "the connection
# was terminated after the ClientHello". They send an operator to different places,
# and §6 has no field that carries it — so this one is an addition to §6, made
# deliberately and flagged, not a quiet extra.
EGRESS_STAGE_DNS = "dns"
EGRESS_STAGE_CONNECT = "connect"

# How many trailing log lines one drain reads. The boundary logs every connection,
# not only the refused ones, so this window is consumed mostly by traffic this store
# discards — which is the honest reason it is large rather than tidy. It bounds two
# things at once: the bytes crossing a possibly-remote runtime connection on a drain
# that runs in front of almost every command, and the memory this holds while
# parsing (~150 bytes a line, so a few megabytes at this size).
EGRESS_LOG_TAIL_LINES = 20000
# Longer than RUNS_PROBE_TIMEOUT because this transfers that window, where the run
# probe asks a yes/no question. Still bounded: a drain is an errand on the way to the
# operator's command, and an unreachable host must cost a pause rather than a hang.
EGRESS_LOG_TIMEOUT = 30.0


def egress_store_dir(host: str, environment: str) -> Path:
    """Where the DURABLE egress events for one environment on one host live.

    A SIBLING of runs_store_dir — not a subdirectory of it, and not rows inside a run
    record. FR-011a made this argument for Feature 014's inventory and it holds here
    for the same two reasons: a different producer (the boundary, not the agent) and a
    different lifetime (continuous, not at run end). Shared placement and shared
    write-safety are the parts that ARE reused.

    >>> egress_store_dir("vps", "demo").parts[-4:]
    ('agent-container', 'egress', 'vps', 'demo')
    """
    return DATA_DIR / "egress" / host / environment


def driver_egress_logs_argv(host: dict, name: str, tail: int = EGRESS_LOG_TAIL_LINES) -> list[str]:
    """Read the boundary's log from the host that runs it.

    `--timestamps` is load-bearing, not decoration: it makes the RUNTIME stamp every
    line, so one time source serves both daemons. squid's own `%ts` field is in its
    access log and unbound's line prefix is a different format again, configurable,
    and has changed across releases — deriving the record's one time field from
    whichever producer wrote the line would mean two parsers that can disagree about
    when the same event happened.

    Not `-f`: this returns what is there and exits. A follow would never return, and
    this runs in front of the operator's real command.

    >>> driver_egress_logs_argv({"driver": "podman", "context": "vps"}, "acme", 10)
    ['podman', '--connection', 'vps', 'logs', '--timestamps', '--tail', '10', 'agent-egress-acme']
    """
    return driver_runtime_argv(host) + [
        "logs",
        "--timestamps",
        "--tail",
        str(tail),
        egress_container_name(name),
    ]


# The runtime's own per-line prefix. Both runtimes emit RFC3339; docker in UTC,
# podman with a local offset — so the offset is APPLIED rather than assumed absent.
# A record whose timestamp silently carried the host's local time would sort and
# prune against records that carried UTC, and the store's whole ordering is by time.
_LOG_STAMP_RE = re.compile(
    r"^(?P<date>\d{4}-\d{2}-\d{2})[T ](?P<time>\d{2}:\d{2}:\d{2})(?:\.\d+)?"
    r"(?P<tz>Z|[+-]\d{2}:?\d{2})?\s"
)


def split_log_stamp(line: str) -> tuple[str | None, str]:
    """(ISO-8601 UTC second, the rest of the line) — the stamp being None when the
    line carries none.

    >>> split_log_stamp("2026-08-10T10:10:10.123456789Z 1754.000 CONNECT")
    ('2026-08-10T10:10:10Z', '1754.000 CONNECT')
    >>> split_log_stamp("2026-08-10T12:10:10.000+02:00 x")
    ('2026-08-10T10:10:10Z', 'x')
    >>> split_log_stamp("no stamp here")
    (None, 'no stamp here')
    """
    m = _LOG_STAMP_RE.match(line)
    if m is None:
        return None, line
    rest = line[m.end() :]
    try:
        epoch = calendar.timegm(time.strptime(f"{m.group('date')}T{m.group('time')}Z", TIME_FORMAT))
    except ValueError:
        return None, rest
    tz = m.group("tz")
    if tz and tz != "Z":
        sign = -1 if tz[0] == "-" else 1
        hh, _, mm = tz[1:].partition(":")
        epoch -= sign * (int(hh) * 3600 + int(mm or "0") * 60)
    return time.strftime(TIME_FORMAT, time.gmtime(epoch)), rest


# THE ACCESS-LOG LINE'S SHAPE, and it is pinned to `logformat egress` in
# image/egress/squid.conf by a test that builds a line from that directive. Bound in
# both directions on purpose: if the format grows a field, this parser reads the
# wrong ones, and a parser that reads the wrong ones stores nothing — which makes an
# empty store mean "nobody could read the record" while presenting as "nothing was
# refused". That is the single confusion US3 scenario 3 forbids, so it is a test and
# a runtime count (see `looks_like_squid_access_line`), not a comment.
SQUID_LOG_FIELDS = 10
SQUID_FIELD_STATUS = 3  # %Ss/%03>Hs
SQUID_FIELD_TARGET = 6  # %ru
SQUID_FIELD_SNI = 8  # sni=%ssl::>sni
SQUID_FIELD_BUMP = 9  # bump=%ssl::bump_mode
_SQUID_STAMP_RE = re.compile(r"\d{9,}\.\d+")
# An IPv6 destination, which HOSTNAME_RE cannot match. Accepted so an event to one is
# not dropped in silence — this store's whole promise is that silence means nothing
# happened.
_IPV6_RE = re.compile(r"[0-9A-Fa-f:]{2,45}")


def looks_like_squid_access_line(rest: str) -> bool:
    """Whether `rest` is one of squid's access-log lines AT ALL.

    Narrow deliberately. squid's own diagnostics (`cache_log stdio:/dev/stderr`) and
    unbound's replies share this stream, so counting those as unreadable records
    would make every drain complain about a boundary that is merely starting up —
    noise that teaches an operator to ignore the one warning that means the record
    is broken. Only the leading epoch-and-fraction of `%ts.%03tu` qualifies.
    """
    return _SQUID_STAMP_RE.fullmatch(rest.split(" ", 1)[0]) is not None


# What `%ssl::bump_mode` reads when the boundary TERMINATED the connection, i.e. the
# SNI was not on the allowlist. squid's own word for the `ssl_bump` decision it took,
# which is why this and not an inference — see `squid_decision`.
SQUID_BUMP_TERMINATE = "terminate"


def squid_decision(status_field: str, bump_mode: str) -> str | None:
    """`refused`, `permitted`, or None — from squid's `%Ss/%03>Hs` and
    `%ssl::bump_mode` fields.

    None means "this transaction ended for a reason that is not a policy verdict at
    all", and it is a third answer rather than a lean toward either because BOTH of
    the other two would be a fabrication.

    Read from the tags this configuration can actually produce, each MEASURED on a
    live boundary and recorded in image/egress/squid.conf:

      * `TCP_DENIED` — an `http_access deny`, i.e. plain HTTP to an undeclared domain,
        or a CONNECT to a port the proxy's surface does not admit. REFUSED, on the tag
        alone (`bump` is `-` there; there was no TLS decision to take).
      * a `NONE…` tag with `bump=terminate` — `ssl_bump terminate all`: the
        ClientHello was peeked, the SNI was not on the allowlist, no upstream
        connection was ever made. REFUSED.
      * a `NONE…` tag with ANYTHING ELSE — NOT an event. Two measured cases sit here
        and both would be fabrications:

        - A PERMITTED request logs a `NONE_NONE/000 … sni=<declared host>
          bump=splice` line BEFORE its `TCP_TUNNEL/200`. Status, code, bytes, method,
          hierarchy and `%err_code` are identical to a terminated line; `bump` is the
          only field that differs. Without it every permitted HTTPS request to a
          declared host was stored as a refusal.
        - `NONE_NONE/409` — research R24 measured squid's intercepted-CONNECT host
          verification rejecting 10 of 12 HTTPS requests to a DECLARED
          `s3.amazonaws.com` because its ipcache and the agent's answer had diverged
          (`SECURITY ALERT: Host header forgery detected`). A divergent-resolution
          failure, not a policy event.

    EVERYTHING ELSE IS PERMITTED BY POLICY, INCLUDING AN UPSTREAM'S OWN STATUS CODE.
    `TCP_MISS/503` and `TCP_MISS/403` are the same case: the policy let the request
    through and the far end answered badly — a WAF, a bot block, a bad API key, a CDN.
    A `403` clause used to sit here, and it turned every one of those into a refusal of
    a DECLARED host. It bought nothing: a genuine denial by this configuration always
    carries `TCP_DENIED`, which the clause above already catches on the tag.

    >>> squid_decision("TCP_TUNNEL/200", "splice"), squid_decision("TCP_DENIED/403", "-")
    ('permitted', 'refused')
    >>> squid_decision("NONE_NONE/000", "terminate"), squid_decision("NONE_NONE/000", "splice")
    ('refused', None)
    >>> [squid_decision(s, "-") for s in ("TCP_MISS/403", "TCP_MISS/503", "NONE_NONE/409")]
    ['permitted', 'permitted', None]
    """
    tag, _, _code = status_field.partition("/")
    if tag == "TCP_DENIED":
        return EGRESS_DECISION_REFUSED
    if tag.startswith("NONE"):
        return EGRESS_DECISION_REFUSED if bump_mode == SQUID_BUMP_TERMINATE else None
    return EGRESS_DECISION_PERMITTED


def bare_host(value: str) -> str | None:
    """The hostname in `value`, or None when there is not one.

    NOTHING BUT THE HOST IS EVER RETURNED, and that is Constitution III rather than
    tidiness. `%ru` on a plain-HTTP request is the FULL URL: a query string can carry
    a token, and `user:pass@host` carries one outright. A durable record built from
    the raw field would make this store a place a credential comes to rest — in the
    feature whose schema exists to say the boundary cannot see one. So the path,
    query and userinfo are dropped here, at the only door, and the caller never sees
    them.

    None for squid's error pseudo-targets: a request-less TCP connection logs
    `error:transaction-end-before-headers` (measured, research R25), and `error` is a
    syntactically fine hostname. An event refusing the host `error` would be a
    fabricated refusal, which is worse than the silence US3 asks for.

    Lower-cased because DNS is case-insensitive and the allowlist is generated in the
    operator's spelling: without this, one destination in two casings is two records
    for one event, and `Api.OpenAI.com` misses an `api.openai.com` allowlist entry.

    >>> bare_host("http://user:tok@example.com/v1/chat?key=SECRET")
    'example.com'
    >>> bare_host("1.2.3.4:443"), bare_host("[2001:db8::1]:443")
    ('1.2.3.4', '2001:db8::1')
    >>> [bare_host(v) for v in ("-", "", "error:transaction-end-before-headers")]
    [None, None, None]
    """
    if not value or value == "-" or value.startswith("error:"):
        return None
    v = value.split("://", 1)[-1]
    v = v.split("/", 1)[0]
    v = v.rsplit("@", 1)[-1]
    if v.startswith("["):
        v = v[1:].split("]", 1)[0]
    else:
        v = v.split(":", 1)[0]
    v = v.lower()
    if len(v) > HOSTNAME_MAX:
        return None
    if HOSTNAME_RE.fullmatch(v) or _IPV6_RE.fullmatch(v):
        return v
    return None


def parse_squid_egress_line(rest: str) -> dict | None:
    """`{host, decision, stage}` for one access-log line, or None when the line
    names no destination or carries no policy verdict.

    The SNI is preferred over the request target because it is the name the client
    asked for and the name `ssl_bump` matched the allowlist against. On an
    intercepted connection `%ru` is the ADDRESS the client chose (squid.conf records
    the measurement), and an address on a CDN answers for thousands of sites — so a
    record built from it would name the wrong destination on exactly the lines an
    operator has to act on.

    A line `squid_decision` calls neither refused nor permitted yields NO event: see
    there for why inventing either verdict for it would be worse than silence. It is
    not counted as unreadable either — the format was read correctly, the transaction
    just was not a policy event (`_egress_line_is_unreadable` checks the arity, which
    is the thing that can actually drift).
    """
    if not looks_like_squid_access_line(rest):
        # ARITY IS NOT ENOUGH TO IDENTIFY THIS FORMAT, measured the moment the format
        # grew its tenth field: unbound's reply lines are also ten whitespace-separated
        # tokens, so `nope.example.com. A IN NXDOMAIN 0.010000 0 45` was read as an
        # access-log line whose status field was a hostname — which is not `TCP_DENIED`
        # and does not start with `NONE`, so every DNS reply became a PERMITTED event at
        # the host `nxdomain`. Field 0 is `%ts.%03tu` by the same directive that fixes
        # the three field positions below, so it is checked with them.
        return None
    parts = rest.split()
    if len(parts) != SQUID_LOG_FIELDS:
        return None
    sni = parts[SQUID_FIELD_SNI]
    sni = sni.removeprefix("sni=") if sni.startswith("sni=") else "-"
    host = bare_host(sni) or bare_host(parts[SQUID_FIELD_TARGET])
    bump = parts[SQUID_FIELD_BUMP].removeprefix("bump=")
    decision = squid_decision(parts[SQUID_FIELD_STATUS], bump)
    if host is None or decision is None:
        return None
    return {
        "host": host,
        "decision": decision,
        "stage": EGRESS_STAGE_CONNECT,
        "declared": None,  # decided against the deployed allowlist by the caller
    }


# unbound's reply lines (`log-replies: yes`, image/egress/unbound.conf, forced off
# syslog by build_unbound_conf). Matched by SHAPE and not by position: the fields
# around it have varied across unbound releases while qname/qtype/`IN`/rcode have
# been these four adjacent tokens throughout. Anchored on ` info: ` so a diagnostic
# line can never reach it.
_UNBOUND_REPLY_RE = re.compile(r" info: \S+ (?P<qname>\S+\.) [A-Za-z0-9]+ IN (?P<rcode>[A-Z]+)\b")


def looks_like_unbound_reply_line(rest: str) -> bool:
    """Whether `rest` is one of unbound's reply lines at all — the loose form of
    _UNBOUND_REPLY_RE, and the two are a matched pair on purpose.

    This one decides "the resolver was reporting a reply here"; the pattern decides
    "and this is what it said". A line the loose form accepts and the pattern rejects
    is a format that has moved, which is the only way this reader can go quiet, and it
    is counted rather than skipped (see `_egress_line_is_unreadable`).
    """
    return " info: " in rest and " IN " in rest


def parse_unbound_egress_line(rest: str) -> dict | None:
    """`{host, decision, stage, declared}` for a REFUSED resolution, or None.

    Only REFUSED, and the exclusions are the point:

      * `NXDOMAIN` is the distinction T131 built the resolver choice around — the
        name genuinely does not exist. That is a fact about the internet, not a
        policy event, and storing it would fill this store with an agent's typos and
        dilute the refusals FR-010 is about.
      * `NOERROR` is a DECLARED name resolving normally: ordinary traffic, which this
        store deliberately does not keep.

    `declared` is False WITHOUT consulting any allowlist, because the resolver's
    verdict IS the allowlist test: build_unbound_conf gives every declared name —
    ported destinations included — a `local-zone … transparent`, and the baked
    catch-all `local-zone "." refuse` answers everything else. A REFUSED reply
    therefore means "not declared" by construction, which is a stronger source than
    re-deriving it from a file.
    """
    m = _UNBOUND_REPLY_RE.search(rest)
    if m is None or m.group("rcode") != "REFUSED":
        return None
    host = bare_host(m.group("qname").rstrip("."))
    if host is None:
        return None
    return {
        "host": host,
        "decision": EGRESS_DECISION_REFUSED,
        "stage": EGRESS_STAGE_DNS,
        "declared": False,
    }


def provider_for_host(host: str) -> str | None:
    """Which provider name the TOOL's mapping gives `host`, or None (§6).

    The tool's table rather than the environment's declaration, deliberately: the
    event worth naming is an agent reaching `api.openai.com` in an environment whose
    `openai` entry was overridden to a gateway precisely to stop that. Reading the
    label off the declaration would answer `null` there — at the one destination
    whose provider is the whole story.

    >>> provider_for_host("api.anthropic.com"), provider_for_host("example.com")
    ('anthropic', None)
    """
    return next((p for p, hosts in PROVIDERS.items() if host in hosts), None)


def deployed_egress_allowlist(host_name: str, name: str) -> list[str] | None:
    """The allowlist the DEPLOYED boundary was given, as squid's own tokens — or None
    when there is no readable model.

    Read from the generated compose artifact, which is the declaration IN FORCE for
    the log lines being ingested, and NOT from the project spec. The spec is what the
    next deploy would apply; judging a refusal that already happened against it would
    report an attempt as declared that the running boundary terminated. That error is
    in the permissive direction, which is the one that matters here.

    None on any read or parse failure — the same answer as "we cannot tell", which is
    what `egress_event_is_recordable` then handles rather than guessing.
    """
    try:
        model = json.loads(compose_file_path(host_name, name).read_text())
    except OSError, json.JSONDecodeError, ValueError:
        return None
    configs = model.get("configs") if isinstance(model, dict) else None
    entry = configs.get("egress_acl") if isinstance(configs, dict) else None
    body = entry.get("content") if isinstance(entry, dict) else None
    if not isinstance(body, str):
        return None
    # Lower-cased to meet `bare_host`, which lower-cases the destination: squid
    # compares these case-insensitively and an operator may have written any casing.
    return [ln.strip().lower() for ln in body.splitlines() if ln.strip()]


def egress_event_is_recordable(decision: str, declared: bool | None) -> bool:
    """Whether an observed event belongs in the durable store.

    THIS STORE IS NOT A TRAFFIC LOG. FR-010 is about UNDECLARED egress, and an agent
    talking to its declared provider produces a line per request — thousands in a
    session, every one of them the expected thing. Keeping those would bury the
    handful of events the requirement exists for, and would set the retention bound
    to work evicting refusals to make room for ordinary traffic.

    Two shapes are kept, and the second is the one that would be easy to omit:

      * anything the boundary REFUSED — the observable form of undeclared egress;
      * anything it PERMITTED that the deployed allowlist does not name — which means
        the running boundary is not the one this tool configured (an operator
        override, a hand-edited container), and is a stronger finding than any
        refusal.

    An unknown `declared` keeps refusals only. Without the allowlist the permitted
    case cannot be told from ordinary traffic, so the alternatives are to fabricate
    findings or to store everything; recording the refusals is what remains true.

    >>> [egress_event_is_recordable("refused", d) for d in (True, False, None)]
    [True, True, True]
    >>> [egress_event_is_recordable("permitted", d) for d in (True, False, None)]
    [False, True, False]
    """
    if decision == EGRESS_DECISION_REFUSED:
        return True
    return declared is False


def build_egress_event(
    environment: str,
    deployment_host: str,
    timestamp: str,
    observed: dict,
    declared: bool | None,
) -> dict:
    """One event, with exactly the fields EGRESS_FIELD_PROVENANCE declares."""
    return {
        "schema": EGRESS_SCHEMA,
        "environment": environment,
        "deployment_host": deployment_host,
        "timestamp": timestamp,
        "host": observed["host"],
        "provider": provider_for_host(observed["host"]),
        "declared": declared,
        "decision": observed["decision"],
        "stage": observed["stage"],
    }


def egress_event_id(event: dict) -> str:
    """The event's name in the store: its timestamp, then a digest of the event.

    CONTENT-ADDRESSED BECAUSE THE SOURCE CANNOT BE CLEARED. A run record is removed
    from its volume once stored (`_clear_ingested`); a container log cannot be, so
    every drain re-reads lines it has already ingested. The same event must therefore
    land on the same name, or the store grows by a copy of itself on every contact.

    What that collapses is worth stating plainly: two events whose every recorded
    field is equal — same second, same destination, same verdict, same stage — become
    one record, because at §6's own resolution they are indistinguishable. So this
    store answers WHAT was reached for and WHEN, never HOW MANY times; the retry
    count is in `logs --egress` while the boundary lives. docs/egress.md says so, so
    nobody has to infer it from a count that does not add up.

    Shaped like a run id (`<compact-timestamp>-<nonce>`) so the store sorts
    chronologically by name, which is also what makes a retention announcement's id
    range readable.
    """
    digest = hashlib.sha256(json.dumps(event, sort_keys=True).encode()).hexdigest()[:8]
    return f"{event['timestamp'].replace('-', '').replace(':', '')}-{digest}"


def ingest_egress_events(host_name: str, host_rec: dict, name: str) -> list[str]:
    """Read <name>'s boundary log and store the undeclared-egress events in it.

    Returns the event ids newly stored — newly, because re-reading an unclearable
    source is normal here and an announcement on every contact for the same event
    would be noise indistinguishable from a new refusal.

    Retention runs in a `finally` around EVERY path, for the reason Feature 016 learned
    the hard way (see `ingest_records`): a bound skipped exactly when the drain fails is
    a bound that stops existing on the environment generating the most events. It wraps
    the early returns too, and that is not symmetry for its own sake — an environment
    whose declaration was WITHDRAWN never reads a log again, so a prune reachable only
    through the reading path would leave its events beyond the documented 90 days
    forever, with the store's own help text still promising otherwise.
    """
    try:
        return _read_egress_log(host_name, host_rec, name)
    finally:
        _prune_egress_and_announce(host_name, name)


def _read_egress_log(host_name: str, host_rec: dict, name: str) -> list[str]:
    """The drain itself — see `ingest_egress_events`, which adds retention around it."""
    if not _previous_model_had_egress(host_name, name):
        # No boundary was ever deployed here, so there is no log to read and nothing
        # observed this environment's egress. A LOCAL file read, so an environment
        # that never declared anything pays no remote call to say so — this runs in
        # front of almost every command.
        return []
    try:
        proc = subprocess.run(
            driver_egress_logs_argv(host_rec, name),
            capture_output=True,
            timeout=EGRESS_LOG_TIMEOUT,
        )
    except (OSError, subprocess.SubprocessError) as e:
        warn(f"{name}: could not read the egress boundary's log on {host_name} ({e})")
        return []
    if proc.returncode != 0:
        _warn_unless_the_boundary_is_gone(host_name, host_rec, name, proc)
        return []
    return _store_egress_events(host_name, name, proc)


def _warn_unless_the_boundary_is_gone(
    host_name: str, host_rec: dict, name: str, proc: subprocess.CompletedProcess
) -> None:
    """A failed log read is a warning UNLESS the boundary container is simply gone.

    A torn-down environment is the normal end state, and its stored events survive
    exactly so they can be read afterwards (US3 scenario 2) — warning on every
    contact that its log cannot be read would be a permanent complaint about nothing,
    and this feature's one rule about noise is that silence must stay meaningful.
    Anything else IS worth a warning: a boundary that exists and will not answer is
    the case where events are accruing and nothing is collecting them.

    The existence probe runs only on this path, so the happy case stays one call.
    """
    if runtime_container_exists(driver_runtime_argv(host_rec), egress_container_name(name)):
        detail = proc.stderr.decode("utf-8", "replace").strip() or f"exit {proc.returncode}"
        warn(
            f"{name}: the egress boundary on {host_name} would not hand over its log "
            f"({detail}); events in it are NOT recorded — `agent-container logs {name} --egress`"
        )


def _stamps_step_back(streams: list[list[tuple[str | None, str]]]) -> bool:
    """Whether any single stream contains a stamp below one already seen in it.

    Per stream, because the boundary writes two of them — squid's access log on
    stdout, unbound's replies on stderr — and the seam between two independently
    ordered producers is a stamp decrease that means nothing. Flattening them first
    and asking the same question would report a step-back on every drain, which is
    the mirror of the defect this exists to close: a detector that always fires is
    as useless as one that never does.

    Lines with no stamp are skipped rather than treated as zero: an unstamped line
    is a format the reader does not recognise, and `_egress_line_is_unreadable`
    already accounts for it. Reading it as time 0 would fake a step-back.

    >>> _stamps_step_back([[("2026-01-01T10:00:00Z", "a"), ("2026-01-01T11:00:00Z", "b")]])
    False
    >>> _stamps_step_back([[("2026-01-01T11:00:00Z", "a"), ("2026-01-01T10:00:00Z", "b")]])
    True
    >>> _stamps_step_back([[("2026-01-01T11:00:00Z", "a")], [("2026-01-01T10:00:00Z", "b")]])
    False
    """
    for stream in streams:
        highest = ""
        for stamp, _rest in stream:
            if stamp is None:
                continue
            if stamp < highest:
                return True
            highest = stamp
    return False


def _store_egress_events(host_name: str, name: str, proc: subprocess.CompletedProcess) -> list[str]:
    """Parse both of the boundary's streams and store what belongs in the store.

    Both streams, because the two producers use different ones: squid's access log is
    on stdout and unbound's replies (with squid's own diagnostics) on stderr. Reading
    one would silently lose a whole class of refusal — and DNS refusals are the
    common shape, since an undeclared name never gets as far as a connection.

    The window is materialised as `(stamp, rest)` pairs BEFORE anything is skipped,
    because the skip has to be decided against the whole window rather than line by
    line: a cursor left ahead of every line in it is indistinguishable from "all of
    this is already read", and that is the one way this reader can go quiet without
    saying so (see `_usable_egress_watermark`). Bounded by the same
    EGRESS_LOG_TAIL_LINES the read is.
    """
    allowlist = deployed_egress_allowlist(host_name, name)
    store = egress_store_dir(host_name, name)
    # PER STREAM, not flattened. The stdout/stderr seam is itself a legitimate stamp
    # decrease — two independent producers, two independent orderings — so a
    # step-back detector run over the concatenation would fire on every drain.
    streams = [
        [split_log_stamp(line) for line in blob.decode("utf-8", "replace").splitlines()]
        for blob in (proc.stdout, proc.stderr)
    ]
    window = list(itertools.chain.from_iterable(streams))
    mark = _usable_egress_watermark(store, name, [s for s, _rest in window if s is not None])
    # ASK THE ORDER, NOT THE MAXIMUM. `_usable_egress_watermark` compares the
    # window's highest stamp against the cursor, which only catches a step-back once
    # the PRE-step lines have aged out of the tail. This log is append-only and never
    # cleared, so the ordinary shape of the next drain holds both pre- and post-step
    # lines: the maximum still clears the mark, the detector stays quiet, and every
    # post-step line is dropped by `stamp < mark` before the parser ever sees it.
    # Measured through this very function: a benign line then a real REFUSED after the
    # clock moved back an hour stored NOTHING and warned NOTHING.
    #
    # A log is appended in clock order, so WITHIN ONE STREAM a stamp below one already
    # seen earlier in that same stream is direct proof of a step-back — and unlike the
    # maximum, that proof survives the pre-step lines still being present.
    if _stamps_step_back(streams):
        warn(
            f"{name}: the egress boundary's log clock stepped BACK inside this window, so "
            f"the read cursor cannot be trusted; re-reading the whole window. Refusals "
            f"logged after the step would otherwise have been skipped in silence."
        )
        _forget_egress_watermark(store)
        mark = ""
    seen = mark
    stored: list[tuple[str, dict]] = []
    unreadable = 0
    for stamp, rest in window:
        if stamp is not None:
            if stamp < mark:
                continue  # already ingested and possibly PRUNED since — see below
            seen = max(seen, stamp)
        event = _egress_event_from_line(host_name, name, stamp, rest, allowlist)
        if event is None:
            unreadable += _egress_line_is_unreadable(stamp, rest)
            continue
        filename = f"{egress_event_id(event)}.json"
        if (store / filename).exists():
            # Same event, same drain window: the watermark admits the whole of its
            # own second (see `advance_egress_watermark`), so this is the check
            # that keeps that overlap idempotent.
            continue
        try:
            atomic_write_json(store, filename, event)
        except OSError as e:
            warn(f"{name}: could not store egress event '{filename}' ({e})")
            continue
        stored.append((filename.removesuffix(".json"), event))
    advance_egress_watermark(store, seen)
    if unreadable:
        warn(
            f"{name}: {unreadable} line(s) of the egress boundary's log could not be read as "
            f"events, so what they recorded is NOT in the store. The log format and this "
            f"tool's reader have diverged — read them with "
            f"`agent-container logs {name} --egress`"
        )
    _announce_egress_events(name, [e for _id, e in stored])
    return [event_id for event_id, _e in stored]


# HOW FAR THIS ENVIRONMENT'S LOG HAS ALREADY BEEN READ — and it exists to stop
# retention from being theatre.
#
# The source cannot be cleared, so every drain re-reads lines it has ingested. That
# is harmless while the events are still in the store (the name is the content, so
# the write is skipped) and NOT harmless once retention has deleted one: the line is
# still in the window, so the next drain re-creates the event, the next prune deletes
# it again, and the tool announces both on every command the operator runs — a bound
# that never converges plus permanent noise, in the feature whose one rule is that
# silence stays meaningful.
#
# Deliberately NOT a `.json` file, so `list_stored_records`' suffix filter keeps it
# out of every listing: it is a cursor, not a record, and a store whose reader had to
# know which of its files are records would be one refactor from reporting a cursor as
# an event.
EGRESS_WATERMARK = "watermark"


def read_egress_watermark(store: Path) -> str:
    """The newest log stamp already read for this store, or "" for a fresh one.

    "" reads as "read everything", which is the safe direction: the worst case is
    re-ingesting events that are already there, and the alternative default would skip
    a window of real events on the first contact after an operator moved the store.
    """
    try:
        return (store / EGRESS_WATERMARK).read_text().strip()
    except OSError:
        return ""


def _usable_egress_watermark(store: Path, name: str, stamps: list[str]) -> str:
    """The stored cursor, or "" when this window proves the log's clock STEPPED BACK
    behind it — in which case the cursor is discarded and the operator is told.

    THIS IS THE ONE WAY THIS READER CAN GO QUIET, so it is detected rather than argued
    away. A mark newer than every line in the window is indistinguishable from "the
    whole window is already read": each line is skipped before the parser or the
    unreadable-line counter ever sees it, nothing is stored, no warning is raised, and
    `egress` then reports the environment as `watched` with nothing refused. Silence
    that means breakage is exactly what T034 forbids.

    ONE failure suffices to get there, and it is not the exotic one. The clamp in
    `advance_egress_watermark` defends only the direction where the boundary's log
    clock ran AHEAD of this tool's; it does nothing when the log clock steps BACKWARD
    from a value at or below it — a restored snapshot, a resumed VM, an operator
    correcting a clock. Every refusal logged in the resulting window would be dropped
    without a trace.

    So the cursor is discarded and re-earned: the window is read in full (idempotent —
    an event id IS its content, so nothing is duplicated), and the file is removed so
    `advance_egress_watermark`'s never-backwards rule cannot re-impose the stale mark
    and warn about the same step on every command forever. What that costs is one round
    of re-announcing events retention has already pruned, which is the direction that
    keeps a refusal rather than losing one.

    An empty window is NOT evidence of a step back — a boundary that has logged nothing
    since the last drain is the ordinary quiet case — so the cursor stands.
    """
    mark = read_egress_watermark(store)
    if not mark or not stamps or max(stamps) >= mark:
        return mark
    warn(
        f"{name}: the egress boundary's log clock has stepped BACK behind how far this "
        f"tool had read it (newest line {max(stamps)}, already read to {mark}). Re-reading "
        f"the whole window rather than skipping it — otherwise a refusal logged in that "
        f"window would be lost and this store would report nothing was refused."
    )
    _forget_egress_watermark(store)
    return ""


def _forget_egress_watermark(store: Path) -> None:
    """Discard the read cursor, so the window is re-read in full.

    Shared by the two step-back detectors — the one that compares the window's
    maximum against the cursor, and the one that finds a decrease WITHIN a stream —
    because a second copy of this could come to differ from the first, and the
    difference would be which of them silently leaves a stale mark in place.

    A failure to unlink is left in place deliberately, so the next drain warns again.
    Noisy and correct: the alternative is going quiet about a window this tool cannot
    account for.
    """
    try:
        (store / EGRESS_WATERMARK).unlink()
    except OSError as e:
        warn(f"could not reset how far {store}'s egress log was read ({e})")


def advance_egress_watermark(store: Path, stamp: str) -> None:
    """Record `stamp` as read — never beyond the tool's own clock, and never backwards.

    THE CLAMP IS LOAD-BEARING. A single line stamped in the future (a skewed host
    clock, a runtime bug) would otherwise park the mark ahead of every real event and
    blind this store permanently — strictly worse than the duplicate work the mark
    exists to avoid. The run store's retention had the same hazard from the other
    direction (a future-dated record was immortal, `_record_epoch`), and both are
    clamped for the same reason.

    What the clamp costs, precisely: after such a line the mark is THIS moment, so a
    line older than it is skipped. Nothing real is lost by that — every line of the
    window was already processed before the mark moved, and a log is appended in clock
    order, so what arrives next is newer.

    THE CLAMP IS NOT ENOUGH ON ITS OWN, and an earlier version of this docstring
    claimed it was. It does nothing about a mark this function wrote correctly that a
    LATER backward step of the boundary's clock leaves ahead of the whole window; that
    needs no bogus stamp and no second failure, and it is caught on the reading side by
    `_usable_egress_watermark` instead.

    Compared as STRINGS, which is safe only because TIME_FORMAT is fixed-width UTC and
    therefore sorts chronologically. That is a property of the format, so it is stated
    here rather than assumed by whoever next touches it.

    A failure to write is a warning, not an error: the drain's real work is already
    durable, and the cost of a lost mark is re-reading — which is exactly what happened
    before this existed.
    """
    now = time.strftime(TIME_FORMAT, time.gmtime())
    mark = min(stamp, now)
    if not mark or mark <= read_egress_watermark(store):
        return
    try:
        store.mkdir(parents=True, exist_ok=True)
        (store / EGRESS_WATERMARK).write_text(mark + "\n")
    except OSError as e:
        warn(f"could not record how far {store}'s egress log was read ({e})")


def _egress_line_is_unreadable(stamp: str | None, rest: str) -> int:
    """Whether a line that produced no event is one this tool SHOULD have read.

    Narrow to the conditions that mean the RECORD IS BROKEN rather than empty:

      * a reply line or an access-log line arriving with NO runtime timestamp, so no
        event can carry §6's `timestamp` at all;
      * a reply line the reply pattern does not match, or an access-log line whose
        field count is not the one `logformat egress` defines — i.e. a producer's
        format moved under its reader.

    Everything else the parser discards is discarded CORRECTLY: ordinary permitted
    traffic, squid's own diagnostics, an NXDOMAIN, a request-less connection that
    names no destination. Counting those would put a warning on every single drain,
    which is exactly how an operator learns to ignore the one that matters — and this
    counter exists only because a reader that has silently stopped matching presents
    as "nothing was refused". Returns an int so it can be summed directly.
    """
    if looks_like_unbound_reply_line(rest):
        return int(stamp is None or _UNBOUND_REPLY_RE.search(rest) is None)
    if looks_like_squid_access_line(rest):
        return int(stamp is None or len(rest.split()) != SQUID_LOG_FIELDS)
    return 0


def _egress_event_from_line(
    host_name: str, name: str, stamp: str | None, rest: str, allowlist: list[str] | None
) -> dict | None:
    """One log line to one storable event, or None when it is not one.

    None covers four different nothings, and they are deliberately not distinguished
    here: not an event at all (a diagnostic), an event this store does not keep
    (ordinary permitted traffic, an NXDOMAIN), an event with no usable time, and a
    line the parser could not read. Only the caller can tell the last two apart from
    the first two — it holds the line — and only those two are worth a warning.
    """
    observed = parse_unbound_egress_line(rest) or parse_squid_egress_line(rest)
    if observed is None or stamp is None:
        # §6's `timestamp` is not optional and a fabricated one would sort and prune
        # against real ones, so a line with no usable time yields no event at all.
        return None
    declared = observed["declared"]
    if declared is None and allowlist is not None:
        declared = squid_allowlist_permits(allowlist, observed["host"])
    if not egress_event_is_recordable(observed["decision"], declared):
        return None
    return build_egress_event(name, host_name, stamp, observed, declared)


def squid_allowlist_permits(tokens: list[str], host: str) -> bool:
    """Whether the deployed allowlist admits `host` over HTTP/HTTPS.

    Uses `squid_token_matches`, i.e. the same rule as the pre-deploy predicates, for
    the reason given there. A `{host, port: 22}` destination is deliberately NOT in
    this list (build_squid_acl excludes ported entries so declaring SSH does not open
    443 — SC-010), so an HTTPS attempt at one is correctly recorded as undeclared.
    """
    return any(squid_token_matches(t, host) for t in tokens)


def _announce_egress_events(name: str, events: list[dict]) -> None:
    """Say, at the drain, that undeclared egress was recorded (US3 scenario 1).

    Without this an operator learns of a refusal only by thinking to ask, and the
    events worth knowing about are exactly the ones nobody expects to exist. It fires
    only when something was newly stored, so the silence when nothing happened is
    intact — that is US3 scenario 3, and it is the reason this is not a per-drain
    "0 events" line.

    Names the destinations rather than only counting them: "recorded 3 events" answers
    a question no operator has. On stderr via `log`, so `--json` mode still carries it
    — a side effect that only the human mode reported would be invisible to the agent
    FR-013 exists for.
    """
    if not events:
        return
    hosts = sorted({str(e["host"]) for e in events})
    shown = ", ".join(hosts[:5]) + (f" (+{len(hosts) - 5} more)" if len(hosts) > 5 else "")
    log(
        f"{name}: recorded {len(events)} undeclared-egress event(s) from the boundary's log "
        f"— {shown}. Read them with `agent-container egress {name}`"
    )


# Retention for the egress store. ITS OWN numbers and ITS OWN AXIS (FR-011a): the run
# store's constants bound a different producer, so a store that inherited them would
# be bounded by a rule decided about something else. The MECHANISM is shared —
# `_round_robin_keeps`, with the destination as the bucket where the run store passes
# the UTC day — because the property both stores want is the same one.
#
# 90 days matches the run store's window by DECISION, not by inheritance: an operator
# reads the two together — "what did this environment do, and what did it reach for" —
# and two different horizons would make "nothing before May" mean two things.
EGRESS_RETENTION_MAX_AGE_DAYS = 90
# 500 is enough because of the axis, not on its own: round-robin over destinations
# means the count is spent on DISTINCT hosts first, so 500 keeps the newest event of
# up to 500 destinations. A store that reaches that has an agent scanning, and 500
# named destinations is the story rather than a limitation.
EGRESS_RETENTION_MAX_RECORDS = 500


def _egress_epoch_and_host(path: Path) -> tuple[float, str]:
    """(when the event happened, its destination) for retention.

    From the record's own `timestamp`, not the file's mtime: mtime is when the tool
    last contacted the host, so an age rule built on it would keep a year-old refusal
    alive because a drain re-read the line this morning.

    An unreadable record falls back to mtime, which is always LATER than the event —
    so the fallback errs toward KEEPING, and pruning is the one operation here that
    cannot be undone. A vanished file returns infinity for the same reason: keep what
    can no longer even be read rather than delete it. Its destination reads as "" and
    lands in a bucket of its own, which is the honest answer for a record whose host
    is unknown.
    """
    try:
        rec = json.loads(path.read_text())
        epoch = float(calendar.timegm(time.strptime(str(rec["timestamp"]), TIME_FORMAT)))
        return epoch, str(rec.get("host") or "")
    except OSError, ValueError, TypeError, KeyError, json.JSONDecodeError:
        try:
            return path.stat().st_mtime, ""
        except OSError:
            return float("inf"), ""


def prune_egress_store(directory: Path, now: float | None = None) -> list[str]:
    """Delete the events past either retention bound; return the names deleted.

    Both bounds, "whichever prunes first", for the reason prune_run_store gives: they
    bound two failures neither of which the other catches.

    THE COUNT BOUND IS `_round_robin_keeps` WITH THE DESTINATION AS THE BUCKET, where
    the run store passes the UTC day. Same rule, different axis, and the axis is the
    decision: there a burst is the tool's own restart loop spread over hours, here it
    is ONE destination an agent retries — a misconfigured provider is refused on every
    attempt — and what an operator asks of this store is WHICH hosts were reached for.
    So the newest event of every distinct host survives before any host gets a second.

    Reads only `directory`. The pending events are in a container log this function
    never touches, so it cannot delete an event that has not been ingested — the one
    loss nobody could notice, made impossible by construction rather than by care.

    A file that cannot be deleted is a warning, not a failure: retention is
    bookkeeping on the way to the operator's actual command.
    """
    paths = list_stored_records(directory)
    if not paths:
        return []
    cutoff = (time.time() if now is None else now) - EGRESS_RETENTION_MAX_AGE_DAYS * 86400
    ordered = sorted(
        ((*_egress_epoch_and_host(p), p) for p in paths),
        key=operator.itemgetter(0, 2),
        reverse=True,
    )
    keep = _round_robin_keeps(
        [row for row in ordered if row[0] >= cutoff], EGRESS_RETENTION_MAX_RECORDS
    )
    removed: list[str] = []
    for _epoch, _host, path in ordered:
        if path in keep:
            continue
        try:
            path.unlink()
        except OSError as e:
            warn(f"could not prune egress event {path} ({e})")
            continue
        removed.append(path.name)
    return removed


def _prune_egress_and_announce(host_name: str, name: str) -> None:
    """Prune <name>'s egress store and say so.

    Deleting a durable record is announced for the same reason Feature 016 announces
    it: the whole feature is that the account outlives the container, so the tool
    removing one is the last thing that may happen quietly.

    NEVER RAISES, because `ingest_egress_events` calls this from a `finally`: an
    exception here would REPLACE the drain's own failure with a complaint about
    bookkeeping, which is the masking failure C11 exists to prevent.
    """
    try:
        pruned = prune_egress_store(egress_store_dir(host_name, name))
    except OSError as e:
        warn(f"{name}: could not apply egress-record retention ({e})")
        return
    if not pruned:
        return
    span = min(pruned).removesuffix(".json")
    if len(pruned) > 1:
        span += f" .. {max(pruned).removesuffix('.json')}"
    log(
        f"{name}: pruned {len(pruned)} egress event(s) past retention "
        f"({EGRESS_RETENTION_MAX_AGE_DAYS} days / {EGRESS_RETENTION_MAX_RECORDS} events per "
        f"environment, spent on distinct destinations first): {span}"
    )


def stored_egress_environments(host: str) -> list[str]:
    """Environments with an egress store on <host>.

    Read from the store, not from the state dir: an environment torn down last month
    has no state and still has events, which is the entire requirement (FR-010).
    """
    try:
        return sorted(p.name for p in (DATA_DIR / "egress" / host).iterdir() if p.is_dir())
    except OSError:
        return []


def stored_egress_events(host: str, environments: list[str]) -> list[dict]:
    """Every stored event for <environments> on <host>, NEWEST FIRST.

    Sorted by the event's own fields rather than by file mtime, which is when the
    tool last contacted the host and is shared to the second by a whole drain — an
    mtime order would look chronological and be nothing of the kind.
    """
    out: list[dict] = []
    for env in environments:
        for p in list_stored_records(egress_store_dir(host, env)):
            rec = read_stored_record(p, kind="egress event")
            if rec is not None:
                out.append(rec)
    return sorted(out, key=_egress_sort_key, reverse=True)


def _egress_sort_key(event: dict) -> tuple[str, str, str, str]:
    """Time first, then the fields that make one event distinct from another in the
    same second. There is no id field to fall back on — an event's identity IS its
    content (see `egress_event_id`) — so the tie-break is that content, which keeps
    two listings of the same store in the same order."""
    return (
        str(event.get("timestamp") or ""),
        str(event.get("environment") or ""),
        str(event.get("host") or ""),
        str(event.get("stage") or ""),
    )


def stage_ssh_injection(host: str, name: str, authorized_keys: list[Path]) -> Path | None:
    """Stage the injected authorized_keys as a LOCAL file under the per-host state
    dir, returned for the compose model to reference as a config (transfers over a
    remote context; a bind resolves empty on a remote host).

    Staged **0644**: compose exposes the source file's mode into the container,
    where the unprivileged `dev` user reads it — and dev's uid (1000) need NOT equal
    the host uid that ran `up` (it does not on CI runners), so a 0600 file is
    unreadable inside the container and crash-loops the entrypoint.

    Feature 018 removed the private-host-key arm of this function. The mode above is
    also WHY it had to be removed rather than tightened: a private key here could not
    have been staged 0600 without breaking the container that reads it, and `mode:`
    on the config reference was measured to be ignored in favour of the source's
    mode. Public keys are fine at 0644 — they are public.
    """
    d = host_state_dir(host)
    d.mkdir(parents=True, exist_ok=True)
    d.chmod(0o700)
    ak_file: Path | None = None
    if authorized_keys:
        blob = ""
        for p in authorized_keys:
            pk = p.expanduser()
            if not pk.is_file():
                die(f"--authorized-key: {p} does not exist or is not a file")
            text = pk.read_text()
            blob += text if text.endswith("\n") else text + "\n"
        ak_file = d / f"{name}.authorized_keys"
        ak_file.write_text(blob)
        ak_file.chmod(0o644)
    return ak_file


def stage_push_injection(
    host: str, name: str, push_key: Path | None, known_hosts: Path | None
) -> list[tuple[str, Path, str]]:
    """Stage the outbound push credential (US1) as LOCAL files under the per-host
    state dir and return the (config-name, staged file, in-container target)
    entries for build_compose_model's `injected_configs`. EPHEMERAL by design: the
    targets live under /run (FR-012), so the entrypoint reads the key there and
    NEVER copies it onto the persisted ~/.ssh volume — the deliberate opposite of
    the inbound host key. Fails fast if a referenced file is absent (FR-016). 0644
    for the same uid-mismatch reason as stage_ssh_injection (the state dir is
    0700). The push key is a DISTINCT credential from the inbound host key (SC-008)."""
    entries: list[tuple[str, Path, str]] = []
    if push_key is known_hosts is None:
        return entries
    d = host_state_dir(host)
    d.mkdir(parents=True, exist_ok=True)
    d.chmod(0o700)
    if push_key is not None:
        pk = push_key.expanduser()
        if not pk.is_file():
            die(f"--push-key: {push_key} does not exist or is not a file")
        validate_private_key(pk)  # reject encrypted — a passphrase would block push
        f = d / f"{name}.push_key"
        f.write_bytes(pk.read_bytes())
        f.chmod(0o644)
        entries.append(("push_key", f, INJECT_PUSH_KEY_PATH))
    if known_hosts is not None:
        kh = known_hosts.expanduser()
        if not kh.is_file():
            die(f"--known-hosts: {known_hosts} does not exist or is not a file")
        f = d / f"{name}.known_hosts"
        f.write_bytes(kh.read_bytes())
        f.chmod(0o644)
        entries.append(("known_hosts", f, INJECT_KNOWN_HOSTS_PATH))
    return entries


def stage_apikey_injection(
    host: str, name: str, cwd: Path | None = None
) -> list[tuple[str, Path, str]]:
    """Stage convention-discovered model/API key FILES (US2, T012) as LOCAL files
    under the per-host state dir and return the (config-name, staged file,
    in-container target) entries for build_compose_model's `injected_configs`.

    EPHEMERAL by construction (H1, FR-012): every provider key targets
    INJECT_APIKEY_DIR/<provider> under /run, so the entrypoint reads it there and
    it is NEVER copied onto a per-agent volume — the tool-injected model credential
    is ALWAYS ephemeral (the deliberate opposite of an operator's interactive,
    on-volume login). Byte-copied without inspecting contents (secret hygiene),
    0644 for the same uid-mismatch reason as stage_push_injection (the state dir is
    0700). Discovery is automatic; absent → [] so env/`.env` delivery stays the
    layered fallback."""
    discovered = discover_apikey_files(name, cwd)
    entries: list[tuple[str, Path, str]] = []
    if not discovered:
        return entries
    d = host_state_dir(host)
    d.mkdir(parents=True, exist_ok=True)
    d.chmod(0o700)
    for provider, src in discovered.items():
        if not src.is_file():  # discovered then vanished/unreadable — fail fast (FR-016)
            die(f"model/API key file for '{provider}' disappeared before staging: {src}")
        f = d / f"{name}.apikey.{provider}"
        f.write_bytes(src.read_bytes())
        f.chmod(0o644)
        entries.append((f"apikey_{provider}", f, f"{INJECT_APIKEY_DIR}/{provider}"))
    return entries


def stage_config_injection(
    host: str, name: str, cwd: Path | None = None
) -> list[tuple[str, Path, str]]:
    """Stage the convention-discovered CANONICAL agent config (US3, T016) as LOCAL
    files under the per-host state dir and return the (config-name, staged file,
    in-container target) entries for build_compose_model's `injected_configs`.

    Canonical files target INJECT_CONFIG_DIR/<home-relative-path> — the entrypoint
    mirrors that tree onto the per-agent volume FRESH each boot, so a local edit
    propagates on the next up/redeploy (FR-007) while the agent's other runtime-
    state files under the home are left untouched (FR-008). Canonical config is
    non-secret by definition (FR-007); real secrets travel the dedicated ephemeral
    key-file channel (US2). Byte-copied without inspecting contents (secret
    hygiene), 0644 for the same uid-mismatch reason as stage_push_injection (the
    state dir is 0700). Discovery is automatic; absent → [] so the default-config
    path is unchanged."""
    discovered = discover_canonical_config(name, cwd)
    entries: list[tuple[str, Path, str]] = []
    if not discovered:
        return entries
    d = host_state_dir(host)
    d.mkdir(parents=True, exist_ok=True)
    d.chmod(0o700)
    for relpath, src in discovered:
        if not src.is_file():  # discovered then vanished/unreadable — fail fast (FR-016)
            die(f"canonical config file disappeared before staging: {src}")
        # Flatten the home-relative path into a single unique local staged filename
        # and a compose-safe config resource name (no leading dot, no slash).
        flat = relpath.replace("/", "__")
        f = d / f"{name}.config.{flat}"
        f.write_bytes(src.read_bytes())
        f.chmod(0o644)
        token = re.sub(r"[^a-z0-9]+", "_", flat.lower()).strip("_")
        entries.append((f"config_{token}", f, f"{INJECT_CONFIG_DIR}/{relpath}"))
    return entries


@dataclass
class ExecSpec:
    """Feature 004 deploy-time execution options, threaded from `up`/`redeploy`
    into the compose model + the entrypoint. Defaults reproduce the pre-004 shape
    (interactive claude on a persistent workspace) so existing call sites and
    every existing deployment are unchanged (Constitution IV)."""

    mode: str = "interactive"
    agent: str = "claude"
    task: str | None = None  # resolved text (after @file); delivered as an injected file
    repo: str | None = None  # clone-on-start source URL (persistent/ephemeral only)
    workspace: str = "persistent"
    workspace_dir: str | None = None  # host dir for a bind workspace (local hosts only)
    foreground: bool = False  # headless: run `compose up` attached (stream + exit code)

    def validate(self) -> None:
        if self.mode not in EXEC_MODES:
            die(f"--mode must be one of {', '.join(EXEC_MODES)} (got '{self.mode}')")
        if self.agent not in AGENTS:
            die(f"--agent must be one of {', '.join(AGENTS)} (got '{self.agent}')")
        if self.workspace not in WORKSPACE_MODES:
            die(f"--workspace must be one of {', '.join(WORKSPACE_MODES)} (got '{self.workspace}')")
        # --foreground is headless-only (FR-017): a clear diagnostic, never silently
        # ignored in interactive mode (analyze L1).
        if self.foreground and self.mode != "headless":
            die("--foreground is only valid with --mode headless")

    def restart_policy(self) -> str:
        """Per-mode compose `restart:` — interactive is kept alive; a headless
        success exits and is not resurrected, a failure follows policy (FR-005)."""
        return "on-failure" if self.mode == "headless" else "unless-stopped"

    def compose_environment(self) -> dict[str, str]:
        """The non-secret settings delivered as compose environment. The clone URL
        env is AGENT_CONTAINER_CLONE_URL — distinct from AGENT_CONTAINER_REPO (the
        CLI's host-side build-context override)."""
        env = {"AGENT_CONTAINER_MODE": self.mode, "AGENT_CONTAINER_AGENT": self.agent}
        # Clone-on-start applies to persistent/ephemeral only — a bind workspace is
        # already present and is never cloned (data-model R4), so don't even set the
        # env for it.
        if self.repo and self.workspace != "bind":
            env["AGENT_CONTAINER_CLONE_URL"] = self.repo
        return env


def resolve_task(task: str | None) -> str | None:
    """Resolve --task: a bare string is the task text; a leading '@' reads a LOCAL
    file whose whole content is the task (research R2). Delivered later as an
    injected file (never argv/env, no size cap).

    Every task the tool accepts funnels through here — `up --task`, `redeploy
    --task`, and a declarative `container.task` — and each is recorded verbatim in
    the run record (C13). A rule stated only at this funnel is read by no operator
    at all, so each acceptance site carries it where its task is TYPED: the help of
    the two flags does that, and a NEW site owes the same. `container.task` is the
    site with no help string to carry it — its statement has to be made where the
    key is documented.
    """
    if task is None:
        return None
    if task.startswith("@"):
        p = Path(task[1:]).expanduser()
        if not p.is_file():
            die(f"--task {task}: file '{p}' does not exist or is not a file")
        return p.read_text()
    return task


def resolve_workspace(spec: ExecSpec, name: str, host_rec: dict) -> tuple[str | None, bool]:
    """Resolve the workspace mode into (/workspace mount | None, declare_volume).

    persistent → the named workspace volume, declared (survives recreation, FR-012);
    bind → a local-abs '<dir>:/workspace' bind, NOT declared, refused on a non-local
    host (FR-011); ephemeral → nothing mounted (the container's own layer), not
    declared (FR-013). Mode is independent of --mode (FR-016)."""
    if spec.workspace == "persistent":
        return f"{volume_name(name)}:/workspace", True
    if spec.workspace == "ephemeral":
        return None, False
    # bind
    if spec.workspace_dir is None:
        die("--workspace bind requires --workspace-dir <local-abs-dir>")
    if not host_is_local(host_rec):
        die(
            "--workspace bind is only supported on a LOCAL host — a remote host "
            "cannot see your local filesystem (use persistent or ephemeral there)"
        )
    p = Path(spec.workspace_dir).expanduser()
    if not p.is_dir():
        die(f"--workspace-dir {spec.workspace_dir} does not exist or is not a directory")
    return f"{p.resolve()}:/workspace", False


def is_ssh_git_url(url: str) -> bool:
    """A git URL that authenticates over SSH (uses the injected push key): an
    ssh:// URL or a scp-like 'user@host:path'. https:// uses GH_TOKEN instead.

    >>> is_ssh_git_url("git@github.com:you/repo.git")
    True
    >>> is_ssh_git_url("ssh://git@github.com/you/repo.git")
    True
    >>> is_ssh_git_url("https://github.com/you/repo.git")
    False
    """
    return url.startswith("ssh://") or bool(re.match(r"^[^/@]+@[^/:]+:", url))


def env_file_defines(env_file: Path | list[Path] | None, key: str) -> bool:
    """Best-effort: does `env_file` define a non-empty `key`? Only for the clone
    fail-fast pre-check — the entrypoint does the authoritative in-container check."""
    if env_file is None:
        return False
    files = [env_file] if isinstance(env_file, Path) else list(env_file)
    return any(_one_env_file_defines(f, key) for f in files)


def _one_env_file_defines(env_file: Path, key: str) -> bool:
    try:
        for raw in env_file.read_text().splitlines():
            line = raw.strip()
            if line.startswith("#") or "=" not in line:
                continue
            k, _, v = line.partition("=")
            if k.strip().removeprefix("export ").strip() == key and v.strip().strip("\"'"):
                return True
    except OSError:
        return False
    return False


def clone_credential_precheck(
    spec: ExecSpec, env_file: Path | list[Path] | None, push_key: Path | None
) -> None:
    """Fail fast (FR-014) when an SSH-URL clone-on-start has no push key: refuse
    BEFORE creating an empty-workspace container. https:// needs no key (GH_TOKEN
    is always present); bind is never cloned. The entrypoint re-checks in-container."""
    if not spec.repo or spec.workspace == "bind":
        return
    if not is_ssh_git_url(spec.repo):
        return  # https:// → GH_TOKEN
    if push_key is not None or env_file_defines(env_file, "SSH_PUSH_KEY_B64"):
        return
    die(
        f"--repo {spec.repo} is an SSH URL but no push key is injected — pass "
        f"--push-key (or set SSH_PUSH_KEY_B64 in the env-file). Refusing to start "
        f"an empty-workspace agent (FR-014)."
    )


def stage_task_injection(
    host: str, name: str, task_text: str | None
) -> list[tuple[str, Path, str]]:
    """Stage the initial/headless task as a LOCAL file and return the
    injected_configs entry (ephemeral /run target — never argv/env, no size cap,
    research R2). Absent task → [] (the default no-task path is unchanged)."""
    if task_text is None:
        return []
    d = host_state_dir(host)
    d.mkdir(parents=True, exist_ok=True)
    d.chmod(0o700)
    f = d / f"{name}.task"
    f.write_text(task_text)
    f.chmod(0o644)
    return [("task", f, INJECT_TASK_PATH)]


def record_never_started(host_name: str, name: str, spec: ExecSpec, reason: str) -> str | None:
    """The ONE record the tool authors itself (C6, research R5).

    By definition nothing inside the container ran, so nothing inside could
    report — the asymmetry R5 insists on stating, or the ingestion path would
    assume every record arrives from a volume. This one is written straight to the
    durable store; there is no volume in this story.

    `exit_code` and `repository` are null rather than `0` and `{}`. A zero would
    read as a clean run that never happened, and an empty repository effect would
    claim the run changed nothing when the truth is that it never looked — which is
    the distinction the spec's edge case asks for between a run that never started
    and one that started and failed.

    Interactive is deliberately absent rather than handled: `never-started` is not
    in the interactive vocabulary (data-model §2), so a session that failed to come
    up is UNREPRESENTABLE as a record instead of being filed under a borrowed word.

    Returns the run id, or None when nothing was recorded.

    NOTHING BELOW MAY RAISE (FR-008, C11). The one caller is an `except Fatal:`
    block that re-raises the failure the operator is actually waiting to read, so an
    exception escaping here would REPLACE that failure with a complaint about
    bookkeeping — the run would report the wrong reason for not starting, which is
    worse than reporting no record. The construction is inside the guard too, and
    not only the write: `build_run_record` refuses an illegal record by calling
    `die`, and a `die` is a Fatal like any other.
    """
    if spec.mode != "headless":
        return None
    now = utc_now()
    try:
        record = build_run_record(
            run_id=new_run_id(),
            environment=name,
            agent=spec.agent,
            kind="headless",
            outcome=RUN_OUTCOME_NEVER_STARTED,
            started_at=now,
            ended_at=now,
            task=spec.task,
            host=host_name,
            repository=None,
            notes=[reason],
        )
        atomic_write_json(runs_store_dir(host_name, name), f"{record['run_id']}.json", record)
    except (Fatal, OSError, ValueError, TypeError) as e:
        # Not silent, though (C11's other half). The run is already failing on its
        # own terms; this warning exists so a missing record is not later mistaken
        # for a run that never happened.
        warn(f"{name}: the run never started and its record could not be written either ({e})")
        return None
    log(f"recorded run {record['run_id']} as never-started ({reason})")
    return record["run_id"]


def compose_up_exec(
    host_name: str,
    host_rec: dict,
    name: str,
    env_file: Path | list[Path],
    mounts: list[str],
    authorized_keys: list[Path],
    redeploy: bool = False,
    push_key: Path | None = None,
    known_hosts: Path | None = None,
    spec: ExecSpec | None = None,
    extra_injected_configs: list[tuple[str, Path, str]] | None = None,
) -> None:
    """Generate the compose project and bring it up on the host (image builds on
    the host). With redeploy=True it force-recreates (US2, deliberately
    non-idempotent) — the container already holds its port, so the port-free
    guard is skipped and its named volumes are preserved across the recreate.

    ALL injected material is staged + validated HERE, before the compose call, so
    a missing/invalid item aborts before the container is created — the fail-fast /
    no-partial-provisioning guard every credential story inherits (FR-016/FR-017).

    Feature 004: `spec` carries the execution mode/agent/task/workspace/repo. The
    per-mode `restart`, the workspace mount + conditional workspace volume, the
    mode/agent/repo env, and the task inject are all threaded into the model here."""
    spec = spec or ExecSpec()
    port = port_for_name(name)
    if not redeploy and host_is_local(host_rec) and not port_free(port):
        # The port is busy RIGHT NOW — but this is a pre-check, not a reservation:
        # we cannot hold the port between here and the daemon's bind, so failing
        # here is a TOCTOU guess. Overwhelmingly the cause is the previous
        # container of this same name still tearing down its published-port
        # forwarding, which resolves on its own in a moment.
        #
        # So: WAIT for it rather than refusing, then proceed regardless and let the
        # daemon — the only component that can actually bind it — be the authority.
        # If something else genuinely holds the port, compose fails with its own
        # precise error, exactly as it already does for remote hosts (which never
        # ran this pre-check at all).
        log(f"port {port} is busy; waiting for it to be released…")
        wait_port_released(port)
    ak_file = stage_ssh_injection(host_name, name, authorized_keys)
    injected = stage_push_injection(host_name, name, push_key, known_hosts)  # US1, ephemeral
    # US2 (T012): convention-discovered model/API key files, staged ephemerally to
    # INJECT_APIKEY_DIR/<provider> (H1/FR-012). Automatic — no flags; env/.env still works.
    injected += stage_apikey_injection(host_name, name)
    # US3 (T016): convention-discovered CANONICAL agent config (non-secret, FR-007),
    # staged fresh each deploy to INJECT_CONFIG_DIR. Automatic — no flags; the
    # entrypoint mirrors it onto the volume each boot (secrets travel the key channel).
    injected += stage_config_injection(host_name, name)
    # Feature 004: the initial/headless task rides the same ephemeral inject channel.
    injected += stage_task_injection(host_name, name, spec.task)
    # Feature 006: the .agent-container spec is delivered READ-ONLY via this same
    # compose-`configs` channel (remote-context-safe; FR-020) — the agent-as-code
    # apply path passes it here so the governing spec is immutable in-container.
    injected += list(extra_injected_configs or [])
    # Feature 004: resolve the workspace mount (persistent volume / local bind /
    # none→container layer) + whether to declare the named workspace volume. bind on
    # a non-local host is refused here, before any runtime call (FR-011).
    ws_mount, declare_ws = resolve_workspace(spec, name, host_rec)
    build_ctx = resolve_build_context()
    # Feature 012: the declaration is read HERE — the one choke point every deploy
    # path passes through (`up`, `apply`, `redeploy`, wizard). A lookup in `do_up`
    # would leave `redeploy` unenforced while the declaration read as enforced.
    egress = resolve_egress_declaration(name)
    # FR-003c BEFORE staging: if an enforced declaration would break `git push`,
    # the operator hears it now, not after the agent has done a session of work.
    _push_mode = (egress or {}).get("enforcement") or "advisory"
    # Resolved ONCE and reused: the SSH push check and the strength statement must
    # not be able to disagree about which mechanism is being deployed.
    _transparent = (
        egress_enforcement_mode(egress, spec.agent, resolve_sidecar_override(name))[0]
        == "transparent"
    )
    check_egress_permits_push(
        egress,
        spec.repo,
        _push_mode,
        # The SSH arm applies only where the boundary is packet-level. Asking here
        # rather than assuming: an environment that falls back to the proxy must not
        # be warned about an SSH push that will work perfectly well.
        transparent=_transparent,
    )
    _mode = (egress or {}).get("enforcement") or "advisory"
    # US2: the disclosure (undeclared + the agent has a default) and FR-003a (declared
    # but the default is outside the set) are mutually exclusive by construction —
    # one asks the operator to decide, the other tells them a decision is incomplete.
    disclose_builtin_default(egress, spec.agent)
    check_builtin_default_declared(egress, spec.agent, _mode)
    if is_egress_declared(egress):
        # The statement must describe the mechanism this environment ACTUALLY gets.
        # Printing the proxy text for a packet-level boundary understates it, and
        # printing the boundary text for a proxy fallback overstates it — the second
        # being the failure this whole requirement exists to prevent.
        log(egress_strength_statement(spec.agent, transparent=_transparent))
    # C6 backstop for the imperative path, where `env_file` IS the operator's file.
    # The apply path checks earlier, before staging, so it can name the operator's
    # file rather than the tool-generated merge (which they have never heard of).
    refuse_operator_proxy_vars(
        egress, spec.agent, env_file, override=resolve_sidecar_override(name)
    )
    _override = resolve_sidecar_override(name)
    # Refuse an opt-out naming a service that does not exist, BEFORE staging:
    # a renamed sidecar would otherwise sit inside the boundary while the
    # declaration says it is outside, or vice versa.
    verify_sidecars_outside_resolve(egress, _override)
    if _override is not None:
        check_sidecar_egress_posture(_override, egress)
    _enforced = enforce_egress_declaration(egress, spec.agent, _override)
    # AFTER `_enforced`, and gated on the SAME variable the boundary overlay below
    # uses — the refusal's entire premise is that the sidecars share the agent's
    # namespace, which is true only when a boundary is deployed. Still before
    # staging and before `down_container`'s port migration, so nothing has been
    # written or torn down when it fires.
    refuse_sidecar_name_in_allow(egress, _override, enforced=_enforced)
    # NOT gated on `not redeploy`: `redeploy` is precisely how a declaration is
    # added or removed, so skipping the migration there meant the one command that
    # triggers the port move was the one command that could not survive it.
    if phase_a_port_owner_stale(host_rec, host_name, name, _enforced):
        # T118 (adopting) and T129d (dropping). Compose cannot hand the published
        # port to a different service while the current owner still holds it, and
        # on the drop side that owner is an ORPHAN of the regenerated model — not
        # something a plain recreate will stand down in time.
        #
        # Announced, not silent: an operator whose container is recreated deserves
        # to know it was a migration and not a bug.
        _moving = "to the egress service" if _enforced else "back to the container"
        warn(
            f"{name}: the published port moves {_moving}, so the deployment is being "
            f"recreated. Volumes are preserved; the port number is unchanged."
        )
        down_container(host_name, host_rec, name, purge=False)
    # Feature 016 T010, the same class of blind spot one level down: the volume SET
    # changed while every identity value stayed the same. Announced here, before the
    # model is written, so the warning describes the shape being left rather than
    # the one already on disk — deployed_volume_set reads that file.
    announce_volume_set_migration(
        host_name,
        name,
        per_container_volumes(name) if declare_ws else other_container_volumes(name),
    )
    _dests = resolve_destinations(egress) if _enforced else []
    # R24/T155: the LAST silent property of the boundary, said out loud at deploy.
    warn_pinned_port_destinations(_dests, transparent=_transparent)
    egress_body = build_squid_acl(_dests) if _enforced else None
    egress_unbound_body = build_unbound_conf(_dests) if _enforced else None
    egress_ports_body = build_netfilter_rules(_dests) if _enforced else None
    # Published so `apply` can read it back and notice an edited declaration. NOT in
    # ExecSpec.compose_environment(): that is a pure function of the 004 execution
    # options with no access to the declaration, and an exact-equality test pins it.
    _token = egress_config_token(egress)
    _egress_env = {} if _token is None else {"AGENT_CONTAINER_EGRESS": _token}
    model = build_compose_model(
        name,
        build_ctx,
        ak_file,
        env_file,
        mounts,
        injected_configs=injected,
        restart=spec.restart_policy(),
        environment=spec.compose_environment() | _egress_env,
        workspace_mount=ws_mount,
        declare_workspace_volume=declare_ws,
        egress_filter_body=egress_body,
        egress_unbound_body=egress_unbound_body,
        egress_ports_body=egress_ports_body,
    )
    compose_file = write_compose_file(host_name, name, model)
    log(f"host={host_name} name={name} port={port} env-file={env_file}")
    log(f"compose={compose_file} mode={spec.mode} agent={spec.agent} workspace={spec.workspace}")
    if spec.workspace == "ephemeral":
        warn(
            "workspace=ephemeral: /workspace is NOT persisted — anything not "
            "committed-and-pushed is LOST on teardown (FR-015)."
        )
    project = compose_project(name)
    override = _override  # validated; merged as a 2nd -f (US4)
    if override is not None:
        log(f"sidecar override={override}")
    boundary_file = None
    if _enforced:
        overlay = build_sidecar_boundary_overlay(override, egress)
        if overlay is not None:
            boundary_file = host_state_dir(host_name) / f"{name}.boundary.yaml"
            boundary_file.write_text(json.dumps(overlay, indent=2) + "\n")
            log(f"sidecars inside the egress boundary: {', '.join(overlay['services'])}")
            # And what that does to how they are ADDRESSED, which the line above
            # does not say and no operator infers from "inside the boundary".
            warn_sidecar_hostnames_moved_to_loopback(list(overlay["services"]))
        outside = sidecars_outside_boundary(egress)
        if outside:
            # FR-023b/SC-015. NAMED, always — an unnamed exception is
            # indistinguishable from a bug, and `enforced: true` would otherwise
            # quietly mean "except for these".
            warn(
                f"egress: {', '.join(outside)} "
                + ("is" if len(outside) == 1 else "are")
                + " OUTSIDE the enforcement boundary by declaration. The agent can reach "
                + ("it" if len(outside) == 1 else "them")
                + ", and anything "
                + ("it" if len(outside) == 1 else "they")
                + " can reach is reachable through "
                + ("it" if len(outside) == 1 else "them")
                + "."
            )
    # Headless --foreground streams the run attached and propagates the agent's
    # exit code (FR-002/SC-004); everything else brings the deployment up detached.
    foreground = spec.foreground and spec.mode == "headless" and not redeploy
    if redeploy:
        argv = driver_redeploy_argv(host_rec, project, compose_file, override, boundary_file)
    else:
        argv = driver_up_argv(
            host_rec,
            project,
            compose_file,
            override,
            foreground=foreground,
            boundary=boundary_file,
        )
    rc = run_child(argv).returncode  # inherited stdio (stdout->stderr in JSON mode)
    if foreground:
        # The container's exit code IS the agent result — surface it as our own.
        write_state(host_name, name, port)
        log(f"headless run finished (exit {rc}); logs: agent-container logs {name}")
        raise typer.Exit(rc)
    if rc != 0:
        die(f"compose {'redeploy' if redeploy else 'up'} failed (exit {rc})")
    write_state(host_name, name, port)
    addr = driver_reachable_address(host_rec)
    # Feature 018: capture and pin, HERE — the one choke point every deploy path
    # passes through, for the reason resolve_egress_declaration records above.
    #
    # UNCONDITIONALLY, on EVERY deploy, and that is what makes FR-007 free rather
    # than stateful: the pin becomes by construction whatever the tool last saw, so
    # a mismatch at attach time means the key changed WITHOUT a deploy — i.e. not by
    # us — and refusing is correct with nothing to attribute. `--purge` + recreate
    # re-pins because the recreate IS a deploy. Do not add change-attribution state
    # here; there is nothing to attribute.
    capture_and_pin(host_name, host_rec, name, addr, port)
    # Feature 014 (FR-001): record the deployment HERE, for the same reason capture
    # and the egress declaration are here — `do_up` serves `up` and `apply`, but
    # `do_redeploy` and the wizard call `compose_up_exec` directly, so a hook in
    # `do_up` records some deploys and not others, and SC-001's 100% is unreachable
    # while nothing looks wrong. host_provisioned comes from the host record so US3
    # can later tell a host the tool CREATED from one merely registered.
    record_inventory_creation(name, host_name, bool(host_rec.get("created_by_tool")))
    # FR-011: an upgrade must not leave a pre-018 private key behind.
    remove_stale_staged_host_key(host_name, name)
    hflag = "" if host_name == DEFAULT_HOST else f" --host {host_name}"
    verb = "redeployed" if redeploy else "started"
    log(f"{verb} {container_name(name)}; attach with: agent-container attach {name}{hflag}")
    log(f"or directly: ssh dev@{addr} -p {port} -t tmux attach -t main")


def do_up(
    name: str,
    host: str | None = None,
    env_file_override: list[Path] | None = None,
    mounts: list[str] | None = None,
    authorized_keys: list[Path] | None = None,
    push_key: Path | None = None,
    known_hosts: Path | None = None,
    spec: ExecSpec | None = None,
    extra_injected_configs: list[tuple[str, Path, str]] | None = None,
) -> None:
    validate_name(name)
    spec = spec or ExecSpec()
    spec.validate()  # mode/agent/workspace choices + --foreground guard (FR-017)
    migrate_flat_state()
    # Resolve binds up front so a bad --mount aborts before any runtime call.
    resolved_mounts = [resolve_bind_mount(m) for m in (mounts or [])]
    host_name, host_rec = resolve_deploy_host(host)
    ensure_tunnel(host_rec)
    # Drain-on-contact (FR-001a, research R7). A detached run — the default
    # headless mode — ends with no CLI attached, so its record waits on the volume
    # until something talks to the host. `up` on the same environment is the most
    # common next contact, and doing it BEFORE the deploy means the previous run's
    # account is safe before this one starts overwriting the volume's state.
    drain_host_records(host_name, host_rec, [name])
    cname = container_name(name)
    hflag = "" if host_name == DEFAULT_HOST else f" --host {host_name}"

    existing = host_container_names(host_rec, include_stopped=True)
    if cname in host_container_names(host_rec):
        port = read_state_port(host_name, name)
        log(
            f"container {cname} already running on {host_name}"
            + (f" (port {port})" if port else "")
        )
        # `up` on a running container is a deliberate no-op, which is exactly why the
        # volume-set migration has to be MENTIONED here (T010). This is the one path
        # that neither recreates nor reconciles, so an environment kept alive by `up`
        # would carry a stale volume set indefinitely with nothing ever saying so.
        # It is not recreated behind the operator's back — `up` never does that — so
        # the message has to name the command that would.
        if announce_volume_set_migration(
            host_name,
            name,
            per_container_volumes(name)
            if spec.workspace == "persistent"
            else other_container_volumes(name),
        ):
            warn(f"run: agent-container redeploy {name}{hflag}")
        log(f"attach with: agent-container attach {name}{hflag}")
        return
    if cname in existing:
        die(
            f"container {cname} exists but is not running on {host_name}. Run: agent-container down {name}{hflag}"
        )

    # Everything from here on can fail with the container never having run — a
    # missing image, an unresolvable credential, compose refusing. C6 says that is
    # a DIFFERENT outcome from a run that started and failed, and only the tool can
    # say so, because there was never anything inside to say it.
    try:
        # FR-004: refuse a pre-011 layout BEFORE anything is created, so an operator
        # never gets a half-configured environment built from files we no longer read.
        refuse_superseded_layout(name)
        env_file = _resolve_env_files(name, env_file_override)

        # Fail fast (FR-014) if an SSH-URL clone-on-start has no push key — before the
        # container is created; https:// uses GH_TOKEN and needs none.
        clone_credential_precheck(spec, env_file, push_key)

        # The image builds on the host via compose (`up --build`) — no pre-built
        # image or registry needed. Serialized against concurrent lifecycle ops (FR-017).
        with deployment_lock(host_name, name):
            compose_up_exec(
                host_name,
                host_rec,
                name,
                env_file,
                resolved_mounts,
                authorized_keys or [],
                push_key=push_key,
                known_hosts=known_hosts,
                spec=spec,
                extra_injected_configs=extra_injected_configs,
            )
    except Fatal as e:
        # Fatal only. A headless FOREGROUND run leaves through typer.Exit carrying
        # the agent's exit code, and that container DID run — recording it here
        # would file a completed run as never-started, which is the one distinction
        # C6 exists to preserve.
        record_never_started(host_name, name, spec, f"never started: {e}")
        raise


def down_container(
    host_name: str, host_rec: dict, name: str, purge: bool, rmi_local: bool = False
) -> None:
    ensure_tunnel(host_rec)
    # Feature 014 (FR-004): torn down while its HOST remained — which is what
    # distinguishes `removed` from `host-gone`. The outcome keys on what
    # disappeared, not on who caused it.
    set_inventory_outcome(name, host_name, "removed")
    cname = container_name(name)
    compose_file = compose_file_path(host_name, name)
    # STOP, THEN DRAIN, THEN REMOVE — all three, in that order, and each for its
    # own reason (FR-001b / C4).
    #
    # The drain is before the removal because `--purge` destroys the runs volume,
    # and a drain placed after that is not a late drain, it is no drain — an
    # environment being destroyed is the single most likely moment for its record
    # to matter and the easiest one to lose it at.
    #
    # The STOP is before the drain because `compose down --volumes` kills the
    # container and drops its volume in ONE step, leaving no instant in between at
    # which the container's own final record could be collected. Measured: without
    # this, tearing down a RUNNING environment stored the pending record written at
    # start and destroyed the `stopped` one the SIGTERM trap wrote — the run ended
    # up recorded with a null outcome forever, which is exactly the ambiguous
    # ending SC-002 requires to be impossible. Stopping first lets the entrypoint
    # complete its own record (it writes before it waits, inside the grace period)
    # so the drain finds a finished one.
    #
    # Only the base file: the sidecars are torn down by the `down` below, and
    # stopping the agent is all the record needs. Unconditional rather than gated
    # on `purge`, because this same function runs the port-owner migration's
    # teardown, and a drain that only happened on the destructive path would leave
    # every other teardown accumulating records that a later `--purge` discards in
    # one go.
    if compose_file.is_file():
        query(driver_stop_argv(host_rec, compose_project(name), compose_file))
    else:
        query(driver_runtime_argv(host_rec) + ["stop", cname])
    drain_host_records(host_name, host_rec, [name])
    # host_container_names is fail-closed (it dies on an unreachable daemon). On
    # the teardown path that would strand the local state: down/wipe must still
    # clear the per-host state (and drop the derived compose artifact on purge)
    # even when the host is gone, so re-adding it later starts clean. Degrade a
    # failed enumeration to 'unknown' and fall through to the compose-file path.
    try:
        exists = cname in host_container_names(host_rec, include_stopped=True)
    except (Fatal, OSError, subprocess.SubprocessError) as e:
        warn(f"could not confirm container state on {host_name} ({e}); clearing local state anyway")
        exists = False
    vols = per_container_volumes(name)

    if exists or compose_file.is_file():
        log(f"stopping and removing {cname} on {host_name}")
        if compose_file.is_file():
            # Merge the sidecar override (US4) so `down`/`wipe` tear the helpers down
            # as one unit — but a now-invalid/removed override must never BLOCK a
            # teardown (compose down also reconciles by project label), so resolve it
            # leniently here, unlike the fail-fast create path.
            try:
                override = resolve_sidecar_override(name)
            except Fatal as e:
                warn(f"ignoring sidecar override for teardown ({e})")
                override = None
            # compose down removes the container (+ networks); --volumes also drops
            # the nine named volumes; --rmi local also removes the locally-built
            # image (wipe). Without those, volumes/image are preserved.
            query(
                driver_down_argv(
                    host_rec, compose_project(name), compose_file, purge, rmi_local, override
                )
            )
        else:
            # No compose file (e.g. a pre-compose container): fall back to rm and,
            # on purge, explicit volume removal. The EGRESS PROXY must be named
            # explicitly — it is deliberately outside CONTAINER_PREFIX, so nothing
            # else in this path would ever match it. query() tolerates the nonzero
            # from an absent container, so this is unconditional rather than probed.
            query(driver_runtime_argv(host_rec) + ["rm", "-f", cname])
            query(driver_runtime_argv(host_rec) + ["rm", "-f", egress_container_name(name)])
            if purge:
                for vname in vols:
                    query(driver_runtime_argv(host_rec) + ["volume", "rm", vname])
        # `rm`/compose-down returns before the daemon frees the published port;
        # wait so an immediate re-`up` on the same name doesn't hit 'port in use'.
        if host_is_local(host_rec):
            wait_port_released(port_for_name(name))
    else:
        # `exists` is computed from host_ps_rows, which filters on CONTAINER_PREFIX —
        # so a SURVIVING PROXY can never make it true. Without this, the tool would
        # report "no container named …" while agent-egress-<name> keeps running under
        # `restart: unless-stopped`, unmentioned by anything, forever.
        query(driver_runtime_argv(host_rec) + ["rm", "-f", egress_container_name(name)])
        log(f"no container named {cname} on {host_name}")

    if purge:
        compose_file.unlink(missing_ok=True)  # the derived artifact goes too
        log(f"purged volumes: {', '.join(vols)}")
    else:
        log(f"volumes preserved (use --purge to remove): {', '.join(vols)}")

    clear_state(host_name, name)


# Host-side paths of the persisted SSH material inside the container (rootless,
# dev-owned). Kept in sync with entrypoint.sh / the Dockerfile HostKey directive.
CONTAINER_HOSTKEY = "/home/dev/.ssh/hostkeys/ssh_host_ed25519_key"
CONTAINER_AUTHKEYS = "/home/dev/.ssh/authorized_keys"


def inject_keys(rt: str, name: str, authorized_keys: list[Path]) -> None:
    """Inject authorized_keys into a RUNNING container, onto the persisted ~/.ssh
    volume, without recreating it. Public keys are streamed over stdin (never on
    argv) and merged with dedup. Rootless: every in-container step runs as the dev
    user, no sudo.

    Feature 018 removed this function's host-key arm, which installed a PRIVATE key
    into a live container. Public keys stay: they are not the exposure, and the
    container's own host identity is now captured rather than supplied.
    """
    cname = container_name(name)
    for p in authorized_keys:
        pk = p.expanduser()
        if not pk.is_file():
            die(f"--authorized-key: {p} does not exist or is not a file")
        append_sh = (
            f"umask 077; mkdir -p /home/dev/.ssh; cat >> {CONTAINER_AUTHKEYS}; "
            f"awk 'NF && !seen[$0]++' {CONTAINER_AUTHKEYS} > {CONTAINER_AUTHKEYS}.tmp && "
            f"mv {CONTAINER_AUTHKEYS}.tmp {CONTAINER_AUTHKEYS}; chmod 600 {CONTAINER_AUTHKEYS}"
        )
        r = subprocess.run(
            [rt, "exec", "-i", cname, "bash", "-lc", append_sh], input=pk.read_bytes()
        )
        if r.returncode != 0:
            die(f"failed to inject authorized key {p}")
        log(f"injected authorized key {pk.name} into {cname}")


def cli_keys(name: str, authorized_keys: list[Path]) -> None:
    validate_name(name)
    if not authorized_keys:
        die("keys: nothing to inject — pass --authorized-key")
    rt = detect_runtime()
    cname = container_name(name)
    if not container_running(rt, cname):
        die(f"container {cname} is not running. Start it first: agent-container up {name}")
    inject_keys(rt, name, authorized_keys)


def cli_down(name: str, purge: bool, yes: bool, host: str | None = None) -> None:
    validate_name(name)
    host_name, host_rec = resolve_deploy_host(host)

    if host_is_local(host_rec) and quadlet_active(name):
        warn(
            f"systemd user unit {container_name(name)}.service is active; "
            f"systemd will restart the container after removal. Manage it via Quadlet instead."
        )
        if not yes:
            eprint("[agent-container] re-run with -y/--yes to proceed despite the active unit")
            raise typer.Exit(2)

    if not yes:
        if not is_tty():
            eprint("[agent-container] refusing destructive 'down' without -y/--yes on a non-TTY")
            raise typer.Exit(2)
        what = f"stop and remove {container_name(name)} on {host_name}"
        if purge:
            what += (
                f" and DELETE all per-container volumes ({', '.join(per_container_volumes(name))})"
            )
        if not questionary.confirm(f"{what}?", default=False).ask():
            log("aborted")
            return

    with deployment_lock(host_name, name):  # serialize vs concurrent lifecycle ops (FR-017)
        down_container(host_name, host_rec, name, purge)


def do_stop(name: str, host: str | None = None) -> None:
    """Pause/reclaim (FR-006): halt the deployment (all services) keeping the
    container and its volumes; `start` resumes it without recreation."""
    validate_name(name)
    host_name, host_rec = resolve_deploy_host(host)
    compose_file = compose_file_path(host_name, name)
    if not compose_file.is_file():
        die(
            f"no deployment named '{name}' on {host_name} to stop (deploy it: agent-container up {name})"
        )
    override = resolve_sidecar_override(name)  # act on the agent + helpers as one unit (US4)
    with deployment_lock(host_name, name):
        ensure_tunnel(host_rec)
        argv = driver_stop_argv(host_rec, compose_project(name), compose_file, override)
        if run_child(argv).returncode != 0:
            die("compose stop failed")
    log(f"stopped {container_name(name)} on {host_name} (resume: agent-container start {name})")


def do_start(name: str, host: str | None = None) -> None:
    """Resume a stopped deployment (FR-006): no recreation, no rebuild."""
    validate_name(name)
    host_name, host_rec = resolve_deploy_host(host)
    compose_file = compose_file_path(host_name, name)
    if not compose_file.is_file():
        die(
            f"no deployment named '{name}' on {host_name} (deploy it: agent-container up {name})",
            code="container_absent",
            entity=name,
            remedy=f"agent-container up {name}",
        )
    override = resolve_sidecar_override(name)  # act on the agent + helpers as one unit (US4)
    with deployment_lock(host_name, name):
        ensure_tunnel(host_rec)
        argv = driver_start_argv(host_rec, compose_project(name), compose_file, override)
        if run_child(argv).returncode != 0:
            die(
                f"compose start failed — if the deployment was disposed, run: agent-container up {name}"
            )
    hflag = "" if host_name == DEFAULT_HOST else f" --host {host_name}"
    log(
        f"started {container_name(name)} on {host_name}; attach with: agent-container attach {name}{hflag}"
    )


def do_redeploy(
    name: str,
    host: str | None = None,
    env_file_override: list[Path] | None = None,
    mounts: list[str] | None = None,
    authorized_keys: list[Path] | None = None,
    push_key: Path | None = None,
    known_hosts: Path | None = None,
    spec: ExecSpec | None = None,
) -> None:
    """Redeploy (FR-008/FR-010): rebuild the image on the host and recreate the
    container, preserving its volumes. Deliberately NON-idempotent — always
    recreates even with no change, since the operator explicitly asked to rebuild.
    A redeploy may also change mode/agent/workspace/repo (Feature 004)."""
    validate_name(name)
    spec = spec or ExecSpec()
    spec.validate()
    migrate_flat_state()
    resolved_mounts = [resolve_bind_mount(m) for m in (mounts or [])]
    host_name, host_rec = resolve_deploy_host(host)
    ensure_tunnel(host_rec)
    # Drain-on-contact, and here it is also protective: `redeploy` recreates the
    # container, which is the moment a still-pending record from the run being
    # replaced would otherwise be overwritten by the new one starting.
    drain_host_records(host_name, host_rec, [name])
    if container_name(name) not in host_container_names(host_rec, include_stopped=True):
        warn(f"no existing container '{name}' on {host_name} — redeploy will create it fresh")
    try:
        # FR-004: refuse a pre-011 layout BEFORE anything is created, so an operator
        # never gets a half-configured environment built from files we no longer read.
        refuse_superseded_layout(name)
        env_file = _resolve_env_files(name, env_file_override)
        clone_credential_precheck(spec, env_file, push_key)
        with deployment_lock(host_name, name):
            compose_up_exec(
                host_name,
                host_rec,
                name,
                env_file,
                resolved_mounts,
                authorized_keys or [],
                redeploy=True,
                push_key=push_key,
                known_hosts=known_hosts,
                spec=spec,
            )
    except Fatal as e:
        # C6, same as `up`: `redeploy --mode headless` is a way to start a run, so
        # a redeploy that never produced a container is a run that never started.
        record_never_started(host_name, name, spec, f"never started: {e}")
        raise


def do_wipe(name: str, yes: bool, host: str | None = None) -> None:
    """Wipe (FR-009): remove the container, its persistent volumes, AND its
    locally-built image. Requires explicit confirmation (destroys durable state)."""
    validate_name(name)
    host_name, host_rec = resolve_deploy_host(host)
    # Feature 014: `wipe` is a second teardown path, and a census that only knew
    # about `down` would leave every wiped environment recorded `active` forever.
    # Marked after the confirmation below, not here — see the call site.
    if not yes:
        if not is_tty():
            eprint("[agent-container] refusing destructive 'wipe' without -y/--yes on a non-TTY")
            raise typer.Exit(2)
        vols = ", ".join(per_container_volumes(name))
        prompt = (
            f"WIPE {container_name(name)} on {host_name} — delete the container, its "
            f"volumes ({vols}), and its locally-built image?"
        )
        if not questionary.confirm(prompt, default=False).ask():
            log("aborted")
            return
    set_inventory_outcome(name, host_name, "removed")  # Feature 014, FR-004
    with deployment_lock(host_name, name):
        down_container(host_name, host_rec, name, purge=True, rmi_local=True)
    log(f"wiped {container_name(name)} on {host_name}")


def gather_rows(rt: str, local_only: bool = False) -> list[dict[str, object]]:
    """Rows for `list`, reconciled against LIVE host state (Feature 002 US3,
    FR-011/012). Local status comes from the local runtime; each REGISTERED remote
    host is queried live via host_ps_rows so status is truthful after an
    out-of-band change (SC-004). A host whose enumeration fails is rendered
    'unreachable' (kept, never shown running, never hangs — bounded + caught), not
    dropped. `local_only` skips all remote round-trips for a fast local-only view."""
    rows: list[dict[str, object]] = []
    seen: set[tuple[str, str]] = set()  # (host, short) — a live row supersedes its placeholder
    reconciled: set[str] = set()  # hosts whose live ps PROVABLY succeeded (orphans -> stale)
    unreachable: set[str] = set()  # hosts whose live ps failed (kept, shown 'unreachable')
    hosts = registry_hosts(load_registry())  # one read; reused for the alias computation below
    # A registered host that actually points at the local daemon (host_is_local) shares
    # the local runtime, so its containers already appear in the local ps below. Query it
    # once (as local), not again as a remote, and skip its state dir so it doesn't render
    # a duplicate 'on remote host' placeholder for a container that is really live locally.
    local_aliases = {h for h, hr in hosts.items() if h != DEFAULT_HOST and host_is_local(hr)}

    # Local host: FAIL-CLOSED (001-US3 lesson) — a failed local `ps` must not read as
    # 'zero containers'; render the local host 'unreachable' instead. include_stopped so a
    # locally stopped container reports its real Exited status, matching the remote path.
    try:
        local_ps = ps_agent_container(rt, include_stopped=True, strict=True)
        reconciled.add(DEFAULT_HOST)
    except Fatal, OSError, subprocess.SubprocessError:
        local_ps = []
        unreachable.add(DEFAULT_HOST)
    for cname, image, status, uptime in local_ps:
        short = cname[len(CONTAINER_PREFIX) :]
        seen.add((DEFAULT_HOST, short))
        rows.append(
            {
                "name": cname,
                "host": DEFAULT_HOST,
                "port": read_state_port(DEFAULT_HOST, short) or "?",
                "image": image,
                "status": status,
                "uptime": uptime,
                "stale": False,
            }  # fmt: skip
        )
    # Live-reconcile every registered REMOTE host (the local host is already covered
    # by ps above). One dead host must never abort or stall the whole listing.
    if not local_only:
        for hname, hrec in sorted(hosts.items()):
            if hname == DEFAULT_HOST or hname in local_aliases:
                continue
            if hrec.get("driver") not in ("docker", "podman"):
                continue  # attach-only/existing-ssh: reachable via ssh, not queryable
            try:
                live = host_ps_rows(
                    hrec, include_stopped=True
                )  # incl. stopped -> real Exited (SC-004)
                reconciled.add(hname)
            except Fatal, OSError, subprocess.SubprocessError:
                unreachable.add(hname)
                continue
            for cname, image, status, uptime in live:
                short = cname[len(CONTAINER_PREFIX) :]
                seen.add((hname, short))
                rows.append(
                    {
                        "name": container_name(short),
                        "host": hname,
                        "port": read_state_port(hname, short) or "?",
                        "image": image,
                        "status": status,
                        "uptime": uptime,
                        "stale": False,
                    }  # fmt: skip
                )
    # Per-host state files (orphans + placeholders for hosts not live-reconciled).
    listed_hosts: set[str] = set()
    if STATE_DIR.is_dir():
        for host_dir in sorted(p for p in STATE_DIR.iterdir() if p.is_dir()):
            hname = host_dir.name
            if hname in local_aliases:
                continue  # its live state is already shown under the local host
            for f in sorted(host_dir.glob("*.port")):
                short = f.stem
                if (hname, short) in seen:  # a live row already supersedes this placeholder
                    continue
                # A state file with no live match on a host we PROVABLY queried means the
                # container is gone (stale); on an unreachable host we can't tell (keep,
                # 'unreachable'); on a host we never queried (--local, attach-only) it's a
                # static 'on remote host' placeholder.
                if hname in unreachable:
                    status, stale = "unreachable", False
                elif hname in reconciled:
                    status, stale = "stale", True
                else:
                    status, stale = "on remote host", False
                listed_hosts.add(hname)
                rows.append(
                    {
                        "name": container_name(short),
                        "host": hname,
                        "port": read_state_port(hname, short) or "?",
                        "image": "-",
                        "status": status,
                        "uptime": "-",
                        "stale": stale,
                    }  # fmt: skip
                )
    # Never let an unreachable host silently vanish: a host that errored but had no
    # state files still gets one marker row.
    for hname in sorted(unreachable - listed_hosts):
        rows.append(
            {
                "name": "-",
                "host": hname,
                "port": "-",
                "image": "-",
                "status": "unreachable",
                "uptime": "-",
                "stale": False,
            }  # fmt: skip
        )
    return rows


def row_known_hosts_entry(row: dict[str, object]) -> str | None:
    """The pinned `known_hosts` line for a `list` row, or None (FR-010, US3).

    Read from LOCAL state, never the daemon — so it still answers for a stopped
    environment or an unreachable host, which is exactly when an operator needs it:
    recovering verified access to something they cannot reach. Making this depend on
    reachability would fail precisely in the case it exists for.

    `None` rather than `""`: a JSON consumer can tell "never captured" from a captured
    value, where an empty string reads as a key that happens to be blank.

    THIS IS THE NON-TOFU PATH FOR A SECOND MACHINE. An entry copied from the machine
    that deployed predates what it checks; a key accepted at attach's prompt does not
    (research R8). It is the better answer whenever it is available.
    """
    addr = ADDRESS_FOR_LOCAL_ROWS if row.get("host") == DEFAULT_HOST else str(row.get("host") or "")
    port = row.get("port")
    if not addr or not port or port == "?":
        return None
    key = pinned_host_key(addr, str(port))
    return None if key is None else known_hosts_entry(addr, str(port), key)


def do_list(as_json: bool, local_only: bool = False) -> None:
    migrate_flat_state()
    rt = detect_runtime()
    rows = gather_rows(rt, local_only)
    if as_json:
        emit_json(
            {
                "containers": [
                    row | {"known_hosts_entry": row_known_hosts_entry(row)} for row in rows
                ]
            }
        )
        return
    if hint := inventory_disagreement_hint():
        warn(hint)  # FR-005a: one line, no classification — that is reconcile's job
    table = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
    for col in ("NAME", "HOST", "PORT", "IMAGE", "STATUS", "UPTIME"):
        table.add_column(col)
    for row in rows:
        style = "dim" if row["stale"] else None
        table.add_row(
            str(row["name"]),
            str(row["host"]),
            str(row["port"]),
            str(row["image"]),
            str(row["status"]),
            str(row["uptime"]),
            style=style,
        )
    console.print(table)


# --- Feature 016: reading the durable store (C1, C2) -------------------------
# Everything below reads STORED RECORDS ONLY. No repository is consulted and no
# container is inspected, which is what lets these answer months later, on a
# machine that no longer has the clone, about an environment that no longer
# exists — the property C3/SC-001 is measured on.


def read_stored_record(path: Path, kind: str = "run record") -> dict | None:
    """One stored record, or None with a warning.

    A record we cannot parse is NAMED rather than skipped quietly: a listing that
    is silently one record short is indistinguishable from a run that never
    happened, and the runs an operator cares about are disproportionately the ones
    that ended badly enough to write something odd.

    `kind` is only the noun in that warning, and it exists so Feature 012's egress
    events can reuse this reader (FR-011a) without being reported as run records —
    a warning that names the wrong store sends an operator to the wrong directory.
    """
    try:
        rec = json.loads(path.read_text())
    except (OSError, json.JSONDecodeError, ValueError) as e:
        warn(f"unreadable {kind} {path} ({e})")
        return None
    if not isinstance(rec, dict):
        warn(f"unreadable {kind} {path}: not an object")
        return None
    return rec


def stored_environments(host: str) -> list[str]:
    """Environments with a record directory on <host>.

    Read from the durable store and NOT from the state dir, because the two answer
    different questions: the state dir knows what is deployed, this knows what has
    run. An environment torn down last month has no state and still has records —
    which is the entire feature (C3).
    """
    try:
        return sorted(p.name for p in (DATA_DIR / "runs" / host).iterdir() if p.is_dir())
    except OSError:
        return []


def stored_records(host: str, environments: list[str]) -> list[dict]:
    """Every stored record for <environments> on <host>, NEWEST FIRST.

    Ordered by `started_at`, not by file mtime. mtime is when the record was
    INGESTED, and a whole host drained in one contact shares it to the second — so
    an mtime ordering would sort a listing by nothing at all, while looking
    chronological. list_stored_records still does the directory read, because its
    suffix filter is what keeps a half-written record out of the listing.
    """
    out: list[dict] = []
    for env in environments:
        for p in list_stored_records(runs_store_dir(host, env)):
            rec = read_stored_record(p)
            if rec is not None:
                out.append(rec)
    return sorted(out, key=_run_sort_key, reverse=True)


def _run_sort_key(rec: dict) -> tuple[str, str]:
    """`started_at` then `run_id`. The id breaks the tie so two runs that started
    in the same second do not reorder between two listings of the same store."""
    return (str(rec.get("started_at") or ""), str(rec.get("run_id") or ""))


def find_stored_record(host: str, run_id: str) -> tuple[str, dict] | None:
    """(environment, record) for <run-id> on <host>, searching every environment.

    `runs show` takes an id and not an environment (C2), and an id does not carry
    the environment it came from — so the search is over directories rather than a
    lookup. The id is validated by the caller before it is ever joined to a path.
    """
    for env in stored_environments(host):
        p = runs_store_dir(host, env) / f"{run_id}.json"
        if p.is_file():
            rec = read_stored_record(p)
            if rec is not None:
                return env, rec
    return None


def render_usage(usage: object) -> str:
    """Usage as one line — `unknown` as THE WORD when the agent reported nothing.

    Never `0` (C9, FR-006, SC-004): a false zero silently understates every total
    it enters, and a total that is quietly wrong is worse than one that admits a
    gap. Reported usage keeps the agent's own keys and names the agent (C10,
    FR-015), so nothing here invites adding two agents' numbers together.

    `reported` must be exactly `True`, not merely truthy. A record written in shell
    and ingested without passing `validate_usage` can carry the STRING `"false"`,
    which is truthy in Python — and a truthiness test would render that record as a
    report, turning the one field that separates "nothing was said" from "nothing
    was consumed" into whichever the writer happened to serialise. Measured, not
    imagined: `{"reported": "no"}` rendered as a reported usage before this.

    >>> render_usage({"reported": False})
    'unknown (the agent reported none)'
    >>> render_usage({"reported": True, "agent": "claude", "units": {"input_tokens": 12}})
    'claude: input_tokens=12'
    >>> render_usage({"reported": "false"})
    'unknown (the agent reported none)'
    >>> render_usage(None)
    'unknown (the agent reported none)'
    """
    if not isinstance(usage, dict) or usage.get("reported") is not True:
        return "unknown (the agent reported none)"
    units = usage.get("units")
    agent = usage.get("agent") or "unnamed agent"
    if not isinstance(units, dict) or not units:
        return f"{agent}: reported, but recorded no units"
    return f"{agent}: " + " ".join(f"{k}={v}" for k, v in sorted(units.items()))


def usage_units(usage: object, agent: str) -> dict[str, float] | None:
    """This record's summable units, or None when its usage is an UNKNOWN
    COMPONENT of any total it would enter (FR-007).

    One rule, deliberately all-or-nothing: a record contributes only when it says
    it reported, names the agent that ran, and carries numbers. Anything else is
    unknown rather than partially counted, because a half-counted record produces a
    total that is quietly wrong — worse than one that admits a gap (research R6).

    The agent-name check is not pedantry. `units` is stored unnormalised (FR-015),
    so a total is only meaningful within one agent; a usage claiming a different
    agent from the one that ran means nobody can say whose units these are, and
    adding them to either bucket would invent the equivalence C10 forbids.

    >>> usage_units({"reported": True, "agent": "claude", "units": {"t": 2}}, "claude")
    {'t': 2}
    >>> usage_units({"reported": False}, "claude") is None
    True
    >>> usage_units({"reported": True, "agent": "codex", "units": {"t": 2}}, "claude") is None
    True
    """
    if not isinstance(usage, dict) or usage.get("reported") is not True:
        return None
    if usage.get("agent") != agent:
        return None
    units = usage.get("units")
    if not isinstance(units, dict) or not units:
        return None
    out: dict[str, float] = {}
    for key, value in units.items():
        if (
            not isinstance(key, str)
            or isinstance(value, bool)
            or not isinstance(value, (int, float))
        ):
            return None
        out[key] = value
    return out


def aggregate_usage(records: list[dict]) -> dict:
    """Usage totals for a listing, PER AGENT, with the unknowns counted (FR-007).

    `unknown_components` is the requirement, not a nicety: FR-007 says an aggregate
    must state when a component is unknown rather than silently excluding it, and a
    sum with three unreported runs quietly missing from it looks exactly like a sum
    of everything. The count is carried at both levels so neither reading of the
    payload can lose it.

    THERE IS NO CROSS-AGENT TOTAL, and its absence is the point (FR-015, C10). Two
    agents' `input_tokens` are not the same quantity; a key here that added them
    would produce the one number a reader would quote, and it would mean nothing.

    >>> agg = aggregate_usage([
    ...     {"agent": "claude", "usage": {"reported": True, "agent": "claude",
    ...                                   "units": {"input_tokens": 10}}},
    ...     {"agent": "claude", "usage": {"reported": False}},
    ... ])
    >>> agg["runs"], agg["unknown_components"]
    (2, 1)
    >>> agg["by_agent"]["claude"]
    {'runs': 2, 'reported': 1, 'unknown_components': 1, 'units': {'input_tokens': 10}}
    """
    by_agent: dict[str, dict] = {}
    unknown = 0
    for rec in records:
        agent = str(rec.get("agent") or "?")
        slot = by_agent.setdefault(
            agent, {"runs": 0, "reported": 0, "unknown_components": 0, "units": {}}
        )
        slot["runs"] += 1
        units = usage_units(rec.get("usage"), agent)
        if units is None:
            slot["unknown_components"] += 1
            unknown += 1
            continue
        slot["reported"] += 1
        for key, value in units.items():
            slot["units"][key] = slot["units"].get(key, 0) + value
    return {"runs": len(records), "unknown_components": unknown, "by_agent": by_agent}


def render_usage_totals(agg: dict) -> list[str]:
    """The usage aggregate as lines for a human, unknowns first.

    The unknown count leads the summary rather than trailing it, because the whole
    of FR-007 is that a reader must not take the figures below for the whole story.
    A summary that printed the totals and mentioned the gap afterwards would be read
    in the order it was printed.

    Nothing is added across agents, and the line says so whenever there is more than
    one — the reader with two rows in front of them is exactly the reader about to
    add them (C10).

    >>> render_usage_totals(aggregate_usage([{"agent": "claude", "usage": {"reported": False}}]))
    ['usage: unknown for all 1 run(s) — no agent reported any (never counted as zero)']
    """
    if not agg["runs"]:
        return []
    reporting = {a: s for a, s in agg["by_agent"].items() if s["units"]}
    if not reporting:
        return [
            f"usage: unknown for all {agg['runs']} run(s) — no agent reported any "
            f"(never counted as zero)"
        ]
    lines = [
        f"usage: {agg['runs']} run(s), {agg['unknown_components']} with no usage reported "
        f"(unknown, never counted as zero)"
    ]
    for agent, slot in sorted(reporting.items()):
        units = " ".join(f"{k}={v}" for k, v in sorted(slot["units"].items()))
        lines.append(f"  {agent}: {slot['reported']} of {slot['runs']} run(s) reported — {units}")
    if len(reporting) > 1:
        lines.append("  (per agent; agents' units are not comparable and are never added together)")
    return lines


PUSH_UNPUSHED = "unpushed"
# One row per push status, so the word the classifier returns and the words the
# operator reads cannot drift apart. `!!` marks the one status that is an alarm.
PUSH_ROWS: dict[str, tuple[str, str]] = {
    PUSH_UNPUSHED: (
        "!! push",
        "COMMITTED WITHOUT PUSHING — the work is only in the container",
    ),
    "pushed": ("push", "pushed"),
    "unknown": ("push", "could not tell (no upstream to compare against)"),
    # NO "nothing" ROW. `push_status` can no longer return it: `pushed: false` means
    # the exit head is provably not on the upstream, so there is always something
    # outstanding. The row existed for an empty `commits` list, which the writer also
    # emits when the list is UNKNOWN — and "nothing to push" was then an affirmative
    # falsehood about a run whose work was only in the container.
}


def push_status(repo: object) -> str:
    """Where this run's commits stand against its upstream, as ONE closed word:
    `none` · `pushed` · `unpushed` · `unknown` · `nothing`.

    One classifier, because the human rendering and the machine-readable flag
    (C8, FR-005) have to agree about what counts as commit-without-push. Two sites
    deciding it separately is how `--json` ends up silent about a run the table
    shouts about, and SC-003 is then measured on whichever one the reader chose.

    `pushed: null` is `unknown` and never `unpushed`: "could not tell" and "did
    not push" are different facts, and only one of them is an alarm (C8).

    A `pushed: false` recorded with NO upstream contradicts C8 — the writer should
    have said null. It is read as `unpushed` regardless, because the two mistakes
    are not symmetric: a false alarm sends an operator to look at work that turns
    out to be safe, while a swallowed one loses the work Constitution I exists to
    protect. Records this tool builds cannot reach that shape
    (`validate_repository_effect` refuses it); one written by an older or foreign
    writer can, and this is the side that meets it.

    >>> push_status({"state": "ok", "commits": ["abc"], "pushed": False})
    'unpushed'
    >>> push_status({"state": "no-upstream", "commits": ["abc"], "pushed": None})
    'unknown'
    >>> push_status({"state": "ok", "commits": [], "pushed": True})
    'pushed'
    >>> push_status(None)
    'none'

    An empty `commits` with `pushed: false` still alarms — the writer emits `[]` for
    an UNKNOWN list as well as an empty one, and the push flag is decided
    independently of it:

    >>> push_status({"state": "ok", "commits": [], "pushed": False, "paths_truncated": True})
    'unpushed'
    """
    if not isinstance(repo, dict):
        return "none"
    pushed = repo.get("pushed")
    if pushed is None:
        return "unknown"
    if pushed:
        return "pushed"
    # `pushed: false` IS the alarm, whatever the commit list says.
    #
    # This used to fall back to "nothing" on an empty `commits`, and that was wrong
    # in the one direction that loses work. `pushed: false` is computed as NOT
    # `merge-base --is-ancestor <end_head> @{u}` — the exit head is PROVABLY not on
    # the upstream, so something is outstanding by definition. Meanwhile the writer
    # emits `commits: []` when the list is UNKNOWN as well as when it is empty:
    # unattributable history, a `rev-list` failure, or the exit-capture deadline
    # expiring under SIGTERM. Reading that as reassurance produced "nothing to push"
    # and an empty `unpushed` key for a run whose work existed only in the
    # container — SC-003's "looks like a clean success", produced by the very check
    # written to prevent it.
    #
    # The asymmetry this file already argues for `pushed: false` with no upstream
    # applies unchanged: a false alarm sends an operator to look at work that turns
    # out to be safe; a swallowed one loses the work Constitution I exists to
    # protect. `commits` is CONTEXT for the alarm, never its trigger.
    return PUSH_UNPUSHED


def unpushed_run_ids(records: list[dict]) -> list[str]:
    """Run ids of the records that committed without pushing (C8, FR-005).

    This is what makes the failure loud in `--json` (T030): each record is served
    verbatim, so an agent reading the list would otherwise have to re-derive the
    alarm from `repository.pushed` — and an agent that forgets to is exactly the
    SC-003 case of a run that "looks like a clean success".
    """
    return [
        str(r.get("run_id") or "?")
        for r in records
        if push_status(r.get("repository")) == PUSH_UNPUSHED
    ]


# How many changed paths `runs show` spells out before it stops and gives a count
# instead. A rendering cap, not a capture cap: the record still holds the whole
# list, and the line says how many there are either way.
PATHS_SHOWN = 10


def render_changed_paths(paths: list, truncated: bool) -> str:
    """The changed-path summary, with the capture cap SAID rather than implied.

    `paths_truncated` is never rendered as a footnote or omitted (C16, R11): a
    list that looks complete is what answers "no run changed that file" with
    confidence when one did, so the truncation travels in the same words as the
    count.

    >>> render_changed_paths(["a.py", "b.py"], False)
    '2 changed: a.py, b.py'
    >>> render_changed_paths(["a.py"], True)
    '1 changed, LIST TRUNCATED AT CAPTURE — the run changed more: a.py'
    >>> render_changed_paths([], False)
    'no files changed'

    An EMPTY list that is flagged truncated is not an empty list — it is an unknown
    one, and saying "no files changed" about it is a definite claim about data the
    writer never had:

    >>> render_changed_paths([], True)
    'changed files UNKNOWN — the list could not be captured'
    """
    if not paths:
        # EMPTY AND TRUNCATED IS UNKNOWN, NOT NONE. The writer emits an empty list
        # both when nothing changed and when the capture failed or hit its deadline,
        # so "no files changed" here would answer a question the record cannot
        # answer — and it is the same sentence a genuinely clean run produces, which
        # is what makes it indistinguishable.
        return (
            "changed files UNKNOWN — the list could not be captured"
            if truncated
            else "no files changed"
        )
    head = f"{len(paths)} changed"
    if truncated:
        head += ", LIST TRUNCATED AT CAPTURE — the run changed more"
    if len(paths) <= PATHS_SHOWN:
        return f"{head}: " + ", ".join(str(p) for p in paths)
    return f"{head} (runs show --json for the full list)"


def render_repository(repo: object) -> list[tuple[str, str]]:
    """Rows describing the repository effect, or a row saying there is none.

    Commit-without-push is stated in those words (C8, FR-005): it is the failure
    Constitution I exists to prevent, and a renderer that printed `pushed: false`
    among nine other fields would technically contain the information while
    guaranteeing nobody reads it.

    >>> render_repository(None)
    [('repository', 'no repository effect recorded')]
    >>> render_repository({"state": "ok", "commits": ["abc"], "pushed": False})[-1]
    ('!! push', 'COMMITTED WITHOUT PUSHING — the work is only in the container')
    >>> render_repository({"state": "ok", "commits": ["abc"], "pushed": None})[-1]
    ('push', 'could not tell (no upstream to compare against)')
    """
    if not isinstance(repo, dict):
        return [("repository", "no repository effect recorded")]
    commits = repo.get("commits") or []
    rows = [
        ("repository", str(repo.get("state") or "unknown")),
        ("commits", ", ".join(str(c) for c in commits) if commits else "none"),
        PUSH_ROWS[push_status(repo)],
    ]
    paths = repo.get("paths")
    if isinstance(paths, list):
        # Only when a list was actually captured. A row saying "no files changed"
        # for a record that never carried the field would answer SC-007 for a run
        # nobody asked about the paths of.
        rows.append(("files", render_changed_paths(paths, bool(repo.get("paths_truncated")))))
    return rows


def render_run_record(environment: str, rec: dict) -> None:
    """Render one record for a human.

    It is a SUMMARY and points at the logs rather than pretending to be them (C15,
    FR-014). The two have opposite lifetimes — the record outlives the container
    and the logs do not — so a rendering that looked like log output would promise
    detail that is already gone.
    """
    table = Table(show_header=False, box=None, pad_edge=False)
    table.add_column("field", style="bold")
    table.add_column("value")
    for field_name, value in (
        ("run", str(rec.get("run_id") or "?")),
        ("environment", str(rec.get("environment") or environment)),
        ("host", str(rec.get("host") or "?")),
        ("agent", str(rec.get("agent") or "?")),
        ("kind", str(rec.get("kind") or "?")),
        ("outcome", str(rec.get("outcome") or "pending")),
        ("exit code", "-" if rec.get("exit_code") is None else str(rec.get("exit_code"))),
        ("started", str(rec.get("started_at") or "?")),
        ("ended", str(rec.get("ended_at") or "still pending")),
        ("task", str(rec.get("task")) if rec.get("task") is not None else "-"),
    ):
        table.add_row(field_name, value)
    for field_name, value in render_repository(rec.get("repository")):
        table.add_row(field_name, value)
    table.add_row("usage", render_usage(rec.get("usage")))
    for note in rec.get("notes") or []:
        table.add_row("note", str(note))
    console.print(table)
    console.print("this is a summary, not the logs.")
    # soft_wrap: the line is a COMMAND. Rich would otherwise fold it at the
    # console width, and a wrapped command is one an operator cannot copy.
    console.print(
        f"logs, while the container lives: agent-container logs {environment}", soft_wrap=True
    )


# --- `runs list --changed <path>` (C16, SC-007) ------------------------------
#
# Answered from STORED RECORDS ONLY. No repository is opened and no SHA is
# resolved: the paths were captured when each run ended (research R11), which is
# what lets this answer months later, on a machine that never had the clone, and
# against history someone has since rebased. Query-time resolution would fail
# exactly when the record is most valuable.
#
# The whole design turns on one asymmetry: a MATCH is a fact, but a NON-match is
# only a fact when the record's path list is known to be complete. Where it is
# not, the run is reported as uncertain — a confident "no run changed that file"
# built on a truncated list is the failure C16 exists to prevent.

CHANGED_MATCH = "match"
CHANGED_UNCERTAIN = "uncertain"
CHANGED_NO = "no"


def normalise_changed_path(path: str) -> str:
    """The path as `repository.paths` holds it: repo-relative, no leading `./`.

    An empty path, an absolute one, or one containing `..` is REFUSED. Records
    carry repo-relative paths and this command reads nothing but records, so there
    is no repository root here to resolve such a path against — searching for it
    would match nothing at all, and a confident empty answer is precisely what
    C16 forbids. Refusing says which question cannot be answered.

    >>> normalise_changed_path("./src/auth/session.py")
    'src/auth/session.py'
    >>> normalise_changed_path("src/auth/")
    'src/auth'
    """
    wanted = path.strip()
    while wanted.startswith("./"):
        wanted = wanted[2:]
    wanted = wanted.rstrip("/")
    if not wanted or Path(wanted).is_absolute() or ".." in wanted.split("/"):
        die(
            f"'{path}' is not a repository-relative path",
            code="invalid_changed_path",
            entity=path,
            remedy="pass the path as the repository sees it, e.g. src/auth/session.py",
        )
    return wanted


def path_is_covered(recorded: str, wanted: str) -> bool:
    """Does one recorded path answer for `wanted`?

    Exact match, or `wanted` naming a directory the recorded path sits under —
    an operator asking about `src/auth` means the directory, and a match on the
    file alone would answer "no run touched it" for a run that rewrote all of it.

    >>> path_is_covered("src/auth/session.py", "src/auth")
    True
    >>> path_is_covered("src/authz/session.py", "src/auth")
    False
    """
    return recorded == wanted or recorded.startswith(f"{wanted}/")


def changed_path_verdict(rec: dict, wanted: str) -> tuple[str, str]:
    """`(verdict, reason)` for one stored record against one path (C16).

    `reason` is non-empty only for `uncertain`, and it names WHICH kind of
    incomplete knowledge produced it — the operator's next step differs between
    "the list was cut" and "this run recorded no list at all".

    Two records can be ruled out with no path list at all, and only two: a
    container that never started and a workspace that held no repository. Neither
    could have changed a file. Every other missing list is silence, and silence is
    not evidence — the same reasoning as truncation, one step earlier.
    """
    # A never-started record carries `repository: null` (C6), and an ingested
    # record can carry anything at all; collapsing both to an empty mapping keeps
    # every lookup below on one path, so a non-dict cannot reach a `.get` and turn
    # an unanswerable query into a traceback.
    raw = rec.get("repository")
    repo: dict = raw if isinstance(raw, dict) else {}
    paths = repo.get("paths")
    if isinstance(paths, list):
        if any(path_is_covered(str(p), wanted) for p in paths):
            return CHANGED_MATCH, ""
        if repo.get("paths_truncated"):
            return (
                CHANGED_UNCERTAIN,
                "the path list was truncated when the run ended; the path may have been cut",
            )
        return CHANGED_NO, ""
    if rec.get("outcome") == RUN_OUTCOME_NEVER_STARTED or repo.get("state") == "no-repository":
        return CHANGED_NO, ""
    return CHANGED_UNCERTAIN, "this run recorded no path list, so it cannot be ruled out"


def select_changed(records: list[dict], wanted: str) -> tuple[list[dict], list[dict]]:
    """Split records into (matched, uncertain) for `wanted`, order preserved.

    The uncertain entries are DELIBERATELY not records: they are not answers to
    the question, and putting them in the same list as the matches would let a
    consumer read `runs` as "the runs that changed this file" and be wrong.
    """
    matched: list[dict] = []
    uncertain: list[dict] = []
    for rec in records:
        verdict, reason = changed_path_verdict(rec, wanted)
        if verdict == CHANGED_MATCH:
            matched.append(rec)
        elif verdict == CHANGED_UNCERTAIN:
            uncertain.append(
                {
                    "run_id": str(rec.get("run_id") or "?"),
                    "environment": str(rec.get("environment") or "?"),
                    "reason": reason,
                }
            )
    return matched, uncertain


def do_runs_list(
    environment: str | None, host: str | None, as_json: bool, changed: str | None = None
) -> None:
    """`runs list [<environment>] [--changed <path>]` (C1, C16), newest first."""
    if environment is not None:
        validate_name(environment)
    host_name, host_rec = resolve_deploy_host(host)
    # Drain first (R7). This is the command an operator runs to look for the record
    # of a detached run, so a listing that read the store without draining would
    # answer "no runs" for a run that finished thirty seconds ago. Scoped to the
    # named environment when there is one, so listing one does not start a
    # throwaway container per environment on the host.
    drain_host_records(host_name, host_rec, [environment] if environment else None)
    envs = [environment] if environment else stored_environments(host_name)
    records = stored_records(host_name, envs)
    uncertain: list[dict] = []
    if changed is not None:
        records, uncertain = select_changed(records, normalise_changed_path(changed))
    if as_json:
        # `runs` stays the verbatim records (C1/C2). The two derived keys are what
        # make the alarms machine-readable (T030, C16): an agent that had to
        # re-derive them from the records is an agent that can forget to, and a
        # forgotten one is a run that "looks like a clean success" (SC-003).
        #
        # `unpushed` and `usage` are ALWAYS present, even empty. A key that appeared
        # only when non-empty would make "no run committed without pushing" and
        # "this build does not report it" identical to a consumer — and the same
        # trap holds for an aggregate whose `unknown_components` vanished exactly
        # when it was zero. `uncertain` appears with --changed, which is the only
        # mode in which the concept exists.
        payload: dict = {
            "runs": records,
            "unpushed": unpushed_run_ids(records),
            "usage": aggregate_usage(records),
        }
        if changed is not None:
            payload["uncertain"] = uncertain
        emit_json(payload)
        return
    if not records:
        # C1: ONE LINE, not an empty screen. "nothing was recorded" and "the command
        # did nothing" look identical when the answer is blank space.
        where = f"{environment} on {host_name}" if environment else host_name
        if changed is not None:
            # Never a bare "none" while a run is unaccounted for: with uncertain
            # candidates present, "no run changed that file" is the confident wrong
            # answer C16 names.
            log(f"no stored record shows a change to {changed} ({where})")
            report_uncertain_changed(uncertain, changed)
            return
        log(f"no run records for {where}")
        return
    table = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
    # ENVIRONMENT is dropped when one was named: the column would repeat the
    # argument on every row, and the width it costs is what pushes the run id past
    # an 80-column terminal.
    cols = ("RUN", "AGENT", "KIND", "OUTCOME", "STARTED")
    if environment is None:
        cols = ("RUN", "ENVIRONMENT", *cols[1:])
    for col in cols:
        # overflow="fold", not the default ellipsis: the RUN column IS the argument
        # `runs show` takes, and on a narrow terminal the default renders it as
        # `20260809T101010Z-…` — a listing whose primary key cannot be copied.
        table.add_column(col, overflow="fold")
    for rec in records:
        cells = [
            str(rec.get("run_id") or "?"),
            str(rec.get("agent") or "?"),
            str(rec.get("kind") or "?"),
            # A null outcome is not a missing field, it is a run still in flight —
            # its container is up and no exit path has run yet. `?` would read as
            # "this record is broken" for the one case where nothing is wrong.
            str(rec.get("outcome") or "pending"),
            str(rec.get("started_at") or "?"),
        ]
        if environment is None:
            cells.insert(1, str(rec.get("environment") or "?"))
        table.add_row(*cells)
    console.print(table)
    report_unpushed(records)
    # After the table and before the uncertainty report: the totals describe the
    # rows above them, while `--changed`'s uncertainty describes what is NOT above
    # them and has to be the last thing read.
    for line in render_usage_totals(aggregate_usage(records)):
        console.print(line, soft_wrap=True)
    if changed is not None:
        report_uncertain_changed(uncertain, changed)


def report_unpushed(records: list[dict]) -> None:
    """Say, after the table, which runs committed without pushing (FR-005, C8).

    A listing does not carry the repository effect per row — the columns an
    operator scans are what the run was, not what it left behind — so without this
    line a commit-without-push is present in the store and invisible on screen,
    which is SC-003's "looks like a clean success" exactly.

    The ids are spelled out because they are the RUN column's values and the
    argument `runs show` takes: a count alone would announce a problem and leave
    the operator to find it.
    """
    ids = unpushed_run_ids(records)
    if not ids:
        return
    console.print(
        f"[red]![/red] {len(ids)} run(s) COMMITTED WITHOUT PUSHING — the work is only in "
        f"the container: {', '.join(ids)}",
        soft_wrap=True,
    )


def report_uncertain_changed(uncertain: list[dict], changed: str) -> None:
    """Name the runs that could NOT be ruled out for `--changed` (C16, T054).

    Omitting them would turn every answer into a confident one, including the
    answers built on a truncated path list — the failure C16 exists to prevent.
    Each is named with its reason, because "the list was cut" and "no list was
    recorded" send the operator to different places.
    """
    if not uncertain:
        return
    console.print(
        f"[red]![/red] {len(uncertain)} run(s) cannot be ruled out for {changed} — "
        f"this answer is NOT complete:",
        soft_wrap=True,
    )
    for item in uncertain:
        console.print(
            f"  {item['run_id']} ({item['environment']}): {item['reason']}", soft_wrap=True
        )


def do_runs_show(run_id: str, host: str | None, as_json: bool) -> None:
    """`runs show <run-id>` (C2) — one complete record."""
    if RUN_ID_RE.fullmatch(run_id) is None:
        # The id is joined to a store path below. Refused, not sanitised: there is
        # no legitimate run id this rejects, and a `..` reaching the join is how a
        # read command becomes a way to read arbitrary files.
        die(
            f"'{run_id}' is not a run id",
            code="invalid_run_id",
            entity=run_id,
            remedy="agent-container runs list",
        )
    host_name, host_rec = resolve_deploy_host(host)
    # An id does not say which environment it belongs to, so the whole host is
    # drained: the record being asked for may be the one still on a volume.
    drain_host_records(host_name, host_rec)
    found = find_stored_record(host_name, run_id)
    if found is None:
        die(
            f"no run record '{run_id}' on {host_name}",
            code="run_not_found",
            entity=run_id,
            remedy=f"agent-container runs list --host {host_name}",
        )
    environment, rec = found
    if as_json:
        # C2: VERBATIM as stored. `runs show --json` is how an agent reads a record,
        # and a rendering step here would make the machine-readable form a
        # derivative of the human one rather than the record itself.
        emit_json(rec)
        return
    render_run_record(environment, rec)


# --- `egress` — reading the durable egress record (Feature 012 T034) ----------
#
# THE HARD PART OF THIS SURFACE IS WHAT SILENCE MEANS. US3 scenario 3 asks for no
# noise when nothing happened, and the trap in delivering it is that an empty answer
# has three causes an operator cannot tell apart from an empty screen:
#
#   1. a boundary is deployed and refused nothing — silence is the good news;
#   2. no boundary is deployed, so NOTHING OBSERVED this environment's egress — the
#      same silence, meaning the opposite, and it is FR-004's disclosure: undeclared
#      egress is unrestricted, and now demonstrably unrecorded;
#   3. the tool knows nothing about this environment at all.
#
# So: no section, no empty table and no "0 events" line anywhere the operator did not
# ask — and when they DO ask, exactly one line that says which of the three it is. The
# same distinction is a field in `--json` (`boundary`), because a consumer cannot read
# prose and would otherwise treat all three as "clean".


def egress_environment_status(host_name: str, name: str) -> str:
    """`watched`, `unwatched` or `unknown` — which of the three silences applies.

    Answered from the generated compose artifact, i.e. what was last DEPLOYED, not
    from the project spec: a spec that has since gained an `egress:` block would
    otherwise report an environment as watched for a period in which nothing was
    watching it. `--purge` removes that artifact, so a purged environment reads as
    `unknown` — which is the truth: its events survive and the tool can no longer say
    what was in force when they were recorded.

    `watched` therefore means "its last deployment declared a boundary", NOT "a
    container is running right now" — and the messages below say exactly that, because
    a stopped environment would otherwise be told its boundary "is watching". A local
    file read on purpose: this is called per environment on a listing, and it must not
    turn a read of the store into one runtime probe per row.
    """
    if _previous_model_had_egress(host_name, name):
        return "watched"
    if compose_file_path(host_name, name).is_file():
        return "unwatched"
    return "unknown"


_EGRESS_SILENCE = {
    "watched": (
        "no undeclared egress recorded for {where} — its last deployment declared an "
        "egress boundary, and nothing collected from that boundary's log was refused"
    ),
    "unwatched": (
        "{where} deploys NO egress boundary, so nothing observes its egress: it is "
        "unrestricted and unrecorded. Silence here does not mean nothing left the "
        "container — declare `egress:` to make it mean that"
    ),
    "unknown": (
        "no egress events for {where}, and no deployment the tool can ask: if this "
        "environment was torn down, this means none were ever ingested — not that "
        "none happened"
    ),
}


def egress_payload_for_environment(host_name: str, name: str, events: list[dict]) -> dict:
    """One environment's egress summary for `--json`.

    `boundary` carries the distinction the prose carries. Always present, never null:
    a consumer that had to infer "nothing was watching" from an empty event list is a
    consumer that will read case 2 above as case 1.

    `permitted_undeclared` is broken out because it is a different and stronger
    finding than a refusal — the running boundary admitted something the deployed
    allowlist does not name — and an aggregate that hid it inside a total would make
    the strongest event in the store the hardest one to notice.
    """
    return {
        "environment": name,
        "boundary": egress_environment_status(host_name, name),
        "events": len(events),
        "refused": sum(1 for e in events if e.get("decision") == EGRESS_DECISION_REFUSED),
        "permitted_undeclared": sum(
            1
            for e in events
            if e.get("decision") == EGRESS_DECISION_PERMITTED and e.get("declared") is False
        ),
    }


def do_egress(environment: str | None, host: str | None, as_json: bool) -> None:
    """`egress [<environment>]` — the DURABLE record of undeclared egress.

    Drains first, for the reason `runs list` does: this is the command an operator
    runs to look for the refusal they just caused, and reading the store without
    collecting from the boundary would answer "nothing" for an event thirty seconds
    old.
    """
    if environment is not None:
        validate_name(environment)
    host_name, host_rec = resolve_deploy_host(host)
    drain_host_records(host_name, host_rec, [environment] if environment else None)
    # The union of "has events" and "is deployed here": an environment whose boundary
    # is watching and has refused nothing must appear in the machine-readable answer,
    # or `boundary: watched` — the whole point of the field — would only ever be
    # visible for environments that already had a finding.
    envs = (
        [environment]
        if environment
        else sorted(set(stored_egress_environments(host_name)) | set(host_environments(host_name)))
    )
    events = stored_egress_events(host_name, envs)
    per_env = [
        egress_payload_for_environment(
            host_name, env, [e for e in events if e.get("environment") == env]
        )
        for env in envs
    ]
    if as_json:
        emit_json({"host": host_name, "environments": per_env, "events": events})
        return
    if not events:
        # ONE LINE, and it names which silence this is. An empty screen would make all
        # three indistinguishable, which is the failure this whole section exists for.
        _report_egress_silence(host_name, environment, per_env)
        return
    _render_egress_events(environment, events)
    _report_egress_unwatched(per_env)


def _report_egress_silence(host_name: str, environment: str | None, per_env: list[dict]) -> None:
    """The empty answer, in one line, saying WHICH silence it is."""
    if environment is not None:
        state = per_env[0]["boundary"] if per_env else "unknown"
        log(_EGRESS_SILENCE[state].format(where=f"{environment} on {host_name}"))
        return
    watched = [p["environment"] for p in per_env if p["boundary"] == "watched"]
    unwatched = [p["environment"] for p in per_env if p["boundary"] == "unwatched"]
    log(
        f"no undeclared egress recorded on {host_name} — "
        f"{len(watched)} environment(s) watched by a boundary"
        + (f" ({', '.join(watched)})" if watched else "")
        + f", {len(unwatched)} with none and therefore unrecorded"
        + (f" ({', '.join(unwatched)})" if unwatched else "")
    )


def _report_egress_unwatched(per_env: list[dict]) -> None:
    """After the table: which environments these events do NOT cover.

    Without it a listing of two refusals reads as the complete account of a host where
    three environments have no boundary at all — an incomplete answer presented as a
    complete one, which is the C16 lesson. One line, and only when there is something
    to say.
    """
    unwatched = [p["environment"] for p in per_env if p["boundary"] == "unwatched"]
    if not unwatched:
        return
    console.print(
        f"[yellow]![/yellow] not covered above: {len(unwatched)} environment(s) deploy no "
        f"boundary, so their egress is unrestricted and unrecorded: {', '.join(unwatched)}",
        soft_wrap=True,
    )


def _render_egress_events(environment: str | None, events: list[dict]) -> None:
    """The table, newest first, plus the one alarm this store can raise."""
    table = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
    # The provider ANNOTATES the destination rather than taking a column of its own:
    # six columns fold at 80 characters, and the column that folds first is the one
    # that has to be readable — a destination broken across two lines is a listing
    # whose whole subject cannot be copied (the same reason `runs list` folds instead
    # of ellipsising its RUN column).
    cols = ("WHEN", "DESTINATION", "DECISION", "DECLARED", "STAGE")
    if environment is None:
        cols = (cols[0], "ENVIRONMENT", *cols[1:])
    for col in cols:
        table.add_column(col, overflow="fold")
    for e in events:
        provider = e.get("provider")
        cells = [
            str(e.get("timestamp") or "?"),
            str(e.get("host") or "?") + (f" ({provider})" if provider else ""),
            str(e.get("decision") or "?"),
            # "unknown" and not "-": `declared` is null when the tool could not read
            # the allowlist that was in force, and a dash beside a `false` reads as
            # "no" for the one case where the answer is "we cannot say".
            {True: "yes", False: "no"}.get(e.get("declared"), "unknown"),
            str(e.get("stage") or "?"),
        ]
        if environment is None:
            cells.insert(1, str(e.get("environment") or "?"))
        table.add_row(*cells)
    console.print(table)
    admitted = [
        e
        for e in events
        if e.get("decision") == EGRESS_DECISION_PERMITTED and e.get("declared") is False
    ]
    if admitted:
        # The strongest thing this store can say, so it is said in the loudest place.
        # A refusal is the boundary working; this is the boundary admitting a
        # destination the deployed allowlist does not name, which means the running
        # allowlist is not the one this tool generated.
        console.print(
            f"[red]![/red] {len(admitted)} event(s) were PERMITTED while UNDECLARED — the "
            f"running boundary is not enforcing the allowlist this tool generated: "
            f"{', '.join(sorted({str(e.get('host')) for e in admitted}))}",
            soft_wrap=True,
        )


def verification_opts() -> list[str]:
    """The `-o` pair that makes an attach VERIFIED (FR-004, Feature 018).

    ONE definition, consumed by every builder below, because a path that omits it
    connects unverified and looks identical to one that does not. `ssh_argv` (the
    print/execute/wizard paths), `ssh_probe_argv` (the dead-session probe — a second
    invocation to the same endpoint, which on a different known_hosts would give two
    verifications that can disagree) and `ssh_config_stanza` all draw from here.

    `StrictHostKeyChecking=yes`, NOT `accept-new`: accept-new silently trusts a host
    that is not yet pinned, which is precisely the behaviour Feature 018 replaces.
    Since a deploy pins, an unpinned host at attach time is itself worth stopping on
    — the caller decides whether to ask (FR-013) or refuse, and ssh must not decide
    for us by quietly accepting.

    """
    return [
        "-o",
        f"UserKnownHostsFile={known_hosts_option_value()}",
        "-o",
        "StrictHostKeyChecking=yes",
    ]


def known_hosts_option_value() -> str:
    """The `UserKnownHostsFile` value: the tool-owned files, ssh-config quoted.

    ssh splits this value on whitespace, so a path containing a space (a HOME with
    one, which is ordinary on macOS) would silently become two bogus paths — and a
    bogus path is an EMPTY known_hosts, i.e. every attach unpinned. Quoted only when
    it must be: `attach --print` hands this to a human, and wrapping the ordinary
    single-file case in quotes it does not need makes the printed command harder to
    read for no gain.
    """
    files = known_hosts_files() or [known_hosts_path(DEFAULT_HOST)]
    if len(files) == 1 and not any(c.isspace() for c in str(files[0])):
        return str(files[0])
    return " ".join(f'"{p}"' for p in files)


def ssh_argv(user: str, host: str, port: str | int, window: str | None = None) -> list[str]:
    # The one true attach command; argv list, never a shell string. Without a
    # window the argv is unchanged (agent-container parity). With one, the remote
    # command is a SINGLE compound string that selects the window (tolerating
    # its absence) THEN attaches — select-window works on a detached session.
    base = ["ssh", f"{user}@{host}", "-p", str(port), *verification_opts(), "-t"]
    if window:
        return base + [f"tmux select-window -t main:{window} 2>/dev/null; exec tmux attach -t main"]
    return base + ["tmux", "attach", "-t", "main"]


def ssh_probe_argv(user: str, host: str, port: str | int, remote_cmd: str) -> list[str]:
    """A non-interactive (no -t) ssh command that runs `remote_cmd` and returns its
    exit code — used for the dead-session probe. BatchMode so it never prompts."""
    return [
        "ssh",
        f"{user}@{host}",
        "-p",
        str(port),
        # Same verification as the real attach (Feature 018): this probe reaches the
        # SAME endpoint, so leaving it on the operator's default known_hosts would
        # give two verifications of one container that can disagree.
        *verification_opts(),
        "-o",
        "BatchMode=yes",
        "-o",
        "ConnectTimeout=10",
        remote_cmd,
    ]


def probe_session(user: str, host: str, port: str | int) -> str:
    """Probe the tmux session 'main' on the target (FR-008). Returns:
      'alive'       — `tmux has-session -t main` succeeded (attach normally),
      'dead'        — ssh connected but the session is gone (report, never a silent
                      empty attach),
      'unreachable' — ssh transport error (let the real attach surface it).
    A dead session is the case FR-008 exists for: reported, not silently empty."""
    argv = ssh_probe_argv(user, host, port, "tmux has-session -t main")
    try:
        rc = subprocess.run(
            argv,
            stdin=subprocess.DEVNULL,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            timeout=20,
        ).returncode
    except OSError, subprocess.SubprocessError:
        return "unreachable"
    if rc == 0:
        return "alive"
    if rc == 255:  # ssh's own failure code (connection refused / auth / timeout)
        return "unreachable"
    return "dead"


def tmux_nest_warning() -> str | None:
    if os.environ.get("TMUX"):
        return "already inside tmux; detach the INNER session with Ctrl-B Ctrl-B d"
    return None


def resolve_attach_target(
    name: str,
    mode: str,  # "auto" | "local" | "remote"
    user_override: str | None = None,
    host_override: str | None = None,
) -> tuple[str, str, str, str]:
    """Returns (user, host, port, kind). kind is 'local' or 'remote'."""
    # Case-insensitive like agent-container: 'My-Box' resolves the same
    # MY_BOX_* keys (and the same lowercase state file agent-container writes).
    name = validate_name(name.lower())
    user = resolve_ssh_user(user_override)
    if host_override is not None and not SSH_HOST_RE.fullmatch(host_override):
        die(f"invalid ssh host '{host_override}'")
    key = name_to_key(name)

    if mode == "remote" and not HOSTS_CONF.is_file():
        die(f"no hosts config at {HOSTS_CONF} — create it (see docs/agent-container-hosts.example)")

    entry = hosts_entry(name)
    if mode == "remote" or (mode == "auto" and entry is not None):
        if entry is None:
            die(f"no host configured for {name} — add {key}_HOST and {key}_PORT to {HOSTS_CONF}")
        host, port = entry
        return user, host_override or host, port, "remote"

    # Local target: per-host state under the implicit 'local' host. (Attaching to
    # a specific registered host by name is a follow-up; today `up`/`down` carry
    # --host and local attach covers the default-host loop.)
    port = read_state_port(DEFAULT_HOST, name)
    if port is None:
        sf = state_file_for(DEFAULT_HOST, name)
        if mode == "local":
            die(
                f"no local state for {name} at {sf} — is the container "
                f"running? (try: agent-container up {name})"
            )
        die(
            f"no attach target for {name}; checked {HOSTS_CONF} (no {key}_HOST/{key}_PORT) and {sf}"
        )
    host = host_override or os.environ.get("AGENT_CONTAINER_HOST") or "localhost"
    return user, host, port, "local"


# --- Feature 005: shell integration (print/emit seam) ------------------------
# Every print-capable operation computes ONE structured ShellAction (env set/unset
# ops + command lines), which is then either RENDERED for a shell dialect (→ stdout)
# or EXECUTED — so print and execute can never drift (FR-010). Output is eval-safe
# (per-dialect quoting), stdout-is-config-only (humans go to stderr via eprint/die),
# registry-only (no probe), and carries NO secret (Constitution III — only
# connection coordinates). The seam is deliberately backend-extensible (FR-012).

SHELL_DIALECTS = ("posix", "fish", "pwsh")
# Every var `host env` can set — `--unset` clears all of them, name-free (FR-008a).
HOST_ENV_VARS = ("DOCKER_CONTEXT", "DOCKER_HOST", "CONTAINER_CONNECTION", "CONTAINER_HOST")


@dataclass
class ShellAction:
    """The one definition print and execute share (Feature 005). `env_set` is
    ordered (NAME, value) pairs; `env_unset` is NAMEs; `commands` is argv lists.
    Carries only non-secret material by construction (Constitution III)."""

    env_set: list[tuple[str, str]] = field(default_factory=list)
    env_unset: list[str] = field(default_factory=list)
    commands: list[list[str]] = field(default_factory=list)


def _quote_fish(s: str) -> str:
    """fish single-quote: only `\\` and `'` are special inside single quotes; each
    is escaped with a backslash. (Adversarial cases are covered in
    test_shell_integration.py, where backslash escaping is unambiguous.)

    >>> _quote_fish("a b")
    "'a b'"
    """
    return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'"


def _quote_pwsh(s: str) -> str:
    """PowerShell single-quoted literal: a literal `'` is escaped by DOUBLING it;
    single quotes suppress `$` expansion, so this is injection/expansion-safe.

    >>> _quote_pwsh("a b")
    "'a b'"
    >>> _quote_pwsh("a'b")
    "'a''b'"
    >>> _quote_pwsh("$env:x")
    "'$env:x'"
    """
    return "'" + s.replace("'", "''") + "'"


def render_action(action: ShellAction, dialect: str) -> str:
    """Render a ShellAction to eval-safe text for `dialect` (posix|fish|pwsh),
    ending in a single newline. An unknown dialect `die`s (empty stdout + non-zero,
    FR-003). Every value/token is quoted for the shell (FR-004)."""
    if dialect not in SHELL_DIALECTS:
        die(f"--shell must be one of {', '.join(SHELL_DIALECTS)} (got '{dialect}')")
    lines: list[str] = []
    if dialect == "posix":
        for name, val in action.env_set:
            lines.append(f"export {name}={shlex.quote(val)}")
        if action.env_unset:
            lines.append("unset " + " ".join(action.env_unset))
        for cmd in action.commands:
            lines.append(shlex.join(cmd))
    elif dialect == "fish":
        for name, val in action.env_set:
            lines.append(f"set -x {name} {_quote_fish(val)}")
        if action.env_unset:
            lines.append("set -e " + " ".join(action.env_unset))
        for cmd in action.commands:
            lines.append(" ".join(_quote_fish(t) for t in cmd))
    else:  # pwsh
        for name, val in action.env_set:
            lines.append(f"$env:{name} = {_quote_pwsh(val)}")
        if action.env_unset:
            targets = ",".join(f"Env:{n}" for n in action.env_unset)
            lines.append(f"Remove-Item {targets} -ErrorAction SilentlyContinue")
        for cmd in action.commands:
            # In PowerShell a quoted string at statement start is a value, not an
            # invocation — leave the executable (cmd[0]) unquoted and quote the args.
            head = cmd[0]
            rest = " ".join(_quote_pwsh(t) for t in cmd[1:])
            lines.append(f"{head} {rest}".rstrip())
    return ("\n".join(lines) + "\n") if lines else ""


def print_shell(text: str) -> None:
    """Write the fully-rendered block to stdout — the ONLY stdout write on the
    print path. Callers resolve + render EVERYTHING first (any failure `die`s to
    stderr before this), so on error stdout stays empty (eval-safe, FR-003/SC-004)."""
    sys.stdout.write(text)
    sys.stdout.flush()


def ensure_pinned_or_ask(name: str, host: str, port: str, *, trust_unpinned: bool = False) -> None:
    """Feature 018's ABSENT branch, and only the absent branch.

    Three states, three answers, and merging them is the one refactor that would
    delete this feature:

      matches  -> ssh connects. Nothing to do here.
      DIFFERS  -> ssh REFUSES, unconditionally, because we set
                  StrictHostKeyChecking=yes. It is deliberately not our decision:
                  a mismatch contradicts a claim the tool made itself, and a
                  prompt there would let the one unconditional refusal in the
                  feature be clicked through (FR-014).
      ABSENT   -> this function. Warn, show the fingerprint, say what accepting
                  does NOT detect, and ask (FR-013/FR-016).

    Why asking is honest and capturing silently would not be: a pin is a WITNESS,
    and its value comes from being OLDER than what it checks. At deploy time the
    tool knows the container is the one it just created. Here it does not — the
    runtime can only say "the container currently called <name>", never "the
    container you created" — so an attacker who replaced the container would have
    their own key captured and then verified against themselves. That is
    trust-on-first-use through a different door, and the operator is the only one
    who can weigh it. Hence the fingerprint: a prompt with nothing to compare
    against is theatre (research R8).
    """
    if pinned_host_key(host, port) is not None:
        return
    try:
        host_name, host_rec = resolve_deploy_host(DEFAULT_HOST)
        key = capture_host_pubkey(host_rec, name, timeout=CAPTURE_POLL_INTERVAL)
    except Fatal:
        key = None
    if key is None:
        die(
            f"no pinned host key for {name} at [{host}]:{port}, and the container's "
            f"key could not be read to offer one. Deploy it (agent-container "
            f"redeploy {name}) to pin, or place the entry from the machine that "
            f"deployed it (agent-container list --json)."
        )
    warn(
        f"NOTHING IS PINNED for {name} at [{host}]:{port}. The container offers "
        f"{pubkey_fingerprint(key)}.\n"
        f"Accepting is a TRUST DECISION, not a check: it CANNOT DETECT A CONTAINER "
        f"THAT WAS REPLACED, because the key would then be the replacement's own. "
        f"Compare the fingerprint against another source first — or take the entry "
        f"from the machine that deployed this environment (agent-container list "
        f"--json), which predates what it checks and this does not."
    )
    if trust_unpinned:
        # As loud as the prompt would have been (FR-015): an operator who pre-accepts
        # on the command line must not end up better informed than one who was asked.
        warn(f"--trust-unpinned given: accepting {pubkey_fingerprint(key)} without asking")
    elif not sys.stdin.isatty():
        die(
            f"nothing pinned for {name} and no terminal to ask on — refusing rather "
            f"than assuming yes. Re-run interactively, or pass --trust-unpinned to "
            f"accept {pubkey_fingerprint(key)} deliberately."
        )
    elif not questionary.confirm(
        f"trust {pubkey_fingerprint(key)} for {name}?", default=False
    ).ask():
        die(f"refused: nothing pinned for {name} and the offered key was not accepted")
    pin_host_key(host_name, host, port, key)
    log(f"pinned {pubkey_fingerprint(key)} for {name} — future attaches verify against it")


def warn_if_unpinned_for_emitted_command(name: str, host: str, port: str) -> None:
    """FR-017: `--print` and `--ssh-config` emit a command and connect to NOTHING,
    so they must never prompt — but they must not hand over an argv that will fail
    for a reason the output never mentioned."""
    if pinned_host_key(host, port) is None:
        warn(
            f"nothing is pinned for {name} at [{host}]:{port}, so the command above "
            f"will REFUSE to connect. Deploy to pin it, or attach once interactively "
            f"to be asked."
        )


def attach_shell_action(user: str, host: str, port: str, window: str | None = None) -> ShellAction:
    """The attach command as a ShellAction — the SINGLE source the print path
    renders and the execute path runs (FR-010 parity). Only connection coordinates."""
    return ShellAction(commands=[ssh_argv(user, host, port, window)])


def ssh_config_stanza(name: str, user: str, host: str, port: str) -> str:
    """A `~/.ssh/config` `Host` block reproducing attach via `ssh <name>` (FR-007).
    All fields are resolved/validated coordinates — no secret, no injection.

    Carries the same verification as `attach` (Feature 018). Without it, this stanza
    would be a DOCUMENTED path out of the feature: `ssh <name>` from the operator's
    own config would connect unverified, and nothing in the output would say so.

    NOTE: `bin/tests/test_shell_integration.py` pins this stanza line by line, so a
    change here is a change there — deliberately, since the emitted text is a
    contract an operator pastes into a file the tool never writes.
    """
    files = known_hosts_option_value()
    return (
        f"Host {name}\n"
        f"    HostName {host}\n"
        f"    User {user}\n"
        f"    Port {port}\n"
        f"    UserKnownHostsFile {files}\n"
        f"    StrictHostKeyChecking yes\n"
        f"    RequestTTY yes\n"
        f"    RemoteCommand tmux attach -t main\n"
    )


def _sanitize_ssh_uri(uri: str) -> str:
    """Strip any PASSWORD from an ssh:// URI's userinfo before it can be emitted
    (Constitution III — least exposure): keep the user, drop ':password'. A
    docker/podman endpoint authenticates by key, so a password in the URI is both
    unnecessary and a secret that must never reach stdout. Non-ssh:// / userinfo-
    free URIs pass through unchanged.

    >>> _sanitize_ssh_uri("ssh://user:hunter2@host:22")
    'ssh://user@host:22'
    >>> _sanitize_ssh_uri("ssh://dev@vps.example.com")
    'ssh://dev@vps.example.com'
    >>> _sanitize_ssh_uri("ssh://vps.example.com")
    'ssh://vps.example.com'
    """
    m = re.match(r"^(ssh://)([^@/]+)@(.*)$", uri)
    if not m:
        return uri
    scheme, userinfo, rest = m.groups()
    user = userinfo.split(":", 1)[0]  # drop ':password' if present
    return f"{scheme}{user}@{rest}"


def _redact_url_userinfo(value: str | None) -> str | None:
    """Strip a `user:password@` / `:token@` from ANY URL-scheme value before it can be
    emitted (Constitution III — least exposure). A declared clone `repo` may embed a
    credential (ssh://user:tok@…, https://x-access-token:ghp_…@…); the drift detail and
    logs must never print it. ssh:// keeps the (non-secret) user and drops the password,
    matching _sanitize_ssh_uri; other schemes drop the whole userinfo (a bare principal
    there is often itself the token). Non-URL / userinfo-free values pass through.

    >>> _redact_url_userinfo("ssh://git:s3cr3t@github.com/o/r.git")
    'ssh://git@github.com/o/r.git'
    >>> _redact_url_userinfo("https://x-access-token:ghp_AAA@github.com/o/r.git")
    'https://github.com/o/r.git'
    >>> _redact_url_userinfo("https://github.com/o/r.git")
    'https://github.com/o/r.git'
    >>> _redact_url_userinfo(None) is None
    True
    """
    if not value:
        return value
    m = re.match(r"^([a-z][a-z0-9+.\-]*://)([^@/]+)@(.*)$", value, re.IGNORECASE)
    if not m:
        return value
    scheme, userinfo, rest = m.groups()
    if scheme.lower() == "ssh://":
        return f"{scheme}{userinfo.split(':', 1)[0]}@{rest}"
    return f"{scheme}{rest}"


def host_env_action(host_rec: dict, endpoint: bool) -> ShellAction:
    """Build the `host env` set-action for a host record (registry-only, no probe).
    Default emits the driver's context reference (DOCKER_CONTEXT / CONTAINER_CONNECTION,
    mirroring driver_runtime_argv); --endpoint emits the raw endpoint
    (DOCKER_HOST / CONTAINER_HOST = ssh://[user@]address). No secret is included."""
    driver = host_rec.get("driver")
    if driver not in ("docker", "podman"):
        die(f"host env: host driver '{driver}' is attach-only — it has no runtime to target")
    ctx = host_rec.get("context") or ""
    if endpoint:
        # Best-effort raw endpoint (FR-008 portability). If the registered context
        # is itself an ssh:// URI, use it verbatim (it carries the working user);
        # otherwise (a named or socket-forwarded context) reconstruct address-only
        # ssh://<address> — the operator's own ~/.ssh/config supplies the user, and
        # this cannot reproduce a socket-forward (the default context-ref form is
        # authoritative). Any password in the URI is stripped (Constitution III).
        if ctx.startswith("ssh://"):
            uri = _sanitize_ssh_uri(ctx)
        else:
            addr = host_rec.get("address") or ""
            if not addr:
                die("host env --endpoint: no address on record to build an endpoint URI")
            uri = f"ssh://{addr}"
        var = "DOCKER_HOST" if driver == "docker" else "CONTAINER_HOST"
        return ShellAction(env_set=[(var, uri)])
    if not ctx:
        # Context-less (implicit local): the default daemon is already the target.
        return ShellAction()
    var = "DOCKER_CONTEXT" if driver == "docker" else "CONTAINER_CONNECTION"
    # A context NAME is not secret; sanitize defensively in case it is an ssh:// URI.
    return ShellAction(env_set=[(var, _sanitize_ssh_uri(ctx))])


def cli_host_env(name: str | None, endpoint: bool, unset: bool, shell: str) -> None:
    """`host env` — emit eval-able env that retargets the operator's docker/podman
    at a host, or a plain `--unset`. Print-only (a child cannot set the parent
    shell's env — FR-009 exempts it). Registry-only; unknown host → empty stdout +
    non-zero. Everything is resolved + rendered BEFORE the single stdout write."""
    if shell not in SHELL_DIALECTS:
        die(f"--shell must be one of {', '.join(SHELL_DIALECTS)} (got '{shell}')")
    if unset:
        # Name-free: clear every var host env can set (FR-008a).
        print_shell(render_action(ShellAction(env_unset=list(HOST_ENV_VARS)), shell))
        return
    if not name:
        die("host env: NAME is required (or use --unset)")
    rec = registry_hosts(load_registry()).get(name)
    if rec is None:
        die(f"unknown host '{name}' — not in the registry (see: agent-container host ls)")
    action = host_env_action(rec, endpoint)
    if not action.env_set:
        eprint(
            f"[agent-container] host '{name}' targets the local daemon already; "
            f"no environment change needed (nothing to eval)"
        )
        return  # nothing on stdout; harmless no-op eval; exit 0
    print_shell(render_action(action, shell))


def cli_attach(
    name: str,
    mode: str,
    user_override: str | None,
    host_override: str | None,
    window: str | None = None,
    print_mode: bool = False,
    ssh_config: bool = False,
    shell: str = "posix",
    trust_unpinned: bool = False,
) -> None:
    # Validate the window BEFORE resolving/building the command (it is embedded
    # in the remote shell string).
    if window:
        validate_window(window)
    if (print_mode or ssh_config) and shell not in SHELL_DIALECTS:
        die(f"--shell must be one of {', '.join(SHELL_DIALECTS)} (got '{shell}')")
    migrate_flat_state()
    user, host, port, _ = resolve_attach_target(name, mode, user_override, host_override)
    # Feature 005 print mode: emit the ssh+tmux command / ssh-config stanza to
    # stdout and do nothing else — no probe, no connection (registry-only). The
    # printed command is the SAME argv the execute path runs (FR-010 parity).
    if ssh_config:
        print_shell(ssh_config_stanza(name, user, host, port))
        warn_if_unpinned_for_emitted_command(name, host, port)
        return
    if print_mode:
        print_shell(render_action(attach_shell_action(user, host, port, window), shell))
        warn_if_unpinned_for_emitted_command(name, host, port)
        return
    # Feature 018: the ABSENT branch, before any connection attempt. A mismatch is
    # not handled here — ssh refuses it unconditionally (FR-014), and keeping the two
    # in separate places is what stops the refusal becoming a question.
    ensure_pinned_or_ask(name, host, port, trust_unpinned=trust_unpinned)
    # Dead-session probe (FR-008): never present a silent empty attach. If ssh can
    # reach the container but the tmux session 'main' is gone (agent/session ended),
    # report it clearly instead of dropping into an empty shell. An 'unreachable'
    # probe falls through so the real attach surfaces the transport error.
    if probe_session(user, host, port) == "dead":
        die(
            f"nothing running to attach to: the tmux session 'main' for {name} has "
            f"ended (the agent/session exited). Start a fresh session with: "
            f"agent-container redeploy {name}"
        )
    if w := tmux_nest_warning():
        warn(w)  # CLI mode: warn and proceed
    session = '"main"' + (f', window "{window}"' if window else "")
    eprint(f"agent-container: {user}@{host}:{port} (tmux session {session})")
    sys.stdout.flush()
    sys.stderr.flush()
    # Full handover: the process is replaced; signals, SIGWINCH, TTY ownership
    # and the exit code belong to ssh — identical to bash `exec ssh`. The argv is
    # built from the SAME ShellAction the print path renders, so `attach --print`
    # is byte-for-byte what execute runs (FR-010 parity, single source).
    os.execvp("ssh", attach_shell_action(user, host, port, window).commands[0])


def _child_default_sigint() -> None:
    # SIG_IGN survives exec; without this reset a child spawned while the
    # parent shields itself from Ctrl-C would ignore SIGINT too.
    signal.signal(signal.SIGINT, signal.SIG_DFL)


def wizard_handover(
    user: str,
    host: str,
    port: str,
    logs_hint: str = "agent-container logs <name>",
    window: str | None = None,
) -> None:
    """Attach without killing the wizard: inherited-stdio subprocess + cleanup."""
    target = f"{user}@{host}:{port}"
    session = '"main"' + (f', window "{window}"' if window else "")
    eprint(f"agent-container: {target} (tmux session {session})")
    sys.stdout.flush()
    sys.stderr.flush()
    old_int = signal.signal(signal.SIGINT, signal.SIG_IGN)  # Ctrl-C goes to ssh only
    try:
        rc = subprocess.run(
            ssh_argv(user, host, port, window), preexec_fn=_child_default_sigint
        ).returncode
    finally:
        signal.signal(signal.SIGINT, old_int)
        if _ORIG_TERMIOS is not None:
            with contextlib.suppress(Exception):
                termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _ORIG_TERMIOS)
    if rc == 0:
        console.print(
            f"Detached — tmux session 'main' is still running on {target}; agents keep working."
        )
    elif rc == 255:
        warn(f"ssh could not connect to {target}; check hosts.conf or the container state.")
    else:
        warn(
            f"remote command failed (exit {rc}); likely 'tmux attach' found no session "
            f"'main'. Check the entrypoint via: {logs_hint}"
        )


def do_logs(name: str, follow: bool, egress: bool = False) -> int:
    """Stream the agent container's log, or — with `egress` — the boundary's.

    THE BOUNDARY'S LOG IS THE ONLY PLACE A REFUSAL IS RECORDED (FR-020d), and
    since T150 it carries BOTH halves. unbound writes one line per reply, so an
    undeclared name leaves `… api.openai.com. A IN REFUSED …`; squid writes one
    line per request, so an undeclared SNI leaves `NONE_NONE/000 0 CONNECT
    140.82.121.10:443 … sni=codeload.github.com`. Neither appears anywhere else —
    the agent container's own log shows a failed lookup or a dropped connection
    with no cause. Without this flag the record existed and was unreachable, which
    for an operator is the same as not existing.
    """
    validate_name(name)
    rt = detect_runtime()
    target = egress_container_name(name) if egress else container_name(name)
    if egress and not container_exists(rt, target):
        # The runtime's own "no such container" reads as a tool fault, when the
        # truth is a policy fact: this environment has no boundary to log. That is
        # the FR-020e confusion — a declaration outcome presented as infrastructure
        # breakage — one level up from DNS, so it gets named rather than passed on.
        die(
            f"'{name}' has no egress boundary to read: no container {target}. An "
            f"environment is only given one when its spec declares `egress:` AND the "
            f"declaration is enforceable — check `agent-container status`."
        )
    argv = [rt, "logs"] + (["-f"] if follow else []) + [target]
    try:
        return run_child(argv).returncode  # inherited stdio (stdout->stderr in JSON mode)
    except KeyboardInterrupt:
        return 130


# --- wizard ------------------------------------------------------------------
# Lifecycle pickers (start/stop/purge/logs) list LOCAL containers only;
# remote (hosts.conf) targets appear exclusively in the Attach picker.


def _validate_wiz_name(text: str):  # questionary validator: True or error string
    return True if NAME_RE.fullmatch(text) else "must match [a-z0-9][a-z0-9_-]*"


def _short(cname: str) -> str:
    return cname.removeprefix(CONTAINER_PREFIX)


def wiz_build(rt: str) -> None:
    tag = questionary.text("image tag", default=IMAGE_NAME).ask()
    if not tag:
        return
    do_build(tag)
    hint("agent-container build" + (f" {tag}" if tag != IMAGE_NAME else ""))


def wiz_up(rt: str) -> None:
    # FR-019: offer a sensible default the operator can accept or edit.
    name = questionary.text("container name", default="dev", validate=_validate_wiz_name).ask()
    if not name:
        return
    migrate_flat_state()
    cname = container_name(name)
    host_rec = implicit_local_host(rt)  # the wizard deploys to the local host

    if cname in host_container_names(host_rec):
        port = read_state_port(DEFAULT_HOST, name)
        log(f"container {cname} already running" + (f" on port {port}" if port else ""))
        return
    if cname in host_container_names(host_rec, include_stopped=True):
        log(f"container {cname} exists but is stopped — remove it first (Stop / remove)")
        return

    env_file = resolve_env_file(name)
    if env_file is None:
        eprint("[agent-container] no env file found; looked in:")
        for c in env_file_candidates(name, Path.cwd()):
            eprint(f"  {c}")
        target = CONFIG_DIR / f"{name}.env"
        if questionary.confirm(f"create/edit {target} in $EDITOR now?", default=True).ask():
            CONFIG_DIR.mkdir(parents=True, exist_ok=True)
            # $EDITOR gets the path only; agent-container never reads env contents.
            subprocess.run([os.environ.get("EDITOR") or "vi", str(target)])
            env_file = resolve_env_file(name)
        if env_file is None:
            log("still no env file; not starting")
            return

    # Optional single host-dir bind (empty = none). Resolved now so a bad path
    # surfaces before the confirmation card; Fatal returns to the menu.
    mounts: list[str] = []
    mount_dir = questionary.text(
        "host directory to mount read-write (empty = none)", default=""
    ).ask()
    if mount_dir:
        mounts = [resolve_bind_mount(mount_dir)]

    card = Table(show_header=False, box=None, pad_edge=False)
    card.add_column(style="bold")
    card.add_column()
    card.add_row("env-file", str(env_file))  # path only, never contents
    card.add_row("port", str(port_for_name(name)))
    card.add_row("volumes", ", ".join(per_container_volumes(name)))
    if mounts:
        card.add_row("bind", mounts[0])
    card.add_row("image", "built on host via compose")
    console.print(card)

    if not questionary.confirm(f"start {cname}?", default=True).ask():
        return
    # Compose build + up (port pre-check + actionable abort inside).
    with deployment_lock(DEFAULT_HOST, name):
        compose_up_exec(DEFAULT_HOST, host_rec, name, env_file, mounts, [])
    hint(f"agent-container up {name}" + (f" --mount {mount_dir}" if mounts else ""))


def wiz_attach(rt: str) -> None:
    choices: list[questionary.Choice] = []
    for cname, _image, _status, _uptime in ps_agent_container(rt):
        short = _short(cname)
        port = read_state_port(DEFAULT_HOST, short) or "?"
        choices.append(
            questionary.Choice(f"{short}  [local :{port}]", value=("local", short, "", ""))
        )
    conf = load_hosts_conf()
    for key in sorted(conf):
        if not key.endswith("_HOST"):
            continue
        base = key[: -len("_HOST")]
        host, port = conf[key], conf.get(f"{base}_PORT")
        if not (host and port):
            continue
        rname = base.lower()  # round-trips through name_to_key for the hint
        choices.append(
            questionary.Choice(
                f"{rname}  [remote {host}:{port}]", value=("remote", rname, host, port)
            )
        )
    if not choices:
        log("nothing to attach to: no running local containers and no hosts.conf entries")
        return

    sel = questionary.select("attach to", choices=choices).ask()
    if sel is None:
        return
    if w := tmux_nest_warning():
        warn(w)
        if not questionary.confirm("continue attaching?", default=True).ask():
            return

    # Optional window to select before attaching (empty = session default).
    # Validated now so a bad name aborts back to the menu (Fatal is caught there).
    window = questionary.text("window to select (empty = default)", default="").ask() or None
    if window:
        validate_window(window)

    kind, name, host, port = sel
    user = resolve_ssh_user()
    if kind == "local":
        port = read_state_port(DEFAULT_HOST, name)
        if port is None:  # TOCTOU: state file vanished since the picker rendered
            warn(f"state file for {name} disappeared; is the container still up?")
            return
        host = os.environ.get("AGENT_CONTAINER_HOST") or "localhost"
    wizard_handover(user, host, port, logs_hint=f"agent-container logs {name}", window=window)
    hint(f"agent-container attach {name} --{kind}" + (f" --window {window}" if window else ""))


def wiz_logs(rt: str) -> None:
    rows = ps_agent_container(rt)
    if not rows:
        log("no running agent-container containers")
        return
    sel = questionary.select("logs for", choices=[_short(r[0]) for r in rows]).ask()
    if sel is None:
        return
    log("streaming logs — Ctrl-C returns to the menu")
    old_int = signal.signal(signal.SIGINT, signal.SIG_IGN)  # Ctrl-C stops the tail only
    try:
        subprocess.run([rt, "logs", "-f", container_name(sel)], preexec_fn=_child_default_sigint)
    finally:
        signal.signal(signal.SIGINT, old_int)
    hint(f"agent-container logs {sel}")


def wiz_down(rt: str) -> None:
    rows = ps_agent_container(rt, include_stopped=True)
    if not rows:
        log("no agent-container containers (running or stopped)")
        return
    choices = []
    for cname, _image, status, _uptime in rows:
        short = _short(cname)
        label = f"{short}  [exited]" if status.lower().startswith("exited") else short
        choices.append(questionary.Choice(label, value=short))
    sel = questionary.select("stop / remove", choices=choices).ask()
    if sel is None:
        return
    cname = container_name(sel)

    if not container_exists(rt, cname):  # TOCTOU re-verify
        warn(f"container {cname} no longer exists")
        return
    if quadlet_active(sel):
        warn(
            f"systemd user unit {cname}.service is active; systemd will restart "
            f"the container after removal. Manage it via Quadlet instead."
        )
        if not questionary.confirm("proceed anyway?", default=False).ask():
            return
    if not questionary.confirm(f"stop and remove {cname}?", default=False).ask():
        log("aborted")
        return

    purge = False
    vols = per_container_volumes(sel)
    if questionary.confirm(
        f"also delete ALL per-container volumes ({', '.join(vols)})?", default=False
    ).ask():
        typed = questionary.text(
            f"type the container name ({cname}) to confirm volume deletion"
        ).ask()
        if typed == cname:
            purge = True
        else:
            log("name mismatch — volumes will be preserved")
    with deployment_lock(DEFAULT_HOST, sel):  # serialize vs concurrent lifecycle ops (FR-017)
        down_container(DEFAULT_HOST, implicit_local_host(rt), sel, purge)  # reports + clears state
    hint(f"agent-container down {sel}" + (" --purge" if purge else ""))


def _volume_names(rt: str) -> list[str]:
    out = query([rt, "volume", "ls", "--format", "{{.Name}}"]).stdout.splitlines()
    return [v for v in out if re.fullmatch(re.escape(CONTAINER_PREFIX) + r".+-workspace", v)]


def wiz_purge_volume(rt: str) -> None:
    containers = {row[0] for row in ps_agent_container(rt, include_stopped=True)}
    orphans = []
    for vol in _volume_names(rt):
        short = vol[len(CONTAINER_PREFIX) : -len("-workspace")]
        if container_name(short) not in containers:
            orphans.append(vol)
    if not orphans:
        log("no orphaned agent-container volumes")
        return
    sel = questionary.select("purge orphaned volume", choices=orphans).ask()
    if sel is None:
        return
    typed = questionary.text(f"type the volume name ({sel}) to confirm deletion").ask()
    if typed != sel:
        log("name mismatch — nothing removed")
        return
    if sel not in _volume_names(rt):  # TOCTOU re-verify
        warn(f"volume {sel} no longer exists")
        return
    if query([rt, "volume", "rm", sel]).returncode != 0:
        warn(f"volume {sel} not removed (in use)")
    else:
        log(f"volume {sel} purged")
    hint(f"{rt} volume rm {sel}")


def wiz_keys(rt: str) -> None:
    rows = ps_agent_container(rt, include_stopped=False)  # running only
    if not rows:
        log("no running agent-container containers")
        return
    sel = questionary.select("inject SSH keys into", choices=[_short(r[0]) for r in rows]).ask()
    if sel is None:
        return
    # No host-key prompt: Feature 018 removed private-host-key injection, and the
    # container's own identity is captured at deploy rather than supplied here.
    ak = questionary.text("authorized public key path (blank to skip)").ask()
    authorized = [Path(ak).expanduser()] if ak else []
    if not authorized:
        log("nothing to inject")
        return
    inject_keys(rt, sel, authorized)
    hint(f"agent-container keys {sel}" + (f" --authorized-key {ak}" if ak else ""))


# --- Feature 007: state-aware guided wizard ----------------------------------
# A PURE recommendation engine (no I/O) drives a thin interactive shell: assemble a
# snapshot of the setup journey for a SINGLE active target, compute the one most
# useful next step, and lead with it (a reason + the secret-free equivalent command)
# while still letting the operator pick any valid action. The engine is pure so its
# rules — exactly one recommendation; never an unmet-hard-prereq action; broken-state
# detection; soft credentials — are hermetically testable (Constitution V).

STAGE_SATISFIED = "satisfied"
STAGE_UNSATISFIED = "unsatisfied"  # absent
STAGE_UNUSABLE = "unusable"  # present but not usable (unreachable / not healthy)
STAGE_KEYS = ("runtime", "host", "image", "credentials", "container", "running")
SOFT_STAGES = frozenset({"credentials"})  # recommended, never a gate (FR-018)


@dataclass
class SetupStage:
    key: str
    status: str  # STAGE_SATISFIED | STAGE_UNSATISFIED | STAGE_UNUSABLE
    detail: str = ""

    @property
    def hard(self) -> bool:
        return self.key not in SOFT_STAGES

    @property
    def satisfied(self) -> bool:
        return self.status == STAGE_SATISFIED


@dataclass
class ActiveTarget:
    host_name: str
    host_rec: dict
    container_name: str | None = None
    ambiguous_host: bool = False


@dataclass
class EnvSnapshot:
    target: ActiveTarget
    stages: list[SetupStage]
    containers: list[tuple[str, str]] = field(default_factory=list)  # (short name, status)
    orphan_volumes: list[str] = field(default_factory=list)
    problems: list[str] = field(default_factory=list)

    def stage(self, key: str) -> SetupStage:
        return next(s for s in self.stages if s.key == key)


@dataclass
class RecommendedAction:
    kind: str
    reason: str
    equivalent_cmd: str = ""  # secret-free (Constitution III) — a flag/path, never a value
    target: ActiveTarget | None = None
    destructive: bool = False
    soft: bool = False  # a recommendation the operator may skip un-blocked (e.g. credentials)


@dataclass
class ActionOutcome:
    ok: bool
    message: str = ""


def assess_stages(p: dict) -> list[SetupStage]:
    """PURE classifier: map raw probe results to the ordered, tri-state stage list (no
    I/O). `credentials` is soft; all others hard. This is the load-bearing assessment,
    tested directly against probe-result dicts."""

    def present_stage(key: str, present: bool, usable: bool, absent_d: str, unusable_d: str):
        if not present:
            return SetupStage(key, STAGE_UNSATISFIED, absent_d)
        return (
            SetupStage(key, STAGE_SATISFIED)
            if usable
            else SetupStage(key, STAGE_UNUSABLE, unusable_d)
        )

    stages = [
        present_stage(
            "runtime",
            p.get("runtime_present", True),
            p.get("runtime_usable", True),
            "no container runtime found",
            p.get("runtime_detail", "runtime unreachable"),
        ),
        present_stage(
            "host",
            p.get("host_present", True),
            p.get("host_usable", True),
            "no host is configured yet",
            p.get("host_detail", "host unreachable"),
        ),
        SetupStage("image", STAGE_SATISFIED if p.get("image_present") else STAGE_UNSATISFIED),
        SetupStage(
            "credentials", STAGE_SATISFIED if p.get("credentials_present") else STAGE_UNSATISFIED
        ),
    ]
    # container: absent → unsatisfied; present+running → satisfied; present+not-running → unusable
    if not p.get("container_present", False):
        stages.append(SetupStage("container", STAGE_UNSATISFIED))
    elif p.get("running"):
        stages.append(SetupStage("container", STAGE_SATISFIED))
    else:
        stages.append(
            SetupStage("container", STAGE_UNUSABLE, p.get("container_detail", "not running"))
        )
    # running: running+session → satisfied; running+dead-session → unusable; else unsatisfied
    if p.get("running") and p.get("session_ok", True):
        stages.append(SetupStage("running", STAGE_SATISFIED))
    elif p.get("running"):
        stages.append(SetupStage("running", STAGE_UNUSABLE, "session not reachable"))
    else:
        stages.append(SetupStage("running", STAGE_UNSATISFIED))
    return stages


def _host_flag(t: ActiveTarget) -> str:
    return "" if t.host_name == DEFAULT_HOST else f" --host {t.host_name}"


def _tname(t: ActiveTarget) -> str:
    return t.container_name or "<name>"


def _rec_ambiguous(s: EnvSnapshot) -> RecommendedAction | None:
    if s.target.ambiguous_host:
        return RecommendedAction(
            "choose_host",
            "more than one host is registered — choose which one to work with",
            "agent-container host ls",
            s.target,
        )
    return None


def _rec_blocked(s: EnvSnapshot) -> RecommendedAction | None:
    """A hard-unmet prerequisite or a broken infra/container — corrective, taking
    precedence over forward progress (SC-003/SC-004). Returns None if nothing blocks."""
    t = s.target
    runtime, host, image = s.stage("runtime"), s.stage("host"), s.stage("image")
    container, running = s.stage("container"), s.stage("running")
    if runtime.status != STAGE_SATISFIED:
        return RecommendedAction(
            "fix_runtime",
            f"the container runtime is not usable ({runtime.detail}) — fix it before any container action",
            "",
            t,
        )
    if host.status == STAGE_UNUSABLE:
        return RecommendedAction(
            "fix_runtime",
            f"host '{t.host_name}' is unreachable ({host.detail}) — fix connectivity first",
            "",
            t,
        )
    if host.status == STAGE_UNSATISFIED:
        return RecommendedAction(
            "setup_host",
            "no host is configured yet — a host is where containers run",
            "agent-container host add <name> --docker-context <ctx>",
            t,
        )
    if image.status != STAGE_SATISFIED:
        return RecommendedAction(
            "build_image",
            "the agent image is not built here yet — a container needs it to start",
            "agent-container build",
            t,
        )
    if STAGE_UNUSABLE in (container.status, running.status):
        return RecommendedAction(
            "view_logs",
            f"container '{_tname(t)}' is not healthy ({container.detail or running.detail}) — "
            f"check its logs, then recreate or remove",
            f"agent-container logs {_tname(t)}",
            t,
        )
    return None


def _rec_forward(s: EnvSnapshot) -> RecommendedAction:
    """All hard prerequisites met: attach if running, else start (soft credentials
    noted but never gating, FR-018); clean leftover volumes before a fresh start."""
    t = s.target
    if s.stage("running").satisfied:
        return RecommendedAction(
            "attach",
            f"container '{_tname(t)}' is running — attach to it",
            f"agent-container attach {_tname(t)}{_host_flag(t)}",
            t,
        )
    if s.stage("container").status == STAGE_UNSATISFIED and s.orphan_volumes:
        return RecommendedAction(
            "clean_volumes",
            f"leftover volumes from a removed container ({', '.join(s.orphan_volumes)}) — "
            f"clean them up first (they hold a prior workspace)",
            "agent-container purge <name>",
            t,
            destructive=True,
        )
    reason = "everything is ready — start the container"
    if not s.stage("credentials").satisfied:
        reason = (
            "ready to start — no credentials found; the agent can authenticate interactively "
            "inside the session, or supply a key first"
        )
    return RecommendedAction("start", reason, f"agent-container up {_tname(t)}{_host_flag(t)}", t)


def recommend_next_step(s: EnvSnapshot) -> RecommendedAction:
    """PURE: the single most useful next step (SC-002). Ambiguity → choose; a broken or
    hard-unmet prerequisite → its corrective/prerequisite action (never the blocked
    action, SC-003); otherwise forward progress to a running, attachable container."""
    return _rec_ambiguous(s) or _rec_blocked(s) or _rec_forward(s)


def valid_actions(s: EnvSnapshot) -> list[RecommendedAction]:
    """Every action valid in the current state — the escape hatch (FR-008/SC-007). Any
    action whose HARD prerequisites are unmet is WITHHELD (FR-004), and `quit` is ALWAYS
    present (FR-015)."""
    t = s.target
    acts: list[RecommendedAction] = []
    runtime_ok = s.stage("runtime").satisfied
    host_ok = s.stage("host").satisfied
    image_ok = s.stage("image").satisfied
    acts.append(
        RecommendedAction(
            "setup_host",
            "register or provision another host",
            "agent-container host add <name> --docker-context <ctx>",
            t,
        )
    )
    if runtime_ok and host_ok and not image_ok:
        acts.append(
            RecommendedAction(
                "build_image", "build the agent image here", "agent-container build", t
            )
        )
    if runtime_ok and host_ok and image_ok:
        if not s.stage("credentials").satisfied:
            acts.append(
                RecommendedAction(
                    "supply_credentials",
                    "provide the agent's credentials/config",
                    f"agent-container up {_tname(t)} --env-file <path>",
                    t,
                    soft=True,
                )
            )
        if s.stage("running").satisfied:
            acts.extend(
                (
                    RecommendedAction(
                        "attach",
                        "attach to a running container",
                        f"agent-container attach {_tname(t)}{_host_flag(t)}",
                        t,
                    ),
                    RecommendedAction(
                        "view_logs",
                        "view a container's logs",
                        f"agent-container logs {_tname(t)}",
                        t,
                    ),
                    RecommendedAction(
                        "remove",
                        "stop and remove a container",
                        f"agent-container down {_tname(t)} --purge",
                        t,
                        destructive=True,
                    ),
                    RecommendedAction(
                        "inject_keys",
                        "inject SSH keys into a running container",
                        f"agent-container keys {_tname(t)}",
                        t,
                    ),
                )
            )
        else:
            acts.append(
                RecommendedAction(
                    "start",
                    "start a container",
                    f"agent-container up {_tname(t)}{_host_flag(t)}",
                    t,
                )
            )
    if s.orphan_volumes:
        acts.append(
            RecommendedAction(
                "clean_volumes",
                "clean up orphaned volumes",
                "agent-container purge <name>",
                t,
                destructive=True,
            )
        )
    acts.append(RecommendedAction("quit", "leave the wizard", "", t))  # FR-015: always
    return acts


# --- snapshot assembler + active-target resolution (impure — reuse existing probes) ---


def _is_running(status: str) -> bool:
    return status.strip().lower().startswith("up")


def resolve_active_target(
    rt: str, selected_host: str | None = None, selected_name: str | None = None
) -> ActiveTarget:
    """Resolve the (host, container) the wizard guides toward (FR-017). Host: the
    selection, else the registry default, else the implicit local host; `ambiguous_host`
    when >1 host is registered and none is selected or defaulted. Container name is
    filled in by build_snapshot (reuse-sole-or-default, FR-019)."""
    reg = load_registry()
    hosts = registry_hosts(reg)
    if selected_host:
        host_name, host_rec = resolve_deploy_host(selected_host)
        ambiguous = False
    elif len(hosts) > 1 and default_host_name(reg) is None:
        host_name, host_rec, ambiguous = DEFAULT_HOST, implicit_local_host(rt), True
    else:
        host_name, host_rec = resolve_deploy_host(None)
        ambiguous = False
    return ActiveTarget(host_name, host_rec, selected_name, ambiguous)


def build_snapshot(rt: str, target: ActiveTarget) -> EnvSnapshot:
    """Assemble the snapshot for the active target by calling EXISTING probes, bounded
    to the active host (FR-017). Impure/thin: the classification lives in assess_stages
    and the recommendation in recommend_next_step (both pure)."""
    hr = target.host_rec
    try:
        host_err = probe_host_runtime(hr)
    except (Fatal, OSError, subprocess.SubprocessError) as e:
        host_err = str(e) or "unreachable"
    reachable = host_err is None
    registered = bool(registry_hosts(load_registry()))
    p: dict = {
        "runtime_present": True,  # a runtime binary is a precondition of the loop
        "runtime_usable": True,
        "host_present": reachable or registered,
        "host_usable": reachable,
        "host_detail": host_err or "",
        "image_present": bool(reachable and image_exists(rt, IMAGE_NAME)),
    }
    containers: list[tuple[str, str]] = []
    orphan: list[str] = []
    if reachable:
        try:
            for cname, _img, status, _up in host_ps_rows(hr, include_stopped=True):
                containers.append((_short(cname), status))
        except Fatal, OSError, subprocess.SubprocessError:
            pass
        present = [n for n, _s in containers]
        if target.container_name is None and len(present) == 1:
            target.container_name = present[0]  # FR-019: reuse the sole existing container
        orphan = _orphan_volumes(rt, containers) if target.host_name == DEFAULT_HOST else []
    active_status = next((st for n, st in containers if n == target.container_name), None)
    p["container_present"] = active_status is not None
    p["running"] = active_status is not None and _is_running(active_status)
    p["container_detail"] = active_status or ""
    p["credentials_present"] = bool(
        target.container_name and resolve_env_file(target.container_name) is not None
    )
    p["session_ok"] = True
    stages = assess_stages(p)
    problems: list[str] = []
    if p["host_present"] and not p["host_usable"]:
        problems.append(f"host '{target.host_name}' unreachable: {host_err}")
    if p["container_present"] and not p["running"]:
        problems.append(f"container '{target.container_name}' is {active_status}")
    if orphan:
        problems.append(f"{len(orphan)} orphaned volume(s): {', '.join(orphan)}")
    return EnvSnapshot(target, stages, containers, orphan, problems)


def _orphan_volumes(rt: str, containers: list[tuple[str, str]]) -> list[str]:
    owners = {container_name(n) for n, _s in containers}
    return [
        v
        for v in _volume_names(rt)
        if container_name(v[len(CONTAINER_PREFIX) : -len("-workspace")]) not in owners
    ]


# Action kind → the EXISTING interactive handler that performs it (each already shows
# its own `hint()` equivalent command on success, FR-010). Kinds absent here
# (setup_host / fix_runtime / choose_host / quit) are handled inline by the shell.
_WIZ_DISPATCH: dict[str, Callable[[str], None]] = {
    "build_image": wiz_build,
    "supply_credentials": wiz_up,  # the env-file prompt lives in wiz_up
    "start": wiz_up,
    "attach": wiz_attach,
    "view_logs": wiz_logs,
    "remove": wiz_down,
    "clean_volumes": wiz_purge_volume,
    "inject_keys": wiz_keys,
}

_STAGE_GLYPH = {
    STAGE_SATISFIED: "[green]✓[/green]",
    STAGE_UNSATISFIED: "[dim]•[/dim]",
    STAGE_UNUSABLE: "[red]![/red]",
}


def _render_state(s: EnvSnapshot) -> None:
    """Compact current-state summary shown every turn (FR-009): the target, the stage
    ladder, and any named problems."""
    console.print(
        f"target: [bold]{s.target.host_name}[/bold] / {s.target.container_name or '(no container)'}",
        style="dim",
    )
    console.print("  ".join(f"{_STAGE_GLYPH[st.status]} {st.key}" for st in s.stages))
    for prob in s.problems:
        console.print(f"[red]![/red] {prob}")


def _action_label(a: RecommendedAction, recommended: bool) -> str:
    star = "★ " if recommended else "  "
    tail = " [dim](confirms)[/dim]" if a.destructive else ""
    return f"{star}{a.kind.replace('_', ' ')} — {a.reason}{tail}"


def _prompt_action(rec: RecommendedAction, actions: list[RecommendedAction]):
    """Show the single marked recommendation first, then every other valid action (the
    escape hatch, FR-008); `quit` is always present (FR-015). Returns the chosen action."""
    ordered = [rec] + [a for a in actions if a.kind != rec.kind]
    choices = [questionary.Choice(_action_label(a, a is rec), value=a) for a in ordered]
    return questionary.select("recommended next step:", choices=choices).ask()


def _pick_host(rt: str) -> str | None:
    hosts = sorted(registry_hosts(load_registry()))
    if not hosts:
        return None
    sel = questionary.select("use which host?", choices=[*hosts, "(local)"]).ask()
    return None if sel in (None, "(local)") else sel


def _perform(rt: str, a: RecommendedAction) -> None:
    handler = _WIZ_DISPATCH.get(a.kind)
    if handler is not None:
        handler(rt)  # performs the action and shows its own equivalent command on success
        return
    # Guidance-only kinds (no underlying interactive handler): explain + show the command.
    console.print(f"[bold]→[/bold] {a.reason}")
    if a.equivalent_cmd:
        hint(a.equivalent_cmd)


def wizard_loop() -> int:
    """State-aware guided wizard: assess the active target, lead with the single most
    useful next step (+ reason + equivalent command), let the operator pick any valid
    action, perform it, and re-evaluate — from an empty machine to a running session."""
    if not is_tty():
        eprint(
            "[agent-container] no TTY; guided wizard needs an interactive terminal. "
            "Use the subcommands instead (agent-container --help)."
        )
        return 2
    try:
        rt = detect_runtime()
    except Fatal as e:
        eprint(f"[agent-container] FATAL: {e}")
        return 1
    selected_host: str | None = None
    while True:
        try:
            snap = build_snapshot(rt, resolve_active_target(rt, selected_host))
            _render_state(snap)
            choice = _prompt_action(recommend_next_step(snap), valid_actions(snap))
            if choice is None or choice.kind == "quit":  # FR-015: quit always available
                return 0
            if choice.kind == "choose_host":
                selected_host = _pick_host(rt)
                continue
            _perform(rt, choice)
            # re-loop → a fresh snapshot is re-assessed (FR-005); a Fatal or a cancelled
            # prompt below just returns to this re-evaluated state, never a dead end
            # (FR-012/FR-015).
        except Fatal as e:
            eprint(f"[agent-container] ERROR: {e}")  # report + re-evaluate, not exit
        except KeyboardInterrupt, EOFError:
            return 0


# --- Feature 006: agent-as-code (declarative .agent-container/ project) -------
# A `.agent-container/` directory is the DESIRED STATE for one or more agent
# environments, reconciled by driving the existing imperative internals (do_up /
# down_container / the host registry). Additive: no marker up the tree ⇒ today's
# behavior. The governing spec is immutable in-container — read ONLY host-side, and
# delivered READ-ONLY via the compose-`configs` channel (remote-safe; FR-020).

PROJECT_MARKER = ".agent-container"
# Credential sources (008). `command` is the generic resolver — an argv list run
# DIRECTLY (no shell, no injection surface); `onepassword`/`bitwarden` are named
# managers whose structured fields the tool assembles into that same argv. The
# `encrypted` (committed-ciphertext) source was REMOVED — secrets never live in the
# git remote; a spec still declaring it is refused with a migration (FR-009).
CRED_SOURCES = ("env", "file", "keychain", "command", "onepassword", "bitwarden")
# How long a resolver may take before it is killed. The operator pre-unlocks the
# manager (resolution is non-interactive), so a fetch is quick — but a manager may
# make a network round-trip (Vault, a cloud store). 30 s covers that while
# guaranteeing a wedged CLI can never hang an apply (FR-005, research R5).
RESOLVER_TIMEOUT = 30
# Credential names that map to a provider API key delivered via the 003 file-first
# apikey channel (never even the env). Everything else is delivered as an env var.
CRED_PROVIDER = {
    "anthropic": "anthropic",
    "ANTHROPIC_API_KEY": "anthropic",
    "openai": "openai",
    "OPENAI_API_KEY": "openai",
}
# A credential may declare an explicit SSH `target` (US2/T012a): the resolved value
# is routed to a Feature 003 ssh-injection channel (multi-line keys the env-file
# channel refuses) instead of an env var — outbound git push identity, inbound sshd
# host identity, or an inbound authorized key. Absent → today's apikey/env routing.
# `host_key` was a target here until Feature 018: a declared PRIVATE host key is
# now REFUSED rather than dropped, because silently ignoring it would leave an
# operator believing their key is in use.
CRED_SSH_TARGETS = ("push_key", "authorized_key")
# US4 declarative host provisioning: a `host:` table's provider discriminator + the
# keys forwarded to the Feature 001 provisioner (provision_host's actual args).
PROVISION_PROVIDERS = ("hetzner",)
PROVISION_KEYS = {"provision", "name", "server_type", "location", "ssh_key"}
# The read-only spec target in the container (defense-in-depth; the tool never
# reads this copy — it reads only the host-side .agent-container/).
INJECT_AAC_DIR = "/workspace/.agent-container"


def find_project_root(start: Path | None = None) -> Path | None:
    # NOTE (Feature 011): the per-environment resolvers defined EARLIER in this
    # file (env_file_candidates, discover_apikey_files, canonical_config_dir,
    # resolve_sidecar_override) call this via project_config_dir(). Python binds
    # names at CALL time, not definition time, so the backwards reference is
    # fine — verified, not assumed. Do not "fix" the ordering; nothing is broken.
    """Walk upward from `start` (cwd) to the nearest ancestor holding a
    `.agent-container/` directory; return it, or None (declarative model inert →
    today's imperative behavior, FR-004). Deterministic regardless of subdir."""
    cur = (start or Path.cwd()).resolve()
    for d in (cur, *cur.parents):
        if (d / PROJECT_MARKER).is_dir():
            return d
    return None


def project_config_dir(cwd: Path | None = None) -> Path | None:
    """The project config directory — `<project root>/.agent-container` — or None
    when `cwd` is not inside a project (Feature 011, FR-001).

    One helper rather than four copies of the same walk: every per-environment
    resolver goes through here, so "where does a project keep its files" has a
    single answer. Returns None rather than guessing, and each caller then falls
    back to user-level configuration on its own terms.

    >>> project_config_dir(Path("/definitely/not/a/project")) is None
    True
    """
    root = find_project_root(cwd)
    return None if root is None else root / PROJECT_MARKER


# A file in `.agent-container/` is identified by KIND, and the rule is one line:
# THE SUFFIX NAMES THE TOP-LEVEL YAML KEY THE FILE CONTAINS. `acme.services.yaml`
# holds `services:`; `prod.environments.yaml` holds `environments:`. Nothing here
# is new except that the spec file finally carries the marker the sidecar always
# had — which is why the two could not previously share a directory (the spec
# loader claimed EVERY *.yaml by glob and died on the sidecar's `services:` key).
SPEC_KIND_SUFFIXES: tuple[str, ...] = (".environments.yaml", ".environments.yml")
SIDECAR_KIND_SUFFIXES: tuple[str, ...] = (".services.yaml", ".services.yml")
YAML_SUFFIXES: tuple[str, ...] = (".yaml", ".yml")


def _is_spec_file(name: str) -> bool:
    """True for a declarative-spec file: `environments.yaml` (bare, the common
    single-file case) or `<anything>.environments.yaml` (split across files).

    >>> [_is_spec_file(n) for n in ("environments.yaml", "prod.environments.yml")]
    [True, True]
    >>> [_is_spec_file(n) for n in ("acme.services.yaml", "project.yaml", "enviroments.yaml")]
    [False, False, False]
    """
    return name in ("environments.yaml", "environments.yml") or name.endswith(SPEC_KIND_SUFFIXES)


def _is_known_kind(name: str) -> bool:
    """True for any filename whose kind the tool recognises. Anything else that is
    still YAML is unrecognised — refused by default, so a typo like
    `enviroments.yaml` fails loudly rather than silently loading no environments.

    >>> [_is_known_kind(n) for n in ("environments.yaml", "acme.services.yml")]
    [True, True]
    >>> _is_known_kind("enviroments.yaml")
    False
    """
    return _is_spec_file(name) or name.endswith(SIDECAR_KIND_SUFFIXES)


def _unrecognised_yaml(root: Path) -> list[Path]:
    d = root / PROJECT_MARKER
    return sorted(
        p
        for p in d.rglob("*")
        if p.is_file() and p.suffix in YAML_SUFFIXES and not _is_known_kind(p.name)
    )


def _spec_yaml_files(root: Path) -> list[Path]:
    """The declarative-spec files ONLY. Selected by kind, never by 'every .yaml
    here' — that glob is what made a spec and a sidecar override unable to coexist
    in the directory Feature 011 mandated for both."""
    d = root / PROJECT_MARKER
    return sorted(p for p in d.rglob("*") if p.is_file() and _is_spec_file(p.name))


def _enum_field(block: dict, key: str, allowed: tuple[str, ...], where: str) -> None:
    v = block.get(key)
    if v is not None and v not in allowed:
        die(f"{where}: {key}={v!r} is not one of {{{', '.join(allowed)}}}")


def validate_credential(cred: object, where: str) -> None:
    if not isinstance(cred, dict):
        die(f"{where}: must be a mapping")
    if not cred.get("name"):
        die(f"{where}: missing required 'name'")
    src = cred.get("source")
    if src == "encrypted":
        # Checked BEFORE the enum error so an upgrading operator gets an actionable
        # migration rather than a bare "not one of {…}" (FR-009).
        die(
            f"{where}: the 'encrypted' credential source was REMOVED — a secret must not "
            f"live in the git remote, even as ciphertext. Migrate to a manager "
            f"(source: onepassword | bitwarden | command), the OS keychain "
            f"(source: keychain), or a file OUTSIDE the project / untracked "
            f"(source: file). See docs/agent-as-code.md."
        )
    if src not in CRED_SOURCES:
        die(f"{where}: source={src!r} is not one of {{{', '.join(CRED_SOURCES)}}}")
    required_by_source = {
        "env": ("var",),
        "file": ("path",),
        "keychain": ("service", "account"),
        "command": ("argv",),
        "onepassword": ("vault", "item", "field"),
        "bitwarden": ("item", "field"),
    }
    detail = required_by_source.get(src, ())
    for k in detail:
        if not cred.get(k):
            die(f"{where}: source={src} requires '{k}'")
    if src == "command":
        # The resolver is run DIRECTLY (never through a shell), so argv must be a real
        # non-empty list of strings — a bare string would be neither runnable nor safe.
        argv = cred.get("argv")
        if not isinstance(argv, list) or not argv:
            die(f"{where}: source=command requires 'argv' to be a non-empty list of strings")
        if not all(isinstance(a, str) for a in argv):
            die(f"{where}: source=command 'argv' must contain only strings")
    tgt = cred.get("target")
    # REFUSED, not merely absent from the set (FR-002): a spec that declares a
    # private host key must be told the channel is gone. The generic "not one of"
    # message below would read as a typo, and an operator would go looking for the
    # right spelling of something that no longer exists.
    if tgt == "host_key":
        refuse_removed_host_key(f"{where}: target=host_key")
    if tgt is not None and tgt not in CRED_SSH_TARGETS:
        die(f"{where}: target={tgt!r} is not one of {{{', '.join(CRED_SSH_TARGETS)}}}")
    allowed = {"name", "source", "target", *detail}
    for k in cred:
        if k not in allowed:
            die(f"{where}: unknown credential key {k!r}")


def validate_provision_table(table: dict, env_name: str, where: str) -> None:
    """Validate a `host:` provision table (US4) BEFORE any allocation (FR-003). The
    provider is the discriminator; the effective host registry name (`name` or the env
    name) must be RFC-1123 (Hetzner rejects underscores), so an underscore-bearing env
    needs an explicit `host.name`."""
    prov = table.get("provision")
    if prov not in PROVISION_PROVIDERS:
        die(f"{where}: host provision={prov!r} is not one of {{{', '.join(PROVISION_PROVIDERS)}}}")
    for k in table:
        if k not in PROVISION_KEYS:
            die(f"{where}: unknown host provision key {k!r}")
    name_val = table.get("name")
    if name_val is not None and not isinstance(name_val, str):
        # A non-string YAML scalar (123, true, 1.5, a list) must die naming the field,
        # not traceback in the regex below (FR-003 — clean refusal; adversarial LOW).
        die(f"{where}: host provision name must be a string, got {type(name_val).__name__}")
    host_name = name_val or env_name
    if not HETZNER_NAME_RE.fullmatch(host_name):
        die(
            f"{where}: provisioned host name '{host_name}' is not RFC-1123 (lowercase "
            f"letters, digits, hyphens; no underscore; <=63). Add an explicit host.name."
        )


HOSTNAME_RE = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$")
# The label group repeats without bound, so the regex alone accepts an arbitrarily
# long name. tinyproxy reads its filter with a 512-byte fgets and regcomps each
# chunk SEPARATELY, so one over-long entry becomes a prefix pattern PLUS a suffix
# pattern — both unanchored, i.e. silent over-permission. 253 is the DNS limit and
# is far below the buffer, so the split can never happen.
HOSTNAME_MAX = 253


def validate_egress(egress: object, where: str) -> None:
    """Validate the optional `egress:` block (Feature 012 Phase B, contracts C9).

    ONE list, `allow`, whose entries take one of four shapes:

        - provider: anthropic                  # tool supplies the hosts
        - provider: openai
          hosts: [llm.corp.internal]           # REPLACES the mapping (FR-001b)
        - host: "*.githubusercontent.com"      # HTTPS, via the proxy
        - host: github.com
          port: 22                             # non-HTTP -> a netfilter rule

    THE PORT SELECTS THE ENFORCEMENT SURFACE, and that is what makes one list
    coherent rather than merely shorter: no port means HTTP/HTTPS through the
    proxy allowlist; a port means an explicit netfilter rule. The operator
    declares DESTINATIONS; the tool decides which surface each needs. A separate
    `ports:` key would make the operator classify their own traffic by mechanism,
    which is the tool's job and leaks implementation into the declaration.

    Three states stay DISTINCT: absent (unrestricted — this function is not called
    at all), `allow: []` (default-deny), and a non-empty list. Coercing absent to
    empty would deny everything for every existing environment on upgrade.
    """
    where = f"{where} egress"
    if not isinstance(egress, dict):
        die(f"{where}: must be a mapping")
    if "providers" in egress:
        # FR-018b: removed, NOT deprecated — one way to say a thing. Refused with
        # the replacement named, because silently ignoring a `providers:` block
        # would deploy an environment permitting far less than its author wrote.
        die(
            f"{where}: `providers:` was replaced by a single `allow:` list. Rewrite "
            f"`providers: [anthropic]` as `allow: [{{provider: anthropic}}]`, and plain "
            f"hosts as `{{host: example.com}}`. A port on an entry "
            f"(`{{host: github.com, port: 22}}`) selects netfilter instead of the proxy."
        )
    for k in egress:
        if k not in {"allow", "enforcement", "sidecars_outside"}:
            die(f"{where}: unknown key {k!r}")
    _enum_field(egress, "enforcement", ENFORCEMENT_MODES, where)
    if "allow" not in egress:
        # Present without `allow` is neither declared nor undeclared. Unrestricted
        # would let `enforcement: strict` sit in a file enforcing nothing; empty
        # would deny everything on a key added for an unrelated reason. Both are
        # silent, so refuse and offer both real states.
        die(
            f"{where}: missing 'allow'. An egress block must say what is permitted — "
            f"write `allow: []` for an environment that may reach nothing, or remove "
            f"the egress block entirely for unrestricted (the default)"
        )
    outside = egress.get("sidecars_outside")
    if outside is not None:
        if not isinstance(outside, list) or not all(isinstance(x, str) and x for x in outside):
            die(f"{where}: 'sidecars_outside' must be a list of sidecar service names")
        if AGENT_SERVICE_KEY in outside or EGRESS_SERVICE_KEY in outside:
            # Neither is an operator sidecar. The agent outside the boundary is the
            # feature switched off while still reporting a declaration; the egress
            # service outside its own namespace is incoherent.
            die(
                f"{where}: 'sidecars_outside' may only name operator sidecars, not "
                f"'{AGENT_SERVICE_KEY}' or '{EGRESS_SERVICE_KEY}'"
            )
    entries = egress.get("allow")
    if not isinstance(entries, list):
        got = type(entries).__name__
        die(f"{where}: 'allow' must be a list, got {got} (a single destination is a 1-item list)")
    for i, entry in enumerate(entries):
        validate_destination(entry, f"{where} allow[{i}]")


def validate_destination(entry: object, where: str) -> None:
    """One destination: `{provider}`, `{provider, hosts}`, `{host}` or `{host, port}`."""
    if not isinstance(entry, dict):
        die(
            f"{where}: must be a mapping — {{provider: <name>}} or {{host: <host>}}, "
            f"optionally with `hosts:` or `port:`"
        )
    keys = {str(k) for k in entry}
    unknown = keys - {"provider", "host", "hosts", "port"}
    if unknown:
        die(f"{where}: unknown key(s) {sorted(unknown)}")
    if ("provider" in keys) == ("host" in keys):
        die(
            f"{where}: give exactly one of `provider:` or `host:` (got {sorted(keys) or 'nothing'})"
        )
    if "provider" in keys:
        name = entry.get("provider")
        if not name or not isinstance(name, str):
            die(f"{where}: `provider` must be a non-empty string")
        if "port" in keys:
            die(
                f"{where}: `port` is not valid on a provider entry — a provider is HTTPS "
                f"via the proxy. For a non-HTTP destination use {{host: <host>, port: <n>}}"
            )
        hosts = entry.get("hosts")
        if hosts is None:
            if name not in PROVIDERS:
                die(
                    f"{where}: unknown provider {name!r}. Known: "
                    f"{', '.join(sorted(PROVIDERS))}. For an endpoint the tool does not "
                    f"know, give it explicit hosts: {{provider: {name}, hosts: [<host>]}}"
                )
            return
        if not isinstance(hosts, list) or not hosts:
            die(f"{where}: `hosts` must be a non-empty list of hostnames")
        for h in hosts:
            validate_egress_host(h, where, allow_wildcard=True)
        return
    if "hosts" in keys:
        die(f"{where}: `hosts` belongs on a provider entry; a host entry already names one")
    validate_egress_host(entry.get("host"), where, allow_wildcard=True)
    if "port" in keys:
        port = entry.get("port")
        if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
            die(f"{where}: `port` must be an integer 1-65535, got {port!r}")
        if port in (80, 443):
            # 80 AND 443 ARE THE PROXY'S SURFACE, AND A RULE FOR THEM CANNOT MATCH.
            # Both ports are REDIRECTed into squid in the nat table, which runs before
            # the filter ACCEPT this entry would render — so the rule is installed,
            # reported as permitting the destination, and never consulted. FR-018a is
            # that the PORT SELECTS THE MECHANISM; naming a port squid already owns
            # asks netfilter for something it will never be given.
            #
            # Refused rather than silently rewritten to the portless form: the two
            # forms differ in what they permit (portless admits the host over
            # HTTP/HTTPS *through the proxy*, where the SNI is checked and the
            # request is logged), and quietly changing an operator's declaration into
            # a different one is how a security control comes to mean something its
            # author did not write.
            die(
                f"{where}: port {port} is served by the proxy, not by netfilter, so a "
                f"`{{host, port: {port}}}` rule can never match — 80 and 443 are "
                f"redirected into the proxy before it is reached. Declare the host "
                f"without a `port:` to permit it over HTTP/HTTPS: "
                f"{{host: {entry.get('host')}}}"
            )
        host = str(entry.get("host"))
        if host.startswith("*."):
            # T148. A WILDCARD WITH A PORT IS A RULE THAT CANNOT EXIST, and it used
            # to validate. `port` selects netfilter (FR-018a), netfilter has no
            # wildcard destination, and `build_netfilter_rules` renders `-d '*.x'`
            # — an operand iptables resolves AT INSERT TIME, so the boundary dies
            # installing it and blames netfilter rather than the declaration.
            #
            # Refused rather than made real because there is nothing to make: a
            # subtree cannot be enumerated into addresses, and the only surface
            # that can match a name subtree is the proxy, which sees the name in
            # the handshake. Meanwhile `egress_permits_endpoint` matches wildcards,
            # so the push check reported such a destination as PERMITTED — a
            # declaration that validates, reads as permitted, and then does not
            # exist is the exact defect class this feature exists to remove.
            die(
                f"{where}: a wildcard host cannot carry a `port:`. A port selects the "
                f"PACKET FILTER, and netfilter has no wildcard destination — the rule "
                f"renders as `-d {host}`, which cannot resolve when the boundary "
                f"installs it, so this entry would be reported as permitted and then "
                f"not exist. Name the exact host for that port, or drop the `port:` "
                f"— a name subtree can only be matched by the proxy, from the name in "
                f"the TLS handshake, and only on ports 80 and 443."
            )


# `validate_provider_entry` lived here: the validator for a `providers:` entry.
# T112 made `providers:` a hard refusal in `validate_egress`, so no input could
# ever reach it again — a validator for a syntax that dies two frames earlier
# reads as a supported shape to anyone grepping for it. vulture at
# --min-confidence 80 does not report unused FUNCTIONS (they score 60), so the
# gate stayed green over it.


def validate_egress_host(h: object, where: str, *, allow_wildcard: bool = False) -> None:
    """One allowlist hostname. `allow_wildcard` permits a leading `*.` meaning the
    domain and its subdomains (FR-001d) — used by `allow:`, not by a provider's
    `hosts:` override."""
    if not isinstance(h, str):
        die(f"{where}: {h!r} is not a hostname (expected a string)")
    bare = h[2:] if (allow_wildcard and h.startswith("*.")) else h
    if not HOSTNAME_RE.fullmatch(bare):
        hint = " (a leading '*.' matches the domain and its subdomains)" if allow_wildcard else ""
        die(
            f"{where}: {h!r} is not a hostname. Give a bare host (no scheme, no "
            f"port, no path){hint} — a URL would be accepted and then never match, "
            f"silently permitting nothing"
        )
    if len(bare) > HOSTNAME_MAX:
        die(
            f"{where}: {h!r} is {len(bare)} characters, over the {HOSTNAME_MAX}-character "
            f"DNS limit. An over-long entry splits across the proxy's line buffer into "
            f"two UNANCHORED patterns, silently permitting far more than it names"
        )


def squid_acl_line(host: str) -> str:
    """One squid `ssl::server_name` / `dstdomain` token.

    TWO SYNTAX TRAPS, both measured (R12a), each yielding a SILENTLY EMPTY
    allowlist rather than an error:

    1. **Never quote the entry.** A quoted token in squid is a FILE PATH, so
       `"*.example.com"` makes squid try to open a file of that name and the acl
       ends up empty. Quoting is the natural instinct when generating from YAML,
       which is why this returns a bare token and the caller writes one per line.
    2. **Subdomains use a LEADING DOT**, not `*.`. `.example.com` matches the
       domain and every subdomain; `*.example.com` matches nothing, silently.

    >>> squid_acl_line("api.anthropic.com"), squid_acl_line("*.githubusercontent.com")
    ('api.anthropic.com', '.githubusercontent.com')
    """
    return f".{host[2:]}" if host.startswith("*.") else host


def build_squid_acl(entries: list[tuple[str, str, int | None, str]]) -> str:
    """The generated squid allowlist — HTTP/HTTPS destinations only.

    PORTED ENTRIES ARE EXCLUDED: they are netfilter's business, and listing them
    here would let a `{host, port: 22}` entry also open 443 to that host, which
    the operator did not ask for. SC-010 requires "that host and that port only".

    An EMPTY body is default-deny, not breakage: squid matches nothing and the
    netfilter policy denies the rest.

    >>> print(build_squid_acl([("anthropic", "api.anthropic.com", None, "tool")]))
    api.anthropic.com
    <BLANKLINE>
    >>> build_squid_acl([("github.com", "github.com", 22, "declaration")])
    ''
    """
    return "".join(f"{squid_acl_line(h)}\n" for _l, h, port, _s in entries if port is None)


def build_netfilter_rules(entries: list[tuple[str, str, int | None, str]]) -> str:
    """Per-destination ACCEPT rules, sourced by the entrypoint before the policy
    flips to DROP. Only PORTED entries appear — everything else is HTTP/HTTPS and
    is handled by the REDIRECT to squid.

    A shell fragment rather than a data file because the image would otherwise
    need a parser to apply it.

    NO WILDCARD CAN REACH HERE, and the absence of handling for one is deliberate:
    `-d '*.example.com'` is not a destination iptables can resolve, so
    `validate_destination` refuses `{host: "*.x", port: n}` outright (T148) rather
    than letting this render a rule the boundary then dies installing.

    >>> print(build_netfilter_rules([("github.com", "github.com", 22, "declaration")]))
    iptables -A OUTPUT -p tcp -d 'github.com' --dport 22 -j ACCEPT
    <BLANKLINE>
    >>> build_netfilter_rules([("anthropic", "api.anthropic.com", None, "tool")])
    ''
    """
    return "".join(
        f"iptables -A OUTPUT -p tcp -d '{h}' --dport {port} -j ACCEPT\n"
        for _l, h, port, _s in entries
        if port is not None
    )


def build_unbound_conf(entries: list[tuple[str, str, int | None, str]]) -> str:
    """The generated unbound allowlist.

    EVERY declared name needs `local-zone: … transparent` AS WELL AS its
    `forward-zone`. Without it the baked catch-all `local-zone: "." refuse`
    matches first and DECLARED NAMES ARE REFUSED TOO — an allowlist permitting
    nothing while passing every refusal test. Observed, not theorised (R17), and
    exactly the failure T136a exists to catch.

    Ported entries are INCLUDED: a `{host, port: 22}` destination is unreachable
    without resolution, and omitting it would make SSH fail like a firewall bug.

    THE LOG DESTINATION IS PART OF THE ALLOWLIST'S JOB (T130/FR-020d), which is
    why it is emitted here rather than left to the baked config. `log-replies:
    yes` is set there, but unbound's `use-syslog` DEFAULTS TO YES and the egress
    image runs no syslogd — so every reply line, including every REFUSED, was
    handed to syslog(3) and discarded. Measured: the boundary answered REFUSED
    for an undeclared name and the container log was completely EMPTY. A config
    that names the record while the record goes nowhere is the failure shape this
    repo keeps hitting, so the pair below is set unconditionally.

    `logfile: ""` alongside it because the two together decide the destination:
    with syslog off and no logfile, unbound writes to stderr, which is the
    container's log and therefore reachable as `agent-container logs <n> --egress`.
    This fragment is `include:`d LAST by the baked unbound.conf, so it also wins
    if that file ever starts setting either option itself.

    EMITTED EVEN FOR AN EMPTY ALLOWLIST — hence no early return. `allow: []` is
    air-gapped, i.e. the environment where EVERY lookup is refused and the record
    is the operator's only account of what the agent reached for; returning "" for
    that case would switch the logging off exactly where it matters most.

    >>> print(build_unbound_conf([("anthropic", "api.anthropic.com", None, "tool")]))
    server:
      use-syslog: no
      logfile: ""
      local-zone: "api.anthropic.com" transparent
    forward-zone:
      name: "api.anthropic.com"
      forward-addr: 1.1.1.1
    <BLANKLINE>
    >>> print(build_unbound_conf([]))
    server:
      use-syslog: no
      logfile: ""
    <BLANKLINE>
    """
    names = list(dict.fromkeys(h.removeprefix("*.") for _l, h, _p, _s in entries))
    out = ["server:", "  use-syslog: no", '  logfile: ""']
    out += [f'  local-zone: "{n}" transparent' for n in names]
    for n in names:
        out += ["forward-zone:", f'  name: "{n}"', f"  forward-addr: {EGRESS_UPSTREAM_DNS}"]
    return "".join(f"{ln}\n" for ln in out)


def _environment_declares_egress_leniently(root: Path, name: str) -> bool:
    """Whether <name> carries an `egress:` key, judged WITHOUT validation.

    Used only on the error path, to tell "this environment declared egress and the
    spec is broken" (must refuse — deploying would be unrestricted while the
    declaration reads as enforced) from "some unrelated file is broken" (may
    proceed). Deliberately lenient: it must answer even for a spec the validator
    rejected, so it asks only whether the KEY IS PRESENT and never what it means.

    `yaml.safe_load` per file, never a regex over the text — flow style, quoted
    keys and multi-document files all defeat a pattern scan, and the miss would be
    silent and in the permissive direction.

    An UNREADABLE file (permissions, encoding) answers False: that is not evidence
    this environment declared anything, and the owning path still reports it. An
    UNPARSEABLE spec file is the opposite case and refuses — see below.
    """
    import yaml

    # `_spec_yaml_files` — THE LOADER'S OWN DISCOVERY, not a second glob. The first
    # version walked `PROJECT_MARKER/*` non-recursively while the loader uses
    # `rglob`, so a spec in a SUBDIRECTORY was invisible here and the fail-open this
    # function exists to close stayed open for exactly those projects. Two
    # discoveries that had to agree, with nothing checking that they did.
    for path in _spec_yaml_files(root):
        try:
            docs = list(yaml.safe_load_all(path.read_text(encoding="utf-8")))
        except OSError, UnicodeDecodeError:
            continue
        except yaml.YAMLError:
            # A SPEC FILE THAT WILL NOT PARSE IS THE UNCERTAIN CASE, and uncertainty
            # must not resolve to "no declaration". Returning False deploys
            # UNRESTRICTED on the single most likely mistake in this feature's own
            # syntax: a mis-indented `egress:` block is a PARSE error, so it never
            # reaches the validator that would have named it. Refusing is the only
            # answer that cannot be silently wrong in the permissive direction.
            die(
                f"{path}: this spec file could not be parsed, so the tool cannot tell "
                f"whether {name!r} declares an `egress:` block. Refusing to deploy — "
                f"continuing would risk running UNRESTRICTED while a declaration exists. "
                f"Fix the YAML; `agent-container plan {name}` names the location."
            )
        for doc in docs:
            if not isinstance(doc, dict):
                continue
            envs = doc.get("environments")
            if not isinstance(envs, list):
                continue
            for env in envs:
                if isinstance(env, dict) and env.get("name") == name and "egress" in env:
                    return True
    return False


def resolve_egress_declaration(name: str, cwd: Path | None = None) -> dict | None:
    """The `egress:` block declared for environment <name>, or None if undeclared.

    Placed here — and called from `compose_up_exec` — because that is the ONLY
    choke point every deploy path passes through. `do_up` serves `up` and `apply`,
    but `do_redeploy` and the wizard call `compose_up_exec` directly, so a lookup
    in `do_up` would leave a `redeploy` running with no proxy while the declaration
    still read as enforced. Mirrors `resolve_sidecar_override`, which already does
    project-config discovery from this same path.

    Returns None when there is no project, no spec, or no matching environment —
    an environment deployed imperatively outside any project is simply undeclared,
    which is the unrestricted default and not an error.
    """
    root = find_project_root(cwd or Path.cwd())
    if root is None:
        return None
    try:
        environments = load_project_spec(root, skip_unknown=True)
    except Fatal:
        # A broken spec is reported by the paths that own it (`plan`/`apply`).
        # Refusing an imperative `up` here would make an unrelated syntax error
        # elsewhere in the project block a deployment that never used the spec.
        #
        # BUT NOT IF THE BROKEN PART IS THIS ENVIRONMENT'S OWN DECLARATION.
        # Returning None then means "undeclared", which deploys UNRESTRICTED while
        # the operator has written a declaration they cannot see is being ignored —
        # the precise failure this feature exists to remove, and it fails OPEN.
        # Silence, not permissiveness, is the defect (FR-007b).
        if _environment_declares_egress_leniently(root, name):
            die(
                f"{name}: an `egress:` declaration exists but the project spec could not "
                f"be validated, so the tool cannot tell what it permits. Refusing to deploy: "
                f"continuing would run this environment UNRESTRICTED while the declaration "
                f"reads as enforced. Fix the spec (`agent-container plan {name}` names the "
                f"offending file and field)."
            )
        return None
    for env in environments:
        if env.get("name") == name:
            return env.get("egress")
    return None


def egress_strength_statement(agent: str | None, *, transparent: bool = False) -> str:
    """FR-008/FR-008a, contract C5 — the honest limits of what a proxy delivers.

    Every clause here is load-bearing and the test asserts their PRESENCE plus the
    ABSENCE of stronger phrasing, because this is the requirement most easily
    satisfied in appearance and violated in substance.

    TWO MECHANISMS, TWO DIFFERENT HONEST ANSWERS (FR-022). Under transparent
    enforcement the old text is no longer merely cautious, it is FALSE: it says
    this feature does not do packet filtering, and Phase B does exactly that. But
    the correction runs in both directions — describing the boundary as absolute
    would be the same defect with the sign flipped, so the residual limits are
    named as specifically as the guarantees.
    """
    if transparent:
        return (
            "egress enforcement is packet-level. Routing is programmed into the network "
            "stack, so it does not depend on the agent's cooperation: a process that "
            "ignores proxy settings and opens a direct connection is denied by a "
            "default-deny policy, and the container holds no capability with which to "
            "change that. It is NOT content inspection and NOT an absolute boundary. "
            "TLS is never terminated, so what travels to a DECLARED destination is "
            "neither seen nor limited — anything reachable through a permitted host "
            "remains reachable. Filtering of TLS uses the name the client asks for, and "
            "the connection is spliced to the address the CLIENT chose, so a process "
            "that opens a connection to an arbitrary address while presenting a "
            "declared name is not stopped by the name check. Sidecars listed in "
            "egress.sidecars_outside are outside the boundary entirely, and anything "
            "they can reach is reachable through them."
        )
    # NOT ENFORCED — and this branch used to describe a PROXY.
    #
    # Phase A had a third state: the declaration was carried by proxy environment
    # variables, which constrained a cooperating client and nothing else. Phase B
    # removed it. `egress_enforcement_mode` now returns only `transparent` or
    # `none` (there is deliberately no `cooperative`), and when it returns `none`
    # NO proxy is deployed either — `egress_filter_body` is None, so the sidecar,
    # the allowlist and the proxy variables are all absent.
    #
    # So the old text here asserted "the proxy refuses requests from clients that
    # honour proxy settings" about an environment with no proxy in it. It was the
    # feature's own honesty requirement describing a mechanism that was not there —
    # printed at exactly the moment the operator most needs to know that nothing is
    # constraining the container.
    #
    # The agent list is still named: which agents WOULD honour proxy settings is
    # what makes the absence concrete rather than abstract.
    honours = ", ".join(sorted(a for a, v in AGENT_HONOURS_PROXY.items() if v))
    known = AGENT_HONOURS_PROXY.get(agent or "", False)
    return (
        f"egress is NOT enforced for this environment. The declaration was read and "
        f"is being reported, but no boundary was deployed: there is no packet filter, "
        f"no allowlisting proxy and no forced resolver, so this container can reach "
        f"anything the host network allows. Nothing here depends on the agent's "
        f"cooperation because there is nothing to cooperate with. Agents that honour "
        f"proxy settings, and would therefore be constrained if one were deployed: "
        f"{honours}{'' if known else f' (NOT including {agent!r})'}. "
        f"A shell inside the container can also set its own proxy settings via "
        f"~/.agent-env/env, which is sourced by every interactive shell from a volume "
        f"that survives teardown — the tool cannot see or prevent that."
    )


def disclose_builtin_default(egress: object, agent: str | None) -> None:
    """FR-006, contract C4 — the specific defect that motivated this feature.

    Feature 010's probe ran opencode with NO operator credential and it answered,
    over the network, via a provider nobody declared. A default that works silently
    is indistinguishable, to the operator, from no network activity at all.

    Fires only when nothing is declared: with a declaration the operator has already
    engaged with the question, and repeating it would be the noise that trains people
    to ignore the message that matters.
    """
    if is_egress_declared(egress):
        return
    provider = AGENT_BUILTIN_DEFAULT.get(agent or "")
    if provider is None:
        return
    warn(
        f"agent {agent!r} has a BUILT-IN DEFAULT PROVIDER ({provider}): it can reach "
        f"a model provider over the network without any credential you supplied, and "
        f"this environment declares no egress restriction. To constrain it, declare "
        # NOT `egress.providers`, which this message said until the sibling message
        # in check_builtin_default_declared was fixed for exactly this: that key is
        # the one shape `validate_egress` refuses OUTRIGHT (FR-018b), so following
        # the advice answered a warning with a hard failure. The sibling had a test;
        # this one did not, so the defect survived one function over.
        f"egress in the environment spec:  egress.allow: [{{provider: {provider}}}]"
    )


def check_builtin_default_declared(egress: object, agent: str | None, mode: str) -> None:
    """FR-003a — the one case knowable BEFORE anything runs.

    The agent picks its provider at run time, so there is no general deploy-time
    detection of undeclared egress. But an agent's built-in default is a fact the
    tool holds, and so is the declared set; when the former is outside the latter,
    waiting for a runtime refusal would be a choice to withhold.
    """
    if not is_egress_declared(egress):
        return
    provider = AGENT_BUILTIN_DEFAULT.get(agent or "")
    if provider is None:
        return
    assert isinstance(egress, dict)  # narrowed by is_egress_declared
    entries = resolve_destinations(egress)
    if any(egress_permits_host(entries, h) for h in PROVIDERS.get(provider, ())):
        return
    msg = (
        f"agent {agent!r} has a built-in default provider ({provider}) that this "
        f"declaration does NOT permit. The agent may try it and be refused at run "
        # `providers:` is the one shape `validate_egress` refuses outright (FR-018b),
        # so naming it here would answer a warning with a hard failure.
        f"time. Either declare it (`egress.allow: [{{provider: {provider}}}]`) or "
        f"configure the agent to use a declared provider."
    )
    if mode == "strict":
        die(msg)
    warn(msg)


def sidecars_outside_boundary(egress: object) -> list[str]:
    """Operator sidecars deliberately placed OUTSIDE the enforcement boundary.

    Declared in the SPEC, beside the allowlist — not in the override file. The
    override is operator-owned and shape-validated by design (Feature 002), and
    giving it security meaning would erode the line between "the operator's helper
    services" and "the tool's security model". Every other security decision lives
    host-side in the spec Feature 006 establishes an agent cannot rewrite; this is
    one, so it lives there too.

    >>> sidecars_outside_boundary({"allow": [], "sidecars_outside": ["feed"]})
    ['feed']
    >>> sidecars_outside_boundary({"allow": []}), sidecars_outside_boundary(None)
    ([], [])
    """
    if not isinstance(egress, dict):
        return []
    named = egress.get("sidecars_outside")
    return [str(x) for x in named] if isinstance(named, list) else []


def verify_sidecars_outside_resolve(egress: object, override: Path | None) -> None:
    """Refuse an opt-out naming a sidecar that does not exist.

    THE FAILURE THIS PREVENTS IS A RENAME. A typo that matches nothing leaves the
    sidecar INSIDE the boundary, which is safe; but a service renamed in the
    override while the spec still names the old one would leave the NEW service
    silently inside and the operator believing it is outside — or, read the other
    way round, leave an exception in the spec that no longer describes anything
    while the operator believes it still does.

    Either way the declaration stops matching reality, and the direction that
    matters is unknowable from here — so both are refused rather than guessed.
    """
    named = sidecars_outside_boundary(egress)
    if not named:
        return
    if override is None:
        die(
            f"egress: 'sidecars_outside' names {named} but this environment declares "
            f"no sidecars (no <name>.services.yaml). Remove the entries, or add the "
            f"services they refer to."
        )
    try:
        declared = set(_yaml_service_keys(override.read_text()))
    except OSError as e:
        die(f"egress: cannot read the sidecar override {override} ({e})")
    missing = sorted(set(named) - declared)
    if missing:
        die(
            f"egress: 'sidecars_outside' names {missing}, which {override} does not "
            f"declare. A renamed service would otherwise sit inside the boundary "
            f"while the declaration says it is outside — so this is refused rather "
            f"than ignored. Declared there: {sorted(declared) or 'nothing'}."
        )


def refuse_sidecar_name_in_allow(egress: object, override: Path | None, *, enforced: bool) -> None:
    """Refuse a declared destination that names a sidecar inside the boundary (T149).

    `enforced` HAS NO DEFAULT, and that is the point. Nothing shares a namespace
    until a boundary is actually deployed, and a declaration can exist without one:
    an `advisory` declaration on a host with no reachable egress image sources (a
    non-editable PyPI install) or under an override that redefines the `egress`
    service deploys UNENFORCED, sidecars stay on the project network, and
    service-name DNS between them keeps working. Refusing there would reject a
    configuration that works, with a message asserting a namespace share that is
    not happening — the whole justification stated as fact about a deployment where
    it is false. `sidecars_inside_boundary` cannot see this: it answers the
    CONDITIONAL question (which sidecars would be inside, if one is deployed), so
    the enforceability half has to be supplied by whoever knows it.

    ADOPTING A DECLARATION CHANGES HOW SIDECARS ARE ADDRESSED, and an entry like
    `{host: redis}` is the shape of the mistake that follows. Under Phase A the
    agent sat on the project network and reached `redis:6379` by service name
    through the proxy, so declaring it was meaningful. Inside the boundary every
    service shares ONE network namespace, so the sidecar is on LOOPBACK and needs
    no declaration at all — while the entry itself cannot work either way:

      * with a port, `build_netfilter_rules` renders `-d redis`, which iptables
        resolves AT INSERT TIME through the sidecar resolver, which forwards
        declared names to a PUBLIC upstream where no such name exists. The rule is
        rejected and the boundary dies rather than starting unenforced.
      * without one, squid gets a `dstdomain` it can never resolve for the same
        reason, so the entry permits nothing.

    So the operator is told at deploy time what actually changed and what to write
    instead, rather than meeting an NXDOMAIN at run time and reading it as a DNS
    fault. Refused rather than warned BECAUSE THE ENTRY CANNOT BE MADE TO WORK:
    advisory/strict is the axis for a declaration that cannot be ENFORCED, not for
    one that names a destination which does not exist.

    Only sidecars INSIDE the boundary are matched. One deliberately placed outside
    is on the project network and unreachable from inside by any name, which is the
    disclosed cost of `sidecars_outside` rather than a mistake in the allowlist.
    """
    if not enforced:
        return
    inside = set(sidecars_inside_boundary(override, egress))
    if not inside:
        return
    assert isinstance(egress, dict)  # sidecars_inside_boundary is empty otherwise
    named = sorted({h for _l, h, _p, _s in resolve_destinations(egress) if h in inside})
    if not named:
        return
    die(
        f"egress: allow names {named}, which {'is' if len(named) == 1 else 'are'} "
        f"sidecar service name(s), not reachable destination(s). Under an enforced "
        f"declaration the sidecars share ONE network namespace with the agent, so "
        f"service-name DNS between them no longer resolves and the name resolves "
        f"NOWHERE — the entry cannot be installed as a rule or matched by the proxy. "
        f"Remove {'it' if len(named) == 1 else 'them'}: loopback traffic inside the "
        f"boundary needs no declaration. Reach the service at 127.0.0.1:<its port>."
    )


def resolve_host_addresses(host: str, port: int, timeout: float = 3.0) -> list[str] | None:
    """Every address `host` currently resolves to, or None if it could not be asked.

    BOUNDED, because this runs on the deploy path. `getaddrinfo` honours no timeout
    argument and a slow or unreachable resolver would otherwise stall a deploy, so
    the lookup runs on a thread that is simply abandoned if it overruns. A daemon
    thread is correct here: the answer is advisory, and the process must not wait on
    it at exit.

    Returns None — not `[]` — when the lookup failed or timed out. The two must not
    collapse: "no addresses" would read as a resolvable host with no records and
    invite a confident warning about something never measured.
    """
    import socket
    import threading

    out: list[str] = []
    err: list[BaseException] = []

    def _lookup() -> None:
        try:
            infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
            seen: dict[str, None] = {}
            for info in infos:
                seen.setdefault(str(info[4][0]), None)
            out.extend(seen)
        except BaseException as exc:  # noqa: BLE001 - advisory probe; never fatal
            err.append(exc)

    t = threading.Thread(target=_lookup, daemon=True)
    t.start()
    t.join(timeout)
    if t.is_alive() or err or not out:
        return None
    return out


def warn_pinned_port_destinations(
    dests: list[tuple[str, str, int | None, str]], *, transparent: bool
) -> None:
    """FR-018, research R24 — say that a ported destination's addresses are PINNED.

    `iptables -d <hostname>` expands the operand at INSERT TIME and stores addresses,
    not the name. R24 measured it: with `{host: github.com, port: 22}` declared, only
    `140.82.121.4` was admitted while `.3` and `.5` timed out, and nothing in the
    boundary re-resolves. So the destination works until the resolver hands the agent
    a different address, then stops. It fails CLOSED, which is the right direction,
    but the operator learns it after a push has already failed. This does not fix the
    pinning; it converts a silent property into a stated one.

    WARNS FOR EVERY NAMED PORTED DESTINATION, and the first version of this did not —
    it warned only when a host resolved to MORE THAN ONE address simultaneously, which
    does not detect the canonical case at all. Measured: `github.com` returns a SINGLE
    address per query here (140.82.121.3) while R24 separately proved .3, .4 and .5 all
    exist. It rotates ACROSS queries over time, not within one answer, so a count-based
    check was silent for exactly the host the warning was written for — a check passing
    while the thing it names is broken.

    So the condition is the one that is actually knowable: a rule built from a NAME is
    pinned, full stop. An IP literal is exempt because there is nothing to re-resolve
    and no rotation to suffer. Currently-resolved addresses are included when the probe
    answers, as information rather than as the trigger.

    ONLY under transparent enforcement: without netfilter there is no pinned rule, and
    a warning on an environment that has none is the noise that teaches operators to
    skip the one that matters.
    """
    if not transparent:
        return
    for label, host, port, _src in dests:
        # Portless entries are the proxy's surface — squid re-resolves per request, so
        # nothing is pinned. A wildcard cannot carry a port (validation refuses it).
        if port is None or host.startswith("*"):
            continue
        if _is_ip_literal(host):
            continue
        addrs = resolve_host_addresses(host, port)
        seen = f" (now: {', '.join(sorted(addrs)[:4])})" if addrs else ""
        warn(
            f"egress: {host!r} (declared as {label}, port {port}) is admitted by a packet "
            f"rule built from the addresses resolved WHEN THE BOUNDARY STARTS{seen}, and "
            f"nothing re-resolves them. If this host's addresses change — round-robin and "
            f"CDN-fronted hosts such as github.com do, without returning more than one at "
            f"a time — the destination stops being reachable while the declaration still "
            f"reads as permitting it. It fails closed and the refusal is logged; "
            f"`redeploy` re-pins."
        )


def _is_ip_literal(host: str) -> bool:
    """True for a literal address, which no rule can pin the wrong way.

    >>> [_is_ip_literal(h) for h in ("10.0.0.1", "::1", "github.com", "1.2.3")]
    [True, True, False, False]
    """
    import ipaddress

    try:
        ipaddress.ip_address(host)
    except ValueError:
        return False
    return True


def warn_sidecar_hostnames_moved_to_loopback(inside: list[str]) -> None:
    """Say what adopting a declaration did to sidecar addressing (T149).

    Measured, and it is not obvious from anything else the tool prints: joining one
    network namespace means service-name DNS BETWEEN the services stops resolving,
    so every connection string naming a sidecar breaks the moment a declaration is
    adopted. The operator gets a name-resolution failure inside the container and
    no reason to connect it to a change they made in the spec.

    The remediation is the part that must be here rather than left to inference,
    because the obvious guess — declare the sidecar's name in `egress.allow` —
    CANNOT work (see refuse_sidecar_name_in_allow). Loopback is the answer, and the
    shared namespace also means the ports are shared, which is the second surprise.
    """
    if not inside:
        return
    warn(
        f"egress: {', '.join(inside)} now share ONE network namespace with the agent, "
        f"so SERVICE-NAME DNS BETWEEN THEM NO LONGER RESOLVES. Reach them on loopback "
        f"and their port (127.0.0.1:5432, not db:5432) — and note that the namespace "
        f"has one port space, so two sidecars cannot both listen on the same port. "
        f"Declaring a service name in `egress.allow` does NOT restore it: loopback "
        f"needs no permission, and the resolver has no such name to answer with."
    )


def override_redefines_egress(override: Path | None) -> bool:
    """Whether an operator sidecar override redefines the proxy service.

    Permitted — the override file is operator-owned and host-side, so redefining it
    is the same authority as declaring no egress at all, and forbidding it would be
    theatre. But it must never be SILENT: the tool did not configure the running
    proxy and therefore cannot vouch for its allowlist.
    """
    if override is None:
        return False
    try:
        return EGRESS_SERVICE_KEY in _yaml_service_keys(override.read_text())
    except OSError:
        return False


# The two mechanisms this feature can deliver, strongest first. They are NOT
# interchangeable and must never be reported as one thing: `transparent` holds
# even against an agent actively evading it; `cooperative` holds only against
# accident and misconfiguration. Reporting `enforced: true` for both would make
# the field mean whichever the operator happened to get (FR-021).
EGRESS_TRANSPARENT = "transparent"
EGRESS_UNENFORCED = "none"
# There is deliberately NO `cooperative` mode returned by this predicate, and the
# absence is a decision rather than an omission.
#
# FR-021 asks for a fallback to Phase A's proxy-variable mechanism under
# `advisory` when transparent enforcement "cannot be delivered". Two things make
# that unbuildable as written:
#
#   1. Nothing rules out transparent enforcement PER AGENT. Its whole point is
#      that it needs nothing from the agent, so an agent nobody has probed still
#      gets the boundary. Every obstacle that remains (no image, an overridden
#      egress service) rules out the proxy too — there is nothing left to fall
#      back TO.
#   2. Whether the daemon grants NET_ADMIN and the kernel accepts the rules is
#      not knowable before running the container. The entrypoint therefore FAILS
#      CLOSED: it dies if it cannot install its rules, `compose up` fails, and no
#      unconstrained container is left running.
#
# A silent downgrade to the weaker mechanism would be the exact failure this
# feature exists to prevent — an environment reporting enforcement while an agent
# can `unset HTTPS_PROXY` and walk out. FR-021 needs amending; see spec.


def egress_enforcement_mode(
    egress: object, agent: str | None, override: Path | None = None
) -> tuple[str, str]:
    """Which mechanism this environment will actually get — `(mode, reason)`.

    `reason` is shown to the operator and must name the specific obstacle, never
    "unsupported": the point of FR-021 is that an operator can tell WHICH
    enforcement they obtained, and a vague message defeats that as thoroughly as
    reporting nothing.

    Every obstacle here rules out enforcement ENTIRELY: there is no partial mode
    to report, because the things that prevent the netfilter boundary (no image,
    an operator-redefined egress service) prevent the proxy just as completely.

    What cannot be settled here: whether the daemon will actually grant
    NET_ADMIN, and whether the kernel will accept the rules. Neither is knowable
    without running the container. That case is handled by FAILING CLOSED rather
    than by guessing — the entrypoint dies if it cannot install its rules, so
    `compose up` fails and no unconstrained container is left running. A
    pre-deploy guess would be worse than the absence of one: it would license a
    deploy on a prediction.

    >>> egress_enforcement_mode({"allow": []}, "claude")[0]
    'transparent'
    >>> egress_enforcement_mode(None, "claude")[0]
    'none'
    >>> egress_enforcement_mode({"allow": []}, "some-future-agent")[0]
    'transparent'
    """
    if not is_egress_declared(egress):
        return EGRESS_UNENFORCED, ""

    try:
        ctx = resolve_build_context()
    except Fatal:
        ctx = None  # no checkout reachable (e.g. a bare PyPI install)
    if ctx is None or not (Path(ctx) / "egress" / "Dockerfile").is_file():
        # Rules out BOTH: without the image there is no proxy either.
        return EGRESS_UNENFORCED, (
            "the egress image sources are not reachable "
            "(expected image/egress/Dockerfile in a checkout)"
        )
    if override_redefines_egress(override):
        return EGRESS_UNENFORCED, (
            f"the sidecar override {override} redefines the "
            f"'{EGRESS_SERVICE_KEY}' service, so the running boundary is not the one "
            f"this tool configured and its allowlist cannot be vouched for"
        )

    # Transparent enforcement needs nothing FROM THE AGENT — that is its whole
    # point — so there is no per-agent obstacle to check here. Proxy adherence
    # only matters for the cooperative fallback, and is therefore checked in that
    # branch rather than as a precondition of enforcement generally.
    return EGRESS_TRANSPARENT, ""


def egress_enforceable(
    egress: object, agent: str | None, override: Path | None = None
) -> tuple[bool, str]:
    """Back-compat shim: enforced at all, by either mechanism.

    Kept because `--json`'s `enforced` field and the strict/advisory decision both
    want the coarse answer. Callers that must distinguish the two mechanisms —
    anything reporting strength to an operator — MUST use
    egress_enforcement_mode() instead, or they will describe a cooperative
    deployment in the language of a boundary.
    """
    mode, reason = egress_enforcement_mode(egress, agent, override)
    return mode != EGRESS_UNENFORCED, reason


# The variables that decide where a request goes. NO_PROXY is the bypass C6 exists
# for; the *_PROXY pair is included because redirecting the agent at a different
# proxy defeats the allowlist just as completely as skipping one.
#
# Under Phase B these are no longer the enforcement — netfilter is — but the
# refusal is KEPT: an operator value would still break the diagnostic layer that
# turns a dropped connection into a nameable refusal, and a declaration whose
# error messages silently stop working is its own kind of failure.
PROXY_ENV_KEYS = (
    "NO_PROXY",
    "no_proxy",
    "HTTPS_PROXY",
    "https_proxy",
    "HTTP_PROXY",
    "http_proxy",
)


def env_file_keys(path: Path) -> set[str]:
    """The variable names an env file defines.

    NAMES ONLY — values are never read, returned or logged. This is the first place
    the tool looks inside an env file at all (it otherwise passes paths to compose,
    which reads them client-side), so the boundary is drawn deliberately: C6 needs
    to know THAT `NO_PROXY` is set, never what it is set to.

    >>> import tempfile, pathlib
    >>> p = pathlib.Path(tempfile.mkdtemp()) / "e"
    >>> _ = p.write_text("# c\\nexport NO_PROXY=*\\nA=1\\nmalformed\\n")
    >>> sorted(env_file_keys(p))
    ['A', 'NO_PROXY']
    """
    keys: set[str] = set()
    try:
        text = path.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return keys
    for raw in text.splitlines():
        line = raw.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        name = line.split("=", 1)[0].strip()
        name = name.removeprefix("export ").strip()
        if name:
            keys.add(name)
    return keys


def find_operator_proxy_var(
    env_files: list[Path] | Path | None,
    cred_names: list[str] | None = None,
    override: Path | None = None,
) -> tuple[str, str] | None:
    """Locate an operator-supplied proxy variable — `(where, key)` or None.

    Three routes, all verified live:
      * an env file (the discovery chain, or an explicit `-e`);
      * a DECLARED CREDENTIAL named `NO_PROXY` — `stage_declared_credentials`
        validates names against `[A-Za-z_][A-Za-z0-9_]*`, which matches, and then
        writes the name into the merged env file;
      * a sidecar override setting `agent.environment.NO_PROXY`, which rides as the
        second `-f` and therefore WINS the compose merge.
    """
    files = [env_files] if isinstance(env_files, Path) else list(env_files or [])
    for f in files:
        for key in sorted(env_file_keys(f) & set(PROXY_ENV_KEYS)):
            return str(f), key
    for cname in cred_names or []:
        if cname in PROXY_ENV_KEYS:
            return "the declared credentials", cname
    if override is not None:
        agent_env = (_yaml_document(override.read_text()).get("services") or {}).get(
            AGENT_SERVICE_KEY
        ) or {}
        env = agent_env.get("environment") if isinstance(agent_env, dict) else None
        names = env if isinstance(env, dict) else {}
        for key in sorted(set(names) & set(PROXY_ENV_KEYS)):
            return str(override), key
    return None


def refuse_operator_proxy_vars(
    egress: object,
    agent: str | None,
    env_files: list[Path] | Path | None,
    cred_names: list[str] | None = None,
    override: Path | None = None,
) -> None:
    """C6 — the feature's most likely SILENT failure.

    An operator `NO_PROXY` would leave the declaration reading as enforced while
    enforcing nothing. **No subset comparison is attempted**: deciding whether one
    `NO_PROXY` is "wider" than another means comparing `*`, `.suffix`, bare hosts,
    IP literals, CIDR blocks and ports — forms that are not even consistent between
    HTTP clients — and a comparison erring PERMISSIVELY reproduces exactly the
    bypass this rule prevents, while passing every test one would think to write.

    So: any value is refused, and the refusal names the file and the variable.
    Only fires when the declaration would actually be enforced — an unenforced
    declaration has no guarantee for the variable to contradict.
    """
    # The override MUST reach this check: an environment whose egress service is
    # operator-redefined is not enforced, so there is no guarantee for NO_PROXY
    # to contradict and refusing would be noise.
    if not is_egress_declared(egress) or not egress_enforceable(egress, agent, override)[0]:
        return
    found = find_operator_proxy_var(env_files, cred_names, override)
    if found is None:
        return
    where, key = found
    die(
        f"egress: {key} is set by {where}, which would override the proxy settings "
        f"the declaration relies on — silently, while the declaration still reads as "
        f"enforced. Remove it; the tool sets the proxy variables itself. (No value is "
        f"accepted here: judging one {key} 'safe' means a comparison that fails open.)"
    )


def https_remote_host(url: str | None) -> str | None:
    """The host of an HTTPS git remote, or None for anything else.

    SSH remotes return None deliberately: `ssh` does not honour `https_proxy`, so
    an SSH push is unaffected by the PROXY and warning about it here would be noise.
    That asymmetry is exactly why this defect is invisible to anyone testing with a
    push key and fatal to anyone using the documented GH_TOKEN path.

    It does NOT follow that SSH is safe under Phase B. Transparent enforcement is
    default-deny at the packet level, which kills port 22 unless it is declared —
    the same Hard Constraint #1 collision arriving from the opposite direction, with
    SSH as the casualty rather than the survivor. `ssh_remote_endpoint` covers that
    case; this function stays about the proxy.

    >>> https_remote_host("https://github.com/you/acme")
    'github.com'
    >>> https_remote_host("https://github.com:443/you/acme")
    'github.com'
    >>> [https_remote_host(u) for u in ("git@github.com:you/acme.git", "ssh://git@h/x", None)]
    [None, None, None]
    """
    if not url or not url.startswith("https://"):
        return None
    rest = url[len("https://") :]
    authority = rest.split("/", 1)[0]
    host = authority.rsplit("@", 1)[-1]  # strip any userinfo
    host = host.split(":", 1)[0]  # strip any port
    return host or None


def https_remote_endpoint(url: str | None) -> tuple[str, int] | None:
    """`(host, port)` for an HTTPS remote on a NON-STANDARD port, else None.

    Returns None for the ordinary 443 case, because that one is the proxy's surface
    and `https_remote_host` + `egress_permits_host` already govern it. This exists
    only for the ports the redirect does not touch:

    >>> https_remote_endpoint("https://git.example.com:8443/you/acme")
    ('git.example.com', 8443)
    >>> [https_remote_endpoint(u) for u in ("https://github.com/x", "https://h:443/x", None)]
    [None, None, None]
    """
    if not url or not url.startswith("https://"):
        return None
    authority = url[len("https://") :].split("/", 1)[0]
    hostport = authority.rsplit("@", 1)[-1]
    if ":" not in hostport:
        return None
    host, _, raw = hostport.partition(":")
    if not host or not raw.isdigit() or not 0 < int(raw) < 65536:
        return None
    port = int(raw)
    return None if port == 443 else (host, port)


def ssh_remote_endpoint(url: str | None) -> tuple[str, int] | None:
    """The `(host, port)` an SSH git remote will actually connect to, or None.

    Both spellings git uses, because only one of them carries a port and an
    operator who wrote the other still cannot push:

    >>> ssh_remote_endpoint("git@github.com:you/acme.git")
    ('github.com', 22)
    >>> ssh_remote_endpoint("ssh://git@github.com:2222/you/acme")
    ('github.com', 2222)
    >>> [ssh_remote_endpoint(u) for u in ("https://github.com/you/acme", "", None)]
    [None, None, None]

    A non-numeric or out-of-range port is treated as NOT an SSH remote rather than
    coerced to 22: guessing would produce a check against an endpoint the push will
    never use, and a check that passes for the wrong endpoint is worse than none.
    """
    if not url:
        return None
    if url.startswith("ssh://"):
        authority = url[len("ssh://") :].split("/", 1)[0]
        hostport = authority.rsplit("@", 1)[-1]
        if ":" in hostport:
            host, _, raw = hostport.partition(":")
            if not raw.isdigit() or not 0 < int(raw) < 65536:
                return None
            return (host, int(raw)) if host else None
        return (hostport, 22) if hostport else None
    # scp-like: user@host:path. Rejected when the part after ':' looks like a port,
    # because `host:2222` is not scp-like syntax at all — git reads it as a path.
    if "://" in url or ":" not in url:
        return None
    authority, _, _path = url.partition(":")
    host = authority.rsplit("@", 1)[-1]
    return (host, 22) if host else None


def egress_permits_endpoint(
    entries: list[tuple[str, str, int | None, str]], host: str, port: int
) -> bool:
    """Whether the allowlist admits `host` on `port` as a NON-HTTP destination.

    The complement of `egress_permits_host`: that one considers only portless
    entries (the proxy's surface), this one only entries carrying a port (the
    netfilter surface). Keeping them separate is the point of FR-018a — the port
    selects the mechanism, so a declaration that permits a host over HTTPS says
    nothing about reaching it on 22.
    """
    return any(
        squid_token_matches(squid_acl_line(pattern), host)
        for _label, pattern, entry_port, _src in entries
        if entry_port == port
    )


def squid_token_matches(token: str, host: str) -> bool:
    """Whether one generated allowlist token covers `host`, THE WAY SQUID WILL: a
    bare token matches that host exactly; a leading-dot token matches the domain and
    any subdomain.

    ONE implementation for every caller — the two pre-deploy predicates below and
    the egress-event ingestion, which asks the same question of the allowlist the
    DEPLOYED boundary was given. Three copies of a rule whose failure mode is
    permissive is how the tool comes to call a host reachable that squid refuses,
    or (in ingestion) to record as declared an attempt the boundary terminated.

    >>> [squid_token_matches(".example.com", h) for h in ("example.com", "a.example.com")]
    [True, True]
    >>> squid_token_matches(".example.com", "example.com.attacker.net")
    False
    """
    if token.startswith("."):
        return host == token[1:] or host.endswith(token)
    return host == token


def egress_permits_host(entries: list[tuple[str, str, int | None, str]], host: str) -> bool:
    """Whether the effective allowlist admits `host` over HTTP/HTTPS.

    Matches the way SQUID will: a bare token matches that host exactly; a
    leading-dot token matches the domain and any subdomain. Evaluating it any
    other way lets this check and the running proxy disagree — and the
    disagreement that matters is the PERMISSIVE one, where the tool calls a host
    reachable that squid then refuses.

    Ported entries are skipped: those are netfilter rules, not proxy allowlist
    entries, so `{host, port: 22}` is NOT reachable over 443 (SC-010).

    >>> e = [("a", "*.githubusercontent.com", None, "d"), ("b", "github.com", None, "d")]
    >>> [egress_permits_host(e, h) for h in ("github.com", "raw.githubusercontent.com")]
    [True, True]
    >>> egress_permits_host(e, "githubusercontent.com.attacker.net")
    False
    >>> egress_permits_host([("g", "github.com", 22, "d")], "github.com")
    False
    """
    return any(
        squid_token_matches(squid_acl_line(pattern), host)
        for _label, pattern, port, _src in entries
        if port is None
    )


def check_egress_permits_push(
    egress: object, repo_url: str | None, mode: str, *, transparent: bool = False
) -> None:
    """FR-003c — the check that protects the project's first hard constraint.

    The proxy governs ALL HTTPS egress, so an enforced declaration that omits the
    git remote makes `git push` fail with 403. Verified by probe: under
    `providers: [anthropic]`, `git ls-remote https://github.com/…` returns
    `CONNECT tunnel failed, response 403` while the declared provider still answers.

    Both facts are known BEFORE anything runs, so this is said at deploy time.
    Discovering it at push time instead means discovering it after the work exists
    and before it is safe — the one ordering "commit AND push every change" forbids.

    Says nothing when the remote is unknown: an environment may push somewhere this
    tool never sees, and a warning on every deployment would be noise that trains
    operators to ignore the one that matters.
    """
    if not is_egress_declared(egress):
        return
    assert isinstance(egress, dict)  # narrowed by is_egress_declared
    dests = resolve_destinations(egress)

    # THE SSH ARM (FR-003c, T132). Only under transparent enforcement: the proxy
    # cannot see an SSH connection, so under Phase A this would be a warning about
    # a push that works — and a check that cries wolf on a healthy environment is
    # how operators learn to ignore the one that matters.
    #
    # Under Phase B it is the reverse of the HTTPS case. Default-deny is at the
    # packet level, so port 22 is closed unless declared, and the SAME hard
    # constraint breaks with SSH as the casualty instead of the survivor.
    # AN HTTPS REMOTE ON A NON-STANDARD PORT IS NETFILTER'S SURFACE, NOT THE PROXY'S.
    # The nat REDIRECT matches dport 443/80 only, so `https://git.example.com:8443/…`
    # is never handed to squid and is denied by default-deny unless declared as a
    # `{host, port}` entry. `https_remote_host` discards the port, so the portless
    # check below would report such a remote as permitted while the push is dropped.
    https_ep = https_remote_endpoint(repo_url) if transparent else None
    if https_ep is not None and egress_permits_endpoint(dests, *https_ep):
        # TERMINAL, not a fall-through. The portless check below is the PROXY's
        # surface and deliberately ignores ported entries (FR-018a), so continuing
        # would demand a second, portless declaration for a destination already
        # correctly declared — refusing a push that will work.
        return
    if https_ep is not None:
        h, prt = https_ep
        _push_refusal(
            f"egress: the declaration does not permit {h!r} on port {prt}, but this "
            f"environment pushes over HTTPS to it on a NON-STANDARD port. Only 80 and 443 "
            f"are routed through the proxy; anything else is denied at the packet level "
            f"unless declared. Add it:  egress.allow: [{{host: {h}, port: {prt}}}]",
            mode,
        )
        return

    ssh = ssh_remote_endpoint(repo_url) if transparent else None
    if ssh is not None and not egress_permits_endpoint(dests, *ssh):
        ssh_host, ssh_port = ssh
        _push_refusal(
            f"egress: the declaration does not permit {ssh_host!r} on port {ssh_port}, "
            f"but this environment pushes over SSH to it. Transparent enforcement is "
            f"DEFAULT-DENY at the packet level, so `git push` will be refused — and it "
            f"will be refused at push time, after the work exists. Add it:  "
            f"egress.allow: [{{host: {ssh_host}, port: {ssh_port}}}]",
            mode,
        )
        return

    host = https_remote_host(repo_url)
    if host is None:
        return
    if egress_permits_host(dests, host):
        return
    msg = (
        f"egress: the declaration does not permit {host!r}, but this environment "
        f"pushes over HTTPS to it. The proxy governs ALL egress, so `git push` will "
        f"be refused — and it will be refused at push time, after the work exists. "
        # The remediation must be VALID as written: `allow` entries are mappings,
        # so a bare `[github.com]` here would send the operator from one refusal
        # into another ("must be a mapping") with the tool having supplied it.
        f"Add it:  egress.allow: [{{host: {host}}}]"
    )
    _push_refusal(msg, mode)


def _push_refusal(msg: str, mode: str) -> None:
    """`strict` refuses, `advisory` warns — the same escalation for both arms.

    Shared so the two cannot drift into treating the same class of breakage
    differently, which would make the severity depend on the remote's URL scheme
    rather than on what the operator asked for.
    """
    if mode == "strict":
        die(msg)
    warn(msg)


def enforce_egress_declaration(
    egress: object, agent: str | None, override: Path | None = None
) -> bool:
    """Apply FR-007b at deploy time. Returns whether the proxy should be deployed.

    Fail-closed by construction: a declaration that cannot be enforced NEVER
    deploys silently. Under `strict` it refuses; under `advisory` it deploys and
    says plainly that the declaration is not being enforced — because the defect
    this feature exists to fix is silence, not permissiveness.
    """
    if not is_egress_declared(egress):
        return False
    assert isinstance(egress, dict)
    mode = egress.get("enforcement") or "advisory"
    ok, reason = egress_enforceable(egress, agent, override)
    if ok:
        log(f"egress: enforcement={mode} (enforced)")
        # FR-020d/FR-020e. Said at the moment the operator opts into default-deny,
        # because that is when a refusal starts being possible and the record is
        # otherwise undiscoverable — it lives in a container the tool deliberately
        # keeps out of the `agent-container-*` namespace every listing scans.
        #
        # COVERS BOTH HALVES since T150: unbound's replies AND squid's access log
        # now go to the boundary container's stdio, so refused CONNECTIONS are in
        # this same stream. Before T150 squid logged to a file inside the boundary
        # and this message said "DNS lookups" for that reason; leaving it scoped
        # that way would send the operator whose HTTPS host is undeclared — the
        # common case — looking for a record it says does not cover them.
        log(
            "egress: refused lookups AND refused connections are recorded in the "
            "boundary's log (`agent-container logs <name> --egress`); REFUSED means "
            "the name is not declared, NXDOMAIN means it does not exist, and a "
            "connection the proxy would not open logs NONE_NONE/000 with the "
            "`sni=` it was reaching for"
        )
        return True
    if mode == "strict":
        die(
            f"egress: enforcement=strict and the declaration cannot be enforced — {reason}. "
            f"Deploying would leave the environment unconstrained while the declaration "
            f"reads as enforced. Set enforcement: advisory to deploy anyway, knowingly."
        )
    warn(
        f"egress: enforcement=advisory and the declaration is NOT ENFORCED — {reason}. "
        f"The environment can reach anything the network allows."
    )
    return False


def _previous_model_had_egress(host_name: str, name: str) -> bool:
    """Whether the last model written for this environment declared the egress
    service — i.e. whether a port-owner migration back to the agent is even
    possible.

    Takes the host NAME, not the host record: the record does not carry its own
    key, and reading one off it silently yields "" — which resolves to a path that
    never exists, so every environment would look like it had no egress and the
    drop-side migration would never fire. That failure is invisible, because "no
    migration needed" is also the answer for the common case.

    Reads the generated compose file rather than asking the runtime: this is
    consulted on EVERY deploy, and an environment that has never had a declaration
    must not pay a runtime probe to learn that nothing moved.
    """
    try:
        model = json.loads(compose_file_path(host_name, name).read_text())
    except OSError, json.JSONDecodeError, ValueError, AttributeError:
        return False
    services = model.get("services") if isinstance(model, dict) else None
    return isinstance(services, dict) and EGRESS_SERVICE_KEY in services


def phase_a_port_owner_stale(host_rec: dict, host_name: str, name: str, enforced: bool) -> bool:
    """Whether a RUNNING container still publishes the port the egress service now
    owns — i.e. it was deployed before the shared-namespace migration.

    THE IDENTITY LOCK CANNOT SEE THIS. Container name, port number and every
    volume name are unchanged; only which service publishes the port moved. So a
    baseline diff passes while the deployed shape is stale, and the environment
    keeps running with Phase A's cooperative enforcement while its declaration
    reads as a boundary.

    SYMMETRIC, because the migration runs BOTH WAYS. Adopting a declaration moves
    the binding from the agent to the egress service; DROPPING one moves it back.
    Only the first direction was handled, so removing an `egress:` block failed the
    redeploy with `port is already allocated` — compose cannot bind a port the
    still-running egress container holds, and that container is an orphan of the
    regenerated model rather than something the new model mentions.

    Returns False when the container cannot be inspected — never a false "stale"
    from a failed probe, which would recreate a healthy environment on every apply.
    """
    # Which container must NOT be publishing, given the shape being deployed.
    holder = egress_container_name(name) if not enforced else container_name(name)
    if not enforced and not _previous_model_had_egress(host_name, name):
        # No egress service was ever deployed here, so the binding cannot be
        # anywhere but the agent and there is nothing to migrate. Checked against
        # the PREVIOUSLY GENERATED MODEL — a file read — rather than by probing the
        # runtime, so an ordinary deployment gains no extra runtime call at all.
        return False
    argv = driver_runtime_argv(host_rec) + [
        "inspect",
        holder,
        "--format",
        "{{json .HostConfig.PortBindings}}",
    ]
    r = query(argv)
    # A probe that did not produce parseable output is NOT evidence of staleness.
    # `stdout` can be absent entirely (no such container, or a driver that reports
    # nothing), and treating that as "stale" would recreate a healthy environment
    # on every deploy — the failure mode this guard exists to avoid.
    if r.returncode != 0 or not isinstance(r.stdout, str) or not r.stdout.strip():
        return False
    try:
        bindings = json.loads(r.stdout)
    except json.JSONDecodeError, ValueError:
        return False
    # Adopting: a Phase A agent container binds 2222; a Phase B one publishes
    # nothing. Dropping: the egress container is the one still holding it.
    return bool(bindings)


def egress_fingerprint(egress: object) -> str | None:
    """A stable token for the effective allowlist AS THE PROXY WILL RECEIVE IT.

    Hashes the GENERATED BODY, not the declared inputs — so it also moves when the
    PROVIDERS table drifts under a tool upgrade, or when the anchoring in
    egress_filter_line changes. Digesting the inputs would miss both, and both
    change what the proxy enforces.

    None when undeclared. That is distinct from the air-gapped `providers: []`,
    whose empty body hashes to a real digest — the two states must never collapse
    (data-model §2), so presence is decided by is_egress_declared, never by the
    digest being falsy.

    >>> egress_fingerprint(None) is None
    True
    >>> egress_fingerprint({"allow": []}) == egress_fingerprint({"allow": [], "enforcement": "advisory"})
    True
    >>> egress_fingerprint({"allow": [{"provider": "anthropic"}]}) != egress_fingerprint({"allow": []})
    True
    """
    if not is_egress_declared(egress):
        return None
    assert isinstance(egress, dict)
    # Hash ALL THREE renderings, not just the proxy's: a change to the netfilter
    # rules or the resolver config changes what is enforced just as much, and
    # digesting only the squid acl would report 'matching' for an edited port.
    entries = resolve_destinations(egress)
    body = build_squid_acl(entries) + build_netfilter_rules(entries) + build_unbound_conf(entries)
    return hashlib.sha256(body.encode()).hexdigest()[:16]


def egress_config_token(egress: object) -> str | None:
    """The value compared for drift: enforcement mode AND allowlist fingerprint.

    The mode rides along because advisory and strict produce an IDENTICAL compose
    model when the declaration is enforceable — so without it, tightening
    `enforcement: advisory` to `strict` would report "matching" and never redeploy.

    `sidecars_outside` rides along for the same reason and it is NOT cosmetic: moving
    a service in or out of the boundary changes which container's egress is filtered,
    and it leaves the allowlist and both enforcement modes untouched. Without it
    `apply` reported "matching" after an operator moved a redis from inside the
    boundary to outside — a change of exactly the kind this feature exists to make
    visible, reported as no change at all.

    Order-insensitive (`sorted`), because a reordered list is the same deployment and
    a token that moved would redeploy every environment whose YAML was tidied.

    >>> egress_config_token({"allow": [], "enforcement": "strict"})[:6]
    'strict'
    >>> egress_config_token(None) is None
    True
    >>> a = egress_config_token({"allow": [], "sidecars_outside": ["redis"]})
    >>> b = egress_config_token({"allow": []})
    >>> a == b
    False
    >>> x = egress_config_token({"allow": [], "sidecars_outside": ["a", "b"]})
    >>> y = egress_config_token({"allow": [], "sidecars_outside": ["b", "a"]})
    >>> x == y
    True
    """
    fp = egress_fingerprint(egress)
    if fp is None:
        return None
    assert isinstance(egress, dict)
    outside = egress.get("sidecars_outside")
    # `str(x)` deliberately: this runs on a DECLARATION, which may not have been
    # validated yet on every path that computes a token, so a non-string entry must
    # still produce a stable token rather than raise inside drift detection.
    outside_part = ",".join(sorted(str(x) for x in outside)) if isinstance(outside, list) else ""
    return f"{egress.get('enforcement') or 'advisory'}:{outside_part}:{fp}"


def is_egress_declared(egress: object) -> bool:
    """Whether an environment DECLARES egress at all.

    Absent means unrestricted; `providers: []` means air-gapped. Both resolve to an
    empty allowlist, so presence can never be inferred from the resolved hosts —
    a consumer that tried would render every undeclared environment air-gapped,
    the upgrade catastrophe data-model §2 exists to prevent. Presence lives here,
    in one place, not in each caller's discipline.

    >>> is_egress_declared(None), is_egress_declared({"allow": []})
    (False, True)
    """
    return isinstance(egress, dict) and "allow" in egress


def resolve_destinations(egress: dict | None) -> list[tuple[str, str, int | None, str]]:
    """The effective destination list: `(label, host, port, source)` per host.

    `port is None` means HTTP/HTTPS through the proxy allowlist; an integer means
    an explicit netfilter rule. That one field routes an entry to a surface.

    An explicit `hosts:` REPLACES the provider's mapping, never extends it
    (FR-001b): an operator routing through a gateway is usually doing so to CLOSE
    the direct vendor path, and extending would leave it open while the
    declaration reads as constrained.

    CALLERS MUST GATE ON is_egress_declared() FIRST — `[]` here means both
    "unrestricted" and "deny everything", which are opposites.

    >>> resolve_destinations({"allow": [{"provider": "anthropic"}]})
    [('anthropic', 'api.anthropic.com', None, 'tool')]
    >>> resolve_destinations({"allow": [{"provider": "anthropic", "hosts": ["gw.corp"]}]})
    [('anthropic', 'gw.corp', None, 'declaration')]
    >>> resolve_destinations({"allow": [{"host": "github.com", "port": 22}]})
    [('github.com', 'github.com', 22, 'declaration')]
    >>> resolve_destinations({"allow": []}), resolve_destinations(None)
    ([], [])
    """
    if not egress:
        return []
    out: list[tuple[str, str, int | None, str]] = []
    for entry in egress.get("allow") or []:
        if "provider" in entry:
            name, hosts = entry["provider"], entry.get("hosts")
            src = "declaration" if hosts else "tool"
            for h in hosts or PROVIDERS.get(name, ()):
                out.append((name, h, None, src))
        else:
            out.append((entry["host"], entry["host"], entry.get("port"), "declaration"))
    return out


def validate_environment(env: object, where: str) -> None:
    """Validate one environment against the pinned schema (contracts §Schema).
    On any error `die` naming the offending field; make no partial change (FR-003)."""
    if not isinstance(env, dict):
        die(f"{where}: must be a mapping")
    name = env.get("name")
    if not name or not isinstance(name, str):
        die(f"{where}: missing required string 'name'")
    validate_name(name)  # deterministic-identity charset
    where = f"{where} ({name})"
    host = env.get("host")
    if host is None:
        die(f"{where}: missing required 'host'")
    if not isinstance(host, (str, dict)):
        die(f"{where}: 'host' must be a host name (string) or a provision table")
    if isinstance(host, dict):
        validate_provision_table(host, name, where)
    container = env.get("container") or {}
    if not isinstance(container, dict):
        die(f"{where}: 'container' must be a mapping")
    _enum_field(container, "mode", EXEC_MODES, where)
    _enum_field(container, "agent", AGENTS, where)
    _enum_field(container, "workspace", WORKSPACE_MODES, where)
    allowed_c = {"mode", "agent", "task", "workspace", "workspace_dir", "repo", "env_file"}
    for k in container:
        if k not in allowed_c:
            die(f"{where}: unknown container key {k!r}")
    creds = env.get("credentials") or []
    if not isinstance(creds, list):
        die(f"{where}: 'credentials' must be a list")
    for j, cred in enumerate(creds):
        validate_credential(cred, f"{where} credentials[{j}]")
    if "egress" in env:
        validate_egress(env.get("egress"), where)
    for k in env:
        if k not in {"name", "host", "container", "credentials", "egress"}:
            die(f"{where}: unknown key {k!r}")


def load_project_spec(root: Path, skip_unknown: bool = False) -> list[dict]:
    """Read + validate the `.agent-container/` YAML into a list of environment
    dicts. `yaml.safe_load` ONLY (never `yaml.load` — an untrusted `!!python/...`
    tag must never construct an object). On any error `die` naming the offending
    file+field with no partial change (FR-003).

    Only files whose KIND is the declarative spec are read (`environments.yaml` or
    `*.environments.yaml`), so a sidecar override sharing the directory is left
    alone rather than parsed as a spec. An unrecognised `*.yaml` is REFUSED by
    default — a typo like `enviroments.yaml` must fail loudly, not silently load
    no environments — and `skip_unknown` downgrades that to a warning for an
    operator who deliberately keeps unrelated YAML there."""
    import yaml

    unknown = _unrecognised_yaml(root)
    if unknown:
        listing = "\n".join(f"  {p.name}" for p in unknown)
        if skip_unknown:
            warn(f"ignoring unrecognised YAML in {root / PROJECT_MARKER}:\n{listing}")
        else:
            die(
                f"{root / PROJECT_MARKER}: unrecognised YAML file(s):\n{listing}\n"
                f"A file's suffix names the top-level key it contains:\n"
                f"  *.environments.yaml  (or bare environments.yaml)  ->  environments:\n"
                f"  *.services.yaml                                   ->  services:\n"
                f"Rename it, or pass --skip-unknown-files to ignore it with a warning."
            )

    files = _spec_yaml_files(root)
    if not files:
        die(
            f"{root / PROJECT_MARKER}: no declarative spec found "
            f"(expected environments.yaml or *.environments.yaml)"
        )
    environments: list[dict] = []
    for f in files:
        try:
            data = yaml.safe_load(f.read_text(encoding="utf-8"))
        except yaml.YAMLError as e:
            die(f"{f}: invalid YAML ({str(e).splitlines()[0]})")
        except (UnicodeDecodeError, OSError) as e:
            die(f"{f}: cannot read spec file ({e})")
        if data is None:
            continue
        if not isinstance(data, dict):
            die(f"{f}: top-level must be a mapping")
        for k in data:
            if k != "environments":
                die(f"{f}: unknown top-level key {k!r} (only 'environments' is allowed)")
        envs = data.get("environments")
        if envs is None:
            die(f"{f}: missing required key 'environments'")
        if not isinstance(envs, list) or not envs:
            die(f"{f}: 'environments' must be a non-empty list")
        for i, env in enumerate(envs):
            validate_environment(env, f"{f}: environments[{i}]")
            assert isinstance(env, dict)  # narrowed by validate_environment; helps the type checker
            environments.append(env)
    names = [e["name"] for e in environments]
    dups = sorted({n for n in names if names.count(n) > 1})
    if dups:
        die(f"duplicate environment name(s) across the project spec: {', '.join(dups)}")
    return environments


def env_host_binding(env: dict) -> tuple[str, dict | None]:
    """Resolve the declared host binding to (host_name, provision_table_or_None): a
    string host is REFERENCED (externally owned, never deprovisioned); a table is
    PROVISIONED (spec-owned, US4). A provisioned host's registry name is the table
    `name` or the env name (validated RFC-1123 at parse time)."""
    h = env["host"]
    if isinstance(h, dict):
        return (h.get("name") or env["name"]), h
    return h, None


def ensure_provisioned_host(host_name: str, table: dict) -> dict:
    """Idempotently provision + register a spec-owned host (US4), driving the Feature
    001 provisioner. A second `apply` must NOT allocate a second (billable) server: an
    existing tool-created host of the same provider is reused unchanged. A name
    collision with a host this spec did not create is refused (never silently reused
    or re-provisioned over)."""
    provider = table["provision"]
    existing = get_host(load_registry(), host_name)
    if existing is not None:
        prov = existing.get("provisioning") or {}
        if existing.get("created_by_tool") and prov.get("provider") == provider:
            log(f"host {host_name}: already provisioned ({provider}) — reusing")
            return existing
        die(
            f"host {host_name}: a host of that name already exists but was not provisioned "
            f"by this spec (provider={prov.get('provider')!r}, created_by_tool="
            f"{bool(existing.get('created_by_tool'))}). Rename the environment's host or "
            f"remove the existing host first."
        )
    log(f"provisioning host {host_name} ({provider}) — billable…")
    record = provision_host(
        provider,
        host_name,
        server_type=table.get("server_type"),
        location=table.get("location"),
        ssh_key=table.get("ssh_key"),
        ssh_pubkey=None,
    )
    reg = load_registry()
    hosts = registry_hosts(reg)
    hosts[host_name] = record
    reg["hosts"] = hosts
    if default_host_name(reg) is None:
        reg["default"] = host_name
    save_registry(reg)
    log(f"registered provisioned host '{host_name}'")
    return record


def env_exec_spec(env: dict) -> ExecSpec:
    """Map a declared `container` block to the 004 ExecSpec."""
    c = env.get("container") or {}
    return ExecSpec(
        mode=c.get("mode", "interactive"),
        agent=c.get("agent", "claude"),
        task=resolve_task(c.get("task")),
        repo=c.get("repo"),
        workspace=c.get("workspace", "persistent"),
        workspace_dir=c.get("workspace_dir"),
    )


def env_live_config(host_rec: dict, name: str) -> dict[str, str | None] | None:
    """Inspect the RUNNING container's agent-config env (the settings `apply` writes:
    AGENT_CONTAINER_MODE / AGENT_CONTAINER_AGENT / AGENT_CONTAINER_CLONE_URL) so drift
    can be reported field-by-field (US3). Returns None if the container can't be
    inspected — the caller then falls back to existence-level state (never a false
    'matching' from a failed probe)."""
    cname = container_name(name)
    argv = driver_runtime_argv(host_rec) + ["inspect", cname, "--format", "{{json .Config.Env}}"]
    r = query(argv)
    if r.returncode != 0:
        return None
    try:
        env_list = json.loads(r.stdout)
    except json.JSONDecodeError, ValueError:
        return None
    env: dict[str, str] = {}
    for item in env_list or []:
        k, _, v = str(item).partition("=")
        env[k] = v
    return {
        # Absent key -> None, which is a FIRST-CLASS value here: it differs from any
        # present token, so declared->undeclared and undeclared->declared both drift.
        "egress": env.get("AGENT_CONTAINER_EGRESS"),
        "mode": env.get("AGENT_CONTAINER_MODE"),
        "agent": env.get("AGENT_CONTAINER_AGENT"),
        "repo": env.get("AGENT_CONTAINER_CLONE_URL"),
    }


def env_desired_config(spec: ExecSpec, egress: object = None) -> dict[str, str | None]:
    """The declared agent-config in the same shape env_live_config returns — the
    clone URL only applies to a non-bind workspace (mirrors compose_environment).

    `egress` defaults to None so every pre-012 call site keeps its meaning: an
    environment with no declaration compares None on both sides.
    """
    return {
        "mode": spec.mode,
        "agent": spec.agent,
        "repo": spec.repo if (spec.repo and spec.workspace != "bind") else None,
        "egress": egress_config_token(egress),
    }


def config_drift(
    desired: dict[str, str | None], live: dict[str, str | None]
) -> list[tuple[str, str | None, str | None]]:
    """Field-level delta (field, desired, live) for the reconcilable agent-config
    settings. Pure — the self-test exercises it.

    >>> config_drift({"mode": "interactive", "agent": "claude", "repo": None},
    ...              {"mode": "interactive", "agent": "claude", "repo": None})
    []
    >>> config_drift({"mode": "headless", "agent": "codex", "repo": None},
    ...              {"mode": "interactive", "agent": "claude", "repo": None})
    [('mode', 'headless', 'interactive'), ('agent', 'codex', 'claude')]
    """
    return [
        (field, desired.get(field), live.get(field))
        for field in ("mode", "agent", "repo", "egress")
        if desired.get(field) != live.get(field)
    ]


def env_reconcile(
    host_rec: dict, name: str, spec: ExecSpec, egress: object = None, *, host_name: str
) -> tuple[str, str]:
    """Reconcile the declared env's DETERMINISTIC identity (Constitution IV — no state
    file) against reality, returning (state, detail):
      absent   — no container for the identity
      drifted  — present-but-stopped, running with a config delta vs the spec, OR
                 deployed with a different VOLUME SET than the spec now declares
      matching — running and both the agent-config and the volume set match
    `detail` is a human-readable delta ('' for absent/matching). Because both the
    desired config and the identity derive only from the spec (never an absolute
    path), a fresh checkout at any location reconciles identically (FR-005/SC-003).

    `host_name` is KEYWORD-ONLY AND REQUIRED, and it is the host's registry key —
    not something read off `host_rec`, which does not carry its own key. The same
    trap `_previous_model_had_egress` documents: an empty key resolves to a path
    that never exists, so every environment would report a matching volume set and
    Feature 016's migration would never fire. That failure is invisible, because
    "no migration needed" is also the answer for the common case.
    """
    cname = container_name(name)
    all_names = host_container_names(host_rec, include_stopped=True)
    if cname not in all_names:
        return "absent", ""
    if cname not in host_container_names(host_rec):
        return "drifted", "present but stopped"
    live = env_live_config(host_rec, name)
    if live is None:
        return "matching", ""  # running but not inspectable — existence-level match
    diffs = config_drift(env_desired_config(spec, egress), live)
    # Redact any credential embedded in the repo/clone URL before it reaches the
    # detail string — status/apply log this, so an ssh://user:tok@ or https://…:token@
    # repo must never be printed verbatim (Constitution III; adversarial-verify MEDIUM).
    parts = []
    for f, want, live in diffs:
        if f == "repo":
            want, live = _redact_url_userinfo(want), _redact_url_userinfo(live)
        parts.append(f"{f}: {live!r}→{want!r}")
    # Feature 016 T010. Without this, an environment deployed before the runs volume
    # existed reports "matching" forever: name, port and all nine original volume
    # names are unchanged, so nothing `apply` compares can see the difference. The
    # container keeps running, writes its run records into its own layer, and
    # teardown destroys them — the exact failure the feature exists to prevent, and
    # a silent one, because a missing record looks like a run that had nothing to say.
    adopt, release = volume_set_migration(
        host_name,
        name,
        per_container_volumes(name)
        if spec.workspace == "persistent"
        else other_container_volumes(name),
    )
    if adopt:
        parts.append(f"volumes to add: {', '.join(adopt)}")
    if release:
        parts.append(f"volumes no longer declared: {', '.join(release)}")
    if not parts:
        return "matching", ""
    return "drifted", "; ".join(parts)


def stage_agent_container_spec(
    host_name: str, name: str, root: Path
) -> list[tuple[str, Path, str]]:
    """Deliver every host-side `.agent-container/` file as a READ-ONLY compose
    `config` (FR-020) targeting `/workspace/.agent-container/<rel>` — remote-context
    -safe (a host bind would fail over a remote context; the 001/003 lesson). The
    tool reads the spec ONLY host-side; this delivery is defense-in-depth so the
    in-container copy the agent sees cannot be altered."""
    d = root / PROJECT_MARKER
    sd = host_state_dir(host_name)
    sd.mkdir(parents=True, exist_ok=True)
    sd.chmod(0o700)
    entries: list[tuple[str, Path, str]] = []
    # Use the enumeration INDEX for the staged filename and the compose-config
    # resource name so both are injective — a lossy path-flattening could otherwise
    # collide two distinct spec files onto one config (and one would silently win).
    for i, src in enumerate(sorted(p for p in d.rglob("*") if p.is_file())):
        rel = src.relative_to(d).as_posix()
        staged = sd / f"{name}.aac.{i}"
        staged.write_bytes(src.read_bytes())
        staged.chmod(0o644)
        entries.append((f"aac_{name}_{i}", staged, f"{INJECT_AAC_DIR}/{rel}"))
    return entries


def _verify_ro_spec_delivery(aac_configs: list[tuple[str, Path, str]], spec: ExecSpec) -> None:
    """FR-020 refuse-if-writable (M3): the spec files must be delivered ONLY via the
    read-only compose-configs channel and never exposed writable. A `bind` workspace
    could mount the project dir RW at /workspace — refuse if it would shadow the
    spec (the operator should use persistent/ephemeral for a self-hosting repo)."""
    if not aac_configs:
        return
    for _tok, _src, target in aac_configs:
        if not target.startswith(INJECT_AAC_DIR + "/"):
            die(f"spec-integrity: refusing to deploy — spec file target {target} is not read-only")
    if spec.workspace == "bind":
        die(
            "spec-integrity (FR-020): a bind workspace would expose /workspace/.agent-container "
            "writable to the agent. Use --workspace persistent|ephemeral for a self-hosting "
            "project, so the spec is delivered read-only."
        )


def _precheck_environments(environments: list[dict], host_override: str | None = None) -> None:
    """Reject unsupported/unsafe environments UP FRONT — before any deploy — so a
    later environment's problem never leaves earlier ones partially applied (FR-003,
    even on the `--host` override path where the per-env host guard is short-circuited)."""
    for env in environments:
        name = env["name"]
        if (
            isinstance(env.get("host"), dict)
            and host_override is None
            and not os.environ.get("HCLOUD_TOKEN")
        ):
            # US4: a provision table is deployable now, but allocation is billable and
            # needs the cloud token — fail before ANY container work if it is absent.
            # A --host override bypasses provisioning entirely, so it needs no token.
            die(
                f"environment {name}: host provisioning ({env['host'].get('provision')}) "
                f"requires HCLOUD_TOKEN in the environment"
            )
        c = env.get("container") or {}
        if c.get("workspace") == "bind":
            die(
                f"environment {name}: workspace=bind would expose /workspace/.agent-container "
                f"writable to the agent (FR-020). Use persistent|ephemeral for a self-hosting project."
            )


def _reconcile_plan(environments: list[dict], host_override: str | None):
    """Compute (env, name, host_name, host_rec, state) per declared environment,
    applying spec-wins precedence (the spec's host, FR-018) and reporting."""
    plans = []
    reg = load_registry()  # detect a not-yet-provisioned host WITHOUT mutating
    for env in environments:
        name = env["name"]
        binding_name, prov_table = env_host_binding(env)
        to_provision = prov_table is not None and get_host(reg, binding_name) is None
        if to_provision and host_override is None:
            # US4: a to-be-provisioned host that does not exist yet. status/plan MUST
            # NOT allocate a billable server — report the intent, host_rec None.
            plans.append(
                (
                    env,
                    name,
                    binding_name,
                    None,
                    "absent",
                    f"host {binding_name} will be provisioned ({prov_table['provision']})",
                )
            )
            log(
                f"  {name} → host={binding_name}: absent (will provision {prov_table['provision']})"
            )
            continue
        host_name, host_rec = resolve_deploy_host(host_override or binding_name)
        ensure_tunnel(host_rec)
        state, detail = env_reconcile(
            host_rec, name, env_exec_spec(env), env.get("egress"), host_name=host_name
        )
        plans.append((env, name, host_name, host_rec, state, detail))
        log(f"  {name} → host={host_name}: {state}" + (f" ({detail})" if detail else ""))
    return plans


def _quiet_override(name: str) -> Path | None:
    """The sidecar override for reporting purposes — never fatal.

    `plan`/`status` must not die because an override is malformed; the paths that
    ACT on it (up/apply) already refuse. Reporting is not the place to enforce.
    """
    try:
        return resolve_sidecar_override(name)
    except Fatal:
        return None


def plan_payload(plans: list) -> list[dict]:
    """The machine-readable form of a reconcile plan (Feature 009 FR-013).

    Fields are named EXPLICITLY rather than dumping the environment dict: that dict
    carries `credentials`, and while those are locators rather than values
    (Feature 008), an allowlist here means a future spec key cannot start appearing
    on stdout merely by existing (Constitution III).
    """
    out: list[dict] = []
    for env, name, host_name, _host_rec, state, detail in plans:
        container = env.get("container") or {}
        agent = container.get("agent") or "claude"
        out.append(
            {
                "name": name,
                "host": host_name,
                "state": state,
                "detail": detail or None,
                "agent": agent,
                "mode": container.get("mode") or "interactive",
                "workspace": container.get("workspace") or "persistent",
                "egress": egress_payload(env.get("egress"), agent, _quiet_override(name)),
                "builtin_default_provider": AGENT_BUILTIN_DEFAULT.get(agent),
                "honours_proxy": AGENT_HONOURS_PROXY.get(agent, False),
            }
        )
    return out


def egress_payload(egress: object, agent: str, override: Path | None = None) -> dict:
    """The egress facts, machine-readable (FR-005/FR-013, contract C7).

    `destinations` is the EFFECTIVE allowlist, so an operator `hosts:` override is
    reported rather than the tool's default — reporting the default while
    enforcing an override would state a permission set the boundary does not
    enforce. Each entry carries `source` (which side supplied it) and `port`
    (which surface enforces it).

    It replaced a flat `hosts` list, which is emitted by NEITHER branch now. It
    used to survive in the undeclared branch alone, where an empty list read as
    "nothing is permitted" in the one case where EVERYTHING is — a consumer that
    kept reading `.hosts` saw the allowlist invert rather than disappear.

    `enforced` is the honest field: a declaration can exist and not be in force
    (advisory mode with an unenforceable declaration), and a caller must be able to
    tell those apart without parsing prose.

    `mechanism` IS NOT A RESTATEMENT OF `enforced` (T151/FR-021). A boolean cannot
    say WHICH enforcement was obtained, and the two this feature can deliver are
    not interchangeable: the packet-level boundary holds against an agent actively
    evading it, a cooperative proxy holds only against accident. FR-021's promise is
    that an operator can tell them apart, and the prose statement said so while the
    machine-readable surface reported one bit for both. Added ALONGSIDE `enforced`
    rather than replacing it — consumers exist, and `enforced` keeps its meaning.

    >>> p = egress_payload(None, "claude")
    >>> p["declared"], p["enforced"], p["mechanism"], p["destinations"]
    (False, False, 'none', [])
    """
    if not is_egress_declared(egress):
        return {
            "declared": False,
            "enforced": False,
            # 'none' rather than null: an undeclared environment HAS a mechanism,
            # and it is nothing. A null would read as "unknown" and invite a
            # consumer to guess, which is the silence this feature exists to end.
            "mechanism": EGRESS_UNENFORCED,
            "enforcement": None,
            "providers": [],
            "destinations": [],
            "unrestricted": True,
        }
    assert isinstance(egress, dict)
    entries = resolve_destinations(egress)
    mechanism, reason = egress_enforcement_mode(egress, agent, override)
    ok = mechanism != EGRESS_UNENFORCED
    return {
        "declared": True,
        "enforced": ok,
        # The two fields are computed from ONE call deliberately. Deriving `enforced`
        # from the mechanism is what keeps them from disagreeing — a payload saying
        # `enforced: true, mechanism: none` would be worse than either field alone.
        "mechanism": mechanism,
        "not_enforced_reason": None if ok else reason,
        "enforcement": egress.get("enforcement") or "advisory",
        "providers": sorted(
            {label for label, _h, port, src in entries if src == "tool" or port is None}
        ),
        "destinations": [
            # `port` is the field that says WHICH SURFACE enforces this entry:
            # null -> the proxy allowlist, an integer -> an explicit netfilter
            # rule. A caller reading only `host` cannot tell those apart, and they
            # have very different reach (SC-010: that host and that port only).
            {"label": label, "host": h, "port": port, "source": source}
            for label, h, port, source in entries
        ],
        # `allow: []` is DENY-EVERYTHING, not unrestricted — the opposite. Stated
        # explicitly so a caller never has to infer it from an empty list.
        "unrestricted": False,
    }


def do_aac_status(host_override: str | None = None, skip_unknown: bool = False) -> list[dict]:
    """`status`/`plan`: print the per-resource plan and mutate NOTHING (FR-008).

    Returns the plan as data so the commands can emit it under `--json`. It used to
    return None and log only to stderr, which meant `status --json` printed NOTHING
    on stdout — a machine-readable command emitting nothing at all. The human path
    is unchanged; the rows were already computed and simply discarded.
    """
    root = find_project_root()
    if root is None:
        die(
            "no .agent-container/ project found up the tree — the declarative model is "
            "inert here (use the imperative commands, or create a .agent-container/ spec)"
        )
    log(f"project root: {root}")
    environments = load_project_spec(root, skip_unknown)
    _precheck_environments(environments, host_override)
    return plan_payload(_reconcile_plan(environments, host_override))


# --- Feature 006 US2: credential references (resolve at apply, inject at runtime) ---
# A declared credential names a SOURCE (env / external file / OS keychain /
# encrypted-at-rest + a decrypt command). We resolve the value IN MEMORY at apply
# and deliver it via the existing 003 runtime channels — never writing plaintext to
# the tracked directory, logs, argv, or the registry (FR-013/014). A missing source
# fails before any change (FR-016); a git-tracked plaintext file is refused (FR-015).


def _keychain_lookup(service: str, account: str, name: str) -> str:
    """Read a secret from the OS store — macOS `security`, Linux `secret-tool`. The
    value is captured from stdout (never argv/disk)."""
    if sys.platform == "darwin":
        argv = ["security", "find-generic-password", "-w", "-s", service, "-a", account]
    else:
        argv = ["secret-tool", "lookup", "service", service, "account", account]
    r = subprocess.run(argv, capture_output=True, text=True, stdin=subprocess.DEVNULL)
    if r.returncode != 0:
        die(
            f"credential {name}: OS keychain lookup failed for service={service!r} "
            f"account={account!r} (FR-016 — the source is unavailable)"
        )
    return r.stdout.rstrip("\n")


RESOLVER_HINT = (
    "check that the manager CLI is installed and its session is unlocked, and that the item exists"
)


def _run_resolver(argv: list[str], name: str, *, timeout: float = RESOLVER_TIMEOUT) -> str:
    """Run a credential resolver HOST-SIDE and return its stdout as the secret (FR-001/002).

    The one audited runner every manager source funnels through, so the least-exposure
    guarantees live in a single place:
      * `argv` is executed DIRECTLY — never through a shell, so nothing in the locator
        can be interpreted (Constitution II/III, no injection surface);
      * stdin is CLOSED — resolution is non-interactive; a resolver that wants to prompt
        fails instead of blocking (FR-005);
      * a `timeout` bounds it — a wedged CLI can never hang an apply (FR-005);
      * on failure the resolver's STDERR IS NEVER ECHOED (it may carry secret material);
        the message is generic + secret-free, but carries a non-specific remediation hint
        so the failure stays actionable (FR-006).
    The value is returned unmodified and held in memory only — newline normalization
    belongs to delivery (FR-012).
    """
    try:
        r = subprocess.run(
            argv, capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=timeout
        )
    except subprocess.TimeoutExpired:
        die(
            f"credential {name}: resolver '{argv[0]}' did not finish within {timeout:g}s "
            f"— {RESOLVER_HINT} (FR-005)"
        )
    except OSError:
        # Missing binary / not executable. Report the program name (a locator, never a
        # secret) but not the OS message verbatim.
        die(f"credential {name}: resolver '{argv[0]}' could not be run — {RESOLVER_HINT}")
    if r.returncode != 0:
        die(
            f"credential {name}: resolver '{argv[0]}' exited {r.returncode} "
            f"— {RESOLVER_HINT} (FR-004)"
        )
    if not r.stdout.strip():
        # Test the STRIPPED output: delivery strips a trailing newline, so a
        # whitespace-only result would otherwise become a silently-injected empty
        # secret (FR-004).
        die(f"credential {name}: resolver '{argv[0]}' produced no value — {RESOLVER_HINT}")
    return r.stdout


def _refuse_git_tracked_plaintext(path: Path, root: Path, name: str) -> None:
    """FR-015: a plaintext secret FILE that is git-tracked inside the project is a
    leak risk — refuse with remediation. (The detection boundary: only files INSIDE
    the project tree and known to git; an external file or an untracked one is not
    flagged — documented.)"""
    try:
        rel = path.resolve().relative_to(root.resolve())
    except ValueError:
        return  # outside the project — the operator's own external file
    r = subprocess.run(
        ["git", "-C", str(root), "ls-files", "--error-unmatch", str(rel)],
        capture_output=True,
        stdin=subprocess.DEVNULL,
    )
    if r.returncode == 0:
        die(
            f"credential {name}: the plaintext secret file '{rel}' is tracked by git in "
            f"the project — a leak risk. Remedy: move it OUTSIDE the project, add it to "
            f".gitignore, or use source=encrypted with a decrypt command (FR-015)."
        )


def resolve_credential_value(cred: dict, root: Path, env_name: str | None = None) -> str:
    """Resolve one credential reference to its value, IN MEMORY (FR-011/012). A
    missing/unavailable source dies naming it (FR-016).

    FR-003b — the vocabulary invariant. Every failure here names THE CREDENTIAL and
    its source, and nothing on this path may attribute a credential problem to the
    `egress` declaration. The tool does NOT infer that a declared provider requires
    a particular credential: no such mapping exists, and any inference would
    false-positive on a provider reached WITHOUT one — the exact case Feature 010
    discovered and Feature 012 exists to surface.

    `env_name` prefixes the environment in a multi-environment apply, so the
    operator is told not just which credential failed but which environment
    declared it.
    """
    src, name = cred["source"], cred["name"]
    if env_name:
        name = f"{env_name}/{name}"
    if src == "env":
        v = os.environ.get(cred["var"])
        if v is None:
            die(f"credential {name}: environment variable {cred['var']} is not set (FR-016)")
        return v
    if src == "file":
        p = Path(cred["path"]).expanduser()
        _refuse_git_tracked_plaintext(p, root, name)
        if not p.is_file():
            die(f"credential {name}: file {p} does not exist (FR-016)")
        return p.read_text()
    if src == "keychain":
        return _keychain_lookup(cred["service"], cred["account"], name)
    # Manager sources (008): each reduces to "run an argv, take stdout". The named
    # sources assemble that argv from their structured fields; `command` is the
    # operator's own. All three go through the one audited runner.
    return _run_resolver(resolver_argv(cred), name)


def resolver_argv(cred: dict) -> list[str]:
    """The argv a manager-source credential resolves through — assembled from typed
    fields for a named manager, taken verbatim for the generic `command` source. Pure,
    so the exact invocation is unit-testable without running anything.

    >>> resolver_argv({"source": "command", "argv": ["pass", "show", "acme/key"]})
    ['pass', 'show', 'acme/key']
    >>> resolver_argv({"source": "onepassword", "vault": "Personal", "item": "anthropic", "field": "key"})
    ['op', 'read', 'op://Personal/anthropic/key']
    >>> resolver_argv({"source": "bitwarden", "item": "gh-token", "field": "password"})
    ['bw', 'get', 'password', 'gh-token']
    """
    src = cred["source"]
    if src == "command":
        return list(cred["argv"])
    if src == "onepassword":
        return ["op", "read", f"op://{cred['vault']}/{cred['item']}/{cred['field']}"]
    return ["bw", "get", cred["field"], cred["item"]]


@dataclass
class SshCreds:
    """Resolved SSH-target credentials (T012a) threaded into do_up's typed params —
    distinct lifecycles: push_key is the ephemeral outbound git identity, host_key the
    persisted inbound sshd identity, authorized_keys accumulate inbound principals."""

    push_key: Path | None = None
    authorized_keys: list[Path] = field(default_factory=list)


def stage_declared_credentials(
    host_name: str, name: str, creds: list[dict] | None, root: Path, base_env_file: Path | None
) -> tuple[list[tuple[str, Path, str]], Path | None, SshCreds]:
    """Resolve the declared credentials and deliver them: an explicit SSH **target**
    (push_key/authorized_key) → a 0600 staged file threaded into the 003
    ssh-injection channels (multi-line keys the env-file rejects, T012a); **provider
    API keys** (by name) via the 003 file-first apikey channel (a config →
    INJECT_APIKEY_DIR/<provider>, never even the env); **everything else** as env vars
    via a per-deployment secrets env-file merged with the declared env_file. Returns
    (extra_configs, env_file, ssh). Plaintext lives ONLY in memory + 0600 staged files
    under the state dir — never the tracked project, logs, argv, or registry
    (FR-013/014). Absent credentials → the base env_file passes through unchanged."""
    d = host_state_dir(host_name)
    d.mkdir(parents=True, exist_ok=True)
    d.chmod(0o700)
    configs: list[tuple[str, Path, str]] = []
    env_lines: list[str] = []
    ssh = SshCreds()
    for cred in creds or []:
        target = cred.get("target")
        if target in CRED_SSH_TARGETS:
            # Multi-line SSH material → the 003 ssh-injection paths, NOT the env-file.
            # Keep a trailing newline (OpenSSH/PEM keys and authorized_keys lines are
            # newline-terminated); do NOT run the env-var mangle checks. do_up re-stages
            # this host-side 0600 file into the correct channel (0600 here is fine — it
            # is read only by the operator uid running the CLI before re-staging).
            value = resolve_credential_value(cred, root, name)
            if not value.endswith("\n"):
                value += "\n"
            f = d / f"{name}.cred.{target}"
            f.write_text(value)
            f.chmod(0o600)
            if target == "push_key":
                ssh.push_key = f
            else:
                ssh.authorized_keys.append(f)
            continue
        # Drop a spurious trailing newline (a key file often ends in one) so the
        # value is delivered byte-clean to both the apikey file and the env var.
        value = resolve_credential_value(cred, root, name).rstrip("\n")
        cname = cred["name"]
        provider = CRED_PROVIDER.get(cname)
        if provider:
            f = d / f"{name}.cred.apikey.{provider}"
            f.write_text(value)
            f.chmod(0o600)
            configs.append((f"cred_apikey_{provider}", f, f"{INJECT_APIKEY_DIR}/{provider}"))
        else:
            if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", cname):
                die(
                    f"credential {cname}: name is not a valid environment-variable identifier "
                    f"for env delivery. Use a provider name (anthropic/openai) for an API key."
                )
            if "\n" in value:
                die(
                    f"credential {cname}: a multi-line value cannot be delivered as an env var. "
                    f"Use a provider name for an API key; multi-line SSH-key delivery is a "
                    f"follow-on increment."
                )
            # Fail closed on values compose's env-file (dotenv) parsing would mangle —
            # leading/trailing whitespace, an inline ' #' comment, or a leading quote.
            if value != value.strip() or " #" in value or value[:1] in {'"', "'"}:
                die(
                    f"credential {cname}: the value contains whitespace/#/quote characters that "
                    f"compose's env-file parsing would mangle — deliver it as a provider API key "
                    f"(file channel), not an env var."
                )
            env_lines.append(f"{cname}={value}")
    if not env_lines:
        return configs, base_env_file, ssh  # nothing to merge — pass the base env through
    merged = d / f"{name}.cred.env"
    parts: list[str] = []
    if base_env_file and base_env_file.is_file():
        parts.append(base_env_file.read_text().rstrip("\n"))
    parts.extend(env_lines)
    merged.write_text("\n".join(p for p in parts if p) + "\n")
    merged.chmod(0o600)
    return configs, merged, ssh


def do_aac_apply(
    host_override: str | None = None, yes: bool = False, skip_unknown: bool = False
) -> None:
    """`apply`: discover → validate → plan → (confirm) → converge by driving `do_up`
    per environment. Idempotent (matching ⇒ no change, SC-002). The `.agent-container/`
    spec is delivered read-only (FR-020)."""
    root = find_project_root()
    if root is None:
        die(
            "no .agent-container/ project found up the tree — the declarative model is "
            "inert here (use the imperative commands, or create a .agent-container/ spec)"
        )
    log(f"project root: {root}")
    environments = load_project_spec(root, skip_unknown)
    _precheck_environments(
        environments, host_override
    )  # reject unsupported/unsafe envs before ANY deploy (FR-003)
    plans = _reconcile_plan(environments, host_override)
    todo = [p for p in plans if p[4] != "matching"]
    if not todo:
        log("no changes — every declared environment already matches the spec")
        return
    if not yes:
        # Surface the detail (a to-be-provisioned host is a BILLABLE allocation).
        what = ", ".join(f"{p[1]} ({p[5] or p[4]})" for p in todo)
        if not is_tty():
            # FR-007: never proceed unauthorized just because nobody can be asked.
            # A non-interactive caller (an agent, CI) must pass -y explicitly —
            # `apply` can create containers and PROVISION BILLABLE hosts.
            die(
                f"refusing to apply {len(todo)} change(s) without -y/--yes on a non-TTY: {what}",
                code="confirmation_required",
                remedy="agent-container apply -y",
            )
        if not questionary.confirm(f"apply {len(todo)} change(s): {what}?", default=False).ask():
            log("aborted")
            return
    # US2: resolve + stage ALL declared credentials for EVERY environment UP FRONT,
    # before any container is touched — a missing/unavailable source in a later env
    # must not leave earlier envs partially deployed (FR-016). The convention `.env`
    # (resolve_env_file) is the merge base when no container.env_file is declared, so
    # GH_TOKEN/GIT_* are preserved, not dropped.
    staged_creds: dict[str, tuple[list[tuple[str, Path, str]], Path | None, SshCreds]] = {}
    for env, name, host_name, _host_rec, _state, _detail in todo:
        c = env.get("container") or {}
        base_env = (root / c["env_file"]).resolve() if c.get("env_file") else resolve_env_file(name)
        # C6 BEFORE staging: stage_declared_credentials merges base_env into a
        # tool-generated <name>.cred.env, so a check placed after it could only name
        # that file — satisfying the requirement and failing the operator, who has
        # never seen it. Credential NAMES are checked here too: `NO_PROXY` matches
        # the name charset and would be written straight into the merged file.
        refuse_operator_proxy_vars(
            env.get("egress"),
            (c.get("agent") or "claude"),
            base_env,
            [str(cr.get("name")) for cr in (env.get("credentials") or [])],
            resolve_sidecar_override(name),
        )
        staged_creds[name] = stage_declared_credentials(
            host_name, name, env.get("credentials"), root, base_env
        )
    # Reconcile each environment independently; a per-env failure is recorded and the
    # rest proceed, then reported — never silently swallowed (FR-010).
    done: list[str] = []
    failures: list[tuple[str, str]] = []
    for env, name, host_name, host_rec, state, detail in todo:
        try:
            if host_rec is None:
                # US4: a spec-owned host not yet provisioned → provision + register it
                # (billable) before deploy. Idempotent — a second apply reuses it.
                # host_rec is None ONLY for a provision-table env (set so in _reconcile_plan).
                _bn, prov_table = env_host_binding(env)
                assert prov_table is not None
                host_rec = ensure_provisioned_host(host_name, prov_table)
                ensure_tunnel(host_rec)
            spec = env_exec_spec(env)
            aac = stage_agent_container_spec(host_name, name, root)  # FR-020 RO delivery
            _verify_ro_spec_delivery(aac, spec)
            cred_configs, env_file, ssh = staged_creds[name]
            aac = aac + cred_configs
            if state == "drifted":
                # present-but-stopped OR a running config delta → recreate to converge,
                # ANNOUNCED before the destructive step. Use the plan's resolved
                # (host_name, host_rec) so the pair is always consistent.
                log(f"{name}: drifted ({detail or 'present, stopped'}) — recreating to converge")
                down_container(host_name, host_rec, name, purge=False)
            # Deploy to the SAME host the plan/preview resolved (honoring --host), not
            # the raw spec host — else the container would land on a host the operator
            # never confirmed. `host_name` is the resolved name; do_up re-resolves it.
            # SSH-target credentials (T012a) ride do_up's typed params (push_key must be
            # a param, not a config, so clone_credential_precheck sees it for an ssh --repo).
            do_up(
                name,
                host=host_name,
                env_file_override=[env_file] if env_file else None,
                spec=spec,
                extra_injected_configs=aac,
                push_key=ssh.push_key,
                authorized_keys=ssh.authorized_keys,
            )
            done.append(name)
        except Fatal as e:
            failures.append((name, str(e)))
            warn(f"{name}: apply failed — {e}")
    if failures:
        die(
            f"apply incomplete: converged [{', '.join(done) or 'none'}]; "
            f"failed [{'; '.join(f'{n}: {e}' for n, e in failures)}] (FR-010)"
        )
    log("apply complete")


def do_aac_destroy(
    host_override: str | None = None,
    yes: bool = False,
    deprovision: bool = False,
    skip_unknown: bool = False,
) -> None:
    """`destroy`: remove ONLY the resources the spec declares/owns — by deterministic
    identity (SC-007). A referenced host is NEVER deprovisioned (FR-009/017); a
    spec-PROVISIONED host is deprovisioned only with explicit `--deprovision` intent,
    and only AFTER its container is removed (spec.md US4-3), reusing the Feature 001
    fail-closed tool-created + provably-empty teardown."""
    root = find_project_root()
    if root is None:
        die("no .agent-container/ project found up the tree")
    log(f"project root: {root}")
    environments = load_project_spec(root, skip_unknown)
    # Fail before any change if a --deprovision would need a cloud token it lacks.
    if deprovision and not os.environ.get("HCLOUD_TOKEN"):
        if any(isinstance(e.get("host"), dict) for e in environments):
            die("--deprovision needs HCLOUD_TOKEN in the environment")
    if not yes:
        names = ", ".join(e["name"] for e in environments)
        extra = " and DEPROVISION their spec-created hosts" if deprovision else ""
        if not is_tty():
            # FR-007: previously this SKIPPED the confirmation on a non-TTY and tore
            # everything down unauthorized — worse than blocking. Refuse instead.
            die(
                f"refusing to destroy {names}{extra} without -y/--yes on a non-TTY",
                code="confirmation_required",
                entity=names,
                remedy="agent-container destroy -y",
            )
        if not questionary.confirm(
            f"destroy the declared environment(s): {names}{extra}?", default=False
        ).ask():
            log("aborted")
            return
    done: list[str] = []
    failures: list[tuple[str, str]] = []
    for env in environments:
        name = env["name"]
        try:
            binding_name, prov_table = env_host_binding(env)
            unprovisioned = (
                prov_table is not None and get_host(load_registry(), binding_name) is None
            )
            if unprovisioned and host_override is None:
                log(f"{name}: host {binding_name} not provisioned — nothing to destroy")
                done.append(name)
                continue
            host_name, host_rec = resolve_deploy_host(host_override or binding_name)
            with deployment_lock(host_name, name):
                down_container(host_name, host_rec, name, purge=True)  # owned identity only
            done.append(name)
            # US4: deprovision ONLY a spec-provisioned host, ONLY on explicit intent,
            # ONLY when not overridden, and NEVER a referenced host. Containers first
            # (down above), THEN the host. cli_host_rm is the single fail-closed
            # tool-created + provably-empty teardown; guard with get_host for idempotency.
            if deprovision and prov_table is not None and host_override is None:
                if get_host(load_registry(), host_name) is not None:
                    cli_host_rm(host_name, destroy=True, yes=True)
        except Fatal as e:
            failures.append((name, str(e)))
            warn(f"{name}: destroy failed — {e}")
    if failures:
        die(
            f"destroy incomplete: removed [{', '.join(done) or 'none'}]; "
            f"failed [{'; '.join(f'{n}: {e}' for n, e in failures)}] (FR-010)"
        )
    log("destroy complete")


# --- entry -------------------------------------------------------------------

app = typer.Typer(
    add_completion=False,
    no_args_is_help=False,
    pretty_exceptions_enable=False,
    help="Interactive wizard + CLI for agent-container containers.",
)


def fatal_exit(e: Fatal) -> NoReturn:
    eprint(f"[agent-container] FATAL: {e}")
    raise typer.Exit(1)


def run_self_test() -> int:
    import doctest

    failures, tests = doctest.testmod(
        sys.modules[__name__], optionflags=doctest.IGNORE_EXCEPTION_DETAIL
    )
    # Hard-coded port corpus: pins the deterministic port hash against regressions.
    corpus = {
        "acme": 2206,
        "blog": 2220,
        "scratch": 2244,
        "my-box": 2204,
        "a": 2297,
        "devbox123": 2298,
    }
    bad = {n: (port_for_name(n), want) for n, want in corpus.items() if port_for_name(n) != want}
    keys_ok = name_to_key("my-box") == "MY_BOX" and name_to_key("acme") == "ACME"
    ok = failures == 0 and not bad and keys_ok
    print(f"doctests: {tests - failures}/{tests} passed")
    print(f"port corpus: {'ok' if not bad else f'MISMATCH {bad}'}")
    print(f"key derivation: {'ok' if keys_ok else 'MISMATCH'}")
    print("self-test:", "PASS" if ok else "FAIL")
    return 0 if ok else 1


@app.command()
def build(
    tag: str = typer.Argument(IMAGE_NAME, help="Image tag to build."),
    context: Path | None = typer.Option(
        None,
        "--context",
        help="Docker build context (repo checkout). Defaults to AGENT_CONTAINER_REPO "
        "or the auto-detected checkout; required when installed from PyPI.",
    ),
    as_json: bool = JSON_OPT,
) -> None:
    """Build the image from a repo checkout (streams build output)."""
    set_json_mode(as_json)
    do_build(tag, context)
    emit_action("build", tag=tag)


@app.command()
def up(
    name: str = typer.Argument(..., help="Container short name (agent-container-<name>)."),
    host: str | None = typer.Option(
        None, "--host", help="Deploy to this registered host (default: the registry default)."
    ),
    env_file: list[Path] = typer.Option(
        None,
        "-e",
        "--env-file",
        help="Env file to load; repeatable, applied in order (later wins). "
        "Bypasses env-file resolution; each path must exist.",
    ),
    mount: list[str] = typer.Option(
        None,
        "--mount",
        help="Bind-mount a host dir read-write (repeatable): HOSTDIR[:CONTAINERPATH]. "
        "Default CONTAINERPATH is /workspace/<basename>. Lima: host dir must be "
        "under a writable Lima mount.",
    ),
    host_key: Path | None = typer.Option(
        None,
        "--host-key",
        hidden=True,
        help="REMOVED (Feature 018). Host identity is captured, not supplied.",
    ),
    authorized_key: list[Path] = typer.Option(
        None,
        "--authorized-key",
        help="Inject an SSH public key into authorized_keys (repeatable), so you "
        "can attach without a manual exec. Persisted on the ~/.ssh volume.",
    ),
    push_key: Path | None = typer.Option(
        None,
        "--push-key",
        help="Inject an OpenSSH PRIVATE key the agent PUSHES with (distinct from "
        "--host-key). Delivered EPHEMERALLY — never written to the ~/.ssh volume. "
        "Must be unencrypted (a passphrase would block non-interactive push).",
    ),
    known_hosts: Path | None = typer.Option(
        None,
        "--known-hosts",
        help="Inject a known_hosts file for the push remote so the first push does "
        "not stall on host-key verification. Ephemeral.",
    ),
    mode: str = typer.Option(
        "interactive",
        "--mode",
        help="Execution mode: interactive (agent in a persistent tmux session) or "
        "headless (agent runs the task as the container's workload and exits).",
    ),
    agent: str = typer.Option(
        "claude", "--agent", help="Primary agent to run: claude | codex | pi | opencode."
    ),
    # The task-text rule is stated HERE, at the flag the operator types the task
    # into, and not only in docs/threat-model.md — a warning read after the paste
    # is not a warning. `task` is the ONE field of a run record an operator authors
    # (C13, research R9); every other field is tool- or git-derived and structurally
    # cannot carry a secret. Nothing redacts it, deliberately: a pattern-based
    # redactor that missed one value would turn the operator's caution into
    # misplaced confidence. `commands --json` re-emits this help by introspection,
    # so the machine-readable interface inherits the statement rather than
    # restating it out of step.
    task: str | None = typer.Option(
        None,
        "--task",
        help="Initial task (interactive) / the task to run (headless). Text, or "
        "@FILE to read a local file. Delivered as an injected file (never argv/env). "
        "RECORDED VERBATIM in the run record, which outlives the container "
        "(`agent-container runs show`) — never put a credential in the task text; "
        "nothing redacts it.",
    ),
    workspace: str = typer.Option(
        "persistent",
        "--workspace",
        help="Workspace at /workspace: persistent (named volume, survives recreate) "
        "| bind (a local dir, local hosts only) | ephemeral (container layer, gone "
        "on teardown).",
    ),
    workspace_dir: str | None = typer.Option(
        None, "--workspace-dir", help="Host directory for a --workspace bind (local hosts only)."
    ),
    repo: str | None = typer.Option(
        None,
        "--repo",
        help="Clone-on-start source URL for a persistent/ephemeral workspace. "
        "git@… uses the injected push key; https://… uses GH_TOKEN.",
    ),
    foreground: bool = typer.Option(
        False,
        "--foreground",
        help="Headless only: stream the run attached and return control (with the "
        "agent's exit code) on completion. Default headless is detached.",
    ),
    as_json: bool = JSON_OPT,
) -> None:
    """Start container agent-container-NAME.

    Default: an interactive claude session on a persistent workspace (attach with
    `agent-container attach NAME`). Use --mode headless for a one-shot job."""
    set_json_mode(as_json)
    if host_key is not None:
        refuse_removed_host_key("up --host-key")
    for _ef in env_file or []:  # FR-001d: each named file must exist — fail fast
        if not _ef.is_file():
            raise typer.BadParameter(f"--env-file {_ef} does not exist")
    spec = ExecSpec(
        mode=mode,
        agent=agent,
        task=resolve_task(task),
        repo=repo,
        workspace=workspace,
        workspace_dir=workspace_dir,
        foreground=foreground,
    )
    do_up(
        name,
        host,
        env_file,
        mount or [],
        authorized_key or [],
        push_key,
        known_hosts,
        spec=spec,
    )
    emit_action(
        "up",
        name=name,
        host=host or DEFAULT_HOST,
        port=port_for_name(name),
        mode=spec.mode,
        agent=spec.agent,
        workspace=spec.workspace,
    )


@app.command()
def keys(
    name: str = typer.Argument(..., help="Container short name."),
    host_key: Path | None = typer.Option(
        None,
        "--host-key",
        hidden=True,
        help="REMOVED (Feature 018). Host identity is captured, not supplied.",
    ),
    authorized_key: list[Path] = typer.Option(
        None,
        "--authorized-key",
        help="Append an SSH public key to authorized_keys in the running "
        "container (repeatable; deduped; persists on the ~/.ssh volume).",
    ),
    as_json: bool = JSON_OPT,
) -> None:
    """Inject SSH host key / authorized keys into a RUNNING container (no recreate)."""
    set_json_mode(as_json)
    if host_key is not None:
        refuse_removed_host_key("keys --host-key")
    cli_keys(name, authorized_key or [])
    emit_action("keys", name=name)


@app.command()
def down(
    name: str = typer.Argument(..., help="Container short name."),
    host: str | None = typer.Option(
        None, "--host", help="Host the container runs on (default: the registry default)."
    ),
    purge: bool = typer.Option(
        False,
        "--purge",
        help="Also delete all ten per-container volumes (workspace + agent logins, shell env, tmux config, ssh, pending run records).",
    ),
    yes: bool = typer.Option(False, "-y", "--yes", help="Skip confirmation."),
    as_json: bool = JSON_OPT,
) -> None:
    """Stop and remove container agent-container-NAME (dispose; volumes kept unless --purge)."""
    set_json_mode(as_json)
    cli_down(name, purge, yes, host)
    emit_action("down", name=name, host=host, purged=purge)


@app.command()
def stop(
    name: str = typer.Argument(..., help="Container short name."),
    host: str | None = typer.Option(None, "--host", help="Host the container runs on."),
    as_json: bool = JSON_OPT,
) -> None:
    """Stop (pause/reclaim) agent-container-NAME — retained with its volumes; `start` resumes it."""
    set_json_mode(as_json)
    do_stop(name, host)
    emit_action("stop", name=name, host=host)


@app.command()
def start(
    name: str = typer.Argument(..., help="Container short name."),
    host: str | None = typer.Option(None, "--host", help="Host the container runs on."),
    as_json: bool = JSON_OPT,
) -> None:
    """Start a stopped agent-container-NAME (no rebuild, no recreate)."""
    set_json_mode(as_json)
    do_start(name, host)
    emit_action("start", name=name, host=host)


@app.command()
def redeploy(
    name: str = typer.Argument(..., help="Container short name."),
    host: str | None = typer.Option(None, "--host", help="Host the container runs on."),
    env_file: list[Path] = typer.Option(
        None,
        "-e",
        "--env-file",
        help="Env file to load; repeatable, applied in order (later wins). "
        "Bypasses env-file resolution; each path must exist.",
    ),
    mount: list[str] = typer.Option(
        None, "--mount", help="Bind-mount a host dir (repeatable), as for `up`."
    ),
    host_key: Path | None = typer.Option(
        None,
        "--host-key",
        hidden=True,
        help="REMOVED (Feature 018). Host identity is captured, not supplied.",
    ),
    authorized_key: list[Path] = typer.Option(
        None, "--authorized-key", help="Inject an SSH public key (repeatable)."
    ),
    push_key: Path | None = typer.Option(
        None, "--push-key", help="Inject the outbound push key (ephemeral; as for `up`)."
    ),
    known_hosts: Path | None = typer.Option(
        None, "--known-hosts", help="Inject known_hosts for the push remote (as for `up`)."
    ),
    mode: str = typer.Option("interactive", "--mode", help="Execution mode (as for `up`)."),
    agent: str = typer.Option("claude", "--agent", help="Primary agent (as for `up`)."),
    # Restated, not delegated to `up`: this is a second place a task is typed, and
    # "as for `up`" is not read by someone who is about to paste one here.
    task: str | None = typer.Option(
        None,
        "--task",
        help="Initial/headless task (as for `up`). RECORDED VERBATIM in the run "
        "record, which outlives the container — never put a credential in it.",
    ),
    workspace: str = typer.Option(
        "persistent", "--workspace", help="Workspace mode (as for `up`)."
    ),
    workspace_dir: str | None = typer.Option(
        None, "--workspace-dir", help="Host dir for a bind workspace (as for `up`)."
    ),
    repo: str | None = typer.Option(None, "--repo", help="Clone-on-start URL (as for `up`)."),
    foreground: bool = typer.Option(
        False, "--foreground", help="Headless: stream the run attached (as for `up`)."
    ),
    as_json: bool = JSON_OPT,
) -> None:
    """Rebuild the image and recreate agent-container-NAME, preserving its volumes.
    Deliberately non-idempotent: always rebuilds + recreates, even with no change.
    May change mode/agent/workspace/repo."""
    set_json_mode(as_json)
    if host_key is not None:
        refuse_removed_host_key("redeploy --host-key")
    for _ef in env_file or []:  # FR-001d: each named file must exist — fail fast
        if not _ef.is_file():
            raise typer.BadParameter(f"--env-file {_ef} does not exist")
    spec = ExecSpec(
        mode=mode,
        agent=agent,
        task=resolve_task(task),
        repo=repo,
        workspace=workspace,
        workspace_dir=workspace_dir,
        foreground=foreground,
    )
    do_redeploy(
        name,
        host,
        env_file,
        mount or [],
        authorized_key or [],
        push_key,
        known_hosts,
        spec=spec,
    )


@app.command()
def wipe(
    name: str = typer.Argument(..., help="Container short name."),
    host: str | None = typer.Option(None, "--host", help="Host the container runs on."),
    yes: bool = typer.Option(False, "-y", "--yes", help="Skip confirmation."),
    as_json: bool = JSON_OPT,
) -> None:
    """WIPE agent-container-NAME: remove the container, its volumes, AND its built image (confirmed)."""
    set_json_mode(as_json)
    do_wipe(name, yes, host)
    emit_action("wipe", name=name, host=host)


@app.command()
def purge(
    name: str = typer.Argument(..., help="Container short name."),
    yes: bool = typer.Option(False, "-y", "--yes", help="Skip confirmation."),
    as_json: bool = JSON_OPT,
) -> None:
    """Sugar for: down NAME --purge."""
    set_json_mode(as_json)
    cli_down(name, purge=True, yes=yes)
    emit_action("purge", name=name)


@app.command(name="list")
def list_cmd(
    as_json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
    local_only: bool = typer.Option(
        False,
        "--local",
        help="Skip remote round-trips: list only the local runtime + per-host state files (fast).",
    ),
) -> None:
    """List agent-container containers, reconciled live against each registered
    host (a --local view skips remote queries)."""
    set_json_mode(as_json)
    do_list(as_json, local_only)


# The `runs` group reads the DURABLE record store, not the host. It is a group and
# not a top-level `runs` verb for the same reason `host` is: `list` and `show` mean
# different things here than they do for containers, and flattening them would give
# the tool two `list`s that answer different questions. Deliberately read-only —
# there is no `runs rm`; retention prunes at ingestion (FR-011), so a record's
# lifetime is a documented rule rather than an operator action that could be
# forgotten on one machine and not another.
runs_app = typer.Typer(
    no_args_is_help=True,
    # The retention rule is stated HERE, in the help an operator reads at the point
    # of use, and interpolated from the constants that enforce it — a number typed
    # into prose beside a different number in the code is this project's recurring
    # defect, and a test binds these two together in both directions.
    help=(
        "Durable run records: what each run did and how it ended (survives teardown). "
        f"Records are pruned at ingestion, per environment, at "
        f"{RETENTION_MAX_AGE_DAYS} days or {RETENTION_MAX_RECORDS} records — "
        f"whichever prunes first, with the count spent on distinct UTC days first, so "
        f"one night of restart records cannot evict the history that explains it."
    ),
)
app.add_typer(runs_app, name="runs")


@runs_app.command("list")
def runs_list(
    environment: str | None = typer.Argument(
        None, help="Environment to list (default: every environment recorded on the host)."
    ),
    host: str | None = typer.Option(
        None, "--host", help="Host whose records to read (default: the registry default)."
    ),
    changed: str | None = typer.Option(
        None,
        "--changed",
        metavar="PATH",
        help="Only runs whose recorded changed paths cover PATH (repository-relative). "
        "Reads stored records only — no repository needed, and a rewritten history "
        "does not change the answer.",
    ),
    as_json: bool = JSON_OPT,
) -> None:
    """List run records newest-first, ingesting anything still pending on the host."""
    set_json_mode(as_json)
    do_runs_list(environment, host, as_json, changed)


@runs_app.command("show")
def runs_show(
    run_id: str = typer.Argument(..., help="Run id (as shown by `runs list`)."),
    host: str | None = typer.Option(
        None, "--host", help="Host whose records to search (default: the registry default)."
    ),
    as_json: bool = JSON_OPT,
) -> None:
    """Show one complete run record (--json emits it verbatim as stored)."""
    set_json_mode(as_json)
    do_runs_show(run_id, host, as_json)


# A TOP-LEVEL COMMAND rather than a group, unlike `runs`: there is one question here
# ("what left, or tried to leave, that the declaration does not name") and inventing
# `egress list` would add a mandatory word that selects nothing. It is deliberately
# NOT a flag on `runs` either — an egress event is a different kind, from a different
# producer, and rows of one inside a listing of the other is exactly what FR-011a
# says not to build.
@app.command(name="egress")
def egress_cmd(
    environment: str | None = typer.Argument(
        None, help="Environment to report (default: every environment on the host)."
    ),
    host: str | None = typer.Option(
        None, "--host", help="Host whose records to read (default: the registry default)."
    ),
    as_json: bool = JSON_OPT,
) -> None:
    """Durable record of UNDECLARED egress: what the boundary refused, and anything it
    permitted that the declaration does not name (survives teardown).

    Silence means nothing was refused — and when an environment has no boundary, this
    says so rather than answering nothing. `logs <name> --egress` is the live stream
    this is distilled from; only the events above are kept, and only what the boundary
    can see: a destination and a verdict, never a request. Events are pruned at
    ingestion, per environment, at {age} days or {count} events — spent on distinct
    destinations first. Each contact reads the last {tail} lines of that log.
    """
    set_json_mode(as_json)
    do_egress(environment, host, as_json)


# Interpolated rather than typed into the docstring above, because typer reads the
# docstring as help and a number written there beside a different number in the code
# is this project's recurring defect. A test binds all three to their constants.
egress_cmd.__doc__ = (egress_cmd.__doc__ or "").format(
    age=EGRESS_RETENTION_MAX_AGE_DAYS,
    count=EGRESS_RETENTION_MAX_RECORDS,
    tail=EGRESS_LOG_TAIL_LINES,
)


# The `host` group manages WHERE containers run (registered targets). Container
# lifecycle (up/down/attach/logs) stays as bare top-level verbs; a container is
# addressed by name and lives on a host chosen with --host (default: the registry
# default). There is deliberately no `host up`/`host down` — those are container
# operations, not host operations. Deprovisioning a host is `host rm --destroy`
# (guarded: refused while containers remain, or for hosts the tool did not create).
inventory_app = typer.Typer(
    no_args_is_help=True,
    help=(
        "Durable record of every environment this tool created — what, where, when, "
        "and what became of it. Survives the container, the host and the registry "
        f"entry, so it can answer 'did we make this?' about something still billing "
        f"you on a host you removed. Kept indefinitely: pruning is COUNT-only at "
        f"{INVENTORY_MAX_ENTRIES} entries and never by age, because the entry most "
        f"worth having is the one you forgot six months ago."
    ),
)
app.add_typer(inventory_app, name="inventory")


@inventory_app.command("reconcile")
def inventory_reconcile(
    as_json: bool = JSON_OPT,
) -> None:
    """Compare the record against what each host reports."""
    set_json_mode(as_json)
    do_inventory_reconcile(as_json)


@inventory_app.command("list")
def inventory_list(
    as_json: bool = JSON_OPT,
) -> None:
    """List every environment this tool created, newest first."""
    set_json_mode(as_json)
    do_inventory_list(as_json)


host_app = typer.Typer(
    no_args_is_help=True,
    help="Manage deployment hosts (the machines/contexts where containers run).",
)
app.add_typer(host_app, name="host")


@host_app.command("add")
def host_add(
    name: str = typer.Argument(..., help="Host short name (registry key)."),
    driver: str = typer.Option("docker", "--driver", help="Runtime driver: docker or podman."),
    docker_context: str | None = typer.Option(
        None,
        "--docker-context",
        help="Existing docker context (local endpoint or ssh://user@host).",
    ),
    connection: str | None = typer.Option(
        None, "--connection", help="Podman system connection name (for --driver podman)."
    ),
    address: str | None = typer.Option(
        None, "--address", help="Attach address override (default: derived from the context)."
    ),
    make_default: bool = typer.Option(
        False, "--default", help="Make this the default deploy target."
    ),
    provider: str | None = typer.Option(
        None, "--provider", help="Cloud provider to provision a server on (e.g. hetzner)."
    ),
    create: bool = typer.Option(
        False, "--create", help="Allocate a NEW cloud server (billable). Requires --provider."
    ),
    reuse: bool = typer.Option(
        False,
        "--reuse",
        help="Register an EXISTING cloud server (no allocation); use with --docker-context ssh://…",
    ),
    server_type: str | None = typer.Option(
        None, "--server-type", help="Cloud server type (e.g. cax11). Provider-specific."
    ),
    location: str | None = typer.Option(
        None, "--location", help="Cloud location (e.g. nbg1). Provider-specific."
    ),
    ssh_key: str | None = typer.Option(
        None, "--ssh-key", help="Existing provider SSH key id/name to also attach (optional)."
    ),
    ssh_pubkey: Path | None = typer.Option(
        None,
        "--ssh-pubkey",
        help="Operator SSH PUBLIC key to authorize on the new server "
        "(default: ~/.ssh/id_ed25519.pub).",
    ),
    as_json: bool = JSON_OPT,
) -> None:
    """Register a container-runtime host (where `up --host NAME` deploys), or
    provision a cloud server with --provider hetzner --create."""
    set_json_mode(as_json)
    cli_host_add(
        name,
        driver,
        docker_context or connection,
        address,
        make_default,
        provider=provider,
        create=create,
        reuse=reuse,
        server_type=server_type,
        location=location,
        ssh_key=ssh_key,
        ssh_pubkey=ssh_pubkey,
    )


@host_app.command("ls")
def host_ls(
    as_json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
) -> None:
    """List registered hosts (name, driver, context, address, default)."""
    set_json_mode(as_json)
    do_host_ls(as_json)


@host_app.command("show")
def host_show(
    name: str = typer.Argument(..., help="Host short name."),
    as_json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
) -> None:
    """Show one host's full record (driver, context, address, provisioning state)."""
    set_json_mode(as_json)
    do_host_show(name, as_json)


@host_app.command("rm")
def host_rm(
    name: str = typer.Argument(..., help="Host short name to remove."),
    destroy: bool = typer.Option(
        False,
        "--destroy",
        help="Also deprovision the cloud server (tool-created hosts only; irreversible).",
    ),
    yes: bool = typer.Option(False, "-y", "--yes", help="Skip the confirmation prompt."),
    as_json: bool = JSON_OPT,
) -> None:
    """Remove a host from the registry. With --destroy also deallocate its cloud
    server — refused if the server still hosts containers or the tool didn't create it."""
    set_json_mode(as_json)
    cli_host_rm(name, destroy, yes)
    emit_action("host rm", name=name, destroyed=destroy)


@host_app.command("env")
def host_env(
    name: str = typer.Argument(None, help="Host short name to target (omit with --unset)."),
    endpoint: bool = typer.Option(
        False,
        "--endpoint",
        help="Emit the raw endpoint (DOCKER_HOST/CONTAINER_HOST=ssh://…) instead of "
        "the registered context reference (DOCKER_CONTEXT/CONTAINER_CONNECTION).",
    ),
    unset: bool = typer.Option(
        False, "--unset", help="Emit a plain unset of all vars host env can set (no name needed)."
    ),
    shell: str = typer.Option("posix", "--shell", help="Dialect: posix (default), fish, or pwsh."),
) -> None:
    """Emit eval-able env that points the operator's own docker/podman at a host.

    Prints by default (it exists to be eval'd): `eval $(agent-container host env NAME)`
    (POSIX/fish) or `agent-container host env NAME --shell pwsh | Invoke-Expression`.
    Registry-only — no connection is made."""
    cli_host_env(name, endpoint, unset, shell)


@app.command()
def attach(
    name: str = typer.Argument(..., help="Container short name."),
    local: bool = typer.Option(False, "--local", help="Force local target (state file)."),
    remote: bool = typer.Option(False, "--remote", help="Force remote target (hosts.conf)."),
    user: str | None = typer.Option(
        None, "--user", help="SSH user (default: AGENT_CONTAINER_USER or dev)."
    ),
    host: str | None = typer.Option(None, "--host", help="Override the resolved host."),
    window: str | None = typer.Option(
        None, "--window", "-w", help="Select tmux window NAME (session 'main') before attaching."
    ),
    print_cmd: bool = typer.Option(
        False,
        "--print",
        help="Print the runnable ssh+tmux command to stdout (eval/alias it) instead "
        "of connecting. Byte-for-byte what execute runs; nothing is connected.",
    ),
    ssh_config: bool = typer.Option(
        False,
        "--ssh-config",
        help="Print a ~/.ssh/config Host stanza to stdout (append it, then `ssh NAME`).",
    ),
    shell: str = typer.Option(
        "posix", "--shell", help="Dialect for --print output: posix (default), fish, or pwsh."
    ),
    trust_unpinned: bool = typer.Option(
        False,
        "--trust-unpinned",
        help="Accept the container's host key WITHOUT being asked when nothing is "
        "pinned. A trust decision, not a check: it cannot detect a container that "
        "was replaced. Prefer pinning by deploying, or copy the entry from the "
        "machine that deployed (agent-container list --json).",
    ),
) -> None:
    """ssh + tmux attach to container NAME (local state file or hosts.conf).

    With --print / --ssh-config, emit the command/config to stdout instead of
    connecting (for `eval $(…)`, aliases, or ~/.ssh/config)."""
    if local and remote:
        raise typer.BadParameter("--local and --remote are mutually exclusive")
    if print_cmd and ssh_config:
        raise typer.BadParameter("--print and --ssh-config are mutually exclusive")
    mode = "local" if local else ("remote" if remote else "auto")
    cli_attach(name, mode, user, host, window, print_cmd, ssh_config, shell, trust_unpinned)


@app.command()
def logs(
    name: str = typer.Argument(..., help="Container short name."),
    no_follow: bool = typer.Option(False, "--no-follow", help="Print logs without following."),
    egress: bool = typer.Option(
        False,
        "--egress",
        help=(
            "Read the egress boundary's log instead of the agent's — where refused "
            "destinations are recorded. REFUSED means the name is not declared; "
            "NXDOMAIN means the name genuinely does not exist."
        ),
    ),
    as_json: bool = JSON_OPT,
) -> None:
    """Tail container logs."""
    set_json_mode(as_json)
    rc = do_logs(name, follow=not no_follow, egress=egress)
    # The log STREAM itself is the container's own output and is not wrapped; the
    # envelope reports the outcome so an agent can tell success from failure.
    # `source` because the two streams answer different questions and a caller
    # that cannot tell them apart would read an agent log as a policy record.
    emit_action(
        "logs",
        name=name,
        source="egress" if egress else "agent",
        container=egress_container_name(name) if egress else container_name(name),
        exit_code=rc,
    )
    raise typer.Exit(rc)


@app.command()
def menu() -> None:
    """Interactive wizard (same as bare agent-container)."""
    raise typer.Exit(wizard_loop())


# --- Feature 006: declarative (agent-as-code) verbs --------------------------
# Active only when a `.agent-container/` project root is discovered up the tree;
# otherwise they report the model is inert (use the imperative commands).


@app.command()
def apply(
    host: str | None = typer.Option(
        None, "--host", help="Override the host for every environment (default: the spec's host)."
    ),
    yes: bool = typer.Option(False, "-y", "--yes", help="Skip the confirmation prompt."),
    skip_unknown_files: bool = SKIP_UNKNOWN_OPT,
    as_json: bool = JSON_OPT,
) -> None:
    """Reconcile the `.agent-container/` project: discover → validate → plan → converge.

    Idempotent — an already-satisfied spec makes no changes. The governing spec is
    delivered read-only into the container (FR-020)."""
    set_json_mode(as_json)
    do_aac_apply(host, yes, skip_unknown_files)
    emit_action("apply", host=host)


@app.command()
def plan(
    host: str | None = typer.Option(None, "--host", help="Override the host for the plan."),
    skip_unknown_files: bool = SKIP_UNKNOWN_OPT,
    as_json: bool = JSON_OPT,
) -> None:
    """Show the per-environment reconcile plan (absent/matching/drifted); mutate nothing."""
    set_json_mode(as_json)
    rows = do_aac_status(host, skip_unknown_files)
    if as_json:
        emit_json({"environments": rows})


@app.command()
def status(
    host: str | None = typer.Option(None, "--host", help="Override the host for the status."),
    skip_unknown_files: bool = SKIP_UNKNOWN_OPT,
    as_json: bool = JSON_OPT,
) -> None:
    """Alias of `plan` — the current state of each declared environment vs the spec."""
    set_json_mode(as_json)
    rows = do_aac_status(host, skip_unknown_files)
    if as_json:
        emit_json({"environments": rows})


@app.command()
def destroy(
    host: str | None = typer.Option(None, "--host", help="Override the host for the teardown."),
    yes: bool = typer.Option(False, "-y", "--yes", help="Skip the confirmation prompt."),
    deprovision: bool = typer.Option(
        False,
        "--deprovision",
        help="Also deprovision each spec-PROVISIONED host (US4). A referenced host is "
        "never touched; requires HCLOUD_TOKEN.",
    ),
    skip_unknown_files: bool = SKIP_UNKNOWN_OPT,
    as_json: bool = JSON_OPT,
) -> None:
    """Remove ONLY the resources the `.agent-container/` spec declares and owns
    (by deterministic identity). A referenced host is never deprovisioned; a
    spec-provisioned host is deprovisioned only with --deprovision."""
    set_json_mode(as_json)
    do_aac_destroy(host, yes, deprovision, skip_unknown_files)
    emit_action("destroy", host=host, deprovisioned=deprovision)


# --- Feature 009: the `context` surface --------------------------------------
# One call an agent loads as context. Deliberately a SERIALIZER over the Feature
# 007 recommendation engine rather than a second assessment path: 007 already
# computes stages, hosts, problems and the suggested next step, and it is PURE —
# so this cannot disagree with the wizard, and it is testable from a constructed
# snapshot with no daemon (research R5).


def credential_locators(root: Path | None) -> list[dict]:
    """Describe declared credentials as LOCATORS — which source, which reference.
    NEVER resolves a value: `context` must not be able to leak a secret even by
    accident (FR-011, Constitution III). Resolution happens only at `apply`."""
    if root is None:
        return []
    out: list[dict] = []
    try:
        # `context` describes the world read-only and already tolerates a bad spec,
        # so an unrecognised stray file must not blank it out entirely.
        environments = load_project_spec(root, skip_unknown=True)
    except Fatal:
        return []  # an invalid spec is reported elsewhere; context still describes the world
    for env in environments:
        for cred in env.get("credentials") or []:
            src = cred.get("source")
            # The reference is the LOCATOR only: a variable name, a path, a vault
            # coordinate — never the value behind it.
            ref = {
                "env": cred.get("var"),
                "file": cred.get("path"),
                "keychain": f"{cred.get('service')}/{cred.get('account')}",
                "command": " ".join(cred.get("argv") or []),
                "onepassword": f"op://{cred.get('vault')}/{cred.get('item')}/{cred.get('field')}",
                "bitwarden": f"{cred.get('item')}/{cred.get('field')}",
            }.get(str(src))
            out.append(
                {
                    "environment": env.get("name"),
                    "name": cred.get("name"),
                    "source": src,
                    "reference": ref,
                    "target": cred.get("target"),
                }
            )
    return out


def build_agent_context(rt: str, selected_host: str | None = None) -> dict:
    """Assemble the `context` payload. Valid in EVERY state — an empty world gives
    empty collections and an unreachable host is a described state, never a failed
    call (FR-010)."""
    target = resolve_active_target(rt, selected_host)
    snap = build_snapshot(rt, target)
    rec = recommend_next_step(snap)
    root = find_project_root()
    reg = load_registry()
    hosts = [
        {"name": hname, "driver": hrec.get("driver"), "context": hrec.get("context")}
        for hname, hrec in sorted(registry_hosts(reg).items())
    ]
    return {
        "target": {"host": target.host_name, "container": target.container_name},
        "stages": [
            {"key": s.key, "status": s.status, "detail": s.detail, "hard": s.hard}
            for s in snap.stages
        ],
        "hosts": hosts,
        "environments": [{"name": n, "status": st} for n, st in snap.containers],
        "conventions": {
            # Paths only — an env file's CONTENTS are never read into the payload.
            "project_root": str(root) if root else None,
            "env_file": str(resolve_env_file(target.container_name))
            if target.container_name and resolve_env_file(target.container_name)
            else None,
        },
        "credentials": credential_locators(root),
        "problems": snap.problems.copy(),
        "orphan_volumes": snap.orphan_volumes.copy(),
        "next_step": {
            "kind": rec.kind,
            "reason": rec.reason,
            "command": rec.equivalent_cmd,
            "destructive": rec.destructive,
        },
    }


@app.command("commands")
def commands_cmd(
    as_json: bool = JSON_OPT,
) -> None:
    """List every command, its arguments and its effect — machine-readable help.

    Derived by INTROSPECTING the real command tree, so it cannot drift from the
    commands that actually exist (FR-008); a hand-maintained catalogue would."""
    set_json_mode(as_json)
    cmds = []
    for cmd in app.registered_commands:
        name = cmd.name or getattr(cmd.callback, "__name__", "?")
        params = []
        for p in inspect.signature(cmd.callback).parameters.values() if cmd.callback else []:
            default = p.default
            decls = getattr(default, "param_decls", None)
            params.append(
                {
                    "name": p.name,
                    "flags": list(decls) if decls else [],
                    "required": getattr(default, "default", None) is ...,
                    "help": getattr(default, "help", None),
                }
            )
        cmds.append(
            {
                "name": name,
                "summary": (cmd.callback.__doc__ or "").strip().splitlines()[0]
                if cmd.callback and cmd.callback.__doc__
                else None,
                "json": "as_json"
                in (inspect.signature(cmd.callback).parameters if cmd.callback else {}),
                "params": params,
            }
        )
    payload = {
        "schema_version": SCHEMA_VERSION,
        "commands": sorted(cmds, key=operator.itemgetter("name")),
    }
    if as_json:
        emit_json(payload)
        return
    for c in payload["commands"]:
        console.print(f"{c['name']:<12} {c['summary'] or ''}")


@app.command()
def context(
    host: str | None = typer.Option(None, "--host", help="Describe this host instead."),
    as_json: bool = JSON_OPT,
) -> None:
    """Print what the tool knows: hosts, environments, conventions and the next step.

    Designed to be loaded as context by an AI agent (`--json`). Credentials appear
    as LOCATORS only — never a secret value."""
    set_json_mode(as_json)
    ctx = build_agent_context(detect_runtime(), host)
    if as_json:
        emit_json(ctx)
        return
    console.print(f"target: {ctx['target']['host']} / {ctx['target']['container'] or '-'}")
    console.print("  ".join(f"{s['key']}={s['status']}" for s in ctx["stages"]))
    for p in ctx["problems"]:
        console.print(f"[red]![/red] {p}")
    console.print(f"next: {ctx['next_step']['kind']} — {ctx['next_step']['reason']}")


# --- Feature 009: the `skill` command ----------------------------------------
# Installs an Agent Skills (agentskills.io) conformant definition so an agent has
# a documented way to invoke this tool. The format is an OPEN STANDARD all four
# supported agents implement, so there is ONE definition and a target is only a
# DISCOVERY PATH — adding an agent is a row here, not new content (FR-017).
# The template is an EMBEDDED constant, not package data: package data exists only
# in a wheel install and would break `uv run --script` (research R6).

SKILL_NAME = "agent-container"
SKILL_MARKER = "x-agent-container-checksum"  # drift detector; extra frontmatter keys are allowed

# agent -> (project-relative dir, home-relative dir). All four consume the same
# SKILL.md standard; only the location differs.
SKILL_TARGETS: dict[str, tuple[str, str]] = {
    "claude": (".claude/skills", ".claude/skills"),
    "codex": (".codex/skills", ".codex/skills"),
    "opencode": (".opencode/skills", ".config/opencode/skills"),
    "pi": (".pi/skills", ".pi/skills"),
}

# EVERY example below carries --json (FR-012c): the flag is per-invocation, so the
# skill is what makes the convention binding rather than leaving it to recall.
SKILL_BODY = """\
## Purpose

`agent-container` manages containerized development environments (create, attach,
inspect, tear down) on local or remote hosts.

## Always pass `--json`

Every command accepts `--json`, which emits ONE envelope on stdout:

    {"schema": "agent-container/v1", "ok": true, "data": {...}}
    {"schema": "agent-container/v1", "ok": false, "error": {"code": "...", "entity": "...", "message": "...", "remedy": "..."}}

ALWAYS pass `--json`. Without it the output is prose meant for humans and will not
parse. Check `schema` before relying on field names, and branch on `error.code` —
never on `error.message`, whose wording may change.

## Start here

Load the current state before acting:

    agent-container context --json

It returns the hosts, environments, applicable conventions, and a suggested
`next_step`. Credentials appear as locators only, never as values.

## Common operations

    agent-container list --json
    agent-container up <name> --json
    agent-container logs <name> --no-follow --json
    agent-container stop <name> --json
    agent-container start <name> --json
    agent-container down <name> --purge -y --json

## Rules

- Destructive commands (`down`, `wipe`, `purge`, `destroy`) require `-y` when not
  attached to a terminal; without it they refuse rather than prompting.
- On failure, read `error.remedy` — it names the command that resolves the problem.
- Never put a secret on the command line; credentials are injected at runtime from
  the sources the spec declares.
"""


def render_skill() -> str:
    """Render the standard-conformant SKILL.md (frontmatter + body + checksum)."""
    digest = body_digest(SKILL_BODY)
    return (
        "---\n"
        f"name: {SKILL_NAME}\n"
        "description: >-\n"
        "  Manage containerized development environments with agent-container —\n"
        "  create, attach to, inspect and tear down containers on local or remote\n"
        "  hosts. Use when the task involves agent-container or its environments.\n"
        f"{SKILL_MARKER}: {digest}\n"
        "---\n\n"
        f"{SKILL_BODY}"
    )


def skill_dir(agent: str, user_scope: bool) -> Path:
    """Where the skill goes for `agent`. Project scope is the DEFAULT so the
    definition is reviewable and version-controlled with the code (FR-012b)."""
    if agent not in SKILL_TARGETS:
        die(
            f"unknown agent '{agent}' (supported: {', '.join(sorted(SKILL_TARGETS))})",
            code="agent_unsupported",
            entity=agent,
        )
    proj, home = SKILL_TARGETS[agent]
    base = Path.home() / home if user_scope else Path.cwd() / proj
    return base / SKILL_NAME


def body_digest(body: str) -> str:
    return hashlib.sha256(body.encode()).hexdigest()[:16]


def read_installed(skill_md: Path) -> tuple[str | None, str | None]:
    """Return (recorded_marker, digest_of_the_body_actually_on_disk).

    Two comparisons matter and they are NOT the same:
      * recorded marker vs the on-disk body's digest  → did the OPERATOR edit it?
      * recorded marker vs the current template digest → is it a STALE version?
    Hashing the template instead of the file would detect a stale version while
    silently missing hand edits — the exact case FR-014 exists to prevent.
    """
    try:
        text = skill_md.read_text()
    except OSError:
        return None, None
    marker = None
    parts = text.split("---\n", 2)  # ['', frontmatter, body]
    if len(parts) < 3:
        return None, None  # not our shape -> treat as foreign
    for line in parts[1].splitlines():
        if line.startswith(f"{SKILL_MARKER}:"):
            marker = line.split(":", 1)[1].strip()
    return marker, body_digest(parts[2].lstrip("\n"))


def do_skill(action: str, agent: str, user_scope: bool, force: bool) -> None:
    d = skill_dir(agent, user_scope)
    md = d / "SKILL.md"
    scope = "user" if user_scope else "project"
    rendered = render_skill()
    current = body_digest(SKILL_BODY)

    if action == "remove":
        if not md.is_file():
            log(f"skill not installed for {agent} ({scope}): {md}")
            emit_action("skill", action="remove", agent=agent, scope=scope, changed=False)
            return
        md.unlink()
        # Remove only the directory WE created, and only if we left it empty.
        with contextlib.suppress(OSError):
            d.rmdir()
        log(f"removed {md}")
        emit_action("skill", action="remove", agent=agent, scope=scope, path=str(md), changed=True)
        return

    if md.is_file():
        marker, on_disk = read_installed(md)
        if marker is None and not force:
            die(
                f"{md} exists but was not written by this tool (no {SKILL_MARKER} marker) — "
                f"refusing to overwrite. Re-run with --force to replace it.",
                code="skill_foreign",
                entity=str(md),
                remedy="agent-container skill install --force",
            )
        if marker != on_disk and not force:
            # The file no longer hashes to what we recorded => hand-edited.
            die(
                f"{md} has been modified since it was installed — refusing to overwrite your "
                f"edits. Re-run with --force to replace it.",
                code="skill_modified",
                entity=str(md),
                remedy="agent-container skill install --force",
            )
        # No-op only when it is OURS, UNMODIFIED and CURRENT — and never under
        # --force, which must always rewrite (otherwise --force silently does nothing
        # for a hand-edited file whose frontmatter marker still reads as current).
        if marker == current == on_disk and not force:
            log(f"skill already current for {agent} ({scope}): {md}")
            emit_action(
                "skill", action=action, agent=agent, scope=scope, path=str(md), changed=False
            )
            return

    d.mkdir(parents=True, exist_ok=True)
    md.write_text(rendered)
    log(f"wrote {md}")
    emit_action("skill", action=action, agent=agent, scope=scope, path=str(md), changed=True)


@app.command()
def skill(
    action: str = typer.Argument(..., help="install | update | remove"),
    agent: str = typer.Option(
        "claude", "--agent", help="claude | codex | opencode | pi (the skill is identical)."
    ),
    user_scope: bool = typer.Option(
        False, "--user", help="Install into the home config instead of this project."
    ),
    force: bool = typer.Option(
        False, "--force", help="Replace a modified/foreign definition (never silent)."
    ),
    as_json: bool = JSON_OPT,
) -> None:
    """Install, update or remove the agent-container skill in a local agent config.

    Writes an Agent Skills standard SKILL.md. Project scope by default; --user for
    the home configuration. Never overwrites your edits without --force."""
    set_json_mode(as_json)
    if action not in ("install", "update", "remove"):
        die(
            f"unknown action '{action}' (use: install | update | remove)",
            code="bad_argument",
            entity=action,
        )
    do_skill(action, agent, user_scope, force)


def _completion_script(shell: str) -> str:
    """Return the bash/zsh completion script text.

    Prefers the on-disk checkout (dev / `uv run --script`, where REPO_ROOT
    resolves via the __file__ marker); falls back to the completions bundled as
    package data in a non-editable PyPI install (where REPO_ROOT is None).
    """
    if REPO_ROOT is not None:
        p = REPO_ROOT / "completions" / f"agent-container.{shell}"
        if p.is_file():
            return p.read_text()
    with contextlib.suppress(
        ModuleNotFoundError, ImportError, FileNotFoundError, TypeError, AttributeError
    ):
        import importlib.resources as ir

        res = ir.files("agent_container").joinpath(f"completions/agent-container.{shell}")
        if res.is_file():
            return res.read_text()
    die(f"completion script for '{shell}' not found (need a checkout or an installed package)")


@app.command()
def completions(
    # Optional + manual validation so a bad/missing shell routes through die()
    # (exit 1, '[agent-container] FATAL:' style), not Typer's exit-2 required-arg error.
    shell: str = typer.Argument("", help="Shell to emit completion for: bash or zsh."),
) -> None:
    """Print the completion script for bash or zsh (from a checkout or package data)."""
    if shell not in ("bash", "zsh"):
        die("usage: agent-container completions <bash|zsh>")
    sys.stdout.write(_completion_script(shell))


def _resolve_version() -> str:
    """The tool's version, single-sourced from pyproject.toml. When installed
    (wheel), it is read from package metadata; when run via `uv run --script`
    from a checkout, it is read from pyproject directly — no hardcoded constant
    to drift from the release version release-please bumps."""
    from importlib.metadata import PackageNotFoundError, version

    with contextlib.suppress(PackageNotFoundError):
        return version("agent-container")
    if REPO_ROOT is not None:
        with contextlib.suppress(Exception):
            import tomllib

            with (REPO_ROOT / "pyproject.toml").open("rb") as f:
                return tomllib.load(f)["project"]["version"]
    return "0.0.0+unknown"


def _version_callback(value: bool) -> None:
    if value:
        typer.echo(_resolve_version())
        raise typer.Exit()


@app.callback(invoke_without_command=True)
def main(
    ctx: typer.Context,
    self_test: bool = typer.Option(
        False, "--self-test", help="Run doctests + port-hash corpus checks."
    ),
    version: bool = typer.Option(
        False,
        "--version",
        callback=_version_callback,
        is_eager=True,
        help="Show the agent-container version and exit.",
    ),
) -> None:
    if self_test:
        raise typer.Exit(run_self_test())
    if ctx.invoked_subcommand is None:
        raise typer.Exit(wizard_loop())


def cli() -> None:
    """Console-script entry point for `uv tool install` (see pyproject.toml).

    uv's generated launcher does `from agent_container import cli; sys.exit(cli())`,
    so the Fatal -> exit-1 handling that used to live only in the __main__ guard
    is hoisted here. NOT named `main` — that is already the Typer @app.callback.
    """
    try:
        app()
    except Fatal as e:
        # THE chokepoint: every failure in the tool arrives here, so the structured
        # rendering is a single site rather than ~100 call sites (research R4).
        # In JSON mode the descriptor goes to stdout as the envelope (an agent has
        # one stream to read); the human line still goes to stderr either way.
        if json_mode():
            emit_json(error=e.descriptor())
        eprint(f"[agent-container] FATAL: {e}")
        sys.exit(1)


if __name__ == "__main__":
    cli()
