@junghanacs/entwurf 0.15.0 → 0.16.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 +1 -0
- package/CHANGELOG.md +299 -0
- package/DELIVERY.md +4 -3
- package/README.md +99 -67
- package/VERIFY.md +13 -7
- package/docs/acp-backend-rail.md +1 -1
- package/docs/external-mcp-host.md +147 -9
- package/docs/setup-clean-host.md +221 -16
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +65 -20
- package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +17 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +172 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +152 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/omp-fresh-preflight.js +271 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +988 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-receive-omp.js +574 -0
- package/mcp/entwurf-bridge/dist/scripts/omp-receive-facts.js +84 -0
- package/mcp/entwurf-bridge/src/index.ts +65 -19
- package/mcp/entwurf-bridge/tsconfig.build.json +19 -3
- package/mcp/tsconfig.json +10 -0
- package/package.json +14 -4
- package/pi/entwurf-capabilities.json +1 -0
- package/pi/meta-bridge-omp/entwurf-meta-omp/package.json +7 -0
- package/pi/omp-receive/entwurf-receive-omp/package.json +7 -0
- package/pi-extensions/entwurf-control.ts +12 -9
- package/pi-extensions/lib/entwurf-self-address.ts +18 -7
- package/pi-extensions/lib/meta-sender-identity.ts +1 -1
- package/pi-extensions/lib/meta-session.ts +219 -5
- package/pi-extensions/lib/mux-fresh-call.ts +171 -17
- package/pi-extensions/lib/omp-fresh-preflight.ts +275 -0
- package/pi-extensions/meta-bridge-omp.ts +1244 -0
- package/pi-extensions/meta-bridge-receive-omp.ts +666 -0
- package/run.sh +1041 -174
- package/scripts/agy-bridge.sh +4 -2
- package/scripts/check-copilot-birth-hook.ts +700 -23
- package/scripts/check-entwurf-self-address.ts +31 -0
- package/scripts/check-fresh-cut-gate.sh +12 -4
- package/scripts/check-gate-qualification.ts +8 -2
- package/scripts/check-harness-admission-parity.ts +143 -0
- package/scripts/check-meta-doctor-oracle.sh +20 -0
- package/scripts/check-omp-birth-hook.ts +1049 -0
- package/scripts/check-omp-fresh-preflight.ts +181 -0
- package/scripts/check-omp-receive-arm.ts +516 -0
- package/scripts/check-setup-qualification.sh +126 -0
- package/scripts/copilot-bridge-doctor.sh +104 -71
- package/scripts/copilot-bridge-install.sh +180 -20
- package/scripts/copilot-bridge-oracle.sh +223 -0
- package/scripts/copilot-bridge-uninstall.sh +137 -0
- package/scripts/dev-bin.sh +50 -9
- package/scripts/fake-copilot-vendor.sh +67 -0
- package/scripts/inventory-verification-surface.ts +1 -1
- package/scripts/meta-bridge-hook-log.sh +9 -1
- package/scripts/mutants/copilot-birth.json +96 -5
- package/scripts/mutants/mux-fresh-call.json +22 -22
- package/scripts/mutants/omp-birth.json +173 -0
- package/scripts/mutants/omp-fresh.json +300 -0
- package/scripts/mutants/omp-receive.json +135 -0
- package/scripts/mutants/pi-package-ownership.json +92 -0
- package/scripts/mutants/self-address.json +34 -0
- package/scripts/mutants/setup-verdict.json +148 -0
- package/scripts/mutants/source-install.json +32 -0
- package/scripts/omp-bridge-doctor.sh +315 -0
- package/scripts/omp-bridge-install.sh +221 -0
- package/scripts/omp-bridge-oracle.sh +154 -0
- package/scripts/omp-bridge-uninstall.sh +57 -0
- package/scripts/omp-mcp-bridge.sh +320 -0
- package/scripts/omp-mcp-config.py +392 -0
- package/scripts/omp-receive-doctor.sh +246 -0
- package/scripts/omp-receive-facts.ts +106 -0
- package/scripts/omp-receive-install.sh +228 -0
- package/scripts/omp-receive-uninstall.sh +60 -0
- package/scripts/omp-tool-surface.py +349 -0
- package/scripts/pi_settings_io.py +22 -0
- package/scripts/raw-omp-measure/README.md +420 -0
- package/scripts/raw-omp-measure/probe-extension.ts +76 -0
- package/scripts/raw-omp-measure/probe-receive-surface.ts +250 -0
- package/scripts/raw-omp-measure/source-audit.md +414 -0
- package/scripts/register-pi-package.py +525 -7
- package/scripts/register-pi-provider.py +162 -12
- package/scripts/smoke-agy-install-state.sh +86 -30
- package/scripts/smoke-agy-statusline-state.sh +9 -6
- package/scripts/smoke-omp-bridge-state.sh +221 -0
- package/scripts/smoke-omp-fresh-live.ts +497 -0
- package/scripts/smoke-omp-mcp-state.sh +327 -0
- package/scripts/smoke-omp-receive-live.ts +340 -0
- package/scripts/smoke-omp-receive-state.sh +196 -0
- package/scripts/smoke-setup-verdict.sh +291 -0
- package/scripts/smoke-user-scope-citizen.sh +388 -1
- package/scripts/tsconfig.json +2 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-omp-receive-arm — the hermetic gate for the OMP RECEIVER unit (#87 bundle B).
|
|
3
|
+
*
|
|
4
|
+
* WHAT IT DRIVES. The REAL assembler (`omp-receive-install.sh --assemble-only`) into a
|
|
5
|
+
* temp dir, then imports the ASSEMBLED `index.ts` and binds it to a mock omp host. So the
|
|
6
|
+
* subject is the artifact an operator would get, not a second copy of the logic — the same
|
|
7
|
+
* discipline `check-omp-birth-hook` holds.
|
|
8
|
+
*
|
|
9
|
+
* WHY A MOCK HOST IS ENOUGH HERE, AND WHERE IT STOPS. Everything on entwurf's side of the
|
|
10
|
+
* vendor boundary — which sessions arm, which refuse, who owns the marker, what the
|
|
11
|
+
* doorbell says, what happens on `/new`, what happens when the watch dies — is decided by
|
|
12
|
+
* this unit and can be proved without a model turn. What a mock CANNOT prove is that
|
|
13
|
+
* `pi.sendUserMessage` really starts a turn on an idle host; that is
|
|
14
|
+
* `smoke-omp-receive-live`'s job, and the two receipts must never stand in for each other.
|
|
15
|
+
*
|
|
16
|
+
* THE SHARED FOUR-ROOT POLICY IS NOT RE-PROVED HERE. `ompMetaRoots` is one leaf with two
|
|
17
|
+
* consumers and `check-omp-birth-hook` already pins it (poisoned `PI_CODING_AGENT_DIR`,
|
|
18
|
+
* the four overrides, the absolute-or-`~` grammar, the doctor's agreement). This gate
|
|
19
|
+
* injects explicit roots instead, so a failure here is always about receive behaviour and
|
|
20
|
+
* never about root resolution — two gates, two subjects.
|
|
21
|
+
*/
|
|
22
|
+
import assert from "node:assert/strict";
|
|
23
|
+
import { execFileSync } from "node:child_process";
|
|
24
|
+
import * as fs from "node:fs";
|
|
25
|
+
import * as os from "node:os";
|
|
26
|
+
import * as path from "node:path";
|
|
27
|
+
import { pathToFileURL } from "node:url";
|
|
28
|
+
import {
|
|
29
|
+
type MetaRootBundle,
|
|
30
|
+
metaReceiverMarkerPath,
|
|
31
|
+
mintMetaIdentity,
|
|
32
|
+
readMetaReceiverMarker,
|
|
33
|
+
serializeMetaIdentity,
|
|
34
|
+
writeMetaSenderMarker,
|
|
35
|
+
} from "../pi-extensions/lib/meta-session.ts";
|
|
36
|
+
|
|
37
|
+
const REPO = path.resolve(import.meta.dirname, "..");
|
|
38
|
+
let passed = 0;
|
|
39
|
+
function ok(label: string, cond: boolean, detail = ""): void {
|
|
40
|
+
assert.ok(cond, `${label}${detail ? `\n${detail}` : ""}`);
|
|
41
|
+
console.log(` ok ${label}`);
|
|
42
|
+
passed++;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "check-omp-receive-"));
|
|
46
|
+
process.on("exit", () => {
|
|
47
|
+
try {
|
|
48
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
49
|
+
} catch {
|
|
50
|
+
/* scratch cleanup is best-effort */
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ── the REAL assembler, into a temp dir ──────────────────────────────────────
|
|
55
|
+
const asm = path.join(tmp, "asm");
|
|
56
|
+
execFileSync("bash", [path.join(REPO, "run.sh"), "install-omp-receive", "--assemble-only"], {
|
|
57
|
+
stdio: "pipe",
|
|
58
|
+
env: { ...process.env, ENTWURF_OMP_RECEIVE_ASM: asm },
|
|
59
|
+
});
|
|
60
|
+
const entry = path.join(asm, "entwurf-receive-omp", "index.ts");
|
|
61
|
+
ok("the real assembler produced an importable unit entry", fs.existsSync(entry), ` ${entry}`);
|
|
62
|
+
const mod = await import(pathToFileURL(entry).href);
|
|
63
|
+
ok("the assembled unit exports bindOmpReceiver", typeof mod.bindOmpReceiver === "function");
|
|
64
|
+
|
|
65
|
+
// ── the mock omp host ────────────────────────────────────────────────────────
|
|
66
|
+
// Narrow on purpose: exactly the surface the unit touches, with the two vendor facts this
|
|
67
|
+
// lane MEASURED baked in — `sendUserMessage` on the FACTORY object, timers on the event
|
|
68
|
+
// ctx with `clearTimer` (never `clearInterval`) as the canceller.
|
|
69
|
+
interface Timer {
|
|
70
|
+
fn: () => void;
|
|
71
|
+
cancelled: boolean;
|
|
72
|
+
}
|
|
73
|
+
class MockCtx {
|
|
74
|
+
timers: Timer[] = [];
|
|
75
|
+
mode: string;
|
|
76
|
+
nativeSessionId: string;
|
|
77
|
+
opts: { withTimers?: boolean; withClear?: boolean };
|
|
78
|
+
constructor(mode: string, nativeSessionId: string, opts: { withTimers?: boolean; withClear?: boolean } = {}) {
|
|
79
|
+
this.mode = mode;
|
|
80
|
+
this.nativeSessionId = nativeSessionId;
|
|
81
|
+
this.opts = opts;
|
|
82
|
+
}
|
|
83
|
+
sessionManager = {
|
|
84
|
+
getSessionId: () => this.nativeSessionId,
|
|
85
|
+
};
|
|
86
|
+
setInterval = (fn: () => void, _ms: number): unknown => {
|
|
87
|
+
if (this.opts.withTimers === false) throw new Error("no timers on this build");
|
|
88
|
+
const t: Timer = { fn, cancelled: false };
|
|
89
|
+
this.timers.push(t);
|
|
90
|
+
return t;
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* PER-CONTEXT OWNERSHIP, and it is the whole point of this mock.
|
|
94
|
+
*
|
|
95
|
+
* A `clearTimer` that cancels any handle it is handed cannot tell a unit which cancels
|
|
96
|
+
* through the CREATING context from one which reaches for whichever context the current
|
|
97
|
+
* edge happens to carry. Nothing measured says a second event context can cancel a timer
|
|
98
|
+
* the first one created, so the oracle assumes it cannot: a foreign handle is a SILENT
|
|
99
|
+
* no-op, exactly as an unowned timer id would be. The overlapping-edge cell below is
|
|
100
|
+
* only a real test under this rule — under the permissive version it passed while the
|
|
101
|
+
* unit was still guessing.
|
|
102
|
+
*/
|
|
103
|
+
clearTimer = (handle: unknown): void => {
|
|
104
|
+
if (!this.timers.includes(handle as Timer)) return;
|
|
105
|
+
(handle as Timer).cancelled = true;
|
|
106
|
+
};
|
|
107
|
+
/** Fire every live timer once — the deterministic stand-in for the vendor's clock. */
|
|
108
|
+
tick(): void {
|
|
109
|
+
for (const t of [...this.timers]) if (!t.cancelled) t.fn();
|
|
110
|
+
}
|
|
111
|
+
liveTimers(): number {
|
|
112
|
+
return this.timers.filter((t) => !t.cancelled).length;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function makeCtx(mode: string, native: string, opts: { withTimers?: boolean; withClear?: boolean } = {}): any {
|
|
116
|
+
const ctx: any = new MockCtx(mode, native, opts);
|
|
117
|
+
if (opts.withClear === false) ctx.clearTimer = undefined;
|
|
118
|
+
if (opts.withTimers === false) ctx.setInterval = undefined;
|
|
119
|
+
return ctx;
|
|
120
|
+
}
|
|
121
|
+
class MockPi {
|
|
122
|
+
handlers = new Map<string, Array<(e: unknown, c: unknown) => void>>();
|
|
123
|
+
sent: string[] = [];
|
|
124
|
+
canSend: boolean;
|
|
125
|
+
constructor(canSend = true) {
|
|
126
|
+
this.canSend = canSend;
|
|
127
|
+
if (!canSend) (this as any).sendUserMessage = undefined;
|
|
128
|
+
}
|
|
129
|
+
on = (evt: string, h: (e: unknown, c: unknown) => void): void => {
|
|
130
|
+
const list = this.handlers.get(evt) ?? [];
|
|
131
|
+
list.push(h);
|
|
132
|
+
this.handlers.set(evt, list);
|
|
133
|
+
};
|
|
134
|
+
sendUserMessage = (text: string): void => {
|
|
135
|
+
this.sent.push(text);
|
|
136
|
+
};
|
|
137
|
+
fire(evt: string, ctx: unknown, event: unknown = {}): void {
|
|
138
|
+
for (const h of this.handlers.get(evt) ?? []) h(event, ctx);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let cellSeq = 0;
|
|
143
|
+
interface Cell {
|
|
144
|
+
roots: MetaRootBundle;
|
|
145
|
+
pid: number;
|
|
146
|
+
logs: string[];
|
|
147
|
+
pi: MockPi;
|
|
148
|
+
receiver: any;
|
|
149
|
+
}
|
|
150
|
+
/** One isolated garden per cell: no cell can pass because another cell left state behind. */
|
|
151
|
+
function newCell(opts: { canSend?: boolean } = {}): Cell {
|
|
152
|
+
const base = path.join(tmp, `cell-${++cellSeq}`);
|
|
153
|
+
const roots: MetaRootBundle = {
|
|
154
|
+
sessionsDir: path.join(base, "meta-sessions"),
|
|
155
|
+
mailboxDir: path.join(base, "meta-mailbox"),
|
|
156
|
+
sendersDir: path.join(base, "meta-senders"),
|
|
157
|
+
receiversDir: path.join(base, "meta-receivers"),
|
|
158
|
+
};
|
|
159
|
+
for (const d of Object.values(roots)) fs.mkdirSync(d, { recursive: true });
|
|
160
|
+
const logs: string[] = [];
|
|
161
|
+
const pi = new MockPi(opts.canSend ?? true);
|
|
162
|
+
const receiver = mod.bindOmpReceiver(pi, {
|
|
163
|
+
pid: process.pid,
|
|
164
|
+
rootsFor: () => roots,
|
|
165
|
+
log: (level: string, message: string) => logs.push(`${level} ${message}`),
|
|
166
|
+
});
|
|
167
|
+
return { roots, pid: process.pid, logs, pi, receiver };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Seed a born citizen: the V3 record plus the sender marker birth would have written. */
|
|
171
|
+
function seedBirth(cell: Cell, nativeSessionId: string): string {
|
|
172
|
+
const identity = mintMetaIdentity({ backend: "omp", nativeSessionId, cwd: "/tmp", transcriptPath: null });
|
|
173
|
+
fs.writeFileSync(
|
|
174
|
+
path.join(cell.roots.sessionsDir, `${identity.gardenId}.meta.json`),
|
|
175
|
+
serializeMetaIdentity(identity),
|
|
176
|
+
);
|
|
177
|
+
writeMetaSenderMarker({
|
|
178
|
+
backend: "omp",
|
|
179
|
+
gardenId: identity.gardenId,
|
|
180
|
+
nativeSessionId,
|
|
181
|
+
cwd: "/tmp",
|
|
182
|
+
ownerPid: cell.pid,
|
|
183
|
+
sendersDir: cell.roots.sendersDir,
|
|
184
|
+
});
|
|
185
|
+
return identity.gardenId;
|
|
186
|
+
}
|
|
187
|
+
function markerFor(cell: Cell, gardenId: string) {
|
|
188
|
+
return readMetaReceiverMarker({ gardenId, receiversDir: cell.roots.receiversDir, verifyOwner: false });
|
|
189
|
+
}
|
|
190
|
+
function logHas(cell: Cell, needle: string): boolean {
|
|
191
|
+
return cell.logs.some((l) => l.includes(needle));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── 1. the §3.5 scope fence ──────────────────────────────────────────────────
|
|
195
|
+
// Every task subagent re-executes this factory by default, so a unit that armed on any
|
|
196
|
+
// mode would publish a doorbell for the HOST's citizen from inside a subagent — a second
|
|
197
|
+
// watcher on one mailbox, and a marker whose owner is not the session it names.
|
|
198
|
+
for (const mode of ["print", "rpc", "json"]) {
|
|
199
|
+
const cell = newCell();
|
|
200
|
+
const gid = seedBirth(cell, `native-${mode}`);
|
|
201
|
+
cell.pi.fire("session_start", makeCtx(mode, `native-${mode}`));
|
|
202
|
+
ok(`[QK:OMP-RECEIVE-SCOPE-FENCE] mode=${mode} arms NOTHING`, markerFor(cell, gid) === null);
|
|
203
|
+
ok(`mode=${mode} leaves a scope-refused receipt in the log`, logHas(cell, `scope-refused`));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── 2. the happy path, and what the marker says ──────────────────────────────
|
|
207
|
+
{
|
|
208
|
+
const cell = newCell();
|
|
209
|
+
const gid = seedBirth(cell, "native-happy");
|
|
210
|
+
cell.pi.fire("session_start", makeCtx("tui", "native-happy"));
|
|
211
|
+
const m = markerFor(cell, gid);
|
|
212
|
+
ok("[QK:OMP-RECEIVE-ARMS-TUI-HOST] a tui host with a born citizen arms a receiver marker", m !== null);
|
|
213
|
+
ok("the marker names the HOST process as the watch owner", m?.ownerKind === "omp-host" && m?.ownerPid === cell.pid);
|
|
214
|
+
ok(
|
|
215
|
+
"the marker carries backend omp and the vendor's native session id",
|
|
216
|
+
m?.backend === "omp" && m?.nativeSessionId === "native-happy",
|
|
217
|
+
);
|
|
218
|
+
ok("armProvenance is session-start (the edge that armed it)", m?.armProvenance === "session-start");
|
|
219
|
+
cell.receiver.unarm("gate-teardown");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── 3. ORDER: no sender marker yet is a DEFER, not an arm ────────────────────
|
|
223
|
+
// The measured failure mode: a discovered extension sorting before the birth unit runs
|
|
224
|
+
// first and finds nothing to join. Arming anyway would publish a doorbell for a citizen
|
|
225
|
+
// that does not exist; refusing forever would leave it permanently unaddressable.
|
|
226
|
+
{
|
|
227
|
+
const cell = newCell();
|
|
228
|
+
const ctx = makeCtx("tui", "native-late");
|
|
229
|
+
cell.pi.fire("session_start", ctx);
|
|
230
|
+
ok(
|
|
231
|
+
"[QK:OMP-RECEIVE-DEFERS-BEFORE-BIRTH] nothing is armed while birth has not run",
|
|
232
|
+
fs.readdirSync(cell.roots.receiversDir).length === 0,
|
|
233
|
+
);
|
|
234
|
+
ok("[QK:OMP-RECEIVE-RETRY-ARMS] a bounded retry timer is running after a deferred arm", ctx.liveTimers() === 1);
|
|
235
|
+
ok("the log says arm-deferred, not arm-refused", logHas(cell, "arm-deferred") && !logHas(cell, "arm-refused"));
|
|
236
|
+
const gid = seedBirth(cell, "native-late");
|
|
237
|
+
ctx.tick();
|
|
238
|
+
ok("the retry arms once birth's marker appears", markerFor(cell, gid) !== null);
|
|
239
|
+
ok("the retry timer is cancelled once armed (ctx.clearTimer, the vendor's only canceller)", ctx.liveTimers() === 0);
|
|
240
|
+
cell.receiver.unarm("gate-teardown");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── 3b. TWO OVERLAPPING EDGES: the first context's timer must really stop ────
|
|
244
|
+
// The gap a review found, and it is the uncancellable-timer failure wearing a different
|
|
245
|
+
// hat. `onEdge` used to call `cancelRetry()` with no argument, so the optional
|
|
246
|
+
// `ctx?.clearTimer?.()` did nothing while `retryHandle` was set to null: the handle was
|
|
247
|
+
// DROPPED rather than cancelled. A second birth edge during a deferred arm then left the
|
|
248
|
+
// first context's 500ms poll running in the operator's TUI with no reference left to stop
|
|
249
|
+
// it, and started a second beside it — one orphan per `/new` while birth was slow.
|
|
250
|
+
//
|
|
251
|
+
// The signed assertion is deliberately the FIRST one the defect breaks. A signature placed
|
|
252
|
+
// after an unsigned assertion the same mutant also breaks reads back as KILLED-WRONG-REASON,
|
|
253
|
+
// which is how this gate learned to place them (see the retry cell above).
|
|
254
|
+
{
|
|
255
|
+
const cell = newCell();
|
|
256
|
+
const ctx1 = makeCtx("tui", "native-one");
|
|
257
|
+
cell.pi.fire("session_start", ctx1);
|
|
258
|
+
ok("a deferred arm leaves a live retry timer on the FIRST context", ctx1.liveTimers() === 1);
|
|
259
|
+
|
|
260
|
+
const gid2 = seedBirth(cell, "native-two");
|
|
261
|
+
const ctx2 = makeCtx("tui", "native-two");
|
|
262
|
+
cell.pi.fire("session_switch", ctx2, { reason: "new" });
|
|
263
|
+
ok(
|
|
264
|
+
"[QK:OMP-RECEIVE-EDGE-CANCELS-OLD-RETRY] a second birth edge CANCELS the first context's timer instead of orphaning it",
|
|
265
|
+
ctx1.liveTimers() === 0,
|
|
266
|
+
` ctx1 live=${ctx1.liveTimers()} ctx2 live=${ctx2.liveTimers()}`,
|
|
267
|
+
);
|
|
268
|
+
ok("the second edge armed for its own citizen", markerFor(cell, gid2) !== null);
|
|
269
|
+
ok("and it started no retry of its own, having armed immediately", ctx2.liveTimers() === 0);
|
|
270
|
+
// An orphaned timer is not merely untidy: it keeps calling back into a receiver that has
|
|
271
|
+
// moved on. Ticking the abandoned context must therefore be inert.
|
|
272
|
+
const before = fs.readdirSync(cell.roots.receiversDir).length;
|
|
273
|
+
ctx1.tick();
|
|
274
|
+
ok("ticking the abandoned context changes nothing", fs.readdirSync(cell.roots.receiversDir).length === before);
|
|
275
|
+
cell.receiver.unarm("gate-teardown");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── 4. the retry is BOUNDED ──────────────────────────────────────────────────
|
|
279
|
+
// An unbounded poll inside the operator's TUI is a leak, and "birth never happened" is a
|
|
280
|
+
// real outcome that deserves a logged giving-up rather than a silent forever-loop.
|
|
281
|
+
{
|
|
282
|
+
const cell = newCell();
|
|
283
|
+
const ctx = makeCtx("tui", "native-never");
|
|
284
|
+
cell.pi.fire("session_start", ctx);
|
|
285
|
+
for (let i = 0; i < 60 && ctx.liveTimers() > 0; i++) ctx.tick();
|
|
286
|
+
ok("[QK:OMP-RECEIVE-RETRY-BOUNDED] the retry gives up instead of polling forever", ctx.liveTimers() === 0);
|
|
287
|
+
ok("giving up is LOGGED as not-addressable", logHas(cell, "arm-gave-up"));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── 5. a timer this build cannot cancel is never started ─────────────────────
|
|
291
|
+
// `ctx.clearInterval` does not exist on omp v18; calling it through `?.` was measured to
|
|
292
|
+
// be a SILENT no-op that left a 500ms poll running for the life of the session. An
|
|
293
|
+
// unarmed citizen is an honest refusal; an uncancellable timer is a defect we installed.
|
|
294
|
+
{
|
|
295
|
+
const cell = newCell();
|
|
296
|
+
const ctx = makeCtx("tui", "native-noclear", { withClear: false });
|
|
297
|
+
cell.pi.fire("session_start", ctx);
|
|
298
|
+
ok("[QK:OMP-RECEIVE-NO-UNCANCELLABLE-TIMER] no retry is started without ctx.clearTimer", ctx.timers.length === 0);
|
|
299
|
+
ok("the refusal names both probed methods", logHas(cell, "arm-retry-unavailable"));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── 6. a doorbell that cannot ring is never advertised ───────────────────────
|
|
303
|
+
{
|
|
304
|
+
const cell = newCell({ canSend: false });
|
|
305
|
+
const gid = seedBirth(cell, "native-nosend");
|
|
306
|
+
cell.pi.fire("session_start", makeCtx("tui", "native-nosend"));
|
|
307
|
+
ok(
|
|
308
|
+
"[QK:OMP-RECEIVE-REFUSES-WITHOUT-WAKE] no marker when pi.sendUserMessage is absent",
|
|
309
|
+
markerFor(cell, gid) === null,
|
|
310
|
+
);
|
|
311
|
+
ok("the refusal is an ERROR, not a silent skip", logHas(cell, "ERROR") && logHas(cell, "cannot ring"));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── 7. drift refusals, on BOTH axes, isolated from each other ────────────────
|
|
315
|
+
// A marker written against a drifted id tells a sender that a reply lands in a session
|
|
316
|
+
// which will never see it.
|
|
317
|
+
{
|
|
318
|
+
const cell = newCell();
|
|
319
|
+
const gid = seedBirth(cell, "native-A");
|
|
320
|
+
cell.pi.fire("session_start", makeCtx("tui", "native-B"));
|
|
321
|
+
ok("a vendor id that disagrees with BOTH the marker and the record arms nothing", markerFor(cell, gid) === null);
|
|
322
|
+
ok("no marker file exists at all for the drifted cell", fs.readdirSync(cell.roots.receiversDir).length === 0);
|
|
323
|
+
}
|
|
324
|
+
{
|
|
325
|
+
// THE ISOLATING CELL, and it exists because a mutant SURVIVED without it. Removing the
|
|
326
|
+
// marker-level id check left this gate green: in the cell above the RECORD check caught
|
|
327
|
+
// the drift too, so the two guards were indistinguishable and only one was really pinned.
|
|
328
|
+
// Here the record AGREES with the vendor while the sender marker is stale — the shape a
|
|
329
|
+
// `/new` produces in the window before birth rewrites the marker. Only the marker-level
|
|
330
|
+
// check can refuse it, and arming here would publish a doorbell off an id the vendor has
|
|
331
|
+
// already moved past.
|
|
332
|
+
const cell = newCell();
|
|
333
|
+
const identity = mintMetaIdentity({
|
|
334
|
+
backend: "omp",
|
|
335
|
+
nativeSessionId: "native-current",
|
|
336
|
+
cwd: "/tmp",
|
|
337
|
+
transcriptPath: null,
|
|
338
|
+
});
|
|
339
|
+
fs.writeFileSync(
|
|
340
|
+
path.join(cell.roots.sessionsDir, `${identity.gardenId}.meta.json`),
|
|
341
|
+
serializeMetaIdentity(identity),
|
|
342
|
+
);
|
|
343
|
+
writeMetaSenderMarker({
|
|
344
|
+
backend: "omp",
|
|
345
|
+
gardenId: identity.gardenId,
|
|
346
|
+
nativeSessionId: "native-stale",
|
|
347
|
+
cwd: "/tmp",
|
|
348
|
+
ownerPid: cell.pid,
|
|
349
|
+
sendersDir: cell.roots.sendersDir,
|
|
350
|
+
});
|
|
351
|
+
cell.pi.fire("session_start", makeCtx("tui", "native-current"));
|
|
352
|
+
ok(
|
|
353
|
+
"[QK:OMP-RECEIVE-ID-DRIFT-REFUSED] a STALE sender marker is refused even when the record agrees with the vendor",
|
|
354
|
+
markerFor(cell, identity.gardenId) === null && fs.readdirSync(cell.roots.receiversDir).length === 0,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── 8. `/new`: the cell the start-key guard can NEVER catch ──────────────────
|
|
359
|
+
// Same pid, same start key, different citizen. Without an explicit unarm the previous
|
|
360
|
+
// garden id keeps reading as an ACTIVE receiver and dispatch enqueues into a mailbox this
|
|
361
|
+
// process has stopped watching — a false deliverability nothing upstream can detect,
|
|
362
|
+
// because from outside the process really is alive.
|
|
363
|
+
{
|
|
364
|
+
const cell = newCell();
|
|
365
|
+
const oldGid = seedBirth(cell, "native-first");
|
|
366
|
+
cell.pi.fire("session_start", makeCtx("tui", "native-first"));
|
|
367
|
+
ok("armed for the first citizen", markerFor(cell, oldGid) !== null);
|
|
368
|
+
const newGid = seedBirth(cell, "native-second");
|
|
369
|
+
cell.pi.fire("session_switch", makeCtx("tui", "native-second"), { reason: "new" });
|
|
370
|
+
ok(
|
|
371
|
+
"[QK:OMP-RECEIVE-NEW-RETIRES-OLD] the previous citizen's marker is GONE after /new",
|
|
372
|
+
markerFor(cell, oldGid) === null,
|
|
373
|
+
);
|
|
374
|
+
ok("the replacement citizen is armed", markerFor(cell, newGid) !== null);
|
|
375
|
+
ok("exactly one receiver marker remains", fs.readdirSync(cell.roots.receiversDir).length === 1);
|
|
376
|
+
cell.receiver.unarm("gate-teardown");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ── 9. ORDER CONTRACT: no watch, no marker ───────────────────────────────────
|
|
380
|
+
// `fs.watch` is a real failure surface. A marker written before it would advertise a
|
|
381
|
+
// doorbell nobody is listening at — the fail-closed rule inverted.
|
|
382
|
+
{
|
|
383
|
+
const cell = newCell();
|
|
384
|
+
const gid = seedBirth(cell, "native-nowatch");
|
|
385
|
+
// Make the mailbox path un-mkdir-able: a FILE where the directory must go.
|
|
386
|
+
fs.writeFileSync(path.join(cell.roots.mailboxDir, gid), "not a directory");
|
|
387
|
+
cell.pi.fire("session_start", makeCtx("tui", "native-nowatch"));
|
|
388
|
+
ok("a pre-watch setup failure leaves NO marker", markerFor(cell, gid) === null);
|
|
389
|
+
ok("the failure is logged as arm-failed", logHas(cell, "arm-failed"));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
{
|
|
393
|
+
// THE ORDERING CELL, and it exists because a mutant SURVIVED without it. The cell above
|
|
394
|
+
// fails at `mkdirSync`, BEFORE either the marker or the watch, so it cannot tell a unit
|
|
395
|
+
// that writes the marker after the watch from one that writes it before. Here the
|
|
396
|
+
// mailbox and the signal both exist and only `fs.watch` fails (EACCES on an unreadable
|
|
397
|
+
// signal — the same class as an exhausted inotify budget), so the two orderings give
|
|
398
|
+
// different answers and only the correct one leaves no marker.
|
|
399
|
+
const cell = newCell();
|
|
400
|
+
const gid = seedBirth(cell, "native-eacces");
|
|
401
|
+
const mailbox = path.join(cell.roots.mailboxDir, gid);
|
|
402
|
+
fs.mkdirSync(mailbox, { recursive: true });
|
|
403
|
+
const signal = path.join(mailbox, "inbox.signal");
|
|
404
|
+
fs.writeFileSync(signal, "");
|
|
405
|
+
fs.chmodSync(signal, 0o000);
|
|
406
|
+
cell.pi.fire("session_start", makeCtx("tui", "native-eacces"));
|
|
407
|
+
fs.chmodSync(signal, 0o600);
|
|
408
|
+
ok(
|
|
409
|
+
"[QK:OMP-RECEIVE-NO-WATCH-NO-MARKER] a watch that FAILED while the mailbox already existed leaves NO marker",
|
|
410
|
+
markerFor(cell, gid) === null,
|
|
411
|
+
);
|
|
412
|
+
ok("the watch failure is logged as arm-failed", logHas(cell, "arm-failed"));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ── 10. the doorbell: announce-only, and what it must not contain ────────────
|
|
416
|
+
{
|
|
417
|
+
const cell = newCell();
|
|
418
|
+
const gid = seedBirth(cell, "native-ring");
|
|
419
|
+
cell.pi.fire("session_start", makeCtx("tui", "native-ring"));
|
|
420
|
+
const mailbox = path.join(cell.roots.mailboxDir, gid);
|
|
421
|
+
const secret = "SECRET-BODY-THAT-MUST-NOT-BE-INJECTED";
|
|
422
|
+
fs.writeFileSync(path.join(mailbox, "2026-01-01T00-00-00-000Z-aaaaaa.msg"), secret);
|
|
423
|
+
fs.writeFileSync(path.join(mailbox, "inbox.signal"), new Date().toISOString());
|
|
424
|
+
// fs.watch is asynchronous even in a hermetic gate; wait on the effect, not a sleep.
|
|
425
|
+
const deadline = Date.now() + 5000;
|
|
426
|
+
while (cell.pi.sent.length === 0 && Date.now() < deadline) {
|
|
427
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
428
|
+
}
|
|
429
|
+
ok(
|
|
430
|
+
"[QK:OMP-RECEIVE-DOORBELL-RINGS] a fresh .msg rings the doorbell",
|
|
431
|
+
cell.pi.sent.length === 1,
|
|
432
|
+
` sent=${JSON.stringify(cell.pi.sent)}`,
|
|
433
|
+
);
|
|
434
|
+
const notice = cell.pi.sent[0] ?? "";
|
|
435
|
+
ok(
|
|
436
|
+
"the notice names the garden id and the drain tool",
|
|
437
|
+
notice.includes(gid) && notice.includes("entwurf_inbox_read"),
|
|
438
|
+
);
|
|
439
|
+
ok("[QK:OMP-RECEIVE-ANNOUNCE-NEVER-PUSHES] the notice does NOT carry the message body", !notice.includes(secret));
|
|
440
|
+
ok("the notice marks the bodies untrusted", notice.includes("untrusted"));
|
|
441
|
+
ok(
|
|
442
|
+
"the body was stamped .delivered BEFORE announcing (rename means rang, not read)",
|
|
443
|
+
fs.readdirSync(mailbox).some((f) => f.endsWith(".msg.delivered")),
|
|
444
|
+
);
|
|
445
|
+
cell.receiver.unarm("gate-teardown");
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ── 11. a vanished mailbox gives the marker back ─────────────────────────────
|
|
449
|
+
// `meta-bridge-fresh-cut` archives the mailbox tree; inotify follows the inode, so the
|
|
450
|
+
// watch survives pointing at a file nobody will ever poke again.
|
|
451
|
+
{
|
|
452
|
+
const cell = newCell();
|
|
453
|
+
const gid = seedBirth(cell, "native-vanish");
|
|
454
|
+
cell.pi.fire("session_start", makeCtx("tui", "native-vanish"));
|
|
455
|
+
ok("armed before the mailbox vanishes", markerFor(cell, gid) !== null);
|
|
456
|
+
const mailbox = path.join(cell.roots.mailboxDir, gid);
|
|
457
|
+
fs.writeFileSync(path.join(mailbox, "2026-01-01T00-00-00-000Z-bbbbbb.msg"), "body");
|
|
458
|
+
fs.rmSync(path.join(mailbox, "inbox.signal"), { force: true });
|
|
459
|
+
// Removing the watched file is itself a watch event, so the unit's own doorbell path
|
|
460
|
+
// runs and finds the signal gone — no test-only entry point is needed or offered.
|
|
461
|
+
const deadline2 = Date.now() + 5000;
|
|
462
|
+
while (markerFor(cell, gid) !== null && Date.now() < deadline2) {
|
|
463
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
464
|
+
}
|
|
465
|
+
ok(
|
|
466
|
+
"[QK:OMP-RECEIVE-VANISHED-SIGNAL-UNARMS] a signal that no longer exists retires the marker rather than advertising a dead doorbell",
|
|
467
|
+
markerFor(cell, gid) === null,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ── 12. teardown removes OUR marker only ─────────────────────────────────────
|
|
472
|
+
// A replacement receiver for the same citizen must never have ITS marker deleted by our
|
|
473
|
+
// teardown — the same identity guard the Copilot receiver holds.
|
|
474
|
+
{
|
|
475
|
+
const cell = newCell();
|
|
476
|
+
const gid = seedBirth(cell, "native-foreign");
|
|
477
|
+
cell.pi.fire("session_start", makeCtx("tui", "native-foreign"));
|
|
478
|
+
const file = metaReceiverMarkerPath(gid, cell.roots.receiversDir);
|
|
479
|
+
const mine = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
480
|
+
fs.writeFileSync(file, JSON.stringify({ ...mine, ownerPid: mine.ownerPid + 1 }, null, 2));
|
|
481
|
+
cell.receiver.unarm("gate-foreign-check");
|
|
482
|
+
ok(
|
|
483
|
+
"[QK:OMP-RECEIVE-UNARM-IS-IDENTITY-GUARDED] a marker owned by another pid survives our teardown",
|
|
484
|
+
fs.existsSync(file),
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ── 13. binding must not leak listeners into the operator's host ─────────────
|
|
489
|
+
// omp re-executes the extension factory PER SESSION, subagents included, and they all
|
|
490
|
+
// share one process. An unconditional `process.on("exit")` at bind time therefore grew one
|
|
491
|
+
// listener per subagent — this gate hit `MaxListenersExceededWarning` at 11 bindings, which
|
|
492
|
+
// in a real session would print that warning into the operator's TUI. Binding is not when a
|
|
493
|
+
// teardown becomes owed; ARMING is, and a subagent never arms.
|
|
494
|
+
{
|
|
495
|
+
const before = process.listenerCount("exit");
|
|
496
|
+
for (let i = 0; i < 20; i++) {
|
|
497
|
+
const cell = newCell();
|
|
498
|
+
cell.pi.fire("session_start", makeCtx("print", `native-sub-${i}`));
|
|
499
|
+
}
|
|
500
|
+
ok(
|
|
501
|
+
"[QK:OMP-RECEIVE-BIND-LEAKS-NO-LISTENERS] 20 subagent bindings add ZERO process exit listeners",
|
|
502
|
+
process.listenerCount("exit") === before,
|
|
503
|
+
` before=${before} after=${process.listenerCount("exit")}`,
|
|
504
|
+
);
|
|
505
|
+
const cell = newCell();
|
|
506
|
+
seedBirth(cell, "native-guard");
|
|
507
|
+
cell.pi.fire("session_start", makeCtx("tui", "native-guard"));
|
|
508
|
+
ok("an ARMED receiver does register exactly one exit guard", process.listenerCount("exit") === before + 1);
|
|
509
|
+
cell.receiver.unarm("gate-teardown");
|
|
510
|
+
ok(
|
|
511
|
+
"unarming gives that listener back (a host cycling /new accumulates none)",
|
|
512
|
+
process.listenerCount("exit") === before,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
console.log(`[check-omp-receive-arm] ${passed} assertions ok`);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# check-setup-qualification.sh — snapshot-safe mutation-attribution oracle for the
|
|
3
|
+
# aggregate-setup verdict claims (#86 C1 five + C3b three). Invoked DIRECTLY by the mutant
|
|
4
|
+
# manifests (`bash scripts/check-setup-qualification.sh`); deliberately NOT a
|
|
5
|
+
# run.sh subcommand and NOT in any check tier. This file proves ONLY that each
|
|
6
|
+
# mutant dies at its own claim token inside the tracked-files qualification
|
|
7
|
+
# snapshot (no node_modules, no network, no pack). It is NOT behavior, package,
|
|
8
|
+
# or preflight evidence: the rich `smoke-setup-verdict` (check:package) and the
|
|
9
|
+
# actual packed consumer row in `check-pack-install` keep those authorities.
|
|
10
|
+
#
|
|
11
|
+
# The fake installed tree copies run.sh+package.json under a temp node_modules
|
|
12
|
+
# and plants ONE explicit PRECONDITION STUB at the installed store-doctor path
|
|
13
|
+
# (exit 0 + named stub verdict) because preflight_v3_store runs before
|
|
14
|
+
# SETUP_RESULTS exists. The stub is not a compiled-product twin. No bridge
|
|
15
|
+
# launcher is planted, so the core component FAILs BY DESIGN and every cell
|
|
16
|
+
# expects setup rc=1 with a computed NON-GREEN summary.
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
19
|
+
REPO_DIR="$(cd "$HERE/.." && pwd)"
|
|
20
|
+
export PYTHONDONTWRITEBYTECODE=1
|
|
21
|
+
|
|
22
|
+
SB="$(mktemp -d -t entwurf-setup-qualification.XXXXXX)"
|
|
23
|
+
trap 'rm -rf "$SB"' EXIT
|
|
24
|
+
PASS=0
|
|
25
|
+
ok() { PASS=$((PASS + 1)); printf ' ok %s\n' "$*"; }
|
|
26
|
+
die() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
|
|
27
|
+
want() { if eval "$2"; then ok "$1"; else die "$1"; fi; }
|
|
28
|
+
|
|
29
|
+
PKG="$SB/node_modules/@junghanacs/entwurf"
|
|
30
|
+
mkdir -p "$PKG/mcp/entwurf-bridge/dist/scripts"
|
|
31
|
+
cp "$REPO_DIR/run.sh" "$PKG/run.sh"; chmod +x "$PKG/run.sh"
|
|
32
|
+
cp "$REPO_DIR/package.json" "$PKG/package.json"
|
|
33
|
+
printf '%s\n' 'console.log("stub-store-doctor: precondition stub for the setup-qualification oracle — empty sandbox store, verdict clean by construction");' \
|
|
34
|
+
> "$PKG/mcp/entwurf-bridge/dist/scripts/meta-bridge-store-doctor.js"
|
|
35
|
+
|
|
36
|
+
# Copilot composition surface (#86 C3b, Cell D): the three writer units and the
|
|
37
|
+
# birth installer's pre-vendor path are real tracked files; only the compiled
|
|
38
|
+
# dist closure is stubbed (the receive installer copies and digests those bytes,
|
|
39
|
+
# it never executes them — same not-a-product-twin discipline as the store stub).
|
|
40
|
+
mkdir -p "$PKG/scripts" "$PKG/mcp/entwurf-bridge/dist/pi-extensions/lib" "$PKG/pi-extensions/lib"
|
|
41
|
+
for _f in copilot-bridge-install.sh copilot-bridge-oracle.sh copilot-mcp-bridge.sh copilot-mcp-config.py \
|
|
42
|
+
copilot-receive-bridge.sh copilot-statusline-bridge.sh copilot-statusline-config.py; do
|
|
43
|
+
cp "$REPO_DIR/scripts/$_f" "$PKG/scripts/$_f"
|
|
44
|
+
done
|
|
45
|
+
cp -r "$REPO_DIR/pi" "$PKG/pi"
|
|
46
|
+
cp "$REPO_DIR/pi-extensions/lib/session-id.js" "$PKG/pi-extensions/lib/session-id.js"
|
|
47
|
+
printf '%s\n' '// dist stub: copied+digested by the receive installer, never executed here' \
|
|
48
|
+
> "$PKG/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js"
|
|
49
|
+
cp "$PKG/pi-extensions/lib/session-id.js" "$PKG/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js"
|
|
50
|
+
|
|
51
|
+
# Fake pnpm: resolvable (so an unconditional require_cmd would pass), but any
|
|
52
|
+
# INVOCATION writes a marker and exits uniquely — the bootstrap tripwire.
|
|
53
|
+
MARKER="$SB/pnpm-invoked.marker"
|
|
54
|
+
mkdir -p "$SB/bin"
|
|
55
|
+
printf '#!/usr/bin/env bash\necho invoked > "%s"\nexit 97\n' "$MARKER" > "$SB/bin/pnpm"
|
|
56
|
+
chmod +x "$SB/bin/pnpm"
|
|
57
|
+
|
|
58
|
+
ABSENT="$SB/definitely-absent"
|
|
59
|
+
run_setup() { # $1=HOME-root $2=project $3=PATH $4=PI_BIN $5=COPILOT_BIN(optional, default absent) → OUT/RC
|
|
60
|
+
mkdir -p "$1/.pi/agent" "$2"
|
|
61
|
+
set +e
|
|
62
|
+
# ONE physical line by contract: check-install-surface S5c is a line-scoped static tripwire,
|
|
63
|
+
# so the sandbox env assignments must ride the same line as the run.sh drive they guard.
|
|
64
|
+
OUT="$(HOME="$1" XDG_DATA_HOME="$1/.local/share" XDG_STATE_HOME="$1/.local/state" XDG_CACHE_HOME="$1/.cache" XDG_CONFIG_HOME="$1/.config" PI_CODING_AGENT_DIR="$1/.pi/agent" PATH="$3" PI_BIN="$4" CLAUDE_BIN="$ABSENT" AGY_BIN="$ABSENT" COPILOT_BIN="${5:-$ABSENT}" bash "$PKG/run.sh" setup "$2" 2>&1)"
|
|
65
|
+
RC=$?
|
|
66
|
+
set -e
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# ── Cell A: installed / all-absent (fake pnpm resolvable, never invoked) ──
|
|
70
|
+
HOME_A="$SB/home-a"; mkdir -p "$HOME_A/.pi/agent"
|
|
71
|
+
printf '{"anthropic":{"type":"oauth","access":"oracle-token"}}\n' > "$HOME_A/.pi/agent/auth.json"
|
|
72
|
+
AUTH_SHA="$(sha256sum "$HOME_A/.pi/agent/auth.json" | cut -d' ' -f1)"
|
|
73
|
+
run_setup "$HOME_A" "$SB/proj-a" "$SB/bin:$PATH" "$ABSENT"
|
|
74
|
+
want "A: the source bootstrap never invoked pnpm in installed mode (marker absent) [QK:SETUP-INSTALLED-NO-BOOTSTRAP]" \
|
|
75
|
+
"[ ! -e '$MARKER' ]"
|
|
76
|
+
want "A: the deliberate core FAIL owns setup exit 1 (computed, never cosmetic green) [QK:SETUP-FALSE-GREEN]" \
|
|
77
|
+
"[ '$RC' -eq 1 ]"
|
|
78
|
+
want "A: no auth.json.bak and credential bytes identical [QK:SETUP-CREDENTIAL-FREE]" \
|
|
79
|
+
"[ ! -e '$HOME_A/.pi/agent/auth.json.bak' ] && [ \"\$(sha256sum '$HOME_A/.pi/agent/auth.json' | cut -d' ' -f1)\" = '$AUTH_SHA' ]"
|
|
80
|
+
want "A: an absent copilot is one zero-state SKIP — no unit composed, no .copilot written [QK:SETUP-COPILOT-ABSENT-SKIP]" \
|
|
81
|
+
"printf '%s' \"\$OUT\" | grep -q 'copilot: SKIP' && [ ! -e '$HOME_A/.copilot' ]"
|
|
82
|
+
want "A control: mode named first, pi/claude/agy SKIP, bins PASS, core FAIL, NON-GREEN summary" \
|
|
83
|
+
"printf '%s' \"\$OUT\" | head -n 1 | grep -q 'mode: installed package' && printf '%s' \"\$OUT\" | grep -q 'pi: SKIP' && printf '%s' \"\$OUT\" | grep -q 'claude: SKIP' && printf '%s' \"\$OUT\" | grep -q 'agy: SKIP' && printf '%s' \"\$OUT\" | grep -q 'bins: PASS' && printf '%s' \"\$OUT\" | grep -q 'core: FAIL' && printf '%s' \"\$OUT\" | grep -q 'NON-GREEN'"
|
|
84
|
+
|
|
85
|
+
# ── Cell B: detected pi below floor — the verdict LABEL is the oracle ──
|
|
86
|
+
printf '#!/usr/bin/env bash\necho 0.1.0\n' > "$SB/pi-stale"; chmod +x "$SB/pi-stale"
|
|
87
|
+
run_setup "$SB/home-b" "$SB/proj-b" "$SB/bin:$PATH" "$SB/pi-stale"
|
|
88
|
+
want "B: a below-floor pi is a detected FAIL naming the supported range, never SKIP [QK:SETUP-PI-FLOOR-SKIP]" \
|
|
89
|
+
"printf '%s' \"\$OUT\" | grep -q 'pi: FAIL' && printf '%s' \"\$OUT\" | grep -q 'outside the supported range' && ! printf '%s' \"\$OUT\" | grep -q 'pi: SKIP'"
|
|
90
|
+
|
|
91
|
+
# ── Cell C: genuinely pnpm-scrubbed PATH — installed setup needs no pnpm ──
|
|
92
|
+
SHIMS="$SB/scrub-shims"; mkdir -p "$SHIMS"
|
|
93
|
+
for _t in bash node python3 head tr cut sed grep dirname readlink cat mkdir cp chmod rm env sha256sum; do
|
|
94
|
+
_s="$(command -v "$_t" 2>/dev/null || true)"; [ -n "$_s" ] && ln -s "$_s" "$SHIMS/$_t"
|
|
95
|
+
done
|
|
96
|
+
SCRUBBED="$SHIMS"
|
|
97
|
+
IFS=':' read -r -a _dirs <<<"$PATH"
|
|
98
|
+
for _d in "${_dirs[@]}"; do
|
|
99
|
+
[ -n "$_d" ] || continue
|
|
100
|
+
[ -x "$_d/pnpm" ] && continue
|
|
101
|
+
SCRUBBED="$SCRUBBED:$_d"
|
|
102
|
+
done
|
|
103
|
+
want "C scrub proof: pnpm absent, needed tools present on the scrubbed PATH" \
|
|
104
|
+
"! PATH=\"$SCRUBBED\" command -v pnpm >/dev/null 2>&1 && PATH=\"$SCRUBBED\" command -v node >/dev/null && PATH=\"$SCRUBBED\" command -v python3 >/dev/null"
|
|
105
|
+
run_setup "$SB/home-c" "$SB/proj-c" "$SCRUBBED" "$ABSENT"
|
|
106
|
+
want "C: installed setup needs no pnpm to decide/compose — summary reached, no Missing-command refusal [QK:SETUP-INSTALLED-NO-PNPM]" \
|
|
107
|
+
"printf '%s' \"\$OUT\" | grep -q 'setup summary (computed from component outcomes)' && ! printf '%s' \"\$OUT\" | grep -q 'Missing command: pnpm'"
|
|
108
|
+
|
|
109
|
+
# ── Cell D: copilot PRESENT, vendor lists failing — independence + no cosmetic PASS ──
|
|
110
|
+
# A two-line always-failing vendor is enough here: the birth installer dies at its
|
|
111
|
+
# read-only `plugin list` preflight (UNKNOWN, never absence), while the three writer
|
|
112
|
+
# units never touch the vendor and land in the sandbox. Behavior evidence for the
|
|
113
|
+
# full success composition lives in smoke-setup-verdict S-6 / check-pack-install.
|
|
114
|
+
BROKEN="$SB/broken-vendor"; mkdir -p "$BROKEN"
|
|
115
|
+
printf '#!/usr/bin/env bash\necho "not authenticated" >&2\nexit 1\n' > "$BROKEN/copilot"
|
|
116
|
+
chmod +x "$BROKEN/copilot"
|
|
117
|
+
run_setup "$SB/home-d" "$SB/proj-d" "$BROKEN:$SB/bin:$PATH" "$ABSENT" "$BROKEN/copilot"
|
|
118
|
+
want "D: a failed birth leaves the other three units attempted with their own rows [QK:SETUP-COPILOT-INDEPENDENT]" \
|
|
119
|
+
"printf '%s' \"\$OUT\" | grep -q 'copilot-mcp: PASS' && printf '%s' \"\$OUT\" | grep -q 'copilot-receive: PASS' && printf '%s' \"\$OUT\" | grep -q 'copilot-statusline: PASS'"
|
|
120
|
+
want "D: the failing-vendor birth is a named FAIL, never a cosmetic PASS [QK:SETUP-COPILOT-COSMETIC-PASS]" \
|
|
121
|
+
"printf '%s' \"\$OUT\" | grep -q 'copilot-birth: FAIL' && ! printf '%s' \"\$OUT\" | grep -q 'copilot-birth: PASS'"
|
|
122
|
+
want "D control: detected copilot never reads SKIP, and the summary names copilot-birth NON-GREEN" \
|
|
123
|
+
"! printf '%s' \"\$OUT\" | grep -q 'copilot: SKIP' && printf '%s' \"\$OUT\" | grep -q 'NON-GREEN' && printf '%s' \"\$OUT\" | grep -q 'copilot-birth'"
|
|
124
|
+
|
|
125
|
+
echo ""
|
|
126
|
+
echo "check-setup-qualification: $PASS checks passed (mutation-attribution oracle only — behavior evidence lives in smoke-setup-verdict and check-pack-install)"
|