#!/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.

A NOTE ON ENV PREFIXES (todlando, 2026-09-07): a shell prefix launch — `VAR=1
cmd` — never enters the SCRIPT's own environment, so grepping `env` earlier in
the driver reads 0 even on a perfectly healthy run. That zero is a wrong-scope
read, the same error one level up that this tool's first version made. If you
--require against a prefix-style launch, the read-back that matters is the one
on the test-binary pid, which is why the descendant walk below is not optional.

!! READ THIS BEFORE TRUSTING ANY VERDICT THIS TOOL PRINTS !!
This tool is ADVISORY, not proof. It was measured failing in BOTH directions on
Windows 2026-09-07 against a 3-level wrapper chain (env -> env -> env -> python,
which is the shape cargo -> cargo-nextest -> test binary actually is):

  FALSE PROVEN  -- a violator at the innermost level was missed entirely. The
                  walk inspected ONE process (the root), found it clean, and
                  reported success. The descendant link did not survive the
                  chain.
  FALSE REFUSED -- the same chain with nothing violating was REFUSED, naming an
                  intermediate whose environment read back as missing the
                  variable while two sibling processes in the same listing
                  carried it.

Root cause as measured: on Windows the spawn chain does not reliably preserve a
parent/child link (one run saw 3 processes, another saw 1 for the same command
shape), and an environment read is a point-in-time sample of an identity that
can change under exec/spawn. Out-of-band inspection of a transient process tree
cannot be a gate.

THE SOUND METHOD IS IN-BAND: have the battery report the environment IT received
-- print the vars at the top of its own output -- and assert on that. An in-band
witness cannot be raced or reparented, and it lands in the .raw you already
keep. An owner-side WEB_SERVE_FOR > 0 is exactly this shape. Gate on that; use
this tool only to produce a .env sidecar for a human to read.

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"NO VIOLATION OBSERVED across {len(inspected)} process(es): {roster}")
        if len(inspected) < 2:
            print("  WARNING: only the ROOT process was inspected. If your command is a\n"
                  "  wrapper chain (cargo, nextest, env, bash -lc) this proves NOTHING\n"
                  "  about the process under test -- the measured false-PROVEN case.")
        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())
