---
name: a-battery-launched-from-a-perch-inherits-the-endpoint-identity
description: A test battery spawned from a live-perch shell carries OWL_SESSION_ID/SPT_AGENT_ID/SPT_ENDPOINT_ID; every daemon-stopping test is refused by the product's own guard (DAEMON_STOP_REFUSED) and the refused daemons leak and pin the lane binary. Scrub AND read the child env back (W0 2026-09-06)
metadata:
  type: feedback
---

A battery launched from a live perch's shell is an ENDPOINT to the binary it tests: the shell
exports the identity trio (`OWL_SESSION_ID`, `SPT_AGENT_ID`, `SPT_ENDPOINT_ID`) plus companions
(`SPT_SESSION_NAME`, `SPT_ADAPTER`, `SPT_HOST_PID`, `SPT_INJECT_VERIFY_ECHO`), and the daemon-stop
guard refuses every `daemon stop` under it — by design, with a message that says `unset
SPT_ENDPOINT_ID`.

**Why:** W0 2026-09-06 (todlando): Windows battery #1 at 02adfbc1 = 4 reds; two were literally
`DAEMON_STOP_REFUSED … ($SPT_ENDPOINT_ID is set)`, and the refused stops left SIX daemons
(`daemon run --detached` + `daemon brain` pairs) holding `target/debug/spt.exe`, so the next
battery's prebuild died `Access is denied (os error 5)`. kitsubito's ssh env had none of it →
2776/2776. Then my PowerShell "scrub" (`Remove-Item env:` / `[Environment]::SetEnvironmentVariable`
before `Start-Process`) did NOT reach the child — psutil read-back showed the driver and its cargo
child still carrying the vars; PowerShell's own view had also hidden `OWL_SESSION_ID`, which bash
showed. I had already reported that battery as scrubbed.

**How to apply:**
- Spawn batteries through a launcher that passes an explicit `env=` AND reads the child's
  environment back (psutil `Process.environ()`), refusing the launch unless the read-back is
  clean: `.spt/launch-battery.py` shape, `LAUNCH PROVEN` / `LAUNCH REFUSED`. doyle adopted it for
  his gate legs (his shell carries all six).
- The driver itself scrubs `PERCH_ENV` from every leg and prints `ENV_SCRUBBED: …` (ws272-w0.py).
- After any refused-stop red, sweep for holders of the lane's `target/` binaries by EXE PATH
  before the next build; a leaked test daemon is the mechanism behind "Access is denied" at link.
- A scrub you did not read back is a claim, not a measurement — the same shape as
  [[never-send-a-claim-composed-before-its-check-ran]]. Related:
  [[a-stopped-local-ssh-does-not-stop-its-remote-command]].
- 2026-09-07 face (hertz), CORRECTING THIS ENTRY'S OWN FIX: "read the child's environment back" is NOT sufficient, and I proved it against my own rebuilt launcher. Reading only the IMMEDIATE child passed BOTH negative tests -- a wrapper (`env VAR=x`, and equally cargo / nextest / `bash -lc`) alters the environment it hands the process BELOW it while its own `environ()` still shows what you passed in, so the verification sits one level above the process under test and reports PROVEN on a launch it exists to refuse. For a cargo battery the wrapper IS cargo and the battery is the test binary, so a read-back that stops at the child proves nothing about the cell. Walk the DESCENDANTS and check every process you can see; print which pids were inspected so the claim's scope is on disk, and state the settle-window blind spot rather than implying whole-tree coverage. Same lesson as [[a-guard-bijected-to-the-wrong-enum-is-silent-by-design]] and IR-80: a pid is not a tree, for READING an environment exactly as for killing. Tool: `.spt/launch-battery.py` (--forbid / --require / --forbid-perch, LAUNCH PROVEN|REFUSED).

- 2026-09-07 face (doyle's measurement, recorded by hertz): a rig SHELL SCRIPT carried CRLF to
  kitsubito and bash died at `set -u<CR>` — the Linux control of the one-box F17 rig, killed by its
  own line terminators before a single cell ran. The driver now ships an LF copy. **This is the
  launcher's problem, not the script's:** anything a Windows box hands a Linux shell (over ssh,
  scp, a git checkout with `autocrlf=true`, or a copy made by a Windows tool) inherits Windows
  terminators unless the ship step normalizes, and the failure surfaces as a syntax error on a line
  that is visibly correct — the CR is invisible in every editor and every `grep`. A launcher that
  proves the child's ENVIRONMENT and never checks the script's BYTES has verified the wrong half.
  Normalize at the SHIP step and assert it there (`tr -cd '' | wc -c` == 0 on the shipped copy,
  never `grep -c` — see [[grep-c-carriage-return-counts-every-line]]), and read the launch log
  before claiming a battery runs ([[kitsubito-remote-launch-traps]]). Same class as
  [[compare-at-one-layer-crlf-meter-class]] and [[include-str-fixture-inherits-checkout-eol]]:
  terminators cross an OS boundary silently and break the thing that was never looking at them.

- 2026-09-07 face, MEASURED BY ME on hfenduleam, sharpening the CRLF face above: **Git Bash on
  Windows TOLERATES a CRLF script — and that tolerance is the whole reason the defect ships.** I
  ran the same `.spt/rig/twohost-web-local.sh` that killed doyle's Linux control, at 83 CRs (`tr -cd
  '' | wc -c` == 83, i.e. fully CRLF), through Windows bash: `bash -n` exit 0, and a real
  invocation reached line 19 and died on `$1: unbound variable` — meaning `set -u` was HONORED, so
  the CR never became part of a token. The script is not "broken"; it is broken **only on the OS it
  is shipped to**. So the authoring box cannot reproduce the failure by running the thing, a green
  local run is not evidence about the remote one, and the whole class hides in the gap between two
  bash builds rather than in the file. Ship-step normalization is therefore not belt-and-braces —
  it is the ONLY place the check can live, because neither the author's run nor `bash -n` on the
  author's box can fail. Same shape as [[include-str-fixture-inherits-checkout-eol]]: green in the
  authoring tree forever, red in every fresh checkout on the other side.
