@codyswann/lisa 4.26.3 → 4.26.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/all/copy-overwrite/scripts/lisa-work-item.mjs +248 -21
  2. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  3. package/dist/core/lisa-owned-hash-ledger.js +12 -0
  4. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  5. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  6. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  7. package/dist/core/upstream-evidence-manifest.js +14 -6
  8. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  9. package/package.json +3 -3
  10. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  11. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  12. package/plugins/lisa/.codex-plugin/skills/lisa-github-build-intake/SKILL.md +40 -6
  13. package/plugins/lisa/rules/eager/leaf-only-lifecycle.md +16 -0
  14. package/plugins/lisa/skills/lisa-github-build-intake/SKILL.md +40 -6
  15. package/plugins/lisa-agy/plugin.json +1 -1
  16. package/plugins/lisa-agy/skills/lisa-github-build-intake/SKILL.md +40 -6
  17. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  20. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-copilot/rules/eager/leaf-only-lifecycle.md +16 -0
  24. package/plugins/lisa-copilot/skills/lisa-github-build-intake/SKILL.md +40 -6
  25. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-cursor/rules/leaf-only-lifecycle.mdc +16 -0
  27. package/plugins/lisa-cursor/skills/lisa-github-build-intake/SKILL.md +40 -6
  28. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  30. package/plugins/lisa-expo-agy/plugin.json +1 -1
  31. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  40. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  41. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  45. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  46. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  50. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  51. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  55. package/plugins/lisa-rails-agy/plugin.json +1 -1
  56. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  60. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  61. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  65. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  66. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  68. package/plugins/src/base/rules/eager/leaf-only-lifecycle.md +16 -0
  69. package/plugins/src/base/skills/lisa-github-build-intake/SKILL.md +40 -6
  70. package/rails/copy-contents/scripts/lisa-mutation.sh +8 -0
  71. package/rails/copy-overwrite/lefthook.yml +7 -2
  72. package/rails/copy-overwrite/scripts/lisa-scratch-run.sh +1030 -0
  73. package/scripts/two-channel-couplings.json +17 -4
@@ -0,0 +1,1030 @@
1
+ #!/bin/sh
2
+ # This file is managed by Lisa and IS replaced on each `lisa` run.
3
+ # Do not edit directly — durable changes belong upstream in Lisa.
4
+ # -----------------------------------------------------------------------------
5
+ # Rails scratch supervisor — run-scoped scratch ownership with an outside
6
+ # cleanup authority that survives the tested process.
7
+ # -----------------------------------------------------------------------------
8
+ # WHY THIS IS SHELL AND NOT NODE
9
+ #
10
+ # A Rails repository is not required to have Node, npm, Bun, Yarn, a populated
11
+ # `node_modules`, or a network at test runtime. The Node-managed scratch
12
+ # supervisor cannot be reused here for exactly that reason. This file assumes
13
+ # only what the routes it wraps already assume: a POSIX shell, coreutils/BSD
14
+ # userland (`ps`, `mkdir`, `rm`, `date`, `od`), and Ruby/Bundler for the payload
15
+ # itself. It never fetches anything.
16
+ #
17
+ # USAGE
18
+ #
19
+ # sh scripts/lisa-scratch-run.sh --suite <label> -- <command> [args...]
20
+ #
21
+ # WHAT IT GUARANTEES
22
+ #
23
+ # 1. Authority precedes allocation. Before the payload can allocate one byte
24
+ # of scratch, a separate cleanup authority process — outside the payload's
25
+ # process group, and outside this shell's — has read and acknowledged the
26
+ # exact run token, the canonical run root, the run root's filesystem
27
+ # identity (device + inode), the suite label, the payload's process-group
28
+ # identity, and the payload's process-birth identity. The payload is held
29
+ # at a gate until that acknowledgement lands. An arming or acknowledgement
30
+ # failure REFUSES: the payload never executes.
31
+ # 2. Cleanup survives the payload. Pass, fail, timeout, SIGTERM, SIGINT,
32
+ # SIGHUP, payload SIGKILL, and foreground-supervisor SIGKILL all end with
33
+ # the owned process group boundedly drained and the owned run root absent,
34
+ # done by this invocation's own already-armed authority. No successor run
35
+ # is started and no global sweep is performed.
36
+ # 3. Only what this invocation owns is touched. The authority signals exactly
37
+ # one process group — the one it acknowledged, re-verified by birth
38
+ # identity so PID reuse cannot broaden it — and deletes exactly one root,
39
+ # re-verified by token and by device+inode so a swap cannot broaden it. An
40
+ # external database service is never signalled, stopped, reset, or deleted.
41
+ # 4. Ambiguity fails closed. Traversal, symlinked roots, inode swaps, owner
42
+ # token changes, malformed or oversized markers, PID reuse, birth
43
+ # mismatch, and foreign quarantine names all exit nonzero with a reason.
44
+ # 5. A fixture leak fails the suite. Unregistered direct children left under
45
+ # the payload's temp root are reported with the suite label, the leaked
46
+ # count, and the sorted leaked basenames, and make the route exit nonzero.
47
+ # A red payload verdict is never overwritten by the leak verdict, and a
48
+ # green payload never masks a leaking child.
49
+ #
50
+ # ENVIRONMENT
51
+ #
52
+ # LISA_SCRATCH_BASE Temp base. Default: $TMPDIR, else /tmp.
53
+ # LISA_SCRATCH_REGISTERED_PREFIXES
54
+ # Comma-separated basename prefixes that are
55
+ # batch-cleaned instead of reported as leaks.
56
+ # Default: "lisa-".
57
+ # LISA_SCRATCH_LEAK_GATE enforce (default) | report | off.
58
+ # LISA_SCRATCH_ARM_TIMEOUT_MS Arming handshake budget, in ms. Default 15000.
59
+ # LISA_SCRATCH_GATE_TIMEOUT_MS How long the held payload waits for the gate
60
+ # to open before refusing. Default 300000.
61
+ # LISA_SCRATCH_AUTHORITY_REFUSE Conformance control. When 1, the cleanup
62
+ # authority declines to acknowledge. The run
63
+ # must then REFUSE before the payload executes.
64
+ # It can only turn a run into a refusal; it can
65
+ # never let one through unsupervised.
66
+ # LISA_SCRATCH_DRAIN_MS SIGTERM→SIGKILL drain budget. Default 5000.
67
+ # LISA_SCRATCH_TRACE Append lifecycle events to this file. The
68
+ # file order IS the ordering proof.
69
+ # LISA_SCRATCH_INHERIT_STDIN 1 to give the payload this shell's stdin.
70
+ # Default: /dev/null (avoids SIGTTIN on a
71
+ # background process group).
72
+ # -----------------------------------------------------------------------------
73
+
74
+ set -eu
75
+
76
+ LISA_SCRATCH_NAMESPACE="lisa-rails-scratch"
77
+ LISA_SCRATCH_MARKER_MAX_BYTES=4096
78
+ LISA_SCRATCH_MARKER_MAX_LINES=64
79
+ LISA_SCRATCH_VERSION=1
80
+
81
+ # Exit codes. Distinct so a caller can tell refusal from payload failure.
82
+ EX_USAGE=64
83
+ EX_LEAK=65
84
+ EX_ARM=70
85
+ EX_AMBIGUOUS=78
86
+
87
+ lisa_die() {
88
+ _code="$1"
89
+ shift
90
+ printf '%s\n' "lisa-scratch-run: $*" >&2
91
+ exit "$_code"
92
+ }
93
+
94
+ lisa_trace() {
95
+ [ -n "${LISA_SCRATCH_TRACE:-}" ] || return 0
96
+ printf '%s %s %s\n' "$(date +%s 2>/dev/null || echo 0)" "$LISA_SCRATCH_ROLE" "$*" \
97
+ >>"$LISA_SCRATCH_TRACE" 2>/dev/null || true
98
+ }
99
+
100
+ # --- portability probes ------------------------------------------------------
101
+
102
+ # Stat device and inode of a path, portably.
103
+ # $1 path -> "<dev> <ino>" on stdout, nonzero when it cannot be determined.
104
+ lisa_dev_ino() {
105
+ _p="$1"
106
+ if _v="$(stat -f '%d %i' "$_p" 2>/dev/null)" && [ -n "$_v" ]; then
107
+ printf '%s\n' "$_v"
108
+ return 0
109
+ fi
110
+ if _v="$(stat -c '%d %i' "$_p" 2>/dev/null)" && [ -n "$_v" ]; then
111
+ printf '%s\n' "$_v"
112
+ return 0
113
+ fi
114
+ # Last-resort fallback: inode from `ls -di`, device stood in for by the
115
+ # mount point reported by `df -P`. Both are POSIX.
116
+ _ino="$(ls -di "$_p" 2>/dev/null | awk '{print $1}')" || return 1
117
+ [ -n "$_ino" ] || return 1
118
+ _dev="$(df -P "$_p" 2>/dev/null | awk 'NR==2 {print $NF}')" || return 1
119
+ [ -n "$_dev" ] || return 1
120
+ printf '%s %s\n' "$_dev" "$_ino"
121
+ }
122
+
123
+ # Process-birth identity for a pid: something that changes when the PID is
124
+ # reused. `lstart` where available, `etime`+`ppid` otherwise.
125
+ # $1 pid -> birth string on stdout, nonzero when the process is gone.
126
+ lisa_birth() {
127
+ _pid="$1"
128
+ if _v="$(ps -o lstart= -p "$_pid" 2>/dev/null)" && [ -n "$_v" ]; then
129
+ printf '%s\n' "$(printf '%s' "$_v" | tr -s ' ' ' ' | sed 's/^ *//;s/ *$//')"
130
+ return 0
131
+ fi
132
+ if _v="$(ps -o ppid=,etime= -p "$_pid" 2>/dev/null)" && [ -n "$_v" ]; then
133
+ printf '%s\n' "$(printf '%s' "$_v" | tr -s ' ' ' ' | sed 's/^ *//;s/ *$//')"
134
+ return 0
135
+ fi
136
+ return 1
137
+ }
138
+
139
+ lisa_pgid_of() {
140
+ ps -o pgid= -p "$1" 2>/dev/null | tr -d ' \t' || return 1
141
+ }
142
+
143
+ # Whether this process leads its own process group.
144
+ #
145
+ # Only ever called from a REAL process, never from a `&` subshell: POSIX `$$`
146
+ # is the parent shell's pid inside a subshell, so a subshell asking this
147
+ # question would be told about its parent. That is why the launcher and the
148
+ # authority are spawned as separate `sh` processes rather than as functions in
149
+ # the background.
150
+ lisa_leads_own_group() {
151
+ _self_pgid="$(lisa_pgid_of "$$" || true)"
152
+ [ -n "$_self_pgid" ] && [ "$_self_pgid" = "$$" ]
153
+ }
154
+
155
+ # Put this process in a process group of its own, without a controlling
156
+ # terminal, and WITHOUT changing its pid.
157
+ #
158
+ # Two mechanisms, because neither is portable alone:
159
+ #
160
+ # `set -m` in the PARENT. Works on Darwin, where /bin/sh is bash. It does
161
+ # not work under dash, whose job control initialisation opens /dev/tty — so
162
+ # on a Linux CI runner, which has no controlling terminal, every background
163
+ # job stays in the parent's group. Measured: the whole Rails supervisor
164
+ # suite refused with "payload did not get an isolated process group" on
165
+ # hosted Linux while passing on Darwin.
166
+ #
167
+ # `setsid`. Always present on Linux, absent from stock Darwin. It forks
168
+ # ONLY when the caller already leads a group, and the caller here provably
169
+ # does not — that is the condition we are in. So it execs in place, the pid
170
+ # is preserved, and the parent's `$!` and `wait` stay valid, which a forking
171
+ # setsid would silently break by returning its own status instead.
172
+ #
173
+ # $1 re-entry guard variable name, $2.. the argv to re-exec.
174
+ lisa_isolate_group() {
175
+ lisa_leads_own_group && return 0
176
+ _guard="$1"
177
+ shift
178
+ eval "_seen=\${$_guard:-0}"
179
+ if [ "$_seen" != "1" ] && command -v setsid >/dev/null 2>&1; then
180
+ export "$_guard=1"
181
+ exec setsid "$@"
182
+ fi
183
+ return 1
184
+ }
185
+
186
+ lisa_alive() {
187
+ kill -0 "$1" 2>/dev/null
188
+ }
189
+
190
+ # Short sleep, degrading to a whole second where fractional sleep is absent.
191
+ #
192
+ # It publishes how long it actually slept, in `LISA_SCRATCH_NAP_MS`, and every
193
+ # wait loop advances its counter by THAT rather than by an assumed 50 ms.
194
+ # POSIX does not require `sleep` to accept a fractional operand — it is a
195
+ # widespread extension, not a guarantee — so on a shell where it is missing a
196
+ # loop that assumed 50 ms would run twenty times its stated budget: the 15 s
197
+ # arming handshake would wait 300 s, and the 5 s drain 100 s, before escalating
198
+ # to SIGKILL. A budget that is only a budget on some machines is not one.
199
+ LISA_SCRATCH_NAP_MS=50
200
+ lisa_nap() {
201
+ if [ "${LISA_SCRATCH_FRACTIONAL_SLEEP:-unknown}" = "unknown" ]; then
202
+ if sleep 0.05 2>/dev/null; then
203
+ LISA_SCRATCH_FRACTIONAL_SLEEP=yes
204
+ LISA_SCRATCH_NAP_MS=50
205
+ else
206
+ LISA_SCRATCH_FRACTIONAL_SLEEP=no
207
+ LISA_SCRATCH_NAP_MS=1000
208
+ fi
209
+ fi
210
+ if [ "$LISA_SCRATCH_FRACTIONAL_SLEEP" = yes ]; then
211
+ sleep 0.05
212
+ else
213
+ sleep 1
214
+ fi
215
+ }
216
+
217
+ # --- marker (arming record) --------------------------------------------------
218
+
219
+ # Read one key out of a marker file, fail-closed on malformed or oversized
220
+ # input. $1 marker path, $2 key.
221
+ lisa_marker_get() {
222
+ _file="$1"
223
+ _key="$2"
224
+ [ -f "$_file" ] || return 1
225
+ if [ -L "$_file" ]; then return 1; fi
226
+ _bytes="$(wc -c <"$_file" 2>/dev/null | tr -d ' ')" || return 1
227
+ [ -n "$_bytes" ] || return 1
228
+ [ "$_bytes" -le "$LISA_SCRATCH_MARKER_MAX_BYTES" ] || return 1
229
+ _lines="$(wc -l <"$_file" 2>/dev/null | tr -d ' ')" || return 1
230
+ [ "$_lines" -le "$LISA_SCRATCH_MARKER_MAX_LINES" ] || return 1
231
+ _hits="$(grep -c "^${_key}=" "$_file" 2>/dev/null || true)"
232
+ [ "$_hits" = "1" ] || return 1
233
+ sed -n "s/^${_key}=//p" "$_file"
234
+ }
235
+
236
+ # --- path safety -------------------------------------------------------------
237
+
238
+ # Canonicalize a directory that must already exist, without readlink -f (absent
239
+ # on stock macOS). Refuses a symlinked leaf outright.
240
+ lisa_canonical_dir() {
241
+ _p="$1"
242
+ [ -d "$_p" ] || return 1
243
+ [ -L "$_p" ] && return 1
244
+ (cd -P -- "$_p" 2>/dev/null && pwd -P) || return 1
245
+ }
246
+
247
+ # A run root must be an absolute, traversal-free path directly beneath the
248
+ # namespace directory of the declared base.
249
+ lisa_root_is_well_formed() {
250
+ _root="$1"
251
+ _base="$2"
252
+ case "$_root" in
253
+ /*) : ;;
254
+ *) return 1 ;;
255
+ esac
256
+ case "$_root" in
257
+ *..*) return 1 ;;
258
+ esac
259
+ case "$_root" in
260
+ "${_base}/${LISA_SCRATCH_NAMESPACE}/"*) : ;;
261
+ *) return 1 ;;
262
+ esac
263
+ _leaf="${_root#"${_base}/${LISA_SCRATCH_NAMESPACE}/"}"
264
+ case "$_leaf" in
265
+ */*) return 1 ;;
266
+ "") return 1 ;;
267
+ esac
268
+ return 0
269
+ }
270
+
271
+ lisa_token_is_well_formed() {
272
+ case "$1" in
273
+ "" | *[!0-9a-f]*) return 1 ;;
274
+ esac
275
+ [ "${#1}" -eq 64 ]
276
+ }
277
+
278
+ lisa_suite_is_well_formed() {
279
+ case "$1" in
280
+ "" | *[!A-Za-z0-9._-]*) return 1 ;;
281
+ esac
282
+ [ "${#1}" -le 64 ]
283
+ }
284
+
285
+ # =============================================================================
286
+ # AUTHORITY MODE — the outside cleanup authority.
287
+ #
288
+ # Started by the supervisor BEFORE the payload's gate is opened, in its own
289
+ # process group, so that killing either the payload group or the supervisor
290
+ # group leaves it running. It is the same file and the same invocation: no
291
+ # successor Lisa run is ever started to do cleanup.
292
+ # =============================================================================
293
+
294
+ lisa_authority_main() {
295
+ LISA_SCRATCH_ROLE=authority
296
+ _root="$1"
297
+
298
+ # The authority's whole job is to outlive a kill aimed at the payload group
299
+ # or at the supervisor, so it must not share either. Best effort rather than
300
+ # fatal: it is the safety net, and a net that refuses to exist is worse than
301
+ # one that is merely in the wrong group. The launcher applies the same test
302
+ # and DOES refuse, so a platform that cannot isolate never gets this far.
303
+ lisa_isolate_group LISA_SCRATCH_AUTHORITY_REENTRY \
304
+ "$LISA_SCRATCH_SH" "$LISA_SCRATCH_SELF" --authority "$_root" || true
305
+
306
+ # Every field is re-derived from the marker and independently verified. A
307
+ # marker this cannot fully validate is never acted on.
308
+ _base="${LISA_SCRATCH_BASE:-${TMPDIR:-/tmp}}"
309
+ _base="${_base%/}"
310
+ _base="$(lisa_canonical_dir "$_base")" ||
311
+ lisa_die "$EX_AMBIGUOUS" "authority: temp base is not a canonical directory"
312
+
313
+ lisa_root_is_well_formed "$_root" "$_base" ||
314
+ lisa_die "$EX_AMBIGUOUS" "authority: refusing malformed run root: $_root"
315
+ if [ -L "$_root" ]; then
316
+ lisa_die "$EX_AMBIGUOUS" "authority: refusing symlinked run root: $_root"
317
+ fi
318
+ _canon="$(lisa_canonical_dir "$_root")" ||
319
+ lisa_die "$EX_AMBIGUOUS" "authority: run root is not a canonical directory: $_root"
320
+ [ "$_canon" = "$_root" ] ||
321
+ lisa_die "$EX_AMBIGUOUS" "authority: run root resolves elsewhere: $_root -> $_canon"
322
+
323
+ _marker="$_root/.lisa-scratch-arm"
324
+ _token="$(lisa_marker_get "$_marker" token)" ||
325
+ lisa_die "$EX_AMBIGUOUS" "authority: unreadable or malformed arming marker"
326
+ lisa_token_is_well_formed "$_token" ||
327
+ lisa_die "$EX_AMBIGUOUS" "authority: malformed run token"
328
+ _version="$(lisa_marker_get "$_marker" version)" ||
329
+ lisa_die "$EX_AMBIGUOUS" "authority: arming marker has no version"
330
+ [ "$_version" = "$LISA_SCRATCH_VERSION" ] ||
331
+ lisa_die "$EX_AMBIGUOUS" "authority: unsupported arming marker version: $_version"
332
+ _mroot="$(lisa_marker_get "$_marker" root)" ||
333
+ lisa_die "$EX_AMBIGUOUS" "authority: arming marker has no root"
334
+ [ "$_mroot" = "$_root" ] ||
335
+ lisa_die "$EX_AMBIGUOUS" "authority: arming marker root disagrees: $_mroot"
336
+ _suite="$(lisa_marker_get "$_marker" suite)" ||
337
+ lisa_die "$EX_AMBIGUOUS" "authority: arming marker has no suite"
338
+ lisa_suite_is_well_formed "$_suite" ||
339
+ lisa_die "$EX_AMBIGUOUS" "authority: malformed suite label"
340
+ _dev_ino="$(lisa_marker_get "$_marker" devino)" ||
341
+ lisa_die "$EX_AMBIGUOUS" "authority: arming marker has no filesystem identity"
342
+ _live_dev_ino="$(lisa_dev_ino "$_root")" ||
343
+ lisa_die "$EX_AMBIGUOUS" "authority: cannot read run root filesystem identity"
344
+ [ "$_dev_ino" = "$_live_dev_ino" ] ||
345
+ lisa_die "$EX_AMBIGUOUS" "authority: run root filesystem identity changed"
346
+ _pgid="$(lisa_marker_get "$_marker" pgid)" ||
347
+ lisa_die "$EX_AMBIGUOUS" "authority: arming marker has no process group"
348
+ case "$_pgid" in
349
+ "" | 0 | 1 | *[!0-9]*)
350
+ lisa_die "$EX_AMBIGUOUS" "authority: refusing process group '$_pgid'"
351
+ ;;
352
+ esac
353
+ _birth="$(lisa_marker_get "$_marker" birth)" ||
354
+ lisa_die "$EX_AMBIGUOUS" "authority: arming marker has no process-birth identity"
355
+ [ -n "$_birth" ] ||
356
+ lisa_die "$EX_AMBIGUOUS" "authority: empty process-birth identity"
357
+ _sup_pid="$(lisa_marker_get "$_marker" suppid)" ||
358
+ lisa_die "$EX_AMBIGUOUS" "authority: arming marker has no supervisor pid"
359
+ case "$_sup_pid" in
360
+ "" | *[!0-9]*)
361
+ lisa_die "$EX_AMBIGUOUS" "authority: refusing supervisor pid '$_sup_pid'"
362
+ ;;
363
+ esac
364
+ _sup_birth="$(lisa_marker_get "$_marker" supbirth)" ||
365
+ lisa_die "$EX_AMBIGUOUS" "authority: arming marker has no supervisor birth"
366
+
367
+ # The payload group must still be the one that was armed. Verifying birth
368
+ # here — not only later — is what makes PID reuse unable to broaden the kill.
369
+ _live_birth="$(lisa_birth "$_pgid" || true)"
370
+ if [ -n "$_live_birth" ] && [ "$_live_birth" != "$_birth" ]; then
371
+ lisa_die "$EX_AMBIGUOUS" "authority: payload process-birth identity mismatch"
372
+ fi
373
+
374
+ if [ "${LISA_SCRATCH_AUTHORITY_REFUSE:-0}" = "1" ]; then
375
+ lisa_trace "ack-declined"
376
+ lisa_die "$EX_ARM" "authority: declining to acknowledge (LISA_SCRATCH_AUTHORITY_REFUSE=1)"
377
+ fi
378
+
379
+ # Acknowledge. Only now may the supervisor open the payload gate.
380
+ printf 'version=%s\ntoken=%s\nroot=%s\ndevino=%s\nsuite=%s\npgid=%s\nbirth=%s\nauthority=%s\n' \
381
+ "$LISA_SCRATCH_VERSION" "$_token" "$_root" "$_dev_ino" "$_suite" "$_pgid" \
382
+ "$_birth" "$$" >"$_root/.lisa-scratch-ack.tmp"
383
+ mv -f "$_root/.lisa-scratch-ack.tmp" "$_root/.lisa-scratch-ack"
384
+ lisa_trace "ack token=$_token pgid=$_pgid suite=$_suite"
385
+
386
+ # Wait for the run to end, by any exit path. The root vanishing means the
387
+ # supervisor completed its own cleanup first; the supervisor's process
388
+ # disappearing means it was killed and cleanup falls to this process.
389
+ while :; do
390
+ [ -d "$_root" ] || break
391
+ if [ -f "$_root/.lisa-scratch-done" ]; then
392
+ break
393
+ fi
394
+ if ! lisa_alive "$_sup_pid"; then
395
+ break
396
+ fi
397
+ _now_birth="$(lisa_birth "$_sup_pid" || true)"
398
+ if [ -n "$_now_birth" ] && [ "$_now_birth" != "$_sup_birth" ]; then
399
+ break
400
+ fi
401
+ lisa_nap
402
+ done
403
+
404
+ lisa_trace "reap-begin"
405
+ lisa_drain_group "$_pgid" "$_birth"
406
+ lisa_remove_root "$_root" "$_token" "$_dev_ino" "$_base"
407
+ if [ -d "$_root" ]; then
408
+ lisa_trace "reap-end root-absent=no"
409
+ else
410
+ lisa_trace "reap-end root-absent=yes"
411
+ fi
412
+ exit 0
413
+ }
414
+
415
+ # =============================================================================
416
+ # Bounded process-group drain.
417
+ #
418
+ # Signals exactly the acknowledged process group, and only while its birth
419
+ # identity still matches. Never signals pid 1, never signals this process's own
420
+ # group, and therefore never reaches a database service running outside it.
421
+ # =============================================================================
422
+
423
+ lisa_drain_group() {
424
+ _pgid="$1"
425
+ _birth="$2"
426
+
427
+ case "$_pgid" in
428
+ "" | 0 | 1 | *[!0-9]*) return 0 ;;
429
+ esac
430
+
431
+ _own_pgid="$(lisa_pgid_of "$$" || true)"
432
+ if [ -n "$_own_pgid" ] && [ "$_own_pgid" = "$_pgid" ]; then
433
+ # Refusing to signal our own group is what keeps a misconfigured run from
434
+ # taking down the shell, the runner, or a sibling service.
435
+ printf '%s\n' "lisa-scratch-run: refusing to drain our own process group" >&2
436
+ return 0
437
+ fi
438
+
439
+ _live_birth="$(lisa_birth "$_pgid" || true)"
440
+ if [ -n "$_live_birth" ] && [ "$_live_birth" != "$_birth" ]; then
441
+ # A live process wearing our group's pid, born at a different moment: PID
442
+ # reuse. Draining it would signal someone else's work.
443
+ printf '%s\n' "lisa-scratch-run: process-birth identity mismatch; not draining $_pgid" >&2
444
+ return 0
445
+ fi
446
+ if [ -z "$_live_birth" ]; then
447
+ # The group LEADER is gone — which is precisely what happens when the
448
+ # payload is SIGKILLed — but a process group outlives its leader, so the
449
+ # survivors are the payload's own children and they are still ours.
450
+ #
451
+ # Returning here instead was a real leak: the orphaned children kept
452
+ # running, and because they still held the run's inherited stdout and
453
+ # stderr, the managed invocation could not report its outcome until they
454
+ # finished on their own. Measured as a 60-second hang on a run that had
455
+ # already been killed.
456
+ #
457
+ # Nothing else can be wearing this group id: a group id is its leader's
458
+ # pid, and no process holds that pid at all.
459
+ if ! kill -0 -"$_pgid" 2>/dev/null; then
460
+ return 0
461
+ fi
462
+ fi
463
+
464
+ kill -TERM -"$_pgid" 2>/dev/null || true
465
+
466
+ _budget="${LISA_SCRATCH_DRAIN_MS:-5000}"
467
+ case "$_budget" in
468
+ "" | *[!0-9]*) _budget=5000 ;;
469
+ esac
470
+ _waited=0
471
+ while [ "$_waited" -lt "$_budget" ]; do
472
+ kill -0 -"$_pgid" 2>/dev/null || return 0
473
+ lisa_nap
474
+ _waited=$((_waited + LISA_SCRATCH_NAP_MS))
475
+ done
476
+
477
+ kill -0 -"$_pgid" 2>/dev/null || return 0
478
+ kill -KILL -"$_pgid" 2>/dev/null || true
479
+ return 0
480
+ }
481
+
482
+ # =============================================================================
483
+ # Token-and-identity-bound root removal.
484
+ #
485
+ # Re-verifies token and device+inode immediately before the rename, renames the
486
+ # root into a quarantine name derived from THIS run's token, and deletes only
487
+ # that name. A quarantine directory whose suffix is not our token is left
488
+ # alone, so a foreign quarantine cannot be swept.
489
+ # =============================================================================
490
+
491
+ lisa_remove_root() {
492
+ _root="$1"
493
+ _token="$2"
494
+ _dev_ino="$3"
495
+ _base="$4"
496
+
497
+ [ -d "$_root" ] || return 0
498
+ if [ -L "$_root" ]; then
499
+ printf '%s\n' "lisa-scratch-run: run root became a symlink; refusing to delete" >&2
500
+ return 1
501
+ fi
502
+ lisa_root_is_well_formed "$_root" "$_base" || {
503
+ printf '%s\n' "lisa-scratch-run: run root no longer well-formed; refusing to delete" >&2
504
+ return 1
505
+ }
506
+ _now="$(lisa_dev_ino "$_root")" || return 1
507
+ if [ "$_now" != "$_dev_ino" ]; then
508
+ printf '%s\n' "lisa-scratch-run: run root filesystem identity changed; refusing to delete" >&2
509
+ return 1
510
+ fi
511
+ _marker_token="$(lisa_marker_get "$_root/.lisa-scratch-arm" token || true)"
512
+ if [ -n "$_marker_token" ] && [ "$_marker_token" != "$_token" ]; then
513
+ printf '%s\n' "lisa-scratch-run: run root owner token changed; refusing to delete" >&2
514
+ return 1
515
+ fi
516
+
517
+ _quarantine="${_base}/${LISA_SCRATCH_NAMESPACE}/.quarantine.${_token}"
518
+ case "$_quarantine" in
519
+ "${_base}/${LISA_SCRATCH_NAMESPACE}/.quarantine.${_token}") : ;;
520
+ *) return 1 ;;
521
+ esac
522
+ if mv -f "$_root" "$_quarantine" 2>/dev/null; then
523
+ rm -rf "$_quarantine" 2>/dev/null || true
524
+ else
525
+ # Another participant of this same invocation may have taken it already.
526
+ [ -d "$_root" ] || return 0
527
+ rm -rf "$_root" 2>/dev/null || true
528
+ fi
529
+ if [ -d "$_root" ]; then return 1; fi
530
+ return 0
531
+ }
532
+
533
+ # =============================================================================
534
+ # GATED PAYLOAD LAUNCHER.
535
+ #
536
+ # Runs as the leader of its own process group, so the group identity exists —
537
+ # and can therefore be acknowledged — before the payload runs. It refuses to
538
+ # exec the payload unless the authority's acknowledgement for this exact token
539
+ # is on disk. That refusal is the enforcement of authority-before-allocation:
540
+ # even if the supervisor were wrong, the payload would not start.
541
+ # =============================================================================
542
+
543
+ lisa_launcher_main() {
544
+ LISA_SCRATCH_ROLE=launcher
545
+ _root="$1"
546
+ _token="$2"
547
+ shift 2
548
+
549
+ if ! lisa_isolate_group LISA_SCRATCH_LAUNCH_REENTRY \
550
+ "$LISA_SCRATCH_SH" "$LISA_SCRATCH_SELF" --launch "$_root" "$_token" "$@"; then
551
+ lisa_die "$EX_ARM" "cannot isolate the payload process group; refusing"
552
+ fi
553
+
554
+ # Report the group identity the authority has to acknowledge. This happens
555
+ # before the gate is even waited on, and long before any scratch exists, so
556
+ # the identity being acknowledged is a fact rather than a prediction.
557
+ _own_birth="$(lisa_birth "$$" || true)"
558
+ [ -n "$_own_birth" ] ||
559
+ lisa_die "$EX_ARM" "cannot read the payload process-birth identity"
560
+ printf 'version=%s\npgid=%s\nbirth=%s\n' \
561
+ "$LISA_SCRATCH_VERSION" "$$" "$_own_birth" \
562
+ >"$_root/.lisa-scratch-pg.tmp" ||
563
+ lisa_die "$EX_ARM" "cannot report the payload process group"
564
+ mv -f "$_root/.lisa-scratch-pg.tmp" "$_root/.lisa-scratch-pg"
565
+ lisa_trace "group-reported pgid=$$"
566
+
567
+ # Deliberately NOT the arming budget: shrinking the arming budget must
568
+ # produce a refusal that is observable as 'the payload never ran', which it
569
+ # cannot be if the held payload has already given up on its own.
570
+ _deadline="${LISA_SCRATCH_GATE_TIMEOUT_MS:-300000}"
571
+ case "$_deadline" in
572
+ "" | *[!0-9]*) _deadline=300000 ;;
573
+ esac
574
+ _waited=0
575
+ while [ ! -f "$_root/.lisa-scratch-go" ]; do
576
+ if [ "$_waited" -ge "$_deadline" ]; then
577
+ lisa_die "$EX_ARM" "payload gate never opened; refusing to run the payload"
578
+ fi
579
+ lisa_nap
580
+ _waited=$((_waited + LISA_SCRATCH_NAP_MS))
581
+ done
582
+
583
+ _ack_token="$(lisa_marker_get "$_root/.lisa-scratch-ack" token || true)"
584
+ [ "$_ack_token" = "$_token" ] ||
585
+ lisa_die "$EX_ARM" "no valid cleanup-authority acknowledgement; refusing to run the payload"
586
+
587
+ [ -d "$_root/tmp" ] ||
588
+ lisa_die "$EX_ARM" "payload scratch root missing; refusing to run the payload"
589
+
590
+ TMPDIR="$_root/tmp"
591
+ TMP="$TMPDIR"
592
+ TEMP="$TMPDIR"
593
+ export TMPDIR TMP TEMP
594
+ lisa_trace "payload-exec"
595
+ if [ "${LISA_SCRATCH_INHERIT_STDIN:-0}" = "1" ]; then
596
+ exec "$@"
597
+ else
598
+ exec "$@" </dev/null
599
+ fi
600
+ }
601
+
602
+ # =============================================================================
603
+ # Leak scan.
604
+ # =============================================================================
605
+
606
+ # Emits the leak report on stderr. Returns 0 clean, 1 leaked.
607
+ lisa_leak_scan() {
608
+ _tmp="$1"
609
+ _suite="$2"
610
+
611
+ [ -d "$_tmp" ] || return 0
612
+
613
+ _registered="${LISA_SCRATCH_REGISTERED_PREFIXES:-lisa-}"
614
+
615
+ _leaked=""
616
+ _count=0
617
+ for _entry in "$_tmp"/* "$_tmp"/.[!.]*; do
618
+ [ -e "$_entry" ] || continue
619
+ _name="${_entry##*/}"
620
+ _is_registered=no
621
+ _rest="$_registered"
622
+ while [ -n "$_rest" ]; do
623
+ case "$_rest" in
624
+ *,*)
625
+ _prefix="${_rest%%,*}"
626
+ _rest="${_rest#*,}"
627
+ ;;
628
+ *)
629
+ _prefix="$_rest"
630
+ _rest=""
631
+ ;;
632
+ esac
633
+ [ -n "$_prefix" ] || continue
634
+ case "$_name" in
635
+ "$_prefix"*) _is_registered=yes ;;
636
+ esac
637
+ done
638
+ if [ "$_is_registered" = yes ]; then
639
+ # Registered prefixes are batch-cleaned, never reported.
640
+ rm -rf "$_entry" 2>/dev/null || true
641
+ continue
642
+ fi
643
+ _count=$((_count + 1))
644
+ _leaked="$_leaked$_name
645
+ "
646
+ done
647
+
648
+ if [ "$_count" -eq 0 ]; then return 0; fi
649
+
650
+ _sorted="$(printf '%s' "$_leaked" | LC_ALL=C sort | tr '\n' ' ' | sed 's/ *$//')"
651
+ printf '%s\n' "lisa-scratch-run: scratch leak in suite '$_suite': $_count unregistered direct child(ren): $_sorted" >&2
652
+ printf '%s\n' "lisa-scratch-run: register benign prefixes via LISA_SCRATCH_REGISTERED_PREFIXES (comma-separated) or fix the fixture that leaked." >&2
653
+ return 1
654
+ }
655
+
656
+ # =============================================================================
657
+ # SUPERVISOR (default mode).
658
+ # =============================================================================
659
+
660
+ # Terminal path for a signal that arrives while the run is still arming, i.e.
661
+ # before the payload has been allowed to allocate anything. Nothing is owned
662
+ # yet beyond the run root and, at most, a payload held at the gate.
663
+ lisa_abort_arming() {
664
+ _sig="$1"
665
+ trap - TERM INT HUP
666
+ lisa_trace "arm-aborted signal=$_sig"
667
+ [ -n "${LISA_SCRATCH_PAYLOAD_PID:-}" ] && kill -TERM "$LISA_SCRATCH_PAYLOAD_PID" 2>/dev/null
668
+ [ -n "${LISA_SCRATCH_AUTHORITY_PID:-}" ] && kill -TERM "$LISA_SCRATCH_AUTHORITY_PID" 2>/dev/null
669
+ if [ -n "${LISA_SCRATCH_ROOT:-}" ] &&
670
+ lisa_root_is_well_formed "$LISA_SCRATCH_ROOT" "${LISA_SCRATCH_BASE_CANONICAL:-}"; then
671
+ rm -rf "$LISA_SCRATCH_ROOT" 2>/dev/null || true
672
+ fi
673
+ kill -"$_sig" $$
674
+ }
675
+
676
+ lisa_supervisor_main() {
677
+ LISA_SCRATCH_ROLE=supervisor
678
+
679
+ _suite=""
680
+ while [ "$#" -gt 0 ]; do
681
+ case "$1" in
682
+ --suite)
683
+ [ "$#" -ge 2 ] || lisa_die "$EX_USAGE" "--suite requires a value"
684
+ _suite="$2"
685
+ shift 2
686
+ ;;
687
+ --suite=*)
688
+ _suite="${1#--suite=}"
689
+ shift
690
+ ;;
691
+ --)
692
+ shift
693
+ break
694
+ ;;
695
+ *)
696
+ lisa_die "$EX_USAGE" "unexpected argument '$1'; usage: --suite <label> -- <command>"
697
+ ;;
698
+ esac
699
+ done
700
+
701
+ lisa_suite_is_well_formed "$_suite" ||
702
+ lisa_die "$EX_USAGE" "--suite must be 1-64 chars of [A-Za-z0-9._-]"
703
+ [ "$#" -ge 1 ] ||
704
+ lisa_die "$EX_USAGE" "no payload command after --"
705
+
706
+ # A supervised payload may not be another supervisor: exactly one boundary
707
+ # per route, no recursive alias.
708
+ case "${LISA_SCRATCH_TOKEN:-}" in
709
+ ?*) lisa_die "$EX_USAGE" "refusing to nest inside an existing supervised run (suite ${LISA_SCRATCH_SUITE:-unknown})" ;;
710
+ esac
711
+ case "$1" in
712
+ *lisa-scratch-run.sh) lisa_die "$EX_USAGE" "refusing to supervise the supervisor" ;;
713
+ esac
714
+
715
+ # --- terminal paths, armed before anything can exist to leak ------------
716
+ # Installed as the very first thing the run does. A handler installed one
717
+ # statement after the directory it protects is a handler with a window, and
718
+ # the window is exactly where an interrupted run leaves its root behind
719
+ # forever. Installing it here means there is no ordering to get wrong: the
720
+ # handler is older than every resource it can be asked to release, and it
721
+ # simply does nothing while there is nothing to release.
722
+ LISA_SCRATCH_ROOT=""
723
+ LISA_SCRATCH_BASE_CANONICAL=""
724
+ LISA_SCRATCH_PAYLOAD_PID=""
725
+ LISA_SCRATCH_AUTHORITY_PID=""
726
+ for _sig in TERM INT HUP; do
727
+ # shellcheck disable=SC2064
728
+ trap "lisa_abort_arming $_sig" "$_sig"
729
+ done
730
+
731
+ # --- base + token -------------------------------------------------------
732
+ _base="${LISA_SCRATCH_BASE:-${TMPDIR:-/tmp}}"
733
+ _base="${_base%/}"
734
+ _base="$(lisa_canonical_dir "$_base")" ||
735
+ lisa_die "$EX_ARM" "temp base is not a canonical directory: ${LISA_SCRATCH_BASE:-${TMPDIR:-/tmp}}"
736
+
737
+ _token="$(od -An -tx1 -N32 /dev/urandom 2>/dev/null | tr -d ' \n')" || _token=""
738
+ lisa_token_is_well_formed "$_token" ||
739
+ lisa_die "$EX_ARM" "cannot obtain a 256-bit run token from /dev/urandom"
740
+
741
+ mkdir -p "$_base/$LISA_SCRATCH_NAMESPACE" ||
742
+ lisa_die "$EX_ARM" "cannot create scratch namespace under $_base"
743
+ chmod 700 "$_base/$LISA_SCRATCH_NAMESPACE" 2>/dev/null || true
744
+
745
+ _root="$_base/$LISA_SCRATCH_NAMESPACE/$_suite.$_token"
746
+ lisa_root_is_well_formed "$_root" "$_base" ||
747
+ lisa_die "$EX_ARM" "computed run root is not well-formed: $_root"
748
+
749
+ # The already-installed handler becomes able to act the moment these are set,
750
+ # which is one statement before the directory they name can exist.
751
+ LISA_SCRATCH_ROOT="$_root"
752
+ LISA_SCRATCH_TOKEN="$_token"
753
+ LISA_SCRATCH_SUITE="$_suite"
754
+ LISA_SCRATCH_BASE_CANONICAL="$_base"
755
+ export LISA_SCRATCH_ROOT LISA_SCRATCH_TOKEN LISA_SCRATCH_SUITE
756
+
757
+ # Exclusive create: mkdir of an existing leaf fails, so two runs can never
758
+ # share a root even if a token somehow repeated.
759
+ mkdir "$_root" || lisa_die "$EX_ARM" "cannot exclusively create run root"
760
+ chmod 700 "$_root" 2>/dev/null || true
761
+ _canon="$(lisa_canonical_dir "$_root")" ||
762
+ lisa_die "$EX_ARM" "run root is not a canonical directory"
763
+ [ "$_canon" = "$_root" ] ||
764
+ lisa_die "$EX_ARM" "run root resolves elsewhere: $_root -> $_canon"
765
+ _dev_ino="$(lisa_dev_ino "$_root")" ||
766
+ lisa_die "$EX_ARM" "cannot read run root filesystem identity"
767
+
768
+ lisa_trace "arm-begin suite=$_suite root=$_root"
769
+
770
+ # --- process group exists before it is acknowledged ---------------------
771
+ # The payload's group identity has to be knowable BEFORE the payload runs, so
772
+ # the launcher is started held at a gate, isolates itself into its own
773
+ # process group, and reports that group back. It cannot allocate scratch
774
+ # until the gate opens.
775
+ #
776
+ # `set -m` is a best-effort assist for the Darwin path, where it is what
777
+ # gives the background child its own group; the launcher verifies the result
778
+ # itself and reaches for setsid when it did not happen, so a shell without
779
+ # working job control is a fallback rather than a failure.
780
+ set -m 2>/dev/null || true
781
+
782
+ # Turning job control on resets the shell's SIGINT disposition — a terminal
783
+ # normally delivers SIGINT to the foreground job's group, so the shell steps
784
+ # out of the way. That silently drops the handler installed above, and a
785
+ # Ctrl-C landing in the handshake window would be discarded rather than
786
+ # honoured: measured as a run that continued to completion 15 seconds after
787
+ # being told to stop. Re-arming here is what closes it.
788
+ for _sig in TERM INT HUP; do
789
+ # shellcheck disable=SC2064
790
+ trap "lisa_abort_arming $_sig" "$_sig"
791
+ done
792
+
793
+ # A real process, not a `&` subshell: a subshell cannot learn its own pid
794
+ # portably, and "am I my own group leader?" is the question the whole
795
+ # handshake rests on.
796
+ "$LISA_SCRATCH_SH" "$LISA_SCRATCH_SELF" --launch "$_root" "$_token" "$@" &
797
+ _payload_pid="$!"
798
+ LISA_SCRATCH_PAYLOAD_PID="$_payload_pid"
799
+
800
+ _pg_deadline="${LISA_SCRATCH_ARM_TIMEOUT_MS:-15000}"
801
+ case "$_pg_deadline" in
802
+ "" | *[!0-9]*) _pg_deadline=15000 ;;
803
+ esac
804
+ _waited=0
805
+ _pgid=""
806
+ while [ "$_waited" -lt "$_pg_deadline" ]; do
807
+ _pgid="$(lisa_marker_get "$_root/.lisa-scratch-pg" pgid || true)"
808
+ [ -n "$_pgid" ] && break
809
+ lisa_alive "$_payload_pid" || break
810
+ lisa_nap
811
+ _waited=$((_waited + LISA_SCRATCH_NAP_MS))
812
+ done
813
+ _birth="$(lisa_marker_get "$_root/.lisa-scratch-pg" birth || true)"
814
+
815
+ _own_pgid="$(lisa_pgid_of "$$" || true)"
816
+ lisa_abort_launch() {
817
+ kill -TERM "$_payload_pid" 2>/dev/null || true
818
+ rm -rf "$_root" 2>/dev/null || true
819
+ lisa_die "$EX_ARM" "$1"
820
+ }
821
+ case "$_pgid" in
822
+ "" | 0 | 1 | *[!0-9]*)
823
+ lisa_abort_launch "payload did not report an isolated process group; refusing"
824
+ ;;
825
+ esac
826
+ # The reported group must be the payload's own pid. Anything else means the
827
+ # launcher was replaced by a forking wrapper, and `wait` would then report
828
+ # the wrapper's status instead of the payload's.
829
+ [ "$_pgid" = "$_payload_pid" ] ||
830
+ lisa_abort_launch "payload process group $_pgid is not the payload pid $_payload_pid; refusing"
831
+ [ "$(lisa_pgid_of "$_pgid" || true)" = "$_pgid" ] ||
832
+ lisa_abort_launch "payload does not lead the group it reported; refusing"
833
+ if [ -n "$_own_pgid" ] && [ "$_pgid" = "$_own_pgid" ]; then
834
+ lisa_abort_launch "payload did not get an isolated process group; refusing"
835
+ fi
836
+ [ -n "$_birth" ] ||
837
+ lisa_abort_launch "cannot read the payload process-birth identity"
838
+ _sup_birth="$(lisa_birth "$$" || true)"
839
+
840
+ # --- write the arming record -------------------------------------------
841
+ {
842
+ printf 'version=%s\n' "$LISA_SCRATCH_VERSION"
843
+ printf 'token=%s\n' "$_token"
844
+ printf 'root=%s\n' "$_root"
845
+ printf 'devino=%s\n' "$_dev_ino"
846
+ printf 'suite=%s\n' "$_suite"
847
+ printf 'pgid=%s\n' "$_pgid"
848
+ printf 'birth=%s\n' "$_birth"
849
+ printf 'suppid=%s\n' "$$"
850
+ printf 'supbirth=%s\n' "$_sup_birth"
851
+ } >"$_root/.lisa-scratch-arm.tmp" || {
852
+ kill -TERM "$_payload_pid" 2>/dev/null || true
853
+ rm -rf "$_root" 2>/dev/null || true
854
+ lisa_die "$EX_ARM" "cannot write the arming record"
855
+ }
856
+ mv -f "$_root/.lisa-scratch-arm.tmp" "$_root/.lisa-scratch-arm"
857
+ lisa_trace "arm-written pgid=$_pgid"
858
+
859
+ # --- start the outside authority and wait for its acknowledgement -------
860
+ # Forked from THIS invocation, before the payload gate opens, into its own
861
+ # process group. It therefore survives both a payload-group kill and a
862
+ # supervisor kill, and no successor Lisa run is ever needed to clean up.
863
+ "$LISA_SCRATCH_SH" "$LISA_SCRATCH_SELF" --authority "$_root" &
864
+ _authority_pid="$!"
865
+ LISA_SCRATCH_AUTHORITY_PID="$_authority_pid"
866
+ lisa_trace "authority-started pid=$_authority_pid"
867
+
868
+ # Both process groups are assigned at fork time, so job control has done
869
+ # its job. Turning it back off keeps the shell's asynchronous 'Terminated'
870
+ # notices out of the payload's own stderr.
871
+ set +m 2>/dev/null || true
872
+
873
+ # Same reason as after `set -m`: the disposition change cuts both ways.
874
+ for _sig in TERM INT HUP; do
875
+ # shellcheck disable=SC2064
876
+ trap "lisa_abort_arming $_sig" "$_sig"
877
+ done
878
+
879
+ _deadline="${LISA_SCRATCH_ARM_TIMEOUT_MS:-15000}"
880
+ case "$_deadline" in
881
+ "" | *[!0-9]*) _deadline=15000 ;;
882
+ esac
883
+ _waited=0
884
+ _acked=no
885
+ while :; do
886
+ _ack_token="$(lisa_marker_get "$_root/.lisa-scratch-ack" token || true)"
887
+ if [ "$_ack_token" = "$_token" ]; then
888
+ _acked=yes
889
+ break
890
+ fi
891
+ if ! lisa_alive "$_authority_pid"; then
892
+ break
893
+ fi
894
+ if [ "$_waited" -ge "$_deadline" ]; then
895
+ break
896
+ fi
897
+ lisa_nap
898
+ _waited=$((_waited + LISA_SCRATCH_NAP_MS))
899
+ done
900
+
901
+ if [ "$_acked" != yes ]; then
902
+ # REFUSE. The payload has not run and never will on this invocation.
903
+ kill -TERM "$_payload_pid" 2>/dev/null || true
904
+ kill -TERM "$_authority_pid" 2>/dev/null || true
905
+ rm -rf "$_root" 2>/dev/null || true
906
+ lisa_trace "arm-refused"
907
+ lisa_die "$EX_ARM" "cleanup authority did not acknowledge the run; refusing to execute the payload"
908
+ fi
909
+ lisa_trace "arm-acked"
910
+
911
+ # --- terminal paths, installed BEFORE the gate opens --------------------
912
+ # A signal arriving in the window between allocation and trap installation
913
+ # would otherwise leave the root behind on the foreground path.
914
+ LISA_SCRATCH_SIGNAL=""
915
+ for _sig in TERM:143 INT:130 HUP:129; do
916
+ _signame="${_sig%%:*}"
917
+ _sigstatus="${_sig##*:}"
918
+ # shellcheck disable=SC2064
919
+ trap "LISA_SCRATCH_SIGNAL=$_signame; lisa_supervisor_finish $_payload_pid $_authority_pid \"\$LISA_SCRATCH_ROOT\" \"\$LISA_SCRATCH_TOKEN\" \"$_dev_ino\" \"$_base\" \"$_pgid\" \"$_birth\" $_sigstatus" "$_signame"
920
+ done
921
+
922
+ # --- allocation may begin -----------------------------------------------
923
+ mkdir "$_root/tmp" || {
924
+ kill -TERM "$_payload_pid" 2>/dev/null || true
925
+ lisa_die "$EX_ARM" "cannot create the payload scratch root"
926
+ }
927
+ chmod 700 "$_root/tmp" 2>/dev/null || true
928
+ : >"$_root/.lisa-scratch-go"
929
+ lisa_trace "gate-open"
930
+
931
+ _status=0
932
+ wait "$_payload_pid" || _status="$?"
933
+ lisa_trace "payload-exit status=$_status"
934
+ trap - TERM INT HUP
935
+ lisa_supervisor_finish "$_payload_pid" "$_authority_pid" "$_root" "$_token" \
936
+ "$_dev_ino" "$_base" "$_pgid" "$_birth" "$_status"
937
+ }
938
+
939
+ # Drain, scan, delete, verify, then exit preserving the payload's verdict.
940
+ lisa_supervisor_finish() {
941
+ _payload_pid="$1"
942
+ _authority_pid="$2"
943
+ _root="$3"
944
+ _token="$4"
945
+ _dev_ino="$5"
946
+ _base="$6"
947
+ _pgid="$7"
948
+ _birth="$8"
949
+ _status="$9"
950
+
951
+ lisa_trace "finish-begin status=$_status"
952
+
953
+ # Drain first: a survivor in the owned group could still be creating
954
+ # scratch, which would make the leak verdict a race rather than a fact.
955
+ lisa_drain_group "$_pgid" "$_birth"
956
+
957
+ _leak=0
958
+ if [ "${LISA_SCRATCH_LEAK_GATE:-enforce}" != "off" ]; then
959
+ lisa_leak_scan "$_root/tmp" "$LISA_SCRATCH_SUITE" || _leak=1
960
+ fi
961
+
962
+ : >"$_root/.lisa-scratch-done" 2>/dev/null || true
963
+ lisa_remove_root "$_root" "$_token" "$_dev_ino" "$_base" || true
964
+
965
+ if [ -d "$_root" ]; then
966
+ # The authority still owns this root. Do not kill it — it is the safety
967
+ # net, and it is already armed.
968
+ printf '%s\n' "lisa-scratch-run: run root still present after cleanup: $_root" >&2
969
+ lisa_trace "finish-root-present"
970
+ if [ "$_status" -eq 0 ]; then _status="$EX_AMBIGUOUS"; fi
971
+ else
972
+ lisa_trace "finish-root-absent"
973
+ # The owned group is drained and the owned root is gone, so the authority
974
+ # has nothing left to own. Retire it and reap it here rather than leaving
975
+ # an orphan behind.
976
+ #
977
+ # Polling `kill -0` instead would not work: a background child that has
978
+ # already exited is a zombie until it is waited for, and signal 0 succeeds
979
+ # against a zombie. That reads as "still working" for the whole budget, and
980
+ # cost a flat two seconds on every single supervised run.
981
+ kill -TERM "$_authority_pid" 2>/dev/null || true
982
+ wait "$_authority_pid" 2>/dev/null || true
983
+ fi
984
+
985
+ if [ "$_leak" -eq 1 ] && [ "${LISA_SCRATCH_LEAK_GATE:-enforce}" = "enforce" ]; then
986
+ # A red payload keeps its own verdict; a green payload does not get to
987
+ # mask a leaking child.
988
+ if [ "$_status" -eq 0 ]; then _status="$EX_LEAK"; fi
989
+ fi
990
+
991
+ if [ -n "${LISA_SCRATCH_SIGNAL:-}" ]; then
992
+ # Re-raise the original terminal signal so the caller sees what really
993
+ # happened, after cleanup has completed.
994
+ trap - "$LISA_SCRATCH_SIGNAL"
995
+ kill -"$LISA_SCRATCH_SIGNAL" $$
996
+ fi
997
+
998
+ exit "$_status"
999
+ }
1000
+
1001
+ # =============================================================================
1002
+ # Entry point.
1003
+ # =============================================================================
1004
+
1005
+ LISA_SCRATCH_ROLE=supervisor
1006
+
1007
+ case "${0}" in
1008
+ /*) LISA_SCRATCH_SELF="$0" ;;
1009
+ *) LISA_SCRATCH_SELF="$(cd -P -- "$(dirname -- "$0")" && pwd -P)/$(basename -- "$0")" ;;
1010
+ esac
1011
+ # The file is POSIX shell throughout, so re-entering it under /bin/sh is exact
1012
+ # regardless of which interpreter the route used to start it.
1013
+ LISA_SCRATCH_SH="/bin/sh"
1014
+ export LISA_SCRATCH_SELF LISA_SCRATCH_SH
1015
+
1016
+ if [ "${1:-}" = "--authority" ]; then
1017
+ [ "$#" -eq 2 ] || lisa_die "$EX_USAGE" "--authority takes exactly one run root"
1018
+ lisa_authority_main "$2"
1019
+ fi
1020
+
1021
+ # Internal: the held payload launcher. Not a user-facing entry point — the
1022
+ # supervisor re-enters here, and so does setsid when it has to build the
1023
+ # process group the handshake acknowledges.
1024
+ if [ "${1:-}" = "--launch" ]; then
1025
+ [ "$#" -ge 4 ] || lisa_die "$EX_USAGE" "--launch takes a run root, a token and a command"
1026
+ shift
1027
+ lisa_launcher_main "$@"
1028
+ fi
1029
+
1030
+ lisa_supervisor_main "$@"