@junghanacs/entwurf 0.15.1 → 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/CHANGELOG.md +229 -0
- package/DELIVERY.md +3 -2
- package/README.md +70 -87
- package/VERIFY.md +4 -1
- package/docs/acp-backend-rail.md +1 -1
- package/docs/external-mcp-host.md +147 -9
- package/docs/setup-clean-host.md +105 -0
- 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 +13 -3
- 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 +296 -4
- package/scripts/check-entwurf-self-address.ts +31 -0
- package/scripts/check-gate-qualification.ts +4 -1
- 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/inventory-verification-surface.ts +1 -1
- package/scripts/meta-bridge-hook-log.sh +9 -1
- package/scripts/mutants/copilot-birth.json +7 -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/self-address.json +34 -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/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/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`);
|
|
@@ -90,7 +90,7 @@ const H_FS = /from\s+["']node:fs["']|require\(["']node:fs["']\)/;
|
|
|
90
90
|
// "process.env.LIVE"; it spawns no live turn of its own. So the predicate runs on the
|
|
91
91
|
// CODE-ONLY projection (comments and inert literals blanked, shell expansions kept)
|
|
92
92
|
// and matches a variable READ or an assignment, never prose.
|
|
93
|
-
const H_LIVE = /process\.env\.LIVE\b|\$\{LIVE[:}
|
|
93
|
+
const H_LIVE = /process\.env\.LIVE\b|\$\{LIVE[:}-]|\$LIVE\b|(?:^|\n)\s*(?:export\s+)?LIVE=1\b/;
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
96
|
* Blank what cannot gate execution, so H_LIVE reads code and not prose.
|
|
@@ -15,10 +15,18 @@
|
|
|
15
15
|
# would make the CLAUDE doctor permanently red for a refusal on a different rail, with
|
|
16
16
|
# a prescription pointing at a Claude wake failure that never happened (cross-review,
|
|
17
17
|
# glm, 2026-08-21). Copilot's own recovery rule lives in copilot-bridge-doctor.sh.
|
|
18
|
+
#
|
|
19
|
+
# The OMP birth unit (#87) joins the same file tagged `LEVEL [omp]`, and the rule extends
|
|
20
|
+
# for the same reason plus a sharper one: omp's own scope fence writes a line for EVERY
|
|
21
|
+
# task subagent it refuses (`INFO [omp] scope-refused`), which is the designed answer, and
|
|
22
|
+
# its failures are mint/marker faults on a rail whose recovery token — `create`/`attach` —
|
|
23
|
+
# is not `armed watch` either. omp's recovery rule lives in omp-bridge-doctor.sh. Excluding
|
|
24
|
+
# a backend here is never a way to quiet a red: it is how each doctor keeps judging only
|
|
25
|
+
# the evidence it owns.
|
|
18
26
|
meta_bridge_hook_log_status() {
|
|
19
27
|
local log="$1"
|
|
20
28
|
local own
|
|
21
|
-
own="$(grep -v ' \[copilot\] ' "$log" 2>/dev/null || true)"
|
|
29
|
+
own="$(grep -v -e ' \[copilot\] ' -e ' \[omp\] ' "$log" 2>/dev/null || true)"
|
|
22
30
|
if ! printf '%s\n' "$own" | grep -q ' ERROR '; then
|
|
23
31
|
echo "no-error"
|
|
24
32
|
return 0
|
|
@@ -94,10 +94,10 @@
|
|
|
94
94
|
},
|
|
95
95
|
{
|
|
96
96
|
"claim": "HOOK-LOG-RAIL-SCOPED",
|
|
97
|
-
"title": "
|
|
97
|
+
"title": "dropping the copilot tag from the shared-hook-log filter lets a Copilot fail-closed refusal redden the CLAUDE doctor forever \u2014 copilot's recovery token cannot exist on that rail",
|
|
98
98
|
"subject": "scripts/meta-bridge-hook-log.sh",
|
|
99
|
-
"find": [" own=\"$(grep -v ' \\[copilot\\] ' \"$log\" 2>/dev/null || true)\""],
|
|
100
|
-
"replace": [" own=\"$(
|
|
99
|
+
"find": [" own=\"$(grep -v -e ' \\[copilot\\] ' -e ' \\[omp\\] ' \"$log\" 2>/dev/null || true)\""],
|
|
100
|
+
"replace": [" own=\"$(grep -v ' \\[omp\\] ' \"$log\" 2>/dev/null || true)\""],
|
|
101
101
|
"gate": ["bash", "run.sh", "check-meta-doctor-oracle"],
|
|
102
102
|
"timeoutSeconds": 600,
|
|
103
103
|
"signature": "[QK:HOOK-LOG-RAIL-SCOPED]",
|
|
@@ -132,9 +132,11 @@
|
|
|
132
132
|
"title": "closing the reader half leaves the writer talking to nobody — the hook writes a copilot marker, the bridge never looks in that directory, and the citizen's sends are refused as anonymous (the #46 shape, which is why writer and reader open in one change)",
|
|
133
133
|
"subject": "pi-extensions/lib/meta-sender-identity.ts",
|
|
134
134
|
"find": [
|
|
135
|
-
"export const META_SENDER_BACKENDS: readonly MetaBackend[] = [\"claude-code\", \"antigravity\", \"copilot\"];"
|
|
135
|
+
"export const META_SENDER_BACKENDS: readonly MetaBackend[] = [\"claude-code\", \"antigravity\", \"copilot\", \"omp\"];"
|
|
136
|
+
],
|
|
137
|
+
"replace": [
|
|
138
|
+
"export const META_SENDER_BACKENDS: readonly MetaBackend[] = [\"claude-code\", \"antigravity\", \"omp\"];"
|
|
136
139
|
],
|
|
137
|
-
"replace": ["export const META_SENDER_BACKENDS: readonly MetaBackend[] = [\"claude-code\", \"antigravity\"];"],
|
|
138
140
|
"gate": ["bash", "run.sh", "check-copilot-birth-hook"],
|
|
139
141
|
"timeoutSeconds": 300,
|
|
140
142
|
"signature": "[QK:COPILOT-SENDER-READER-OPEN]",
|