#!/usr/bin/env python3
"""Launch a test battery with a PROVEN child environment.

Rebuilt 2026-09-07 by hertz after the original (W0, 2026-09-06) was lost with a
`.spt/` sweep. Two directions, because we have now been bitten by both:

  --forbid VAR   the child must NOT carry it (perch identity leaking INTO a
                 battery: OWL_SESSION_ID / SPT_AGENT_ID / SPT_ENDPOINT_ID make
                 every `daemon stop` refuse by design, the refused daemons leak,
                 and the next build dies "Access is denied (os error 5)").
  --require VAR=VALUE
                 the child MUST carry it with that value (rig configuration
                 failing to REACH the battery: the cells then no-op and pass
                 uniformly in ~0.0s, which reads as a green run).

The point of both is the same and it is the only reason this file exists:
**a scrub or an export you did not READ BACK OUT OF THE CHILD is a claim, not a
measurement.** PowerShell's own view of its environment has already been shown to
disagree with what a child actually receives (PowerShell hid OWL_SESSION_ID that
bash showed), so the parent's opinion of its env is not evidence about the child.

**AND THE CHILD IS NOT THE BATTERY.** Measured 2026-09-07 while self-testing this
very file: reading only the IMMEDIATE child's environment passed BOTH negative
tests, because `env VAR=wrong <cmd>` (and equally `cargo`, `nextest`, `bash -lc`,
any wrapper) modifies the environment it hands its OWN child while its own
`environ()` still shows what we passed in. The verification was one level above
the process under test and reported PROVEN on a launch it existed to refuse.
So this tool verifies the child AND its descendants — `a pid is not a tree`
(IR-80/IR-81) applies to reading an environment exactly as it does to killing.
The `.env` sidecar records every process actually inspected, by pid, so the
claim's SCOPE is on disk and not in a sentence.

Exit codes:
  0   LAUNCH PROVEN, battery ran, battery's own exit code is reported separately
  2   LAUNCH REFUSED — the child env did not match; nothing was run
  3   usage / could not read the child environment at all

Usage:
  python .spt/launch-battery.py \
      --forbid OWL_SESSION_ID --forbid SPT_AGENT_ID --forbid SPT_ENDPOINT_ID \
      --require W2_ROLE=b --require SPT_DOCS_PORT=5480 \
      --out .spt/w2_verify \
      -- cargo nextest run --workspace -E 'test(twohost)'

Writes `<out>.raw` (combined stdout+stderr), `<out>.exit` (the battery's exit
code as a bare integer), and `<out>.env` (the child environment actually read
back). The `.exit` FILE is the verdict — a harness notification has said 0 while
the leg exited 101 (v0.63.0 publish), so read the file.
"""

import argparse
import os
import subprocess
import sys
import time

try:
    import psutil
except ImportError:  # pragma: no cover - the whole tool depends on it
    print("LAUNCH REFUSED: psutil is not importable; cannot read the child env back")
    sys.exit(3)

# The perch identity trio plus its companions. Passing --forbid-perch is the
# same as naming all seven, and is what a battery launched from a live agent's
# shell almost always wants.
PERCH_VARS = [
    "OWL_SESSION_ID",
    "SPT_AGENT_ID",
    "SPT_ENDPOINT_ID",
    "SPT_SESSION_NAME",
    "SPT_ADAPTER",
    "SPT_HOST_PID",
    "SPT_INJECT_VERIFY_ECHO",
]


def _env_of(pid):
    try:
        return dict(psutil.Process(pid).environ()), None
    except psutil.NoSuchProcess:
        return None, "gone"
    except (psutil.AccessDenied, OSError) as e:
        return None, f"{type(e).__name__}: {e}"


def read_tree_envs(proc, settle_s, deadline_s=15.0):
    """Read the environments of the child AND every descendant we can see.

    Reading only `proc` is NOT enough and that is the bug this function exists
    to avoid: a wrapper (`env`, `cargo`, `nextest`, `bash -lc`) alters the
    environment of the process BELOW it while its own `environ()` still shows
    what we handed it. Verifying the wrapper therefore certifies a launch we
    meant to refuse.

    Returns (list of (pid, name, env), error). The list is what was ACTUALLY
    inspected — callers must report it rather than implying whole-tree coverage,
    because a descendant that starts after the settle window, or one whose env
    we may not read, is simply not in it.
    """
    end = time.time() + deadline_s
    root_env, err = None, None
    while time.time() < end:
        root_env, err = _env_of(proc.pid)
        if root_env is not None:
            break
        if err == "gone":
            return [], "child exited before its environment could be read"
        time.sleep(0.2)
    if root_env is None:
        return [], err or "timed out reading the child environment"

    seen = [(proc.pid, _name_of(proc.pid), root_env)]
    # Let wrappers spawn what they are going to spawn. This window is the
    # tool's honest limit: it cannot see a process that has not started yet.
    time.sleep(settle_s)
    try:
        kids = psutil.Process(proc.pid).children(recursive=True)
    except psutil.NoSuchProcess:
        kids = []
    for k in kids:
        env, e = _env_of(k.pid)
        if env is not None:
            seen.append((k.pid, _name_of(k.pid), env))
    return seen, None


def _name_of(pid):
    try:
        return psutil.Process(pid).name()
    except Exception:
        return "?"


def main():
    ap = argparse.ArgumentParser(add_help=True)
    ap.add_argument("--forbid", action="append", default=[], metavar="VAR")
    ap.add_argument("--forbid-perch", action="store_true",
                    help=f"shorthand for --forbid on each of: {', '.join(PERCH_VARS)}")
    ap.add_argument("--require", action="append", default=[], metavar="VAR=VALUE")
    ap.add_argument("--out", required=True, help="output prefix; writes .raw/.exit/.env")
    ap.add_argument("--settle", type=float, default=2.0,
                    help="seconds to let wrappers spawn before reading the tree "
                         "(default 2.0). This is the tool's honest blind spot: a "
                         "process started after it is not inspected.")
    ap.add_argument("cmd", nargs=argparse.REMAINDER,
                    help="-- followed by the battery command")
    args = ap.parse_args()

    cmd = args.cmd[1:] if args.cmd and args.cmd[0] == "--" else args.cmd
    if not cmd:
        print("LAUNCH REFUSED: no battery command given (put it after `--`)")
        return 3

    forbid = list(args.forbid) + (PERCH_VARS if args.forbid_perch else [])
    require = {}
    for pair in args.require:
        if "=" not in pair:
            print(f"LAUNCH REFUSED: --require needs VAR=VALUE, got {pair!r}")
            return 3
        k, v = pair.split("=", 1)
        require[k] = v

    # Build the child env EXPLICITLY. This dict is NOT the evidence — the
    # read-back below is, and it is read from the processes, not from here.
    child_env = dict(os.environ)
    for var in forbid:
        child_env.pop(var, None)
    child_env.update(require)

    raw_path = args.out + ".raw"
    exit_path = args.out + ".exit"
    env_path = args.out + ".env"

    with open(raw_path, "wb") as raw:
        proc = subprocess.Popen(cmd, env=child_env, stdout=raw,
                                stderr=subprocess.STDOUT)

        inspected, err = read_tree_envs(proc, args.settle)
        if not inspected:
            proc.kill()
            proc.wait()
            print(f"LAUNCH REFUSED: {err}")
            return 2

        with open(env_path, "w", encoding="utf-8") as fh:
            fh.write(f"# processes inspected: {len(inspected)}\n")
            for pid, name, env in inspected:
                fh.write(f"\n# ---- pid {pid} ({name}) ----\n")
                for k in sorted(env):
                    fh.write(f"{k}={env[k]}\n")

        problems = []
        for pid, name, env in inspected:
            for var in forbid:
                if var in env:
                    problems.append(
                        f"  FORBIDDEN present in pid {pid} ({name}): {var}={env[var]!r}")
            for var, want in require.items():
                got = env.get(var)
                if got != want:
                    problems.append(
                        f"  REQUIRED wrong in pid {pid} ({name}): "
                        f"{var}={got!r}, wanted {want!r}")

        roster = ", ".join(f"{pid}:{name}" for pid, name, _ in inspected)
        if problems:
            # Kill the TREE, not the pid — the wrapper is the thing we hold and
            # the battery is the thing that would keep running (IR-80).
            _kill_tree(proc)
            print("LAUNCH REFUSED: the environment is not what was asked for.")
            print("\n".join(problems))
            print(f"  inspected {len(inspected)} process(es): {roster}")
            print(f"  full read-back in {env_path}")
            print("  NOTHING USABLE WAS RUN -- do not report this leg as a result.")
            return 2

        print(f"LAUNCH PROVEN across {len(inspected)} process(es): {roster}")
        print(f"  {len(forbid)} forbidden absent, {len(require)} required present, "
              f"in EVERY process inspected")
        print(f"  read back from the processes, not the parent: {env_path}")
        print(f"  SCOPE: processes alive within {args.settle}s of launch; anything "
              f"spawned later is not covered by this proof")
        rc = proc.wait()

    with open(exit_path, "w", encoding="utf-8") as fh:
        fh.write(str(rc))
    print(f"battery exit {rc} (authoritative copy in {exit_path}); output in {raw_path}")
    return 0


def _kill_tree(proc):
    """Kill the wrapper AND its descendants. A single-pid kill here would leave
    the battery running under a refused launch — the exact IR-80 shape."""
    try:
        victims = psutil.Process(proc.pid).children(recursive=True)
    except psutil.NoSuchProcess:
        victims = []
    for v in victims:
        try:
            v.kill()
        except Exception:
            pass
    try:
        proc.kill()
    except Exception:
        pass
    try:
        proc.wait(timeout=5)
    except Exception:
        pass


if __name__ == "__main__":
    sys.exit(main())
