@junghanacs/entwurf 0.13.1 → 0.14.0
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.
- package/AGENTS.md +48 -15
- package/BASELINE.md +3 -3
- package/CHANGELOG.md +45 -0
- package/CONTRIBUTING.md +13 -9
- package/DELIVERY.md +7 -6
- package/README.md +27 -27
- package/VERIFY.md +22 -14
- package/demo/README.md +1 -1
- package/demo/demo-baseline.sh +1 -3
- package/demo/demo.sh +2 -5
- package/docs/acp-backend-rail.md +9 -4
- package/docs/external-mcp-host.md +4 -5
- package/docs/setup-clean-host.md +8 -7
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +148 -28
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +8 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
- package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
- package/mcp/entwurf-bridge/src/index.ts +164 -28
- package/mcp/entwurf-bridge/start.sh +2 -2
- package/mcp/entwurf-bridge/test.sh +23 -9
- package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
- package/package.json +22 -11
- package/pi-extensions/entwurf-control.ts +218 -40
- package/pi-extensions/lib/acp/backend.ts +71 -12
- package/pi-extensions/lib/acp/overlay.ts +8 -6
- package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
- package/pi-extensions/lib/entwurf-core.ts +15 -15
- package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
- package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
- package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
- package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
- package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
- package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
- package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
- package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
- package/pi-extensions/lib/meta-session.ts +93 -5
- package/pi-extensions/lib/mux-fresh-call.ts +328 -0
- package/pi-extensions/lib/mux-launch.ts +267 -0
- package/pi-extensions/lib/mux-placement.ts +387 -0
- package/pi-extensions/lib/mux-resume-call.ts +221 -0
- package/pi-extensions/lib/resume-launch-identity.ts +162 -0
- package/pi-extensions/lib/session-id.js +8 -5
- package/pi-extensions/lib/socket-discovery.ts +3 -3
- package/run.sh +471 -201
- package/scripts/agy-bridge-config.py +5 -1
- package/scripts/check-acp-backend-preflight.ts +1 -1
- package/scripts/check-acp-overlay.ts +13 -3
- package/scripts/check-acp-stream-hooks.ts +504 -0
- package/scripts/check-elapsed.sh +25 -0
- package/scripts/check-entwurf-bridge-boot.ts +51 -4
- package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
- package/scripts/check-entwurf-control-rpc.ts +4 -3
- package/scripts/check-entwurf-resume-args.ts +72 -70
- package/scripts/check-entwurf-session-identity.ts +14 -10
- package/scripts/check-entwurf-v2-contract.ts +34 -59
- package/scripts/check-entwurf-v2-decider.ts +17 -177
- package/scripts/check-entwurf-v2-lock.ts +5 -2
- package/scripts/check-entwurf-v2-matrix.ts +3 -53
- package/scripts/check-entwurf-v2-production.ts +2 -91
- package/scripts/check-entwurf-v2-release.ts +10 -105
- package/scripts/check-entwurf-v2-runner.ts +4 -85
- package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
- package/scripts/check-entwurf-v2-send.ts +0 -28
- package/scripts/check-entwurf-v2-surface.ts +157 -128
- package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
- package/scripts/check-fresh-cut-gate.sh +1 -1
- package/scripts/check-gate-qualification.ts +98 -7
- package/scripts/check-install-container.sh +10 -2
- package/scripts/check-install-surface.ts +1 -1
- package/scripts/check-keyset-overlap.py +1 -1
- package/scripts/check-meta-facts.ts +249 -0
- package/scripts/check-meta-identity-consumers.ts +1 -1
- package/scripts/check-meta-session.ts +169 -0
- package/scripts/check-mux-launch-tmux.ts +316 -0
- package/scripts/check-mux-launch.ts +288 -0
- package/scripts/check-mux-launcher-fence.ts +264 -0
- package/scripts/check-mux-parent-artifact.ts +195 -0
- package/scripts/check-mux-placement-tmux.ts +322 -0
- package/scripts/check-mux-placement.ts +323 -0
- package/scripts/check-mux-resume-call.ts +282 -0
- package/scripts/check-probe-cli-shim.ts +25 -22
- package/scripts/check-probe-ordering.ts +84 -76
- package/scripts/check-release-gate-outcomes.ts +127 -7
- package/scripts/check-resume-launch-identity.ts +244 -0
- package/scripts/check-socket-discovery.ts +1 -1
- package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
- package/scripts/inventory-verification-surface.ts +349 -0
- package/scripts/lib/claude-launcher-fence.ts +322 -0
- package/scripts/lib/mutation-qualify.ts +109 -3
- package/scripts/meta-bridge-doctor.sh +6 -8
- package/scripts/meta-facts.ts +60 -0
- package/scripts/mutants/acp-overlay.json +17 -0
- package/scripts/mutants/acp-stream-hooks.json +158 -0
- package/scripts/mutants/bridge-boot-resume.json +45 -0
- package/scripts/mutants/meta-facts.json +50 -0
- package/scripts/mutants/meta-identity.json +36 -0
- package/scripts/mutants/mux-boundary.json +196 -0
- package/scripts/mutants/mux-fresh-call.json +185 -0
- package/scripts/mutants/mux-launcher-fence.json +123 -0
- package/scripts/mutants/mux-parent-artifact.json +39 -0
- package/scripts/mutants/mux-resume-call.json +148 -0
- package/scripts/mutants/probe-ordering.json +0 -1037
- package/scripts/mutants/release-gate.json +35 -0
- package/scripts/mutants/resume-args.json +76 -0
- package/scripts/mutants/resume-launch-identity.json +96 -0
- package/scripts/mutants/v2-surface.json +58 -18
- package/scripts/mutants/v2-visible-resume.json +215 -0
- package/scripts/new-session-id.ts +9 -4
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-agy-native-push-live.ts +6 -17
- package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
- package/scripts/smoke-meta-honesty.sh +1 -1
- package/scripts/smoke-mux-fresh-call-live.ts +365 -0
- package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
- package/scripts/smoke-pi-attach.ts +1 -1
- package/scripts/smoke-user-scope-citizen.sh +1 -1
- package/scripts/tsconfig.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
- package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
- package/scripts/check-acp-sdk-surface.ts +0 -275
- package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
- package/scripts/check-entwurf-v2-spawn.ts +0 -399
- package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -467
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* inventory-verification-surface — Phase 0 of issue #62 (vitest adoption).
|
|
3
|
+
*
|
|
4
|
+
* The issue's own measurements shifted with the glob used; this script FIXES the
|
|
5
|
+
* denominator. Review (2026-08-07) found the first cut mis-measured the migration
|
|
6
|
+
* itself, so the denominator is now BOTH verification axes together:
|
|
7
|
+
*
|
|
8
|
+
* legacy axis scripts/ (hand-built gates, lib, mutants, fixtures)
|
|
9
|
+
* framework axis test/ + vitest.config.ts (vitest-managed lanes)
|
|
10
|
+
*
|
|
11
|
+
* both from `git ls-files --cached --others --exclude-standard` (tracked plus
|
|
12
|
+
* untracked-non-ignored — the same work surface qualification hashes), so moving a
|
|
13
|
+
* gate from scripts/ into test/ moves lines BETWEEN axes and the combined total
|
|
14
|
+
* stays honest: migration is not subtraction unless the combined number drops.
|
|
15
|
+
*
|
|
16
|
+
* PRIMARY classification is the issue's own six semantic classes, decided by the
|
|
17
|
+
* first matching rule below (re-derivable; narrow explicit overrides carry a
|
|
18
|
+
* reason and are printed). `unclassified` is asserted to be ZERO — a file no rule
|
|
19
|
+
* reaches is a loud error, never a silent bucket:
|
|
20
|
+
*
|
|
21
|
+
* 1 real-live name *-live.* OR gates on LIVE=1 (needs real
|
|
22
|
+
* accounts/models/tmux; smoke-*-install-state is NOT
|
|
23
|
+
* live — it matches rule 2 first by name)
|
|
24
|
+
* 2 package-install name carries pack|install (tarball/install surface)
|
|
25
|
+
* 3 hermetic-integration spawns a subprocess, stands a local server, or is a
|
|
26
|
+
* .sh gate — real processes, no external accounts
|
|
27
|
+
* 4 source-topology reads product source as text WITHOUT importing it
|
|
28
|
+
* 5 behavioral-contract imports and executes product code, with fs/fixtures
|
|
29
|
+
* or auxiliary source reads
|
|
30
|
+
* 6 pure-unit imports and executes product code, no fs, no text
|
|
31
|
+
* reads, no processes
|
|
32
|
+
*
|
|
33
|
+
* The SECONDARY style axis (imports-product / source-text / subprocess / mixed /
|
|
34
|
+
* shell / other) is the issue's measurement table, kept for continuity with the
|
|
35
|
+
* recorded e405d64 baseline snapshot — style describes HOW a gate is written,
|
|
36
|
+
* the six classes describe WHAT it proves.
|
|
37
|
+
*
|
|
38
|
+
* A test/*.test.ts file is classified together with the ./helpers/* bodies it
|
|
39
|
+
* imports: the helper is part of how that lane earns its green (a bridge boot
|
|
40
|
+
* hidden in a helper still makes the lane an integration lane).
|
|
41
|
+
*
|
|
42
|
+
* Plus the mutant inventory per lane, split by whether the mutant's SUBJECT is
|
|
43
|
+
* product code or the verification surface itself (scripts/).
|
|
44
|
+
*
|
|
45
|
+
* Read-only and not a gate: it never writes; its one assertion (unclassified=0)
|
|
46
|
+
* exists so the classification cannot silently rot. Phase 1–4 decisions cite this
|
|
47
|
+
* one reproducible table instead of ad-hoc greps.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { execFileSync } from "node:child_process";
|
|
51
|
+
import * as fs from "node:fs";
|
|
52
|
+
import * as path from "node:path";
|
|
53
|
+
import { fileURLToPath } from "node:url";
|
|
54
|
+
|
|
55
|
+
const REPO_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
56
|
+
|
|
57
|
+
function git(args: string[]): string {
|
|
58
|
+
return execFileSync("git", args, { cwd: REPO_DIR, encoding: "utf8" });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function listWorkSurface(pathspec: string[]): string[] {
|
|
62
|
+
return [
|
|
63
|
+
...new Set(
|
|
64
|
+
git(["ls-files", "--cached", "--others", "--exclude-standard", "--", ...pathspec])
|
|
65
|
+
.split("\n")
|
|
66
|
+
.filter((f) => f.length > 0 && fs.existsSync(path.join(REPO_DIR, f))),
|
|
67
|
+
),
|
|
68
|
+
].sort();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const legacyFiles = listWorkSurface(["scripts/"]);
|
|
72
|
+
const frameworkFiles = listWorkSurface(["test/", "vitest.config.ts"]);
|
|
73
|
+
|
|
74
|
+
function countLines(rel: string): number {
|
|
75
|
+
const body = fs.readFileSync(path.join(REPO_DIR, rel), "utf8");
|
|
76
|
+
if (body.length === 0) return 0;
|
|
77
|
+
return body.split("\n").length - (body.endsWith("\n") ? 1 : 0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── rule predicates (printed, so every number is re-derivable) ───────────────
|
|
81
|
+
const H_IMPORTS = /(?:from\s+["']|import\(\s*["'])(?:\.\.\/)+(?:pi-extensions|mcp)\//;
|
|
82
|
+
const H_TEXT = /readFileSync[^\n]*(?:pi-extensions|mcp\/|\.ts["'`]|SOURCE|SRC)|\bread\(["'](?:pi-extensions|mcp)\//;
|
|
83
|
+
const H_PROC = /\b(?:spawn|spawnSync|execFile|execFileSync|execSync|fork)\s*\(|subprocess\.(?:run|Popen|check_)/;
|
|
84
|
+
const H_NET = /\b(?:http|net)\.createServer|\.listen\(/;
|
|
85
|
+
const H_FS = /from\s+["']node:fs["']|require\(["']node:fs["']\)/;
|
|
86
|
+
// H_LIVE asks whether the file's OWN CODE reads the LIVE switch — not whether the
|
|
87
|
+
// three letters appear. The naive `\bLIVE=1\b` form classified
|
|
88
|
+
// scripts/check-release-gate-outcomes.ts as real-live because that gate QUOTES the
|
|
89
|
+
// sentence "a CUT needs LIVE=1, SKIP=0" and greps other files for the literal
|
|
90
|
+
// "process.env.LIVE"; it spawns no live turn of its own. So the predicate runs on the
|
|
91
|
+
// CODE-ONLY projection (comments and inert literals blanked, shell expansions kept)
|
|
92
|
+
// and matches a variable READ or an assignment, never prose.
|
|
93
|
+
const H_LIVE = /process\.env\.LIVE\b|\$\{LIVE[:}\-]|\$LIVE\b|(?:^|\n)\s*(?:export\s+)?LIVE=1\b/;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Blank what cannot gate execution, so H_LIVE reads code and not prose.
|
|
97
|
+
*
|
|
98
|
+
* TS: `//` and block comments, and the contents of every quoted literal.
|
|
99
|
+
* Shell: `#` comments and single-quoted literals only — a double-quoted shell string
|
|
100
|
+
* still expands `${LIVE:-0}`, so blanking it would hide a real read.
|
|
101
|
+
*/
|
|
102
|
+
function codeOnly(body: string, isShell: boolean): string {
|
|
103
|
+
if (isShell) {
|
|
104
|
+
return body.replace(/(^|\s)#[^\n]*/g, "$1").replace(/'[^'\n]*'/g, "''");
|
|
105
|
+
}
|
|
106
|
+
return body
|
|
107
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
108
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, "$1")
|
|
109
|
+
.replace(/`(?:[^`\\]|\\.)*`/g, "``")
|
|
110
|
+
.replace(/"(?:[^"\\\n]|\\.)*"/g, '""')
|
|
111
|
+
.replace(/'(?:[^'\\\n]|\\.)*'/g, "''");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type SemanticClass =
|
|
115
|
+
| "real-live"
|
|
116
|
+
| "package-install"
|
|
117
|
+
| "hermetic-integration"
|
|
118
|
+
| "source-topology"
|
|
119
|
+
| "behavioral-contract"
|
|
120
|
+
| "pure-unit";
|
|
121
|
+
|
|
122
|
+
/** Narrow explicit overrides for files the mechanical rules cannot reach. Every
|
|
123
|
+
* entry carries its reason and is printed with the table — extending this map is
|
|
124
|
+
* a reviewed decision, not a default. */
|
|
125
|
+
const CLASS_OVERRIDES: Record<string, { cls: SemanticClass; reason: string }> = {
|
|
126
|
+
// These three read repo files through paths assembled at runtime, so the
|
|
127
|
+
// H_TEXT line-level heuristic cannot see them; each is a structure-over-text
|
|
128
|
+
// gate with no product execution.
|
|
129
|
+
"scripts/check-entwurf-bridge-pi-free.ts": {
|
|
130
|
+
cls: "source-topology",
|
|
131
|
+
reason: "walks the bridge's static value-import closure as text to forbid @earendil-works imports",
|
|
132
|
+
},
|
|
133
|
+
"scripts/check-shell-quote.ts": {
|
|
134
|
+
cls: "source-topology",
|
|
135
|
+
reason: "reads the shellQuote() implementation sites as text and cross-checks the POSIX escape contract",
|
|
136
|
+
},
|
|
137
|
+
// Imports only scripts/lib (the shared launcher-fence), spawns nothing, and its
|
|
138
|
+
// smoke-wiring reads assemble paths at runtime — so no mechanical rule reaches it.
|
|
139
|
+
"scripts/check-mux-launcher-fence.ts": {
|
|
140
|
+
cls: "behavioral-contract",
|
|
141
|
+
reason:
|
|
142
|
+
"executes the shared claude-launcher-fence lib against disposable fs fixtures and source-reads both mux LIVE smoke wiring sites",
|
|
143
|
+
},
|
|
144
|
+
"scripts/check-mux-parent-artifact.ts": {
|
|
145
|
+
cls: "source-topology",
|
|
146
|
+
reason: "asserts the scrubbed parent-transcript fixture's recorded shape — an artifact contract, no product run",
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A single class name is the file's STRONGEST tier; some gates also ship a weaker
|
|
152
|
+
* one. Recording that here keeps "real-live" from being read as "never runs without
|
|
153
|
+
* LIVE=1" — and keeps the reverse, a two-tier gate quietly counted as deterministic,
|
|
154
|
+
* from passing as honest either. Stale entries throw.
|
|
155
|
+
*/
|
|
156
|
+
const TWO_TIER_NOTES: Record<string, string> = {
|
|
157
|
+
"scripts/smoke-meta-async-drift.sh":
|
|
158
|
+
"deterministic default tier (pin/marker/doorbell contract — no model, no network) + LIVE=1 add-on tier that delegates to repro-plugin-idle-wake.sh and spawns one metered `claude -p`. Counted real-live because its strongest tier is a real live turn; `pnpm check` only ever runs the deterministic tier.",
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
type Style = "imports-product" | "source-text" | "subprocess" | "mixed" | "shell" | "other";
|
|
162
|
+
|
|
163
|
+
interface Row {
|
|
164
|
+
file: string;
|
|
165
|
+
axis: "legacy" | "framework";
|
|
166
|
+
lines: number;
|
|
167
|
+
cls: SemanticClass;
|
|
168
|
+
overridden: boolean;
|
|
169
|
+
style: Style;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A test file is judged together with the ./helpers/ bodies it imports. */
|
|
173
|
+
function effectiveBody(rel: string): string {
|
|
174
|
+
let body = fs.readFileSync(path.join(REPO_DIR, rel), "utf8");
|
|
175
|
+
if (rel.startsWith("test/") && rel.endsWith(".test.ts")) {
|
|
176
|
+
for (const m of body.matchAll(/from\s+["']\.\/(helpers\/[^"']+?)(?:\.ts)?["']/g)) {
|
|
177
|
+
const helper = path.join(REPO_DIR, "test", `${m[1]}.ts`.replace(/\.ts\.ts$/, ".ts"));
|
|
178
|
+
if (fs.existsSync(helper)) body += `\n${fs.readFileSync(helper, "utf8")}`;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return body;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function classify(rel: string, axis: "legacy" | "framework"): Row {
|
|
185
|
+
const lines = countLines(rel);
|
|
186
|
+
const isShell = rel.endsWith(".sh");
|
|
187
|
+
const body = isShell ? fs.readFileSync(path.join(REPO_DIR, rel), "utf8") : effectiveBody(rel);
|
|
188
|
+
const base = path.basename(rel);
|
|
189
|
+
|
|
190
|
+
const style: Style = (() => {
|
|
191
|
+
if (isShell) return "shell";
|
|
192
|
+
const axes = [H_IMPORTS.test(body), H_TEXT.test(body), H_PROC.test(body)];
|
|
193
|
+
const n = axes.filter(Boolean).length;
|
|
194
|
+
if (n >= 2) return "mixed";
|
|
195
|
+
if (axes[0]) return "imports-product";
|
|
196
|
+
if (axes[1]) return "source-text";
|
|
197
|
+
if (axes[2]) return "subprocess";
|
|
198
|
+
return "other";
|
|
199
|
+
})();
|
|
200
|
+
|
|
201
|
+
const override = CLASS_OVERRIDES[rel];
|
|
202
|
+
const cls: SemanticClass | null = override
|
|
203
|
+
? override.cls
|
|
204
|
+
: /-live\.(ts|sh)$/.test(base) || H_LIVE.test(codeOnly(body, isShell))
|
|
205
|
+
? "real-live"
|
|
206
|
+
: /pack|install/.test(base)
|
|
207
|
+
? "package-install"
|
|
208
|
+
: isShell || H_PROC.test(body) || H_NET.test(body)
|
|
209
|
+
? "hermetic-integration"
|
|
210
|
+
: H_TEXT.test(body) && !H_IMPORTS.test(body)
|
|
211
|
+
? "source-topology"
|
|
212
|
+
: H_IMPORTS.test(body) && (H_TEXT.test(body) || H_FS.test(body))
|
|
213
|
+
? "behavioral-contract"
|
|
214
|
+
: H_IMPORTS.test(body)
|
|
215
|
+
? "pure-unit"
|
|
216
|
+
: null;
|
|
217
|
+
if (cls === null) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`unclassified gate: ${rel} — no rule reaches it; extend the rules or add a reasoned CLASS_OVERRIDES entry`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return { file: rel, axis, lines, cls, overridden: Boolean(override), style };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── buckets ──────────────────────────────────────────────────────────────────
|
|
226
|
+
const legacyGates = legacyFiles.filter((f) => /^scripts\/(check-|smoke-)/.test(f));
|
|
227
|
+
const frameworkGates = frameworkFiles.filter((f) => /^test\/.*\.test\.ts$/.test(f));
|
|
228
|
+
const lib = legacyFiles.filter((f) => f.startsWith("scripts/lib/"));
|
|
229
|
+
const mutantManifests = legacyFiles.filter((f) => f.startsWith("scripts/mutants/") && f.endsWith(".json"));
|
|
230
|
+
const fixtures = legacyFiles.filter((f) => f.startsWith("scripts/fixtures/"));
|
|
231
|
+
const legacyOther = legacyFiles.filter(
|
|
232
|
+
(f) => !legacyGates.includes(f) && !lib.includes(f) && !mutantManifests.includes(f) && !fixtures.includes(f),
|
|
233
|
+
);
|
|
234
|
+
const frameworkSupport = frameworkFiles.filter((f) => !frameworkGates.includes(f));
|
|
235
|
+
|
|
236
|
+
const rows: Row[] = [
|
|
237
|
+
...legacyGates.map((f) => classify(f, "legacy")),
|
|
238
|
+
...frameworkGates.map((f) => classify(f, "framework")),
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
function sum(rs: Row[]): number {
|
|
242
|
+
return rs.reduce((a, r) => a + r.lines, 0);
|
|
243
|
+
}
|
|
244
|
+
function total(files: string[]): number {
|
|
245
|
+
return files.reduce((a, f) => a + countLines(f), 0);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const head = git(["rev-parse", "--short", "HEAD"]).trim();
|
|
249
|
+
console.log(`inventory-verification-surface — work surface (tracked + untracked-non-ignored) @ HEAD ${head}`);
|
|
250
|
+
console.log(`
|
|
251
|
+
rule predicates (re-derive any number from these):
|
|
252
|
+
H_IMPORTS ${H_IMPORTS}
|
|
253
|
+
H_TEXT ${H_TEXT}
|
|
254
|
+
H_PROC ${H_PROC}
|
|
255
|
+
H_NET ${H_NET}
|
|
256
|
+
H_FS ${H_FS}
|
|
257
|
+
H_LIVE ${H_LIVE}
|
|
258
|
+
H_LIVE is applied to the CODE-ONLY projection (comments and inert literals blanked;
|
|
259
|
+
shell double-quoted expansions kept), so a gate that merely QUOTES "LIVE=1" is not
|
|
260
|
+
real-live. Two-tier gates are listed under the class breakdown.
|
|
261
|
+
semantic classes, first match wins: override → real-live (name -live | H_LIVE on code) →
|
|
262
|
+
package-install (name pack|install) → hermetic-integration (.sh | H_PROC | H_NET) →
|
|
263
|
+
source-topology (H_TEXT ∧ ¬H_IMPORTS) → behavioral-contract (H_IMPORTS ∧ (H_TEXT ∨ H_FS)) →
|
|
264
|
+
pure-unit (H_IMPORTS) → ERROR (unclassified is asserted zero)
|
|
265
|
+
test/*.test.ts is classified together with the ./helpers/* bodies it imports.
|
|
266
|
+
`);
|
|
267
|
+
|
|
268
|
+
const legacyTotal = total(legacyFiles);
|
|
269
|
+
const frameworkTotal = total(frameworkFiles);
|
|
270
|
+
console.log(`legacy axis (scripts/): ${legacyFiles.length} files, ${legacyTotal} lines`);
|
|
271
|
+
console.log(
|
|
272
|
+
` gates (check-*/smoke-*): ${legacyGates.length} files, ${sum(rows.filter((r) => r.axis === "legacy"))} lines`,
|
|
273
|
+
);
|
|
274
|
+
console.log(` scripts/lib/: ${lib.length} files, ${total(lib)} lines`);
|
|
275
|
+
console.log(` scripts/mutants/: ${mutantManifests.length} manifests`);
|
|
276
|
+
console.log(` scripts/fixtures/: ${fixtures.length} files`);
|
|
277
|
+
console.log(` other: ${legacyOther.length} files, ${total(legacyOther)} lines`);
|
|
278
|
+
console.log(`framework axis (test/ + vitest.config.ts): ${frameworkFiles.length} files, ${frameworkTotal} lines`);
|
|
279
|
+
console.log(
|
|
280
|
+
` vitest lanes (test/**/*.test.ts): ${frameworkGates.length} files, ${sum(rows.filter((r) => r.axis === "framework"))} lines`,
|
|
281
|
+
);
|
|
282
|
+
console.log(` helpers/config: ${frameworkSupport.length} files, ${total(frameworkSupport)} lines`);
|
|
283
|
+
console.log(
|
|
284
|
+
`COMBINED verification total: ${legacyFiles.length + frameworkFiles.length} files, ${legacyTotal + frameworkTotal} lines`,
|
|
285
|
+
);
|
|
286
|
+
console.log(" (migration moves lines between axes; only a drop in THIS number is subtraction)");
|
|
287
|
+
|
|
288
|
+
console.log("\nsemantic class breakdown (all gates, both axes):");
|
|
289
|
+
const classes: SemanticClass[] = [
|
|
290
|
+
"pure-unit",
|
|
291
|
+
"behavioral-contract",
|
|
292
|
+
"source-topology",
|
|
293
|
+
"hermetic-integration",
|
|
294
|
+
"package-install",
|
|
295
|
+
"real-live",
|
|
296
|
+
];
|
|
297
|
+
for (const c of classes) {
|
|
298
|
+
const rs = rows.filter((r) => r.cls === c);
|
|
299
|
+
console.log(` ${c.padEnd(21)} ${String(rs.length).padStart(3)} files ${String(sum(rs)).padStart(7)} lines`);
|
|
300
|
+
}
|
|
301
|
+
console.log(` ${"unclassified".padEnd(21)} 0 files 0 lines (asserted: the classifier throws otherwise)`);
|
|
302
|
+
|
|
303
|
+
console.log("\ntwo-tier gates (one class name, two execution tiers):");
|
|
304
|
+
for (const [rel, note] of Object.entries(TWO_TIER_NOTES)) {
|
|
305
|
+
const row = rows.find((r) => r.file === rel);
|
|
306
|
+
if (!row) throw new Error(`stale TWO_TIER_NOTES entry: ${rel} is not a classified gate — remove or repoint it`);
|
|
307
|
+
console.log(` ${rel} [${row.cls}] — ${note}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
console.log("\nstyle axis (secondary, e405d64-baseline continuity):");
|
|
311
|
+
const styles: Style[] = ["imports-product", "source-text", "subprocess", "mixed", "shell", "other"];
|
|
312
|
+
for (const s of styles) {
|
|
313
|
+
const rs = rows.filter((r) => r.style === s);
|
|
314
|
+
console.log(` ${s.padEnd(21)} ${String(rs.length).padStart(3)} files ${String(sum(rs)).padStart(7)} lines`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const overridden = rows.filter((r) => r.overridden);
|
|
318
|
+
if (overridden.length > 0) {
|
|
319
|
+
console.log("\nexplicit overrides in effect (each is a reviewed decision):");
|
|
320
|
+
for (const r of overridden) console.log(` ${r.file} → ${r.cls} (${CLASS_OVERRIDES[r.file].reason})`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── mutant inventory ─────────────────────────────────────────────────────────
|
|
324
|
+
interface MutantEntry {
|
|
325
|
+
subject: string;
|
|
326
|
+
}
|
|
327
|
+
let totalMutants = 0;
|
|
328
|
+
let infraMutants = 0;
|
|
329
|
+
console.log("\nmutant lanes (subject=scripts/* counts as verification-infra):");
|
|
330
|
+
for (const manifest of mutantManifests) {
|
|
331
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(REPO_DIR, manifest), "utf8")) as {
|
|
332
|
+
lane: string;
|
|
333
|
+
mutants: MutantEntry[];
|
|
334
|
+
};
|
|
335
|
+
const infra = parsed.mutants.filter((m) => m.subject.startsWith("scripts/")).length;
|
|
336
|
+
totalMutants += parsed.mutants.length;
|
|
337
|
+
infraMutants += infra;
|
|
338
|
+
console.log(
|
|
339
|
+
` ${parsed.lane.padEnd(24)} ${String(parsed.mutants.length).padStart(3)} mutants (${infra} infra-subject)`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
console.log(` ${"TOTAL".padEnd(24)} ${String(totalMutants).padStart(3)} mutants (${infraMutants} infra-subject)`);
|
|
343
|
+
|
|
344
|
+
console.log("\nper-gate table (axis, class, style, lines):");
|
|
345
|
+
for (const r of rows) {
|
|
346
|
+
console.log(
|
|
347
|
+
` ${r.axis.padEnd(9)} ${r.cls.padEnd(21)} ${r.style.padEnd(16)} ${String(r.lines).padStart(6)} ${r.file}${r.overridden ? " [override]" : ""}`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claude-launcher-fence — the shared fail-closed protection for the operator's real `claude`
|
|
3
|
+
* launcher, owed by BOTH mux LIVE smokes (issue #67).
|
|
4
|
+
*
|
|
5
|
+
* The observed incident: a Claude child launched with the operator's real HOME but a fixture
|
|
6
|
+
* `XDG_DATA_HOME` self-updated INTO the fixture tree and rewrote the real launcher (the
|
|
7
|
+
* `~/.local/bin/claude` install shim) to point at `<fixture>/claude/versions/<v>`. Fixture
|
|
8
|
+
* teardown then deleted that tree and left the operator's launcher dangling. This module owns
|
|
9
|
+
* the three protections the smokes share, so neither carries its own copy:
|
|
10
|
+
*
|
|
11
|
+
* 1. PREFLIGHT (`snapshotClaudeLauncher`) — fail-closed. Before any Claude-capable child
|
|
12
|
+
* starts, resolve the actual absolute launcher from the exact child PATH, require it and
|
|
13
|
+
* its symlink/realpath-resolved target to be present, regular, executable and OUTSIDE the
|
|
14
|
+
* fixture cleanup root, and capture enough identity/content evidence (kind, link text,
|
|
15
|
+
* resolved path, content hash) to prove "unchanged" later. Any doubt throws — a smoke that
|
|
16
|
+
* cannot pin the launcher's identity must not launch the child that could destroy it.
|
|
17
|
+
* 2. INTEGRITY ORACLE (`verifyClaudeLauncher`) — on success AND on every failure teardown,
|
|
18
|
+
* before fixture removal, re-derive the same facts and report every mismatch by name.
|
|
19
|
+
* 3. CLEANUP GUARD (`launcherReferencesFixture`) — the one question that decides whether
|
|
20
|
+
* `rm -rf <fixture>` is destructive: does the launcher, or what it currently resolves to,
|
|
21
|
+
* live inside the fixture root? If yes, the smoke must BLOCK its own cleanup loudly and
|
|
22
|
+
* leave the tree in place rather than dangle the operator's launcher.
|
|
23
|
+
*
|
|
24
|
+
* It also owns the env repair that removes the incident's trigger: `restoreOriginalXdg` gives a
|
|
25
|
+
* real-HOME Claude cell EXACT operator-env parity on the four XDG roots — each variable restored
|
|
26
|
+
* to its original value if it was set, and DELETED if it was originally absent. Filling a
|
|
27
|
+
* canonical default for an absent variable would be a guess about how the runtime reads its
|
|
28
|
+
* config; parity, not widening, is the contract. That parity covers the four PERSISTENT
|
|
29
|
+
* CONFIG/DATA/STATE/CACHE roots only: `XDG_RUNTIME_DIR` deliberately remains the smokes' private
|
|
30
|
+
* fixture runtime surface, so this is not a claim of total operator-env parity.
|
|
31
|
+
*
|
|
32
|
+
* This is a scripts-side harness module. It never inspects or alters anything unless the caller
|
|
33
|
+
* hands it an environment and a fixture root, and it must stay out of shipped production source.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { createHash } from "node:crypto";
|
|
37
|
+
import fs from "node:fs";
|
|
38
|
+
import path from "node:path";
|
|
39
|
+
|
|
40
|
+
/** The four XDG roots a real-HOME Claude cell must see with exact operator parity. */
|
|
41
|
+
export const CLAUDE_CELL_XDG_VARS = ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"] as const;
|
|
42
|
+
|
|
43
|
+
/** Original presence/value of each XDG root: a string if it was set (even empty), null if absent. */
|
|
44
|
+
export type OriginalXdg = Record<string, string | null>;
|
|
45
|
+
|
|
46
|
+
/** Capture the four XDG roots BEFORE any fixture redirect touches the environment. */
|
|
47
|
+
export function snapshotOriginalXdg(env: NodeJS.ProcessEnv): OriginalXdg {
|
|
48
|
+
const out: OriginalXdg = {};
|
|
49
|
+
for (const name of CLAUDE_CELL_XDG_VARS) out[name] = env[name] ?? null;
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Restore EXACT original presence/value of the four XDG roots on a cell env. An originally
|
|
55
|
+
* absent variable is deleted, never filled with a canonical default — parity, not a guess.
|
|
56
|
+
*/
|
|
57
|
+
export function restoreOriginalXdg(env: NodeJS.ProcessEnv, originals: OriginalXdg): NodeJS.ProcessEnv {
|
|
58
|
+
for (const name of CLAUDE_CELL_XDG_VARS) {
|
|
59
|
+
const original = originals[name];
|
|
60
|
+
if (original === null || original === undefined) delete env[name];
|
|
61
|
+
else env[name] = original;
|
|
62
|
+
}
|
|
63
|
+
return env;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Everything the preflight pinned, and the oracle later re-derives. */
|
|
67
|
+
export interface ClaudeLauncherSnapshot {
|
|
68
|
+
/** Absolute launcher path selected from the exact child PATH. */
|
|
69
|
+
launcherPath: string;
|
|
70
|
+
/** What the launcher IS at its own path: a symlink or a regular file. */
|
|
71
|
+
kind: "symlink" | "file";
|
|
72
|
+
/** Raw readlink text when the launcher is a symlink, else null. */
|
|
73
|
+
linkText: string | null;
|
|
74
|
+
/** Fully realpath-resolved target the launcher executes. */
|
|
75
|
+
resolvedPath: string;
|
|
76
|
+
/** sha256 of the resolved target's content — the one axis that catches a same-path, same-link in-place rewrite. */
|
|
77
|
+
sha256: string;
|
|
78
|
+
/** Realpath of the fixture cleanup root the launcher must stay outside of. */
|
|
79
|
+
fixtureRoot: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isInside(parent: string, p: string): boolean {
|
|
83
|
+
const rel = path.relative(parent, p);
|
|
84
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sha256File(file: string): string {
|
|
88
|
+
return createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isExecutableFile(p: string): boolean {
|
|
92
|
+
try {
|
|
93
|
+
if (!fs.statSync(p).isFile()) return false;
|
|
94
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
95
|
+
return true;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolve `cmd` the way the child's exec will: first PATH entry holding an executable file.
|
|
103
|
+
* Returns the joined candidate path (not yet symlink-resolved), or null when nothing matches.
|
|
104
|
+
*/
|
|
105
|
+
export function resolveFromPath(cmd: string, env: NodeJS.ProcessEnv): string | null {
|
|
106
|
+
for (const dir of (env.PATH ?? "").split(path.delimiter)) {
|
|
107
|
+
if (!dir) continue;
|
|
108
|
+
const candidate = path.join(dir, cmd);
|
|
109
|
+
if (isExecutableFile(candidate)) return candidate;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* FAIL-CLOSED PREFLIGHT. Pin the launcher's identity before any Claude-capable child starts,
|
|
116
|
+
* or throw. Every refusal names itself: a smoke that cannot prove the launcher is a real,
|
|
117
|
+
* executable, fixture-independent file must not launch the child that could rewrite it.
|
|
118
|
+
*/
|
|
119
|
+
export function snapshotClaudeLauncher(opts: { env: NodeJS.ProcessEnv; fixtureRoot: string }): ClaudeLauncherSnapshot {
|
|
120
|
+
const fixtureRoot = fs.realpathSync(opts.fixtureRoot);
|
|
121
|
+
const launcherPath = resolveFromPath("claude", opts.env);
|
|
122
|
+
if (!launcherPath) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
"claude-launcher-fence: fail-closed — no executable `claude` on the exact child PATH, so the launcher's identity cannot be pinned before launch",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (!path.isAbsolute(launcherPath)) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`claude-launcher-fence: fail-closed — \`claude\` resolved through a relative PATH entry (${launcherPath}); a cwd-dependent launcher cannot be pinned`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (isInside(fixtureRoot, launcherPath)) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`claude-launcher-fence: fail-closed — the selected launcher ${launcherPath} is INSIDE the fixture cleanup root ${fixtureRoot}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const lst = fs.lstatSync(launcherPath);
|
|
138
|
+
const kind: ClaudeLauncherSnapshot["kind"] = lst.isSymbolicLink() ? "symlink" : "file";
|
|
139
|
+
if (kind === "file" && !lst.isFile()) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`claude-launcher-fence: fail-closed — the launcher ${launcherPath} is neither a regular file nor a symlink`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const linkText = kind === "symlink" ? fs.readlinkSync(launcherPath) : null;
|
|
145
|
+
let resolvedPath: string;
|
|
146
|
+
try {
|
|
147
|
+
resolvedPath = fs.realpathSync(launcherPath);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`claude-launcher-fence: fail-closed — the launcher ${launcherPath} does not resolve to a real target: ${
|
|
151
|
+
err instanceof Error ? err.message : String(err)
|
|
152
|
+
}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (!isExecutableFile(resolvedPath)) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`claude-launcher-fence: fail-closed — the launcher's resolved target ${resolvedPath} is not a regular executable file`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
if (isInside(fixtureRoot, resolvedPath)) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`claude-launcher-fence: fail-closed — the launcher's resolved target ${resolvedPath} is INSIDE the fixture cleanup root ${fixtureRoot}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
launcherPath,
|
|
167
|
+
kind,
|
|
168
|
+
linkText,
|
|
169
|
+
resolvedPath,
|
|
170
|
+
sha256: sha256File(resolvedPath),
|
|
171
|
+
fixtureRoot,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* INTEGRITY ORACLE. Re-derive every pinned fact and return one problem string per mismatch —
|
|
177
|
+
* empty means the launcher, its link, its resolved target and that target's content are all
|
|
178
|
+
* exactly as the preflight saw them, and the target is still a regular executable. Runs on the
|
|
179
|
+
* success path AND on every failure teardown, BEFORE fixture removal.
|
|
180
|
+
*/
|
|
181
|
+
export function verifyClaudeLauncher(snapshot: ClaudeLauncherSnapshot): string[] {
|
|
182
|
+
const problems: string[] = [];
|
|
183
|
+
let lst: fs.Stats;
|
|
184
|
+
try {
|
|
185
|
+
lst = fs.lstatSync(snapshot.launcherPath);
|
|
186
|
+
} catch {
|
|
187
|
+
problems.push(`the launcher ${snapshot.launcherPath} no longer exists`);
|
|
188
|
+
return problems;
|
|
189
|
+
}
|
|
190
|
+
const kindNow: ClaudeLauncherSnapshot["kind"] = lst.isSymbolicLink() ? "symlink" : "file";
|
|
191
|
+
if (kindNow !== snapshot.kind) {
|
|
192
|
+
problems.push(`the launcher changed kind: was ${snapshot.kind}, is now ${kindNow}`);
|
|
193
|
+
}
|
|
194
|
+
if (kindNow === "symlink") {
|
|
195
|
+
// A raced-away link is an INTEGRITY PROBLEM, never an exception that could mask the run
|
|
196
|
+
// error this oracle runs alongside.
|
|
197
|
+
let linkNow: string | null = null;
|
|
198
|
+
try {
|
|
199
|
+
linkNow = fs.readlinkSync(snapshot.launcherPath);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
problems.push(
|
|
202
|
+
`the launcher's link text could not be re-read: ${err instanceof Error ? err.message : String(err)}`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (linkNow !== null && linkNow !== snapshot.linkText) {
|
|
206
|
+
problems.push(`the launcher's link text changed: was ${snapshot.linkText ?? "(none)"}, is now ${linkNow}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
let resolvedNow: string | null = null;
|
|
210
|
+
try {
|
|
211
|
+
resolvedNow = fs.realpathSync(snapshot.launcherPath);
|
|
212
|
+
} catch {
|
|
213
|
+
problems.push(`the launcher ${snapshot.launcherPath} no longer resolves to a real target`);
|
|
214
|
+
}
|
|
215
|
+
if (resolvedNow !== null) {
|
|
216
|
+
if (resolvedNow !== snapshot.resolvedPath) {
|
|
217
|
+
problems.push(`the launcher's resolved target moved: was ${snapshot.resolvedPath}, is now ${resolvedNow}`);
|
|
218
|
+
}
|
|
219
|
+
if (!isExecutableFile(resolvedNow)) {
|
|
220
|
+
problems.push(`the launcher's resolved target ${resolvedNow} is no longer a regular executable file`);
|
|
221
|
+
} else {
|
|
222
|
+
try {
|
|
223
|
+
if (sha256File(resolvedNow) !== snapshot.sha256) {
|
|
224
|
+
problems.push(`the launcher's resolved target ${resolvedNow} changed content (sha256 mismatch)`);
|
|
225
|
+
}
|
|
226
|
+
} catch (err) {
|
|
227
|
+
problems.push(
|
|
228
|
+
`the launcher's resolved target ${resolvedNow} could not be hashed: ${err instanceof Error ? err.message : String(err)}`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (isInside(snapshot.fixtureRoot, resolvedNow)) {
|
|
233
|
+
problems.push(
|
|
234
|
+
`the launcher's resolved target ${resolvedNow} now lies INSIDE the fixture cleanup root ${snapshot.fixtureRoot}`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return problems;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** What the cleanup guard could prove, stated honestly: a KNOWN fixture reference and an
|
|
242
|
+
* UNPROVABLE state both block removal, but each is named as itself in `problems`. */
|
|
243
|
+
export interface LauncherCleanupVerdict {
|
|
244
|
+
/** True only when removal is PROVEN safe: the launcher demonstrably does not reference the fixture. */
|
|
245
|
+
safeToRemove: boolean;
|
|
246
|
+
/** Named reasons removal is blocked — a fixture reference, or a syscall that left safety unproven. */
|
|
247
|
+
problems: string[];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* CLEANUP GUARD. Decides whether `rm -rf <fixture>` is destructive: does the launcher, its
|
|
252
|
+
* current link target, or its current resolved path lie inside the fixture cleanup root — the
|
|
253
|
+
* exact state in which removal would dangle the operator's launcher? FAIL-CLOSED on uncertainty:
|
|
254
|
+
* a readlink/lstat/realpath error blocks removal with a "not proven safe" problem rather than
|
|
255
|
+
* being laundered into either a false "references fixture" or a false all-clear. A launcher that
|
|
256
|
+
* is missing ENTIRELY is safe to clean around — nothing is left for removal to sever, and the
|
|
257
|
+
* integrity oracle reports that damage by name.
|
|
258
|
+
*/
|
|
259
|
+
export function assessLauncherCleanup(snapshot: ClaudeLauncherSnapshot): LauncherCleanupVerdict {
|
|
260
|
+
if (isInside(snapshot.fixtureRoot, snapshot.launcherPath)) {
|
|
261
|
+
return {
|
|
262
|
+
safeToRemove: false,
|
|
263
|
+
problems: [`the launcher ${snapshot.launcherPath} itself lies inside the fixture cleanup root`],
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
let lst: fs.Stats;
|
|
267
|
+
try {
|
|
268
|
+
lst = fs.lstatSync(snapshot.launcherPath);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return { safeToRemove: true, problems: [] };
|
|
271
|
+
return {
|
|
272
|
+
safeToRemove: false,
|
|
273
|
+
problems: [
|
|
274
|
+
`the launcher ${snapshot.launcherPath} could not be inspected (${
|
|
275
|
+
err instanceof Error ? err.message : String(err)
|
|
276
|
+
}) — removal is not proven safe`,
|
|
277
|
+
],
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
if (lst.isSymbolicLink()) {
|
|
281
|
+
let hop: string;
|
|
282
|
+
try {
|
|
283
|
+
hop = path.resolve(path.dirname(snapshot.launcherPath), fs.readlinkSync(snapshot.launcherPath));
|
|
284
|
+
} catch (err) {
|
|
285
|
+
return {
|
|
286
|
+
safeToRemove: false,
|
|
287
|
+
problems: [
|
|
288
|
+
`the launcher's link text could not be read (${
|
|
289
|
+
err instanceof Error ? err.message : String(err)
|
|
290
|
+
}) — removal is not proven safe`,
|
|
291
|
+
],
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (isInside(snapshot.fixtureRoot, hop)) {
|
|
295
|
+
return {
|
|
296
|
+
safeToRemove: false,
|
|
297
|
+
problems: [`the launcher's link target ${hop} lies inside the fixture cleanup root`],
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
const resolved = fs.realpathSync(snapshot.launcherPath);
|
|
303
|
+
if (isInside(snapshot.fixtureRoot, resolved)) {
|
|
304
|
+
return {
|
|
305
|
+
safeToRemove: false,
|
|
306
|
+
problems: [`the launcher's resolved target ${resolved} lies inside the fixture cleanup root`],
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
} catch (err) {
|
|
310
|
+
// A dangling or unresolvable chain: the first hop above was proven outside the fixture, but
|
|
311
|
+
// the FULL chain was not — fail closed rather than guess.
|
|
312
|
+
return {
|
|
313
|
+
safeToRemove: false,
|
|
314
|
+
problems: [
|
|
315
|
+
`the launcher ${snapshot.launcherPath} could not be fully resolved (${
|
|
316
|
+
err instanceof Error ? err.message : String(err)
|
|
317
|
+
}) — removal is not proven safe`,
|
|
318
|
+
],
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
return { safeToRemove: true, problems: [] };
|
|
322
|
+
}
|