@junghanacs/entwurf 0.12.6 → 0.12.7
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 +31 -17
- package/BASELINE.md +42 -8
- package/CHANGELOG.md +22 -0
- package/DELIVERY.md +71 -14
- package/README.md +77 -45
- package/VERIFY.md +18 -13
- package/docs/setup-clean-host.md +80 -22
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +90 -66
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +54 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +153 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +436 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +157 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +105 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +90 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +194 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +153 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +42 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +49 -13
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +104 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +30 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +57 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +10 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +9 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +21 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +17 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +125 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +158 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +61 -0
- package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +166 -0
- package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +130 -0
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +178 -0
- package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +24 -0
- package/mcp/entwurf-bridge/src/index.ts +101 -67
- package/mcp/entwurf-bridge/test.sh +1 -1
- package/mcp/entwurf-bridge/tsconfig.build.json +23 -3
- package/package.json +10 -5
- package/pi-extensions/lib/entwurf-deliverability.ts +62 -9
- package/pi-extensions/lib/entwurf-self-address.ts +58 -15
- package/pi-extensions/lib/entwurf-v2-contract.ts +120 -12
- package/pi-extensions/lib/entwurf-v2-decider.ts +60 -0
- package/pi-extensions/lib/entwurf-v2-native-push.ts +86 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +20 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +9 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +29 -1
- package/pi-extensions/lib/entwurf-v2-send.ts +7 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +17 -0
- package/pi-extensions/lib/meta-sender-identity.ts +154 -0
- package/pi-extensions/lib/native-push/adapter.ts +255 -0
- package/pi-extensions/lib/native-push/register.ts +99 -0
- package/run.sh +756 -214
- package/scripts/agy-bridge-config.py +446 -0
- package/scripts/agy-bridge.sh +359 -0
- package/scripts/agy-hooks-bridge.sh +193 -0
- package/scripts/agy-hooks-config.py +257 -0
- package/scripts/agy-imprint.sh +28 -0
- package/scripts/agy-imprint.ts +193 -0
- package/scripts/agy-statusline-bridge.sh +176 -0
- package/scripts/agy-statusline-config.py +213 -0
- package/scripts/agy-statusline.sh +256 -0
- package/scripts/build-bridge.sh +20 -0
- package/scripts/check-agy-sender-identity.ts +364 -0
- package/scripts/check-entwurf-bridge-boot.ts +8 -2
- package/scripts/check-entwurf-deliverability.ts +34 -0
- package/scripts/check-entwurf-self-address.ts +78 -11
- package/scripts/check-entwurf-v2-contract.ts +136 -1
- package/scripts/check-entwurf-v2-decider.ts +95 -1
- package/scripts/check-entwurf-v2-matrix.ts +14 -3
- package/scripts/check-entwurf-v2-native-push.ts +193 -0
- package/scripts/check-entwurf-v2-production.ts +68 -1
- package/scripts/check-entwurf-v2-runner.ts +58 -0
- package/scripts/check-entwurf-v2-surface.ts +35 -0
- package/scripts/check-install-surface.ts +357 -0
- package/scripts/check-native-push-adapter.ts +319 -0
- package/scripts/check-native-push-register.ts +130 -0
- package/scripts/dev-bin.sh +195 -0
- package/scripts/doctor-pi-provider.ts +140 -0
- package/scripts/meta-bridge-doctor.sh +36 -2
- package/scripts/register-pi-package.py +37 -3
- package/scripts/register-pi-provider.py +287 -0
- package/scripts/smoke-agy-hooks-state.sh +172 -0
- package/scripts/smoke-agy-install-state.sh +660 -0
- package/scripts/smoke-agy-native-push-live.ts +243 -0
- package/scripts/smoke-agy-statusline-state.sh +300 -0
- package/scripts/smoke-meta-async-drift.sh +9 -2
- package/scripts/smoke-meta-install-state.sh +20 -0
- package/scripts/smoke-pi-provider-state.sh +182 -0
- package/scripts/smoke-user-scope-citizen.sh +62 -0
- package/scripts/with-dist-lock.sh +81 -0
- package/scripts/__pycache__/meta-bridge-state.cpython-312.pyc +0 -0
- package/scripts/__pycache__/meta-bridge-state.cpython-313.pyc +0 -0
- package/scripts/__pycache__/register-pi-package.cpython-313.pyc +0 -0
|
@@ -43,6 +43,7 @@ import type {
|
|
|
43
43
|
SuccessReceipt,
|
|
44
44
|
} from "../pi-extensions/lib/entwurf-v2-decider.ts";
|
|
45
45
|
import type { LockClaim } from "../pi-extensions/lib/entwurf-v2-lock.ts";
|
|
46
|
+
import type { NativePushPlan, NativePushSendResult } from "../pi-extensions/lib/entwurf-v2-native-push.ts";
|
|
46
47
|
import {
|
|
47
48
|
type DispatchExecutorDeps,
|
|
48
49
|
type EntwurfV2RunDeps,
|
|
@@ -109,6 +110,16 @@ const MAILBOX_PLAN: MetaMailboxPlan = {
|
|
|
109
110
|
wantsReply: false,
|
|
110
111
|
message: "m",
|
|
111
112
|
};
|
|
113
|
+
const NATIVE_PUSH_PLAN: NativePushPlan = {
|
|
114
|
+
transport: "native-push",
|
|
115
|
+
action: "send",
|
|
116
|
+
targetGardenId: GID,
|
|
117
|
+
backend: "antigravity",
|
|
118
|
+
nativeSessionId: "conv-xyz",
|
|
119
|
+
route: { lsAddress: "127.0.0.1:5599" },
|
|
120
|
+
wantsReply: false,
|
|
121
|
+
message: "m",
|
|
122
|
+
};
|
|
112
123
|
|
|
113
124
|
const SUCCESS_RECEIPT: SuccessReceipt = {
|
|
114
125
|
ok: true,
|
|
@@ -128,12 +139,14 @@ interface Trace {
|
|
|
128
139
|
controlArgs?: { plan: ControlSocketPlan; lock: LockClaim | null };
|
|
129
140
|
spawnArgs?: { plan: SpawnBgPlan; lock: LockClaim | null };
|
|
130
141
|
mailboxArgs?: { plan: MetaMailboxPlan; lock: LockClaim | null };
|
|
142
|
+
nativePushArgs?: { plan: NativePushPlan; lock: LockClaim | null };
|
|
131
143
|
}
|
|
132
144
|
|
|
133
145
|
interface FakeSpec {
|
|
134
146
|
control?: { result: ControlSocketSendResult } | { throw: unknown };
|
|
135
147
|
spawn?: { result: SpawnBgResumeResult } | { throw: unknown };
|
|
136
148
|
mailbox?: { result: RpcSendResult } | { throw: unknown };
|
|
149
|
+
nativePush?: { result: NativePushSendResult } | { throw: unknown };
|
|
137
150
|
}
|
|
138
151
|
|
|
139
152
|
function makeDeps(spec: FakeSpec): { deps: DispatchExecutorDeps; trace: Trace } {
|
|
@@ -160,6 +173,13 @@ function makeDeps(spec: FakeSpec): { deps: DispatchExecutorDeps; trace: Trace }
|
|
|
160
173
|
if ("throw" in spec.mailbox) throw spec.mailbox.throw;
|
|
161
174
|
return spec.mailbox.result;
|
|
162
175
|
},
|
|
176
|
+
async sendNativePush(plan, lock) {
|
|
177
|
+
trace.calls.push("sendNativePush");
|
|
178
|
+
trace.nativePushArgs = { plan, lock };
|
|
179
|
+
if (!spec.nativePush) throw new Error("test: sendNativePush called but no spec");
|
|
180
|
+
if ("throw" in spec.nativePush) throw spec.nativePush.throw;
|
|
181
|
+
return spec.nativePush.result;
|
|
182
|
+
},
|
|
163
183
|
};
|
|
164
184
|
return { deps, trace };
|
|
165
185
|
}
|
|
@@ -253,6 +273,44 @@ async function main(): Promise<void> {
|
|
|
253
273
|
);
|
|
254
274
|
}
|
|
255
275
|
|
|
276
|
+
// ── 4b: native-push execute → sendNativePush(plan, null), lock-free (봉인 4) ──
|
|
277
|
+
{
|
|
278
|
+
const { deps, trace } = makeDeps({ nativePush: { result: { success: true, retried: false } } });
|
|
279
|
+
const res = await executeDispatch(executeDecision(NATIVE_PUSH_PLAN, null), deps);
|
|
280
|
+
ok("4b: only sendNativePush ran", trace.calls.length === 1 && trace.calls[0] === "sendNativePush");
|
|
281
|
+
ok(
|
|
282
|
+
"4b: NULL lock passed verbatim (lock-free rail)",
|
|
283
|
+
trace.nativePushArgs?.lock === null && trace.nativePushArgs?.plan === NATIVE_PUSH_PLAN,
|
|
284
|
+
);
|
|
285
|
+
ok(
|
|
286
|
+
"4b: executed{native-push, success, retried:false}",
|
|
287
|
+
res.kind === "executed" &&
|
|
288
|
+
res.outcome.transport === "native-push" &&
|
|
289
|
+
res.outcome.success === true &&
|
|
290
|
+
res.outcome.retried === false,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── 4c: native-push retried flag surfaces onto the executed outcome ──────────
|
|
295
|
+
{
|
|
296
|
+
const { deps } = makeDeps({ nativePush: { result: { success: true, retried: true } } });
|
|
297
|
+
const res = await executeDispatch(executeDecision(NATIVE_PUSH_PLAN, null), deps);
|
|
298
|
+
ok(
|
|
299
|
+
"4c: retried:true carried onto executed{native-push}",
|
|
300
|
+
res.kind === "executed" && res.outcome.transport === "native-push" && res.outcome.retried === true,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── 4d: native-push hand THROWS → execution-failed, retry-unsafe ─────────────
|
|
305
|
+
{
|
|
306
|
+
const { deps } = makeDeps({ nativePush: { throw: new Error("agy send boom") } });
|
|
307
|
+
const res = await executeDispatch(executeDecision(NATIVE_PUSH_PLAN, null), deps);
|
|
308
|
+
ok(
|
|
309
|
+
"4d: native-push throw → execution-failed (retrySafe:false)",
|
|
310
|
+
res.kind === "execution-failed" && res.transport === "native-push" && res.retrySafe === false,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
256
314
|
// ── 5: N3 — control rejected with rejectReason carried ────────────────────
|
|
257
315
|
{
|
|
258
316
|
const { deps } = makeDeps({
|
|
@@ -179,6 +179,41 @@ async function main(): Promise<void> {
|
|
|
179
179
|
!renderEntwurfV2Result(mailbox).isError && renderEntwurfV2Result(mailbox).text.includes("enqueued"),
|
|
180
180
|
);
|
|
181
181
|
|
|
182
|
+
// native-push → delivered (no retry)
|
|
183
|
+
const np: EntwurfV2RunResult = {
|
|
184
|
+
kind: "executed",
|
|
185
|
+
receipt: { ...SUCCESS_RECEIPT, transport: "native-push" },
|
|
186
|
+
transport: "native-push",
|
|
187
|
+
outcome: { transport: "native-push", success: true, retried: false },
|
|
188
|
+
};
|
|
189
|
+
ok(
|
|
190
|
+
"2: native-push → not error + delivered",
|
|
191
|
+
!renderEntwurfV2Result(np).isError && renderEntwurfV2Result(np).text.includes("delivered"),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// native-push delivered after a 1-shot re-probe retry → retry note surfaced
|
|
195
|
+
const npRetried: EntwurfV2RunResult = {
|
|
196
|
+
kind: "executed",
|
|
197
|
+
receipt: { ...SUCCESS_RECEIPT, transport: "native-push" },
|
|
198
|
+
transport: "native-push",
|
|
199
|
+
outcome: { transport: "native-push", success: true, retried: true },
|
|
200
|
+
};
|
|
201
|
+
ok(
|
|
202
|
+
"2: native-push retried → not error + retry note surfaced",
|
|
203
|
+
!renderEntwurfV2Result(npRetried).isError && renderEntwurfV2Result(npRetried).text.includes("retry"),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// native-push owned reject → hint to switch to fire-and-forget
|
|
207
|
+
const npReject: EntwurfV2RunResult = {
|
|
208
|
+
kind: "rejected",
|
|
209
|
+
receipt: { ok: false, reason: "native-push-no-resume-authority", observedLiveness: "alive" },
|
|
210
|
+
};
|
|
211
|
+
const npRej = renderEntwurfV2Result(npReject);
|
|
212
|
+
ok(
|
|
213
|
+
"2: native-push-no-resume-authority → isError + fire-and-forget hint",
|
|
214
|
+
npRej.isError && npRej.text.includes("fire-and-forget"),
|
|
215
|
+
);
|
|
216
|
+
|
|
182
217
|
// N1: execution-failed with finalizedOutcome + releaseFailed → delivered-but-dirty
|
|
183
218
|
const n1: EntwurfV2RunResult = {
|
|
184
219
|
kind: "execution-failed",
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-install-surface — the structural half of the node_modules strip-types fence.
|
|
3
|
+
*
|
|
4
|
+
* Node REFUSES `--experimental-strip-types` for any .ts below node_modules. An installed
|
|
5
|
+
* package therefore MUST reach compiled JS for every surface an operator can invoke. That
|
|
6
|
+
* rule was enforced by hand, per surface, and it failed three times: the agy imprint hook
|
|
7
|
+
* (0.12.7 blocker) plus `doctor-pi-provider` / `new-session-id` / `meta-bridge-prune`,
|
|
8
|
+
* which all shipped dead under node_modules while every dev clone stayed green.
|
|
9
|
+
*
|
|
10
|
+
* check-pack-install proves the fence is crossed by DRIVING the installed commands. This
|
|
11
|
+
* gate proves no NEW surface can reintroduce the class, statically and with no side
|
|
12
|
+
* effects — sweeping every subcommand for real would run installers and LIVE gates.
|
|
13
|
+
*
|
|
14
|
+
* S1 run.sh reaches .ts ONLY through run_ts (one fence, one place).
|
|
15
|
+
* S2 every operator subcommand (one that is neither a check- nor a smoke- gate) that runs
|
|
16
|
+
* a .ts — directly OR through a helper function, which is the house style — has a
|
|
17
|
+
* compiled twin declared in tsconfig.build.json. The exact miss that killed the three.
|
|
18
|
+
* S3a no npm bin points at a raw .ts at all; S3b every .sh bin that execs one branches.
|
|
19
|
+
* S4 dev-only gates are NOT emitted — the tarball ships operator surfaces, not 70+ gate
|
|
20
|
+
* leaves (a build bloat regression is as real as a missing artifact).
|
|
21
|
+
* S5 no offline smoke carries an obvious write to the operator's real $HOME, and any
|
|
22
|
+
* smoke that swaps HOME into a sandbox swaps XDG_DATA_HOME with it — moving HOME
|
|
23
|
+
* alone still writes real install-state below the inherited XDG root, which is
|
|
24
|
+
* exactly how a verification sweep polluted a live host's provenance (2026-07-14).
|
|
25
|
+
*
|
|
26
|
+
* HONEST SCOPE — what a green run does and does not mean. S1-S4 are structural: they read the
|
|
27
|
+
* dispatch graph and the build manifest, so they hold for any entrypoint written in this repo's
|
|
28
|
+
* style. S5 is a static TRIPWIRE over shell source: it catches a literal live path and ONE hop
|
|
29
|
+
* of variable aliasing, and it does not see a path assembled across several variables, built
|
|
30
|
+
* inside an embedded python/node heredoc, or reached through a helper in another file. A green
|
|
31
|
+
* S5 therefore means "no obvious destructive line", NOT "verification is sandboxed". The real
|
|
32
|
+
* guarantee is running the whole offline floor under a swapped HOME; that is an open item in
|
|
33
|
+
* NEXT.md, not a claim made here. Every S was mutation-checked, including bypasses found in
|
|
34
|
+
* review — do not add an S without proving it fails on the bug it names.
|
|
35
|
+
*
|
|
36
|
+
* Read-only: parses sources, spawns nothing.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
40
|
+
import path from "node:path";
|
|
41
|
+
import { fileURLToPath } from "node:url";
|
|
42
|
+
|
|
43
|
+
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
44
|
+
const RUN_SH = readFileSync(path.join(REPO, "run.sh"), "utf8");
|
|
45
|
+
const BUILD_TSCONFIG = readFileSync(path.join(REPO, "mcp/entwurf-bridge/tsconfig.build.json"), "utf8");
|
|
46
|
+
const PACKAGE_JSON = JSON.parse(readFileSync(path.join(REPO, "package.json"), "utf8")) as {
|
|
47
|
+
bin?: Record<string, string>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
let failed = 0;
|
|
51
|
+
function ok(label: string, cond: boolean, detail?: string): void {
|
|
52
|
+
if (cond) {
|
|
53
|
+
console.log(` ok ${label}`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
failed += 1;
|
|
57
|
+
console.error(` FAIL ${label}`);
|
|
58
|
+
if (detail) console.error(detail.replace(/^/gm, " "));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Lines of run.sh with comments and the run_ts definition body excluded. */
|
|
62
|
+
function runShCodeLines(): { line: string; n: number }[] {
|
|
63
|
+
const out: { line: string; n: number }[] = [];
|
|
64
|
+
let inRunTs = false;
|
|
65
|
+
RUN_SH.split("\n").forEach((line, i) => {
|
|
66
|
+
if (/^run_ts\(\)\s*\{/.test(line)) inRunTs = true;
|
|
67
|
+
else if (inRunTs && /^\}/.test(line)) inRunTs = false;
|
|
68
|
+
else if (!inRunTs && !/^\s*#/.test(line)) out.push({ line, n: i + 1 });
|
|
69
|
+
});
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── S1 — run.sh crosses the fence in exactly one place ──────────────────────────────
|
|
74
|
+
{
|
|
75
|
+
const raw = runShCodeLines().filter(({ line }) => /node\s+--experimental-strip-types/.test(line));
|
|
76
|
+
ok(
|
|
77
|
+
"S1: run.sh reaches .ts only through run_ts (no raw strip-types call sites)",
|
|
78
|
+
raw.length === 0,
|
|
79
|
+
raw.map(({ line, n }) => `run.sh:${n}: ${line.trim()}`).join("\n"),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── S2/S4 — operator commands are compiled; dev gates are not ───────────────────────
|
|
84
|
+
const emitted = new Set(
|
|
85
|
+
[...BUILD_TSCONFIG.matchAll(/"\.\.\/\.\.\/scripts\/([a-z0-9-]+)\.ts"/g)].map((m) => m[1] as string),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* subcommand → every .ts it can reach.
|
|
90
|
+
*
|
|
91
|
+
* The naive version — read the `run_ts` call sitting directly under a `case` label — is what the
|
|
92
|
+
* dominant style in this file does NOT look like: nearly every subcommand delegates to a function
|
|
93
|
+
* (`check-shell-quote) check_shell_quote ;;`). A gate that only sees direct calls would pass a new
|
|
94
|
+
* operator command written in the house style with no compiled twin, which is the very bug this
|
|
95
|
+
* file exists to prevent. So resolve through function bodies too.
|
|
96
|
+
*/
|
|
97
|
+
const RUN_TS_CALL = /run_ts\s+scripts\/([a-z0-9-]+)\.ts/;
|
|
98
|
+
|
|
99
|
+
function functionTargets(): Map<string, string[]> {
|
|
100
|
+
const byFn = new Map<string, string[]>();
|
|
101
|
+
let fn: string | null = null;
|
|
102
|
+
for (const { line } of runShCodeLines()) {
|
|
103
|
+
// Read the definition line's own tail too: `f() { run_ts scripts/x.ts; }` is a single line,
|
|
104
|
+
// and a parser that only looks at SUBSEQUENT lines walks right past it.
|
|
105
|
+
const def = line.match(/^(?:function\s+)?([a-z_][a-z0-9_]*)\s*\(\)\s*\{(.*)$/);
|
|
106
|
+
if (def) {
|
|
107
|
+
fn = def[1] as string;
|
|
108
|
+
byFn.set(fn, []);
|
|
109
|
+
const tail = def[2] ?? "";
|
|
110
|
+
const inlineCall = tail.match(RUN_TS_CALL);
|
|
111
|
+
if (inlineCall) byFn.get(fn)?.push(inlineCall[1] as string);
|
|
112
|
+
if (tail.includes("}")) fn = null; // one-liner: body opened and closed here
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (fn && /^\}/.test(line)) {
|
|
116
|
+
fn = null;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const call = line.match(RUN_TS_CALL);
|
|
120
|
+
if (fn && call) byFn.get(fn)?.push(call[1] as string);
|
|
121
|
+
}
|
|
122
|
+
return byFn;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function subcommandTargets(): Map<string, string[]> {
|
|
126
|
+
const byFn = functionTargets();
|
|
127
|
+
const map = new Map<string, string[]>();
|
|
128
|
+
let current: string | null = null;
|
|
129
|
+
let inCaseBody = false;
|
|
130
|
+
for (const { line } of runShCodeLines()) {
|
|
131
|
+
const label = line.match(/^\s{2}([a-z][a-z0-9-]*)\)\s*$/);
|
|
132
|
+
if (label) {
|
|
133
|
+
current = label[1] as string;
|
|
134
|
+
inCaseBody = true;
|
|
135
|
+
map.set(current, []);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (!current || !inCaseBody) continue;
|
|
139
|
+
if (/^\s{4};;\s*$/.test(line)) {
|
|
140
|
+
inCaseBody = false;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const direct = line.match(/run_ts\s+scripts\/([a-z0-9-]+)\.ts/);
|
|
144
|
+
if (direct) map.get(current)?.push(direct[1] as string);
|
|
145
|
+
// …and one hop through a helper function, which is how most subcommands are written.
|
|
146
|
+
for (const word of line.trim().split(/[\s;|&(]+/)) {
|
|
147
|
+
const viaFn = byFn.get(word);
|
|
148
|
+
if (viaFn?.length) map.get(current)?.push(...viaFn);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return map;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const targets = subcommandTargets();
|
|
155
|
+
const isDevGate = (cmd: string): boolean => cmd.startsWith("check-") || cmd.startsWith("smoke-");
|
|
156
|
+
const operatorCmds = [...targets].filter(([cmd, ts]) => !isDevGate(cmd) && ts.length > 0);
|
|
157
|
+
|
|
158
|
+
{
|
|
159
|
+
const dead = operatorCmds.flatMap(([cmd, tss]) => tss.filter((ts) => !emitted.has(ts)).map((ts) => [cmd, ts]));
|
|
160
|
+
ok(
|
|
161
|
+
`S2: every operator subcommand has a compiled twin (${operatorCmds.length} operator surfaces, direct + via helper)`,
|
|
162
|
+
dead.length === 0,
|
|
163
|
+
dead
|
|
164
|
+
.map(
|
|
165
|
+
([cmd, ts]) =>
|
|
166
|
+
`'entwurf ${cmd}' runs scripts/${ts}.ts, which tsconfig.build.json does not emit —\n` +
|
|
167
|
+
`it will die under node_modules with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING.\n` +
|
|
168
|
+
`Add "../../scripts/${ts}.ts" to the build include and to the check-pack artifact list.`,
|
|
169
|
+
)
|
|
170
|
+
.join("\n\n"),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const bloat = [...targets].flatMap(([cmd, tss]) =>
|
|
174
|
+
isDevGate(cmd) ? tss.filter((ts) => emitted.has(ts)).map((ts) => [cmd, ts]) : [],
|
|
175
|
+
);
|
|
176
|
+
ok(
|
|
177
|
+
"S4: dev-only gates are not emitted into the tarball",
|
|
178
|
+
bloat.length === 0,
|
|
179
|
+
bloat.map(([cmd, ts]) => `'${cmd}' is a dev gate but scripts/${ts}.ts is in the build include`).join("\n"),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── S3 — no npm bin can reach a raw .ts when installed ──────────────────────────────
|
|
184
|
+
{
|
|
185
|
+
// Two ways a bin dies under node_modules: it IS a .ts, or it is a wrapper that execs one
|
|
186
|
+
// without branching. The first is flatly forbidden — there is no correct way to point an npm
|
|
187
|
+
// bin at a raw .ts, since Node cannot strip types there at all.
|
|
188
|
+
const rawTsBins: string[] = [];
|
|
189
|
+
const unbranchedWrappers: string[] = [];
|
|
190
|
+
for (const [name, rel] of Object.entries(PACKAGE_JSON.bin ?? {})) {
|
|
191
|
+
if (/\.ts$/.test(rel)) {
|
|
192
|
+
rawTsBins.push(`${name} → ${rel}`);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (!rel.endsWith(".sh")) continue;
|
|
196
|
+
const src = readFileSync(path.join(REPO, rel), "utf8");
|
|
197
|
+
const execsTs = /--experimental-strip-types[^\n]*\.ts|\.ts"?\s*$/m.test(src) && /\.ts/.test(src);
|
|
198
|
+
if (!execsTs) continue; // python3/JS-only wrappers (the statuslines) never hit the fence
|
|
199
|
+
if (!/node_modules\/\*/.test(src)) unbranchedWrappers.push(rel);
|
|
200
|
+
}
|
|
201
|
+
ok(
|
|
202
|
+
"S3a: no npm bin points at a raw .ts (Node cannot strip types under node_modules at all)",
|
|
203
|
+
rawTsBins.length === 0,
|
|
204
|
+
rawTsBins.map((b) => `bin ${b} is a raw .ts — it can never run from an installed package`).join("\n"),
|
|
205
|
+
);
|
|
206
|
+
ok(
|
|
207
|
+
"S3b: every npm bin wrapper that execs a .ts branches on node_modules",
|
|
208
|
+
unbranchedWrappers.length === 0,
|
|
209
|
+
unbranchedWrappers
|
|
210
|
+
.map((f) => `${f} execs a .ts with no */node_modules/* branch — it dies when installed`)
|
|
211
|
+
.join("\n"),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── S5 — verification must not rewire the operator's live host ──────────────────────
|
|
216
|
+
{
|
|
217
|
+
// A LIVE gate legitimately drives the real host (that is what LIVE means). The offline
|
|
218
|
+
// floor — everything in `pnpm check` — must not: it runs on every commit, and a smoke
|
|
219
|
+
// that writes the real ~/.claude, ~/.gemini or ~/.pi would uninstall the operator's
|
|
220
|
+
// own bridge as a side effect of "testing".
|
|
221
|
+
const LIVE_GATES = new Set(["smoke-resident-garden-guard.sh", "smoke-meta-async-drift.sh"]);
|
|
222
|
+
|
|
223
|
+
// Presence of a HOME= override SOMEWHERE in the file proves nothing — an isolated smoke
|
|
224
|
+
// can still carry one destructive line aimed at the real install. Look at the WRITES
|
|
225
|
+
// themselves: a mutation whose target is a literal $HOME/~ config path is operating on
|
|
226
|
+
// the operator's own bridge, whatever the rest of the file does. Reads ($HOME/.current-device,
|
|
227
|
+
// probing whether a real bundle exists) stay legal — they cannot uninstall anything.
|
|
228
|
+
const LIVE_ROOTS = String.raw`(?:\$HOME|\$\{HOME\}|~)/\.(?:claude|gemini|pi|config/pi|local/share/(?:claude|entwurf))`;
|
|
229
|
+
const MUTATORS = String.raw`rm\s+-\w+|rm|mv|cp|install|mkdir\s+-p|mkdir|touch|tee|ln\s+-s\w*|truncate`;
|
|
230
|
+
|
|
231
|
+
// A mutating run.sh drive is shell code, not prose. `bad "… re-run ./run.sh install-meta-bridge"`
|
|
232
|
+
// and a python assertion string both carry the same token, so normalize a line to its code
|
|
233
|
+
// before looking for a drive: drop message arguments, keep "$RUN" / "$REPO/run.sh" as the
|
|
234
|
+
// executable tokens they are, and blank every other quoted literal.
|
|
235
|
+
const MSG_ARG = /\b(?:bad|ok|echo|log|warn|fail|printf)\s+"(?:[^"\\]|\\.)*"/g;
|
|
236
|
+
const EXEC_TOKEN = /"(\$\{?RUN\}?|\$[A-Za-z_]\w*\/run\.sh|\.{0,2}\/?run\.sh)"/g;
|
|
237
|
+
const shellCode = (l: string) =>
|
|
238
|
+
l
|
|
239
|
+
.replace(MSG_ARG, "")
|
|
240
|
+
.replace(EXEC_TOKEN, "$1")
|
|
241
|
+
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
|
|
242
|
+
.replace(/'(?:[^'\\]|\\.)*'/g, "''");
|
|
243
|
+
const RUNSH_DRIVE = /(?:\$\{?RUN\}?|run\.sh)\s+(install|setup:links|setup|remove-user-scope|remove)\b/;
|
|
244
|
+
// ensure_agent_dir_symlinks() hard-codes "$HOME/.pi/agent" and ignores PI_CODING_AGENT_DIR
|
|
245
|
+
// entirely (run.sh:424), so these commands relink the operator's REAL agent dir no matter how
|
|
246
|
+
// the override is set. Sandboxing the agent dir is not isolation for them; only HOME is.
|
|
247
|
+
const HOME_ROOTED = new Set(["install", "setup", "setup:links"]);
|
|
248
|
+
|
|
249
|
+
const offenders: { file: string; line: number; text: string }[] = [];
|
|
250
|
+
const xdgOffenders: string[] = [];
|
|
251
|
+
const agentDirXdgOffenders: string[] = [];
|
|
252
|
+
for (const f of readdirSync(path.join(REPO, "scripts"))) {
|
|
253
|
+
if (!/^(smoke|check)-.*\.sh$/.test(f) || LIVE_GATES.has(f)) continue;
|
|
254
|
+
const lines = readFileSync(path.join(REPO, "scripts", f), "utf8").split("\n");
|
|
255
|
+
|
|
256
|
+
// The install smokes DO write "$HOME/.claude/…" — legally, because they first swap the
|
|
257
|
+
// process HOME to a sandbox (`export HOME="$TMP/home"`), after which $HOME no longer names
|
|
258
|
+
// the operator's home at all. So the offense is not "writes $HOME"; it is writing a live
|
|
259
|
+
// path while $HOME still points at the real one: with no swap, or before the swap.
|
|
260
|
+
const swapAt = lines.findIndex((l) => /^\s*export\s+HOME=/.test(l) && !/(\$HOME|~)\//.test(l));
|
|
261
|
+
|
|
262
|
+
// A HOME swap DECLARES isolation — and isolation that inherits the operator's real
|
|
263
|
+
// XDG_DATA_HOME is a lie: every install adapter roots its state at
|
|
264
|
+
// ${XDG_DATA_HOME:-$HOME/.local/share}/entwurf, so the "sandboxed" run keeps writing
|
|
265
|
+
// real state records whose managed targets are sandbox paths. That is the exact leak
|
|
266
|
+
// that broke a live host's provenance on 2026-07-14 (AGENTS.md hard rule 11: swap BOTH).
|
|
267
|
+
// The swap must not itself lean on the unswapped variable ($XDG_DATA_HOME on the RHS).
|
|
268
|
+
const xdgSwapAt = lines.findIndex((l) => /^\s*export\s+XDG_DATA_HOME=/.test(l) && !/\$\{?XDG_DATA_HOME/.test(l));
|
|
269
|
+
const xdgSwapped = xdgSwapAt !== -1;
|
|
270
|
+
if (swapAt !== -1 && !xdgSwapped) xdgOffenders.push(f);
|
|
271
|
+
|
|
272
|
+
// A mutating run.sh drive writes through THREE roots, and no single override covers them:
|
|
273
|
+
// the agent dir (settings target), ${XDG_DATA_HOME}/entwurf (the ownership state whose
|
|
274
|
+
// recorded managedSettingsPath an inverse FOLLOWS), and $HOME (the hard-coded agent-dir
|
|
275
|
+
// symlinks). smoke-user-scope-citizen sandboxed the first and passed the operator's real
|
|
276
|
+
// second, so `pnpm check` removed the live MCP key while reporting green (2026-07-14).
|
|
277
|
+
// Find the drive, then demand what that command actually needs — in whatever env form
|
|
278
|
+
// carries it. The leak shipped as inline env; the same drive hoisted into an `export` is
|
|
279
|
+
// the same leak, and a tripwire that only sees one syntactic form is how the next one ships.
|
|
280
|
+
const agentDirExportAt = lines.findIndex((l) => /^\s*export\s+PI_CODING_AGENT_DIR=/.test(l));
|
|
281
|
+
lines.forEach((line, i) => {
|
|
282
|
+
if (/^\s*#/.test(line)) return;
|
|
283
|
+
const code = shellCode(line);
|
|
284
|
+
const drive = code.match(RUNSH_DRIVE);
|
|
285
|
+
if (!drive) return;
|
|
286
|
+
const cmd = drive[1] as string;
|
|
287
|
+
const homeOk = /(?:^|\s)HOME=/.test(code) || (swapAt !== -1 && i > swapAt);
|
|
288
|
+
const xdgOk = /XDG_DATA_HOME=/.test(code) || (xdgSwapAt !== -1 && i > xdgSwapAt);
|
|
289
|
+
const agentOk = /PI_CODING_AGENT_DIR=/.test(code) || (agentDirExportAt !== -1 && i > agentDirExportAt) || homeOk;
|
|
290
|
+
const missing: string[] = [];
|
|
291
|
+
if (!xdgOk)
|
|
292
|
+
missing.push(
|
|
293
|
+
"XDG_DATA_HOME (real ownership state — an inverse follows its managedSettingsPath to the live host)",
|
|
294
|
+
);
|
|
295
|
+
if (!agentOk) missing.push("PI_CODING_AGENT_DIR or HOME (real ~/.pi/agent/settings.json)");
|
|
296
|
+
if (HOME_ROOTED.has(cmd) && !homeOk)
|
|
297
|
+
missing.push(
|
|
298
|
+
"HOME (ensure_agent_dir_symlinks hard-codes $HOME/.pi/agent — the agent-dir override does not reach it)",
|
|
299
|
+
);
|
|
300
|
+
if (missing.length)
|
|
301
|
+
agentDirXdgOffenders.push(
|
|
302
|
+
`scripts/${f}:${i + 1} drives \`run.sh ${cmd}\` without a sandbox ${missing.join("\n and without a sandbox ")}`,
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// One hop of aliasing: `VICTIM="$HOME/.gemini/…"` taints VICTIM, so `rm -rf "$VICTIM"` is
|
|
307
|
+
// caught too. Without this, renaming the path into a variable walks straight past the
|
|
308
|
+
// tripwire — which it did, until a review mutation proved it.
|
|
309
|
+
const tainted = new Set<string>();
|
|
310
|
+
lines.forEach((line, i) => {
|
|
311
|
+
if (/^\s*#/.test(line)) return;
|
|
312
|
+
const assign = line.match(new RegExp(String.raw`^\s*(?:local\s+|export\s+)?([A-Za-z_]\w*)=["']?${LIVE_ROOTS}`));
|
|
313
|
+
if (assign && (swapAt === -1 || i < swapAt)) tainted.add(assign[1] as string);
|
|
314
|
+
});
|
|
315
|
+
const aliasAlt = tainted.size
|
|
316
|
+
? String.raw`|(?:${MUTATORS})\s+(?:-\w+\s+)*["']?\$\{?(?:${[...tainted].join("|")})\b`
|
|
317
|
+
: "";
|
|
318
|
+
const WRITE_TO_LIVE = new RegExp(
|
|
319
|
+
String.raw`(?:(?:${MUTATORS})\s+(?:-\w+\s+)*["']?${LIVE_ROOTS}|>>?\s*["']?${LIVE_ROOTS}${aliasAlt})`,
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
lines.forEach((line, i) => {
|
|
323
|
+
if (/^\s*#/.test(line)) return;
|
|
324
|
+
if (!WRITE_TO_LIVE.test(line)) return;
|
|
325
|
+
const sandboxed = swapAt !== -1 && i > swapAt;
|
|
326
|
+
if (!sandboxed) offenders.push({ file: f, line: i + 1, text: line.trim() });
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
ok(
|
|
330
|
+
"S5: no offline smoke carries an obvious write to the real $HOME (static tripwire, not a sandbox proof)",
|
|
331
|
+
offenders.length === 0,
|
|
332
|
+
offenders.map((o) => `scripts/${o.file}:${o.line} mutates the operator's live install:\n ${o.text}`).join("\n"),
|
|
333
|
+
);
|
|
334
|
+
ok(
|
|
335
|
+
"S5b: every offline smoke that swaps HOME also swaps XDG_DATA_HOME (HOME alone still writes real install-state)",
|
|
336
|
+
xdgOffenders.length === 0,
|
|
337
|
+
xdgOffenders
|
|
338
|
+
.map(
|
|
339
|
+
(f) =>
|
|
340
|
+
`scripts/${f} exports a sandbox HOME but never exports a sandbox XDG_DATA_HOME —\n` +
|
|
341
|
+
`its install-state writes land under the operator's real ~/.local/share/entwurf.`,
|
|
342
|
+
)
|
|
343
|
+
.join("\n"),
|
|
344
|
+
);
|
|
345
|
+
ok(
|
|
346
|
+
"S5c: every mutating run.sh drive in the offline floor is sandboxed at every root it writes",
|
|
347
|
+
agentDirXdgOffenders.length === 0,
|
|
348
|
+
agentDirXdgOffenders.join("\n"),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
console.log(
|
|
353
|
+
failed === 0
|
|
354
|
+
? "\ncheck-install-surface: install surface is structurally fenced"
|
|
355
|
+
: `\ncheck-install-surface: ${failed} FAILED`,
|
|
356
|
+
);
|
|
357
|
+
process.exit(failed === 0 ? 0 : 1);
|