@junghanacs/entwurf 0.15.1 → 0.16.1
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 -1
- package/CHANGELOG.md +354 -0
- package/DELIVERY.md +3 -2
- package/README.md +68 -88
- package/VERIFY.md +4 -1
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +1 -1
- package/docs/external-mcp-host.md +147 -9
- package/docs/setup-clean-host.md +125 -6
- 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/acp/backend-adapter.js +19 -10
- 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 +19 -9
- 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/acp/backend-adapter.ts +19 -9
- package/pi-extensions/lib/acp/backend.ts +125 -7
- package/pi-extensions/lib/acp/claude-acp-launch.js +100 -0
- 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 +399 -31
- package/scripts/check-acp-launch-namespace.ts +127 -0
- package/scripts/check-acp-prompt-lifecycle.ts +145 -2
- package/scripts/check-copilot-birth-hook.ts +28 -1
- package/scripts/check-entwurf-self-address.ts +31 -0
- package/scripts/check-gate-qualification.ts +7 -3
- 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 +208 -0
- package/scripts/check-omp-receive-arm.ts +516 -0
- package/scripts/check-setup-qualification.sh +40 -2
- package/scripts/copilot-bridge-oracle.sh +14 -6
- package/scripts/fake-copilot-vendor.sh +4 -2
- package/scripts/inventory-verification-surface.ts +1 -1
- package/scripts/meta-bridge-hook-log.sh +9 -1
- package/scripts/mutants/acp-launch-namespace.json +34 -0
- package/scripts/mutants/acp-prompt-lifecycle.json +67 -2
- package/scripts/mutants/copilot-birth.json +10 -10
- 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/pack-install.json +2 -2
- package/scripts/mutants/self-address.json +34 -0
- package/scripts/mutants/setup-verdict.json +35 -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-config-xdev.py +310 -0
- package/scripts/omp-config-xdev.sh +76 -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 +400 -0
- package/scripts/raw-acp-child-exit-measure/README.md +285 -0
- package/scripts/raw-acp-child-exit-measure/acp-turn-population.py +89 -0
- package/scripts/raw-acp-child-exit-measure/reaper-correlation.py +47 -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/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 +48 -3
- 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`);
|
|
@@ -42,11 +42,23 @@ for _f in copilot-bridge-install.sh copilot-bridge-oracle.sh copilot-mcp-bridge.
|
|
|
42
42
|
copilot-receive-bridge.sh copilot-statusline-bridge.sh copilot-statusline-config.py; do
|
|
43
43
|
cp "$REPO_DIR/scripts/$_f" "$PKG/scripts/$_f"
|
|
44
44
|
done
|
|
45
|
+
# OMP composition surface (Cells E/F): same discipline — every omp unit script is a real
|
|
46
|
+
# tracked file here, because the claims are about what the composition actually reaches.
|
|
47
|
+
for _f in omp-bridge-install.sh omp-bridge-oracle.sh omp-mcp-bridge.sh omp-mcp-config.py \
|
|
48
|
+
omp-tool-surface.py omp-config-xdev.sh omp-config-xdev.py omp-receive-install.sh; do
|
|
49
|
+
cp "$REPO_DIR/scripts/$_f" "$PKG/scripts/$_f"
|
|
50
|
+
done
|
|
45
51
|
cp -r "$REPO_DIR/pi" "$PKG/pi"
|
|
46
52
|
cp "$REPO_DIR/pi-extensions/lib/session-id.js" "$PKG/pi-extensions/lib/session-id.js"
|
|
47
53
|
printf '%s\n' '// dist stub: copied+digested by the receive installer, never executed here' \
|
|
48
54
|
> "$PKG/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js"
|
|
49
55
|
cp "$PKG/pi-extensions/lib/session-id.js" "$PKG/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js"
|
|
56
|
+
# The omp units select the same compiled closure in installed mode. Same stub discipline:
|
|
57
|
+
# the installers copy and digest these bytes, they never execute them here.
|
|
58
|
+
for _e in meta-bridge-omp.js meta-bridge-receive-omp.js; do
|
|
59
|
+
printf '%s\n' '// dist stub: copied+digested by the omp installers, never executed here' \
|
|
60
|
+
> "$PKG/mcp/entwurf-bridge/dist/pi-extensions/$_e"
|
|
61
|
+
done
|
|
50
62
|
|
|
51
63
|
# Fake pnpm: resolvable (so an unconditional require_cmd would pass), but any
|
|
52
64
|
# INVOCATION writes a marker and exits uniquely — the bootstrap tripwire.
|
|
@@ -56,12 +68,12 @@ printf '#!/usr/bin/env bash\necho invoked > "%s"\nexit 97\n' "$MARKER" > "$SB/bi
|
|
|
56
68
|
chmod +x "$SB/bin/pnpm"
|
|
57
69
|
|
|
58
70
|
ABSENT="$SB/definitely-absent"
|
|
59
|
-
run_setup() { # $1=HOME-root $2=project $3=PATH $4=PI_BIN $5=COPILOT_BIN(
|
|
71
|
+
run_setup() { # $1=HOME-root $2=project $3=PATH $4=PI_BIN $5=COPILOT_BIN(opt) $6=OMP_BIN(opt) → OUT/RC
|
|
60
72
|
mkdir -p "$1/.pi/agent" "$2"
|
|
61
73
|
set +e
|
|
62
74
|
# ONE physical line by contract: check-install-surface S5c is a line-scoped static tripwire,
|
|
63
75
|
# 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)"
|
|
76
|
+
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}" OMP_BIN="${6:-$ABSENT}" ENTWURF_OMP_AGENT_DIR="$1/.omp/agent" bash "$PKG/run.sh" setup "$2" 2>&1)"
|
|
65
77
|
RC=$?
|
|
66
78
|
set -e
|
|
67
79
|
}
|
|
@@ -79,6 +91,8 @@ want "A: no auth.json.bak and credential bytes identical [QK:SETUP-CREDENTIAL-FR
|
|
|
79
91
|
"[ ! -e '$HOME_A/.pi/agent/auth.json.bak' ] && [ \"\$(sha256sum '$HOME_A/.pi/agent/auth.json' | cut -d' ' -f1)\" = '$AUTH_SHA' ]"
|
|
80
92
|
want "A: an absent copilot is one zero-state SKIP — no unit composed, no .copilot written [QK:SETUP-COPILOT-ABSENT-SKIP]" \
|
|
81
93
|
"printf '%s' \"\$OUT\" | grep -q 'copilot: SKIP' && [ ! -e '$HOME_A/.copilot' ]"
|
|
94
|
+
want "A: an absent omp is one zero-state SKIP — no unit composed, no .omp written [QK:SETUP-OMP-ABSENT-SKIP]" \
|
|
95
|
+
"printf '%s' \"\$OUT\" | grep -q 'omp: SKIP' && [ ! -e '$HOME_A/.omp' ]"
|
|
82
96
|
want "A control: mode named first, pi/claude/agy SKIP, bins PASS, core FAIL, NON-GREEN summary" \
|
|
83
97
|
"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
98
|
|
|
@@ -122,5 +136,29 @@ want "D: the failing-vendor birth is a named FAIL, never a cosmetic PASS [QK:SET
|
|
|
122
136
|
want "D control: detected copilot never reads SKIP, and the summary names copilot-birth NON-GREEN" \
|
|
123
137
|
"! printf '%s' \"\$OUT\" | grep -q 'copilot: SKIP' && printf '%s' \"\$OUT\" | grep -q 'NON-GREEN' && printf '%s' \"\$OUT\" | grep -q 'copilot-birth'"
|
|
124
138
|
|
|
139
|
+
# ── Cell E: omp PRESENT — the four units compose, and the SETTING is a real writer ──
|
|
140
|
+
# The omp unit scripts never spawn the vendor (they probe `omp` on PATH and write into the
|
|
141
|
+
# agent dir), so a stub binary is a faithful presence pin. Two claims live here: the
|
|
142
|
+
# composition reaches omp at all, and the tools.xdev writer refuses an EXPLICIT operator
|
|
143
|
+
# `true` by name rather than overwriting a decision it disagrees with.
|
|
144
|
+
STUB_OMP="$SB/stub-omp"; mkdir -p "$STUB_OMP"
|
|
145
|
+
printf '#!/usr/bin/env bash\necho "omp/18.0.0"\n' > "$STUB_OMP/omp"
|
|
146
|
+
chmod +x "$STUB_OMP/omp"
|
|
147
|
+
run_setup "$SB/home-e" "$SB/proj-e" "$STUB_OMP:$SB/bin:$PATH" "$ABSENT" "" "$STUB_OMP/omp"
|
|
148
|
+
# One assertion on purpose: the four PASS rows AND the artifacts behind them. Split in two,
|
|
149
|
+
# the row half alone passes a composition that reports PASS without running the unit.
|
|
150
|
+
want "E: a detected omp composes all four units — own rows, each backed by its ARTIFACT rather than an exit code [QK:SETUP-OMP-INDEPENDENT]" \
|
|
151
|
+
"printf '%s' \"\$OUT\" | grep -q 'omp-birth: PASS' && printf '%s' \"\$OUT\" | grep -q 'omp-mcp: PASS' && printf '%s' \"\$OUT\" | grep -q 'omp-config: PASS' && printf '%s' \"\$OUT\" | grep -q 'omp-receive: PASS' && ! printf '%s' \"\$OUT\" | grep -q 'omp: SKIP' && [ -d '$SB/home-e/.omp/agent/extensions/entwurf-meta-omp' ] && [ -d '$SB/home-e/.omp/agent/extensions/entwurf-receive-omp' ] && [ -f '$SB/home-e/.omp/agent/mcp.json' ]"
|
|
152
|
+
want "E control: the setting reached the config the vendor reads (effective xdev-off)" \
|
|
153
|
+
"[ \"\$(python3 '$PKG/scripts/omp-tool-surface.py' '$SB/home-e/.omp/agent' | awk '/^verdict /{print \$2}')\" = 'xdev-off' ]"
|
|
154
|
+
# Now the disagreement branch: an operator who wrote xdev: true explicitly owns that value.
|
|
155
|
+
mkdir -p "$SB/home-f/.omp/agent"
|
|
156
|
+
printf 'tools: \n xdev: true\n' > "$SB/home-f/.omp/agent/config.yml"
|
|
157
|
+
run_setup "$SB/home-f" "$SB/proj-f" "$STUB_OMP:$SB/bin:$PATH" "$ABSENT" "" "$STUB_OMP/omp"
|
|
158
|
+
want "F: an EXPLICIT operator tools.xdev:true is refused by name, never overwritten [QK:SETUP-OMP-CONFIG-NO-OVERWRITE]" \
|
|
159
|
+
"printf '%s' \"\$OUT\" | grep -q 'omp-config: FAIL' && grep -q 'xdev: true' '$SB/home-f/.omp/agent/config.yml' && ! grep -q 'xdev: false' '$SB/home-f/.omp/agent/config.yml'"
|
|
160
|
+
want "F control: the disagreement is a component FAIL that leaves the other omp units composed" \
|
|
161
|
+
"printf '%s' \"\$OUT\" | grep -q 'omp-birth: PASS' && printf '%s' \"\$OUT\" | grep -q 'omp-receive: PASS' && printf '%s' \"\$OUT\" | grep -q 'NON-GREEN'"
|
|
162
|
+
|
|
125
163
|
echo ""
|
|
126
164
|
echo "check-setup-qualification: $PASS checks passed (mutation-attribution oracle only — behavior evidence lives in smoke-setup-verdict and check-pack-install)"
|
|
@@ -77,13 +77,20 @@ PY
|
|
|
77
77
|
# QUALIFIED is absent, present exactly once with a parsed nonempty version, or in a
|
|
78
78
|
# shape nobody may act on. rc 0 prints `absent` or `one <version>`; rc 1 with the
|
|
79
79
|
# reason on stderr for a MALFORMED exact row (claims our qualified id but does not
|
|
80
|
-
# parse as `<qualified> (v<nonempty>)
|
|
81
|
-
#
|
|
80
|
+
# parse as `<qualified> (v<nonempty>)`, optionally followed by the vendor's own
|
|
81
|
+
# `(enabled)`/`(disabled)` state token — measured on Copilot CLI 1.0.81) or MULTIPLE
|
|
82
|
+
# exact rows. Longer tokens that merely contain the qualified id remain foreign and
|
|
83
|
+
# are ignored, same as above.
|
|
82
84
|
copilot_exact_row_version() {
|
|
83
85
|
local list_text="$1" qualified="$2"
|
|
84
86
|
LIST_TEXT_ENV="$list_text" QUALIFIED_ENV="$qualified" python3 - <<'PY'
|
|
85
|
-
import os, sys
|
|
87
|
+
import os, re, sys
|
|
86
88
|
qualified = os.environ["QUALIFIED_ENV"]
|
|
89
|
+
# The measured row tail. Copilot CLI 1.0.81 appends its own state token after the
|
|
90
|
+
# version (`... (v0.1.0) (enabled)`), so the grammar carries that OPTIONAL trailing
|
|
91
|
+
# `(enabled)`/`(disabled)` explicitly instead of reading it as a garbled version.
|
|
92
|
+
# Anything else in the tail is still malformed.
|
|
93
|
+
ROW_TAIL = re.compile(r"\(v(?P<version>[^()]*)\)(?:[ \t]+\((?:enabled|disabled)\))?\Z")
|
|
87
94
|
versions = []
|
|
88
95
|
for raw in os.environ["LIST_TEXT_ENV"].splitlines():
|
|
89
96
|
row = raw.strip()
|
|
@@ -100,10 +107,11 @@ for raw in os.environ["LIST_TEXT_ENV"].splitlines():
|
|
|
100
107
|
if not parts or parts[0] != qualified:
|
|
101
108
|
continue # foreign row, including longer ids that merely contain ours
|
|
102
109
|
rest = parts[1] if len(parts) == 2 else ""
|
|
103
|
-
|
|
104
|
-
|
|
110
|
+
match = ROW_TAIL.fullmatch(rest)
|
|
111
|
+
if match is None:
|
|
112
|
+
print(f"malformed exact row for {qualified}: {row!r} does not parse as '<qualified> (v<version>)' with an optional '(enabled)'/'(disabled)' state token", file=sys.stderr)
|
|
105
113
|
sys.exit(1)
|
|
106
|
-
version =
|
|
114
|
+
version = match.group("version")
|
|
107
115
|
if not version:
|
|
108
116
|
print(f"malformed exact row for {qualified}: empty version in {row!r}", file=sys.stderr)
|
|
109
117
|
sys.exit(1)
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
# cannot drift: smoke-setup-verdict's copilot-present cell and check-pack-install's
|
|
5
5
|
# installed copilot-present consumer row. The answer shapes are the MEASURED CLI's
|
|
6
6
|
# (copilot 1.0.80, 2026-08-27) — the same shapes check-copilot-birth-hook.ts bakes
|
|
7
|
-
# into its own TS fake: `plugin list` prints qualified ids with a `(vX)` suffix
|
|
7
|
+
# into its own TS fake: `plugin list` prints qualified ids with a `(vX)` suffix
|
|
8
|
+
# followed by the vendor's `(enabled)` state token and an indented `from <path>`
|
|
9
|
+
# continuation line (measured on copilot 1.0.81, 2026-08-31),
|
|
8
10
|
# `plugin marketplace list` prints `<name> (Local: <abs path>)`, and `--force`
|
|
9
11
|
# anywhere is refused loudly (the real `marketplace remove --force` uninstalls that
|
|
10
12
|
# marketplace's plugins as a side effect; no entwurf surface may reach for it).
|
|
@@ -51,7 +53,7 @@ case "\$1 \$2 \$3" in
|
|
|
51
53
|
"plugin marketplace remove") awk -F"\t" -v n="\$4" '\$1 != n' "\$MKTS" > "\$MKTS.tmp"; mv "\$MKTS.tmp" "\$MKTS"; exit 0 ;;
|
|
52
54
|
esac
|
|
53
55
|
case "\$1 \$2" in
|
|
54
|
-
"plugin list") echo "
|
|
56
|
+
"plugin list") echo "Live Plugins (loaded from a local marketplace directory, never copied):"; while read -r id; do [ -n "\$id" ] && { echo " • \$id (v\$VER) (enabled)"; echo " from $dir"; }; done < "\$STATE"; exit 0 ;;
|
|
55
57
|
"plugin uninstall") grep -Fvx "\$3" "\$STATE" > "\$STATE.tmp"; mv "\$STATE.tmp" "\$STATE"; exit 0 ;;
|
|
56
58
|
"plugin install") echo "\$3" >> "\$STATE"; exit 0 ;;
|
|
57
59
|
esac
|