@junghanacs/entwurf 0.14.2 → 0.15.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 +8 -5
- package/CHANGELOG.md +79 -0
- package/DELIVERY.md +72 -59
- package/README.md +55 -4
- package/VERIFY.md +2 -2
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +1 -1
- package/docs/external-mcp-host.md +26 -4
- package/docs/setup-clean-host.md +3 -3
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +8 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/copilot-fresh-preflight.js +253 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +10 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +102 -28
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +77 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-copilot.js +288 -0
- package/mcp/entwurf-bridge/src/index.ts +10 -6
- package/mcp/entwurf-bridge/tsconfig.build.json +2 -1
- package/package.json +22 -11
- package/pi/copilot-receive/entwurf-receive/extension.mjs +323 -0
- package/pi/entwurf-capabilities.json +1 -0
- package/pi/meta-bridge-copilot/.claude-plugin/marketplace.json +11 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/hooks/hooks.json +7 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/scripts/copilot-hook-launch.sh +85 -0
- package/pi-extensions/entwurf-control.ts +17 -8
- package/pi-extensions/lib/copilot-fresh-preflight.ts +251 -0
- package/pi-extensions/lib/meta-sender-identity.ts +10 -2
- package/pi-extensions/lib/meta-session.ts +102 -29
- package/pi-extensions/lib/mux-fresh-call.ts +82 -11
- package/pi-extensions/meta-bridge-hook-copilot.ts +323 -0
- package/run.sh +334 -16
- package/scripts/check-copilot-birth-hook.ts +497 -0
- package/scripts/check-copilot-launch.ts +395 -0
- package/scripts/check-copilot-receive-arm.ts +734 -0
- package/scripts/check-copilot-statusline.ts +122 -0
- package/scripts/check-entwurf-capabilities.ts +25 -6
- package/scripts/check-fresh-cut-gate.sh +92 -23
- package/scripts/check-gate-qualification.ts +7 -1
- package/scripts/check-install-container.sh +2 -2
- package/scripts/check-install-surface.ts +1 -1
- package/scripts/check-meta-capability-source.ts +89 -5
- package/scripts/check-meta-doctor-oracle.sh +26 -0
- package/scripts/check-meta-manifest-schema.py +38 -0
- package/scripts/check-meta-receiver-marker.ts +25 -2
- package/scripts/check-meta-session.ts +32 -0
- package/scripts/copilot-bridge-doctor.sh +209 -0
- package/scripts/copilot-bridge-install.sh +224 -0
- package/scripts/copilot-launch.sh +236 -0
- package/scripts/copilot-mcp-bridge.sh +177 -0
- package/scripts/copilot-mcp-config.py +221 -0
- package/scripts/copilot-receive-bridge.sh +483 -0
- package/scripts/copilot-statusline-bridge.sh +149 -0
- package/scripts/copilot-statusline-config.py +201 -0
- package/scripts/copilot-statusline.sh +68 -0
- package/scripts/dev-bin.sh +4 -1
- package/scripts/meta-bridge-hook-log.sh +17 -5
- package/scripts/mutants/capability-cache.json +64 -0
- package/scripts/mutants/copilot-birth.json +155 -0
- package/scripts/mutants/copilot-launch.json +187 -0
- package/scripts/mutants/copilot-receive.json +263 -0
- package/scripts/mutants/fresh-cut.json +17 -0
- package/scripts/mutants/mux-fresh-call.json +216 -2
- package/scripts/mutants/pack-install.json +17 -0
- package/scripts/raw-async-delivery/README.md +170 -125
- package/scripts/raw-async-delivery/copilot-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/copilot-extension-receive/extension.mjs +123 -0
- package/scripts/smoke-copilot-mcp-state.sh +153 -0
- package/scripts/smoke-copilot-statusline-state.sh +131 -0
- package/scripts/smoke-mux-fresh-call-live.ts +2 -0
- package/scripts/smoke-mux-lifecycle-live.ts +3 -1
- package/scripts/tsconfig.json +1 -0
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-copilot-receive-arm — #82 RAIL 5 gate: the Copilot RECEIVER, proven without
|
|
3
|
+
* Copilot.
|
|
4
|
+
*
|
|
5
|
+
* WHAT IT BINDS. The chain from the shipped unit to a citizen a sender may deliver to:
|
|
6
|
+
*
|
|
7
|
+
* copilot-receive-bridge.sh install (the REAL installer, into a temp extensions dir)
|
|
8
|
+
* -> the vendor's required entry name, with the compiled lib beside it
|
|
9
|
+
* -> the REAL extension.mjs, forked the way Copilot forks it (stdio child, the SDK
|
|
10
|
+
* specifier resolved by a loader hook — the same mechanism the vendor's own
|
|
11
|
+
* `extension_sdk_resolver.mjs` uses)
|
|
12
|
+
* -> an arm that WAITS for birth, then binds to the record
|
|
13
|
+
* -> a receiver marker owned by the WATCHER pid
|
|
14
|
+
* -> mailboxConversationalDeliverable(copilot) === true
|
|
15
|
+
* -> a mailbox poke rings ONE doorbell that carries the id, not the body
|
|
16
|
+
*
|
|
17
|
+
* WHY IT DRIVES THE INSTALLER. Same reason the birth gate does: a gate that staged the
|
|
18
|
+
* unit itself would assert against its own copy of the layout, and the shipped installer
|
|
19
|
+
* could drift underneath it and stay green. The install is also where the ownership
|
|
20
|
+
* refusals live, so they are exercised here rather than described.
|
|
21
|
+
*
|
|
22
|
+
* WHY A STUB SDK IS FAITHFUL ENOUGH — AND WHERE IT STOPS. The stub answers exactly what
|
|
23
|
+
* the vendor contract gives an extension: a `sessionId` from `joinSession()`, an event
|
|
24
|
+
* subscription, and a `send()` that returns a message id. Everything this gate asserts is
|
|
25
|
+
* on OUR side of that boundary — which garden id we bind to, which pid owns the marker,
|
|
26
|
+
* what the doorbell says, and when we refuse. What it CANNOT prove is that a real Copilot
|
|
27
|
+
* turn starts on `session.send({mode:"enqueue"})`; that is a live-harness fact, measured
|
|
28
|
+
* separately (raw transport receipt, 2026-08-23) and owed again by the managed lane's LIVE
|
|
29
|
+
* acceptance. A green run here is mechanism evidence, never admission.
|
|
30
|
+
*
|
|
31
|
+
* Hermetic: temp dirs only, no network, no Copilot, no model turn. Needs the compiled
|
|
32
|
+
* bridge closure (`pnpm run build-bridge`), because the receiver ships compiled JS on
|
|
33
|
+
* purpose — the CLI's own Node runs it.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import assert from "node:assert/strict";
|
|
37
|
+
import { type ChildProcess, execFileSync, spawn, spawnSync } from "node:child_process";
|
|
38
|
+
import {
|
|
39
|
+
chmodSync,
|
|
40
|
+
existsSync,
|
|
41
|
+
mkdirSync,
|
|
42
|
+
mkdtempSync,
|
|
43
|
+
readdirSync,
|
|
44
|
+
readFileSync,
|
|
45
|
+
rmSync,
|
|
46
|
+
writeFileSync,
|
|
47
|
+
} from "node:fs";
|
|
48
|
+
import { tmpdir } from "node:os";
|
|
49
|
+
import * as path from "node:path";
|
|
50
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
51
|
+
import {
|
|
52
|
+
mailboxConversationalDeliverable,
|
|
53
|
+
receiverMarkerMatchesIdentity,
|
|
54
|
+
} from "../pi-extensions/lib/entwurf-deliverability.ts";
|
|
55
|
+
import {
|
|
56
|
+
enqueueMetaMessage,
|
|
57
|
+
metaCapabilityFor,
|
|
58
|
+
readMetaIdentityByGardenId,
|
|
59
|
+
readMetaReceiverMarker,
|
|
60
|
+
upsertMetaSession,
|
|
61
|
+
writeMetaSenderMarker,
|
|
62
|
+
} from "../pi-extensions/lib/meta-session.ts";
|
|
63
|
+
|
|
64
|
+
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
65
|
+
const UNIT = "entwurf-receive";
|
|
66
|
+
|
|
67
|
+
let passed = 0;
|
|
68
|
+
function ok(label: string, cond: boolean): void {
|
|
69
|
+
assert.ok(cond, label);
|
|
70
|
+
console.log(` ok ${label}`);
|
|
71
|
+
passed++;
|
|
72
|
+
}
|
|
73
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
74
|
+
/** Poll a predicate up to `ms`. Returns whether it became true — never throws, so the
|
|
75
|
+
* assertion that follows names the missing fact instead of a timeout stack. */
|
|
76
|
+
async function waitUntil(fn: () => boolean, ms = 15000): Promise<boolean> {
|
|
77
|
+
const deadline = Date.now() + ms;
|
|
78
|
+
while (Date.now() < deadline) {
|
|
79
|
+
try {
|
|
80
|
+
if (fn()) return true;
|
|
81
|
+
} catch {
|
|
82
|
+
/* the store may be mid-write; keep polling */
|
|
83
|
+
}
|
|
84
|
+
await sleep(40);
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
return fn();
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const root = mkdtempSync(path.join(tmpdir(), "entwurf-copilot-receive."));
|
|
94
|
+
const home = path.join(root, "home");
|
|
95
|
+
const store = path.join(root, "agent");
|
|
96
|
+
const extRoot = path.join(root, "extensions");
|
|
97
|
+
const xdg = path.join(root, "xdg");
|
|
98
|
+
for (const d of [home, store, extRoot, xdg]) mkdirSync(d, { recursive: true });
|
|
99
|
+
// Every lib call in THIS process resolves into the sandbox too, so the gate's own
|
|
100
|
+
// record/marker writes never touch the operator's store (Hard Rule 12).
|
|
101
|
+
process.env.PI_CODING_AGENT_DIR = store;
|
|
102
|
+
delete process.env.ENTWURF_META_SESSIONS_DIR;
|
|
103
|
+
delete process.env.ENTWURF_META_SENDERS_DIR;
|
|
104
|
+
delete process.env.ENTWURF_META_RECEIVERS_DIR;
|
|
105
|
+
delete process.env.ENTWURF_META_MAILBOX_DIR;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The compiled writer the receiver ships, wherever it can be had.
|
|
109
|
+
*
|
|
110
|
+
* A dev clone and CI both have `mcp/entwurf-bridge/dist` (build-bridge runs from
|
|
111
|
+
* `prepare`). The gate-qualification SNAPSHOT does not: it replicates the git surface,
|
|
112
|
+
* and dist is gitignored — so a gate that simply required dist would be CONTROL-RED in
|
|
113
|
+
* every mutant run, proving nothing about any mutation. Emitting the two-file closure
|
|
114
|
+
* into a temp dir costs a couple of seconds and keeps the gate honest in both worlds.
|
|
115
|
+
* The emit uses the repo's OWN tsc flags (extends the bridge build config's parent, same
|
|
116
|
+
* `rewriteRelativeImportExtensions`), so what is installed here is what ships.
|
|
117
|
+
*/
|
|
118
|
+
function compiledLibDir(): string {
|
|
119
|
+
const dist = path.join(REPO, "mcp", "entwurf-bridge", "dist", "pi-extensions", "lib");
|
|
120
|
+
if (existsSync(path.join(dist, "meta-session.js"))) return dist;
|
|
121
|
+
const outDir = path.join(root, "closure");
|
|
122
|
+
const cfg = path.join(root, "tsconfig.receive-closure.json");
|
|
123
|
+
writeFileSync(
|
|
124
|
+
cfg,
|
|
125
|
+
JSON.stringify({
|
|
126
|
+
extends: path.join(REPO, "tsconfig.json"),
|
|
127
|
+
compilerOptions: {
|
|
128
|
+
allowImportingTsExtensions: true,
|
|
129
|
+
rewriteRelativeImportExtensions: true,
|
|
130
|
+
noEmit: false,
|
|
131
|
+
declaration: false,
|
|
132
|
+
sourceMap: false,
|
|
133
|
+
outDir,
|
|
134
|
+
rootDir: REPO,
|
|
135
|
+
// The config lives in a temp dir, so `types: ["node"]` would resolve against
|
|
136
|
+
// a directory with no node_modules. Point type resolution back at the repo.
|
|
137
|
+
typeRoots: [path.join(REPO, "node_modules", "@types")],
|
|
138
|
+
},
|
|
139
|
+
exclude: [path.join(REPO, "node_modules")],
|
|
140
|
+
include: [path.join(REPO, "pi-extensions", "lib", "meta-session.ts")],
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
const tsc = path.join(REPO, "node_modules", ".bin", "tsc");
|
|
144
|
+
execFileSync(existsSync(tsc) ? tsc : "tsc", ["-p", cfg], { cwd: REPO, stdio: "pipe" });
|
|
145
|
+
return path.join(outDir, "pi-extensions", "lib");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const installEnv = {
|
|
149
|
+
...process.env,
|
|
150
|
+
HOME: home,
|
|
151
|
+
XDG_DATA_HOME: xdg,
|
|
152
|
+
COPILOT_EXTENSIONS_DIR: extRoot,
|
|
153
|
+
PI_CODING_AGENT_DIR: store,
|
|
154
|
+
ENTWURF_COPILOT_RECEIVE_LIB_DIR: compiledLibDir(),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// ── 1. the real installer ────────────────────────────────────────────────────
|
|
158
|
+
// Through `run.sh`, not straight at the script: the verb dispatch is part of the install
|
|
159
|
+
// surface (check-pack-install once caught a verb that its own script refused).
|
|
160
|
+
//
|
|
161
|
+
// `|| true` is not leniency — it is what makes the claim below REACHABLE. An installer
|
|
162
|
+
// that produces the wrong entry name also fails while stamping its install-state, so an
|
|
163
|
+
// exec that threw would kill this gate before a single assertion ran: the defect would be
|
|
164
|
+
// caught, but nothing would say WHICH contract broke (measured as WRONG-REASON in gate
|
|
165
|
+
// qualification). Capture the run, then assert the artifact.
|
|
166
|
+
const installOut = execFileSync(
|
|
167
|
+
"bash",
|
|
168
|
+
["-c", `bash ${JSON.stringify(path.join(REPO, "run.sh"))} install-copilot-receive 2>&1 || true`],
|
|
169
|
+
{ env: installEnv, encoding: "utf8" },
|
|
170
|
+
);
|
|
171
|
+
const unitDir = path.join(extRoot, UNIT);
|
|
172
|
+
const entry = path.join(unitDir, "extension.mjs");
|
|
173
|
+
// THE ARTIFACT CLAIM COMES FIRST, before "did the installer say it worked". An install
|
|
174
|
+
// that lands the wrong entry name also dies stamping its install-state, so an
|
|
175
|
+
// install-output assertion in front of this one would take the blame for a defect it
|
|
176
|
+
// does not name — which is precisely what gate qualification reported as WRONG-REASON.
|
|
177
|
+
// The vendor discovers a unit by this exact file name; nothing else about the install is
|
|
178
|
+
// worth asserting until that is true.
|
|
179
|
+
ok(
|
|
180
|
+
"[QK:COPILOT-RECEIVE-VENDOR-ENTRY-NAME] the installed unit is `<dir>/extension.mjs` — the vendor's required entry name",
|
|
181
|
+
existsSync(entry),
|
|
182
|
+
);
|
|
183
|
+
ok("the install ran to completion and said where it landed", installOut.includes("installed the receiver unit at"));
|
|
184
|
+
ok(
|
|
185
|
+
"the compiled lib and the capability registry travel inside the unit",
|
|
186
|
+
existsSync(path.join(unitDir, "lib", "meta-session.js")) &&
|
|
187
|
+
existsSync(path.join(unitDir, "entwurf-capabilities.json")),
|
|
188
|
+
);
|
|
189
|
+
const stateFile = path.join(xdg, "entwurf", "copilot-receive", "install-state.json");
|
|
190
|
+
ok("install-state records the path it owns", JSON.parse(readFileSync(stateFile, "utf8")).path === unitDir);
|
|
191
|
+
|
|
192
|
+
// Ownership: a unit this installer did not put there is somebody else's, and the state
|
|
193
|
+
// file is the only thing that licenses a replacement.
|
|
194
|
+
{
|
|
195
|
+
const foreignRoot = path.join(root, "foreign-extensions");
|
|
196
|
+
mkdirSync(path.join(foreignRoot, UNIT), { recursive: true });
|
|
197
|
+
writeFileSync(path.join(foreignRoot, UNIT, "extension.mjs"), "// somebody else's unit\n");
|
|
198
|
+
const res = execFileSync(
|
|
199
|
+
"bash",
|
|
200
|
+
["-c", `bash ${JSON.stringify(path.join(REPO, "run.sh"))} install-copilot-receive 2>&1 || true`],
|
|
201
|
+
{
|
|
202
|
+
env: { ...installEnv, COPILOT_EXTENSIONS_DIR: foreignRoot, XDG_DATA_HOME: path.join(root, "xdg-empty") },
|
|
203
|
+
encoding: "utf8",
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
ok(
|
|
207
|
+
"[QK:COPILOT-RECEIVE-INSTALL-REFUSES-FOREIGN] an unclaimed unit of the same name is REFUSED, not overwritten",
|
|
208
|
+
res.includes("Refusing to overwrite") &&
|
|
209
|
+
readFileSync(path.join(foreignRoot, UNIT, "extension.mjs"), "utf8").includes("somebody else"),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── 2. the stub SDK — the vendor boundary, and nothing past it ───────────────
|
|
214
|
+
const stubDir = path.join(root, "sdk-stub");
|
|
215
|
+
mkdirSync(stubDir, { recursive: true });
|
|
216
|
+
writeFileSync(
|
|
217
|
+
path.join(stubDir, "extension.js"),
|
|
218
|
+
`import fs from "node:fs";
|
|
219
|
+
const box = process.env.STUB_BOX;
|
|
220
|
+
export async function joinSession() {
|
|
221
|
+
const handlers = new Map();
|
|
222
|
+
let last = "";
|
|
223
|
+
// The CLI drives events; here the gate does, through a file. Kept REFERENCED so the
|
|
224
|
+
// child stays alive before it arms — exactly as the real CLI child does.
|
|
225
|
+
setInterval(() => {
|
|
226
|
+
let cur = "";
|
|
227
|
+
try { cur = fs.readFileSync(box + "/emit", "utf8").trim(); } catch {}
|
|
228
|
+
if (cur && cur !== last) {
|
|
229
|
+
last = cur;
|
|
230
|
+
for (const h of handlers.get(cur) ?? []) h({ type: cur, data: {} });
|
|
231
|
+
}
|
|
232
|
+
}, 25);
|
|
233
|
+
return {
|
|
234
|
+
sessionId: process.env.STUB_SESSION_ID,
|
|
235
|
+
on(type, handler) {
|
|
236
|
+
if (!handlers.has(type)) handlers.set(type, []);
|
|
237
|
+
handlers.get(type).push(handler);
|
|
238
|
+
return () => {};
|
|
239
|
+
},
|
|
240
|
+
async send(options) {
|
|
241
|
+
fs.appendFileSync(box + "/sends.jsonl", JSON.stringify(options) + "\\n");
|
|
242
|
+
return "stub-message-id";
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
`,
|
|
247
|
+
);
|
|
248
|
+
writeFileSync(
|
|
249
|
+
path.join(stubDir, "loader.mjs"),
|
|
250
|
+
`import { pathToFileURL } from "node:url";
|
|
251
|
+
export async function resolve(specifier, context, nextResolve) {
|
|
252
|
+
if (specifier === "@github/copilot-sdk/extension") {
|
|
253
|
+
return { url: pathToFileURL(process.env.STUB_SDK).href, shortCircuit: true };
|
|
254
|
+
}
|
|
255
|
+
return nextResolve(specifier, context);
|
|
256
|
+
}
|
|
257
|
+
`,
|
|
258
|
+
);
|
|
259
|
+
writeFileSync(
|
|
260
|
+
path.join(stubDir, "register.mjs"),
|
|
261
|
+
`import { register } from "node:module";
|
|
262
|
+
register("./loader.mjs", import.meta.url);
|
|
263
|
+
`,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
interface Harness {
|
|
267
|
+
child: ChildProcess;
|
|
268
|
+
box: string;
|
|
269
|
+
sends: () => string[];
|
|
270
|
+
emit: (type: string) => void;
|
|
271
|
+
log: () => string;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function launch(label: string, sessionId: string, extra: NodeJS.ProcessEnv = {}): Harness {
|
|
275
|
+
const box = path.join(root, `box-${label}`);
|
|
276
|
+
mkdirSync(box, { recursive: true });
|
|
277
|
+
const child = spawn(process.execPath, ["--import", path.join(stubDir, "register.mjs"), entry], {
|
|
278
|
+
env: {
|
|
279
|
+
...process.env,
|
|
280
|
+
HOME: home,
|
|
281
|
+
PI_CODING_AGENT_DIR: store,
|
|
282
|
+
STUB_BOX: box,
|
|
283
|
+
STUB_SDK: path.join(stubDir, "extension.js"),
|
|
284
|
+
STUB_SESSION_ID: sessionId,
|
|
285
|
+
SESSION_ID: sessionId,
|
|
286
|
+
// The vendor's carrier. This gate process IS the fork parent, which is also
|
|
287
|
+
// what makes the sender-marker join real rather than mocked: the marker below
|
|
288
|
+
// is written under the very pid the child will look itself up by.
|
|
289
|
+
COPILOT_EXTENSION_PARENT_PID: String(process.pid),
|
|
290
|
+
...extra,
|
|
291
|
+
},
|
|
292
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
293
|
+
});
|
|
294
|
+
child.stdout?.resume();
|
|
295
|
+
child.stderr?.resume();
|
|
296
|
+
return {
|
|
297
|
+
child,
|
|
298
|
+
box,
|
|
299
|
+
sends: () => {
|
|
300
|
+
const f = path.join(box, "sends.jsonl");
|
|
301
|
+
return existsSync(f)
|
|
302
|
+
? readFileSync(f, "utf8")
|
|
303
|
+
.split("\n")
|
|
304
|
+
.filter((l) => l.trim().length > 0)
|
|
305
|
+
: [];
|
|
306
|
+
},
|
|
307
|
+
emit: (type: string) => writeFileSync(path.join(box, "emit"), `${type}\n`),
|
|
308
|
+
log: () => {
|
|
309
|
+
const f = path.join(store, "meta-bridge-receive-copilot.log");
|
|
310
|
+
return existsSync(f) ? readFileSync(f, "utf8") : "";
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
const running: ChildProcess[] = [];
|
|
315
|
+
function stop(h: Harness): void {
|
|
316
|
+
h.child.kill("SIGTERM");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── 3. no citizen yet → no arm, and no noise about it ────────────────────────
|
|
320
|
+
// A Copilot session is born on its FIRST PROMPT, so an extension that armed at CLI
|
|
321
|
+
// startup legitimately finds no sender marker. That is the ORDINARY state, not a fault:
|
|
322
|
+
// refusing loudly here would train an operator to ignore the log, and arming anyway
|
|
323
|
+
// would claim a receiver for a citizen that does not exist.
|
|
324
|
+
const NATIVE = "cop-recv-0001";
|
|
325
|
+
const main = launch("main", NATIVE);
|
|
326
|
+
running.push(main.child);
|
|
327
|
+
const receiversDir = path.join(store, "meta-receivers");
|
|
328
|
+
await sleep(600);
|
|
329
|
+
ok(
|
|
330
|
+
"before birth: no receiver marker is written (a session with no record is not a citizen)",
|
|
331
|
+
!existsSync(receiversDir) || readdirSync(receiversDir).length === 0,
|
|
332
|
+
);
|
|
333
|
+
ok(
|
|
334
|
+
"before birth: the log records the join, not an error",
|
|
335
|
+
main.log().includes("joined session=") && !main.log().includes("ERROR"),
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
// ── 4. birth, then the arm ───────────────────────────────────────────────────
|
|
339
|
+
// Exactly what the birth hook does on the first prompt, in the same order: record first
|
|
340
|
+
// (identity authority), then the sender marker that names its garden id — keyed to THIS
|
|
341
|
+
// process, the pid the child sees as its parent.
|
|
342
|
+
const born = upsertMetaSession({ input: { backend: "copilot", nativeSessionId: NATIVE, cwd: REPO } });
|
|
343
|
+
const GID = born.record.gardenId;
|
|
344
|
+
writeMetaSenderMarker({
|
|
345
|
+
backend: "copilot",
|
|
346
|
+
gardenId: GID,
|
|
347
|
+
nativeSessionId: NATIVE,
|
|
348
|
+
cwd: REPO,
|
|
349
|
+
ownerPid: process.pid,
|
|
350
|
+
});
|
|
351
|
+
main.emit("user.message");
|
|
352
|
+
const markerFile = path.join(receiversDir, `${GID}.json`);
|
|
353
|
+
ok(
|
|
354
|
+
"[QK:COPILOT-RECEIVE-ARMS-AFTER-BIRTH] the extension arms once the record and the sender marker exist",
|
|
355
|
+
await waitUntil(() => existsSync(markerFile)),
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
const marker = readMetaReceiverMarker({ gardenId: GID });
|
|
359
|
+
ok("the armed marker reads back as LIVE (its owner is a running process)", marker !== null);
|
|
360
|
+
ok(
|
|
361
|
+
"[QK:COPILOT-RECEIVE-OWNER-IS-WATCHER] the marker's owner is the EXTENSION child — the process that actually holds the watch",
|
|
362
|
+
marker?.ownerPid === main.child.pid && marker?.ownerKind === "copilot-extension",
|
|
363
|
+
);
|
|
364
|
+
ok("the arm provenance names the vendor surface it came from", marker?.armProvenance === "extension-join");
|
|
365
|
+
ok(
|
|
366
|
+
"the marker binds the record's identity (garden id, backend, native session id)",
|
|
367
|
+
receiverMarkerMatchesIdentity(marker, readMetaIdentityByGardenId(GID)),
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
// ── 5. the capability this arm makes true ────────────────────────────────────
|
|
371
|
+
const capability = metaCapabilityFor("copilot");
|
|
372
|
+
ok(
|
|
373
|
+
"[QK:COPILOT-RECEIVE-WAKE-MODE-SELF-FETCH] copilot's registered wake mode is self-fetch — the mailbox rail's entry condition",
|
|
374
|
+
capability.wakeMode === "self-fetch",
|
|
375
|
+
);
|
|
376
|
+
// The SAME composition entwurf-v2-production.ts:217 performs, so a green cell here is the
|
|
377
|
+
// dispatch answer, not a lookalike of it.
|
|
378
|
+
function deliverability(): { deliverable: boolean; reason: string } {
|
|
379
|
+
const m = readMetaReceiverMarker({ gardenId: GID });
|
|
380
|
+
const matched = receiverMarkerMatchesIdentity(m, readMetaIdentityByGardenId(GID));
|
|
381
|
+
return mailboxConversationalDeliverable({
|
|
382
|
+
wakeMode: metaCapabilityFor("copilot").wakeMode,
|
|
383
|
+
recordBacked: true,
|
|
384
|
+
ownerAlive: matched,
|
|
385
|
+
watchArmed: matched,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
ok("an armed Copilot citizen is mailbox-DELIVERABLE (the v2 seam's own composition)", deliverability().deliverable);
|
|
389
|
+
|
|
390
|
+
// ── 6. the doorbell: one ring, the id, never the body ────────────────────────
|
|
391
|
+
const SECRET = "body-that-must-not-be-injected-8f2a";
|
|
392
|
+
const mailbox = path.join(store, "meta-mailbox", GID);
|
|
393
|
+
enqueueMetaMessage({ gardenId: GID, body: `hello from the gate :: ${SECRET}` });
|
|
394
|
+
// Wait for the doorbell's OWN first act — the `.msg` -> `.msg.delivered` stamp — not just
|
|
395
|
+
// for "a send happened". A receiver that rang for something else satisfies a bare send
|
|
396
|
+
// count, and then every assertion below reads the wrong notice; that is how the
|
|
397
|
+
// stale-rering mutant came back WRONG-REASON instead of killed.
|
|
398
|
+
ok(
|
|
399
|
+
"the poke rings the doorbell",
|
|
400
|
+
await waitUntil(() => readdirSync(mailbox).some((f) => f.endsWith(".msg.delivered")) && main.sends().length >= 1),
|
|
401
|
+
);
|
|
402
|
+
const sent = main.sends();
|
|
403
|
+
const notice = JSON.parse(sent[sent.length - 1] ?? "{}") as { prompt?: string; mode?: string };
|
|
404
|
+
ok(
|
|
405
|
+
"[QK:COPILOT-RECEIVE-DOORBELL-NOT-BODY] the notice announces the inbox and does NOT carry the message body",
|
|
406
|
+
typeof notice.prompt === "string" &&
|
|
407
|
+
notice.prompt.includes(GID) &&
|
|
408
|
+
notice.prompt.includes("entwurf_inbox_read") &&
|
|
409
|
+
!notice.prompt.includes(SECRET),
|
|
410
|
+
);
|
|
411
|
+
ok("it is enqueued, not forced into a running turn", notice.mode === "enqueue");
|
|
412
|
+
ok(
|
|
413
|
+
"the body stays in the mailbox, stamped as DELIVERED (a wake attempt) and not as read",
|
|
414
|
+
!readdirSync(mailbox).some((f) => f.endsWith(".read")),
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
// A bare poke with no fresh body must not re-ring: the backlog belongs to the model's own
|
|
418
|
+
// read, and re-announcing it would wake a session for nothing. The COUNT carries this
|
|
419
|
+
// claim twice over — a receiver that rings on every poke has usually already rung once at
|
|
420
|
+
// arm time, on an empty mailbox, long before this poke happens.
|
|
421
|
+
writeFileSync(path.join(mailbox, "inbox.signal"), `${new Date().toISOString()}\n`);
|
|
422
|
+
await sleep(700);
|
|
423
|
+
ok(
|
|
424
|
+
"[QK:COPILOT-RECEIVE-NO-STALE-RERING] one message, one ring: an empty mailbox and a bare poke both ring nothing",
|
|
425
|
+
main.sends().length === 1,
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
// ── 7. the refusals — each one names its own cause ───────────────────────────
|
|
429
|
+
// A drifted id is the dangerous one: a marker written against it would tell a sender
|
|
430
|
+
// that a reply lands in a session that will never see it.
|
|
431
|
+
{
|
|
432
|
+
const h = launch("drift", "cop-recv-SOMEONE-ELSE");
|
|
433
|
+
running.push(h.child);
|
|
434
|
+
await waitUntil(() => h.log().includes("id-drift"), 8000);
|
|
435
|
+
ok(
|
|
436
|
+
"[QK:COPILOT-RECEIVE-REFUSES-ID-DRIFT] a session whose SDK id disagrees with the record's native id does NOT arm",
|
|
437
|
+
h.log().includes("arm-refused") && h.log().includes("id-drift") && readdirSync(receiversDir).length === 1,
|
|
438
|
+
);
|
|
439
|
+
stop(h);
|
|
440
|
+
}
|
|
441
|
+
// An untrusted parent: the vendor's carrier and our real parent disagree, so the sender
|
|
442
|
+
// marker we would read belongs to somebody else's session.
|
|
443
|
+
//
|
|
444
|
+
// THE ORACLE IS PER-PROCESS, and it has to be. This log is host-shared and the refusing
|
|
445
|
+
// child would arm the SAME garden id as the healthy one — so "the log says arm-refused"
|
|
446
|
+
// (a line the id-drift child already wrote) and "one marker file exists" (the count is
|
|
447
|
+
// unchanged by an overwrite) are both satisfied with the guard REMOVED. Gate
|
|
448
|
+
// qualification proved exactly that: the mutant SURVIVED. What discriminates is this
|
|
449
|
+
// child's own refusal line, and the fact that the armed marker still belongs to the
|
|
450
|
+
// process that legitimately holds the watch.
|
|
451
|
+
{
|
|
452
|
+
const h = launch("foreign-parent", NATIVE, { COPILOT_EXTENSION_PARENT_PID: String(process.pid + 1) });
|
|
453
|
+
running.push(h.child);
|
|
454
|
+
const ownRefusal = `pid=${h.child.pid} arm-refused: COPILOT_EXTENSION_PARENT_PID=`;
|
|
455
|
+
await waitUntil(() => h.log().includes(ownRefusal), 8000);
|
|
456
|
+
ok(
|
|
457
|
+
"[QK:COPILOT-RECEIVE-REFUSES-FOREIGN-PARENT] a parent-pid carrier that disagrees with the real parent does NOT arm — it refuses under its OWN pid, and the marker keeps belonging to the process that holds the watch",
|
|
458
|
+
h.log().includes(ownRefusal) && readMetaReceiverMarker({ gardenId: GID })?.ownerPid === main.child.pid,
|
|
459
|
+
);
|
|
460
|
+
stop(h);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ── 8. the arm is given back when the watcher goes ───────────────────────────
|
|
464
|
+
// Two separate facts, and the second is the load-bearing one: teardown REMOVES the
|
|
465
|
+
// marker, and even if it could not, a marker whose owner is gone reads as inactive.
|
|
466
|
+
stop(main);
|
|
467
|
+
ok("a terminated extension removes its own marker", await waitUntil(() => !existsSync(markerFile), 8000));
|
|
468
|
+
writeFileSync(
|
|
469
|
+
markerFile,
|
|
470
|
+
JSON.stringify({
|
|
471
|
+
gardenId: GID,
|
|
472
|
+
backend: "copilot",
|
|
473
|
+
nativeSessionId: NATIVE,
|
|
474
|
+
ownerPid: main.child.pid,
|
|
475
|
+
ownerStartKey: "ghost-start-key",
|
|
476
|
+
ownerKind: "copilot-extension",
|
|
477
|
+
armProvenance: "extension-join",
|
|
478
|
+
updatedAt: new Date().toISOString(),
|
|
479
|
+
}),
|
|
480
|
+
{ mode: 0o600 },
|
|
481
|
+
);
|
|
482
|
+
ok(
|
|
483
|
+
"[QK:COPILOT-RECEIVE-DEAD-EXTENSION-REFUSED] a marker whose watcher is gone is NOT deliverable — the reply is refused, not enqueued into a void",
|
|
484
|
+
!deliverability().deliverable,
|
|
485
|
+
);
|
|
486
|
+
rmSync(markerFile, { force: true });
|
|
487
|
+
|
|
488
|
+
// ── 9. the doctor: it must SEE the two silent failures ───────────────────────
|
|
489
|
+
// A doctor that only certifies files is a doctor for problems that were never the
|
|
490
|
+
// problem. These two cells are the ones that cost a real session: a deployed writer the
|
|
491
|
+
// checkout has moved past, and a CLI launched without the scan flag.
|
|
492
|
+
function doctor(env: NodeJS.ProcessEnv = {}): string {
|
|
493
|
+
return execFileSync(
|
|
494
|
+
"bash",
|
|
495
|
+
["-c", `bash ${JSON.stringify(path.join(REPO, "run.sh"))} doctor-copilot-receive 2>&1 || true`],
|
|
496
|
+
// The live-process axis is host-global, so the gate SUPPLIES the process set
|
|
497
|
+
// (empty unless a cell is testing that branch) — otherwise a copilot the operator
|
|
498
|
+
// happens to be running would decide this gate's colour.
|
|
499
|
+
{ env: { ...installEnv, ENTWURF_COPILOT_RECEIVE_PIDS: "", ...env }, encoding: "utf8" },
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
ok(
|
|
503
|
+
"a clean install passes the doctor, and it says WHICH writer is deployed",
|
|
504
|
+
doctor().includes("the deployed writer matches this checkout's compiled writer"),
|
|
505
|
+
);
|
|
506
|
+
{
|
|
507
|
+
// THE ORIGINAL DEFECT OF THIS LANE, made a permanent cell: a HEALTHY INFO-only log
|
|
508
|
+
// once killed the doctor mid-section — `set -euo pipefail` turned a no-match grep
|
|
509
|
+
// (exit 1) into the assignment's status and -e ended the script before any verdict.
|
|
510
|
+
// The failure shape is "no final line and a non-zero exit", which a substring
|
|
511
|
+
// assertion cannot see: only the doctor's own PASS verdict as the LAST line plus
|
|
512
|
+
// exit 0 can. Refused WARN lines are present here on purpose (the drift/foreign-parent
|
|
513
|
+
// cells earlier in this gate wrote them) — they must stay notes, not red.
|
|
514
|
+
//
|
|
515
|
+
// FIRST among the doctor cells, and that position is load-bearing. This defect kills the
|
|
516
|
+
// doctor before ANY verdict prints, so every later cell that reads a verdict line —
|
|
517
|
+
// stale-writer, the flag cells, unreadable-environ, scanner-failure — fails too when it
|
|
518
|
+
// is re-planted. Whichever of them runs first is the one the mutant is attributed to, and
|
|
519
|
+
// qualification then reports WRONG-REASON for a claim that was perfectly true. Measured:
|
|
520
|
+
// it did exactly that once the scanner-failure cell was added above it (bg02, 229/230).
|
|
521
|
+
// A cell that shares a failure mode with later cells must run before them.
|
|
522
|
+
const clean = spawnSync("bash", [path.join(REPO, "run.sh"), "doctor-copilot-receive"], {
|
|
523
|
+
env: { ...installEnv, ENTWURF_COPILOT_RECEIVE_PIDS: "" },
|
|
524
|
+
encoding: "utf8",
|
|
525
|
+
});
|
|
526
|
+
ok(
|
|
527
|
+
"[QK:COPILOT-RECEIVE-DOCTOR-PASSES-CLEAN-LOG] a healthy log ends in the doctor's own PASS verdict and exit 0 — no silent mid-script death",
|
|
528
|
+
clean.status === 0 && clean.stdout.trimEnd().endsWith("[copilot-receive-doctor] PASS"),
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
{
|
|
532
|
+
// Exactly the failure this lane hit while it was being built: the unit kept a copy of
|
|
533
|
+
// a writer that predated the `extension-join` provenance, every arm threw inside the
|
|
534
|
+
// extension, and the only symptom was a citizen that never became deliverable.
|
|
535
|
+
const deployed = path.join(unitDir, "lib", "meta-session.js");
|
|
536
|
+
const good = readFileSync(deployed, "utf8");
|
|
537
|
+
writeFileSync(
|
|
538
|
+
deployed,
|
|
539
|
+
`${good}
|
|
540
|
+
// a writer this checkout has moved past
|
|
541
|
+
`,
|
|
542
|
+
);
|
|
543
|
+
const out = doctor();
|
|
544
|
+
ok(
|
|
545
|
+
"[QK:COPILOT-RECEIVE-DOCTOR-CATCHES-STALE-WRITER] a deployed writer that differs from this checkout's is called STALE, not merely present",
|
|
546
|
+
out.includes("the deployed writer is STALE") && out.includes("FAIL"),
|
|
547
|
+
);
|
|
548
|
+
writeFileSync(deployed, good);
|
|
549
|
+
}
|
|
550
|
+
{
|
|
551
|
+
// The flag branch, on REAL processes read through their own /proc entries.
|
|
552
|
+
//
|
|
553
|
+
// The fixtures are VENDOR-SHAPED, and that shape is the point. This block used to
|
|
554
|
+
// spawn `sleep 30`, which was enough while discovery was `pgrep -x copilot` — a
|
|
555
|
+
// predicate that, measured on the reference host, matched NOTHING EVER: `copilot` is
|
|
556
|
+
// a shim whose branches all end `exec node …/@github/copilot/npm-loader.js`, and
|
|
557
|
+
// `exec` replaces the image, so no live CLI can carry that comm. A `sleep` fixture
|
|
558
|
+
// could not have caught it, because a `sleep` is not shaped like the thing that was
|
|
559
|
+
// being missed. So the fixture now IS the vendor layout: node, exec'd with a
|
|
560
|
+
// `@github/copilot/npm-loader.js` entry, exactly as a real launch arrives.
|
|
561
|
+
//
|
|
562
|
+
// Note what is NOT asserted anywhere here: a comm value. On the reference host 44
|
|
563
|
+
// live processes reported comm `MainThread` — the entwurf bridge child, 42 stub
|
|
564
|
+
// extension children, and anything else nodejs-slim 24 started — while other node
|
|
565
|
+
// builds report `node`. Pinning the native signature to a comm string would be a
|
|
566
|
+
// cell that is green today and silently false after a node bump.
|
|
567
|
+
const vendorDir = path.join(root, "vendor", "node_modules", "@github", "copilot");
|
|
568
|
+
mkdirSync(vendorDir, { recursive: true });
|
|
569
|
+
const loader = path.join(vendorDir, "npm-loader.js");
|
|
570
|
+
writeFileSync(loader, "setTimeout(() => {}, 60_000);\n");
|
|
571
|
+
// The lookalike: our own unit's child, launched the way the vendor launches one —
|
|
572
|
+
// the SDK is injected FROM the copilot package, so the child's argv carries a
|
|
573
|
+
// `@github/copilot/**.js` element too. That is deliberate: it makes the child
|
|
574
|
+
// positively identifiable as vendor-adjacent, so the only thing keeping it out of the
|
|
575
|
+
// session count is the extension-entry exclusion itself. A fixture that failed the
|
|
576
|
+
// identity test for some other reason would let that exclusion rot untested.
|
|
577
|
+
const sdkEntry = path.join(vendorDir, "copilot-sdk", "index.js");
|
|
578
|
+
mkdirSync(path.dirname(sdkEntry), { recursive: true });
|
|
579
|
+
writeFileSync(sdkEntry, "\n");
|
|
580
|
+
const lookalikeDir = path.join(root, "vendor", "extensions", "entwurf-receive");
|
|
581
|
+
mkdirSync(lookalikeDir, { recursive: true });
|
|
582
|
+
const lookalikeEntry = path.join(lookalikeDir, "extension.mjs");
|
|
583
|
+
writeFileSync(lookalikeEntry, "setTimeout(() => {}, 60_000);\n");
|
|
584
|
+
|
|
585
|
+
const withFlag = spawn(process.execPath, [loader], {
|
|
586
|
+
env: { ...process.env, COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS" },
|
|
587
|
+
stdio: "ignore",
|
|
588
|
+
});
|
|
589
|
+
const withoutFlag = spawn(process.execPath, [loader], {
|
|
590
|
+
env: { ...process.env, COPILOT_CLI_ENABLED_FEATURE_FLAGS: "" },
|
|
591
|
+
stdio: "ignore",
|
|
592
|
+
});
|
|
593
|
+
// Armed AND contaminated: the two axes must not absorb each other. A session can
|
|
594
|
+
// carry the scan flag and still speak under a parent pi garden id.
|
|
595
|
+
const contaminated = spawn(process.execPath, [loader], {
|
|
596
|
+
env: {
|
|
597
|
+
...process.env,
|
|
598
|
+
COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS",
|
|
599
|
+
PI_SESSION_ID: "0199aaaa-bbbb-7ccc-8ddd-eeeeffff0000",
|
|
600
|
+
PI_AGENT_ID: "pi-agent-fixture",
|
|
601
|
+
},
|
|
602
|
+
stdio: "ignore",
|
|
603
|
+
});
|
|
604
|
+
const extensionChild = spawn(process.execPath, ["--import", pathToFileURL(sdkEntry).href, lookalikeEntry], {
|
|
605
|
+
env: { ...process.env, COPILOT_EXTENSION_PARENT_PID: String(withFlag.pid ?? 1) },
|
|
606
|
+
stdio: "ignore",
|
|
607
|
+
});
|
|
608
|
+
running.push(withFlag, withoutFlag, extensionChild, contaminated);
|
|
609
|
+
// Reap in a finally, not at the end of the file: an assertion failure below must not
|
|
610
|
+
// be able to leave these three behind as ppid=1 orphans holding a temp tree open.
|
|
611
|
+
try {
|
|
612
|
+
await sleep(200);
|
|
613
|
+
|
|
614
|
+
const armedOut = doctor({ ENTWURF_COPILOT_RECEIVE_PIDS: String(withFlag.pid) });
|
|
615
|
+
|
|
616
|
+
// The identity claim, stated against the predicate that used to be here: this
|
|
617
|
+
// process IS a native CLI and its comm is NOT `copilot`, so anything keying on the
|
|
618
|
+
// command name would report the benign "no live copilot" note instead of a verdict.
|
|
619
|
+
// "Found it" and "reported its flag" are ONE assertion on purpose — split in two,
|
|
620
|
+
// a discovery regression would trip the unlabelled half first and a mutant would
|
|
621
|
+
// come back WRONG-REASON instead of killed at this claim.
|
|
622
|
+
const comm = readFileSync(`/proc/${withFlag.pid}/comm`, "utf8").trim();
|
|
623
|
+
ok(
|
|
624
|
+
`[QK:COPILOT-RECEIVE-DOCTOR-FINDS-EXECED-NATIVE] an exec'd vendor entry is found by argv identity, not by command name (comm here is "${comm}", never "copilot")`,
|
|
625
|
+
comm !== "copilot" && armedOut.includes("carry COPILOT_CLI_ENABLED_FEATURE_FLAGS"),
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
const barefootOut = doctor({ ENTWURF_COPILOT_RECEIVE_PIDS: String(withoutFlag.pid) });
|
|
629
|
+
ok(
|
|
630
|
+
"[QK:COPILOT-RECEIVE-DOCTOR-SEES-MISSING-FLAG] a CLI launched WITHOUT the scan flag is red — that session can never arm, and Copilot says nothing about it",
|
|
631
|
+
barefootOut.includes("lack COPILOT_CLI_ENABLED_FEATURE_FLAGS") && barefootOut.includes("FAIL"),
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
// The exclusion, in the direction that matters: an extension child is unflagged
|
|
635
|
+
// (its parent's flag does not propagate as ours), so a discovery that counted it
|
|
636
|
+
// would go RED and blame the operator for a session that does not exist.
|
|
637
|
+
const lookalikeOut = doctor({ ENTWURF_COPILOT_RECEIVE_PIDS: String(extensionChild.pid) });
|
|
638
|
+
ok(
|
|
639
|
+
"[QK:COPILOT-RECEIVE-DOCTOR-EXCLUDES-EXTENSION-CHILD] our own extension child is not a Copilot session — it is excluded, not counted and not blamed",
|
|
640
|
+
lookalikeOut.includes("no live GitHub Copilot CLI process") &&
|
|
641
|
+
!lookalikeOut.includes("lack COPILOT_CLI_ENABLED_FEATURE_FLAGS"),
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
const contaminatedOut = doctor({ ENTWURF_COPILOT_RECEIVE_PIDS: String(contaminated.pid) });
|
|
645
|
+
ok(
|
|
646
|
+
"[QK:COPILOT-RECEIVE-DOCTOR-PI-CARRIER-IS-RED] an identified CLI that inherited PI_SESSION_ID/PI_AGENT_ID is RED on its own axis — it can be perfectly armed and still speak under the parent pi garden id, so the flag verdict must not absorb it",
|
|
647
|
+
contaminatedOut.includes("inherited PI_SESSION_ID/PI_AGENT_ID") && contaminatedOut.includes("FAIL"),
|
|
648
|
+
);
|
|
649
|
+
} finally {
|
|
650
|
+
withFlag.kill("SIGKILL");
|
|
651
|
+
withoutFlag.kill("SIGKILL");
|
|
652
|
+
extensionChild.kill("SIGKILL");
|
|
653
|
+
contaminated.kill("SIGKILL");
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
{
|
|
657
|
+
// AN IDENTIFIED CLI WHOSE ENVIRONMENT WE CANNOT READ IS UNKNOWN, AND UNKNOWN IS RED.
|
|
658
|
+
// Identity has already succeeded for such a process — it IS a Copilot CLI — and the one
|
|
659
|
+
// thing this whole section exists to decide about it is unanswerable. Reporting that as
|
|
660
|
+
// a note let the doctor end in PASS while a session that can never arm was running.
|
|
661
|
+
//
|
|
662
|
+
// The fixture makes the environment REALLY unreadable rather than simulating it: the
|
|
663
|
+
// child calls prctl(PR_SET_DUMPABLE, 0) on itself, which reparents its /proc entries to
|
|
664
|
+
// root, and it carries a vendor-shaped argv so production identity still matches it.
|
|
665
|
+
// The cell asserts the unreadability FIRST — a fixture that quietly stayed readable
|
|
666
|
+
// would make the rest of this vacuous.
|
|
667
|
+
const nonDumpable = path.join(root, "nondumpable.py");
|
|
668
|
+
writeFileSync(
|
|
669
|
+
nonDumpable,
|
|
670
|
+
[
|
|
671
|
+
"import ctypes, sys, time",
|
|
672
|
+
"assert ctypes.CDLL('libc.so.6').prctl(4, 0, 0, 0, 0) == 0",
|
|
673
|
+
"sys.stderr.write('ready\\n'); sys.stderr.flush()",
|
|
674
|
+
"time.sleep(60)",
|
|
675
|
+
].join("\n") + "\n",
|
|
676
|
+
);
|
|
677
|
+
const opaque = spawn(
|
|
678
|
+
"python3",
|
|
679
|
+
[nonDumpable, path.join(root, "node_modules", "@github", "copilot", "npm-loader.js")],
|
|
680
|
+
{
|
|
681
|
+
stdio: "ignore",
|
|
682
|
+
},
|
|
683
|
+
);
|
|
684
|
+
running.push(opaque);
|
|
685
|
+
try {
|
|
686
|
+
await sleep(400);
|
|
687
|
+
let environReadable = true;
|
|
688
|
+
try {
|
|
689
|
+
readFileSync(`/proc/${opaque.pid}/environ`);
|
|
690
|
+
} catch {
|
|
691
|
+
environReadable = false;
|
|
692
|
+
}
|
|
693
|
+
const out = doctor({ ENTWURF_COPILOT_RECEIVE_PIDS: String(opaque.pid) });
|
|
694
|
+
ok(
|
|
695
|
+
"[QK:COPILOT-RECEIVE-DOCTOR-UNREADABLE-ENVIRON-IS-RED] an identified CLI whose environment cannot be read is UNKNOWN, and the doctor ends RED rather than passing with a note",
|
|
696
|
+
!environReadable && out.includes("is UNKNOWN and is NOT assumed armed") && out.includes("FAIL"),
|
|
697
|
+
);
|
|
698
|
+
} finally {
|
|
699
|
+
opaque.kill("SIGKILL");
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
{
|
|
703
|
+
// THE SCANNER'S OWN FAILURE IS THIS DOCTOR'S VERDICT, NOT ITS DEATH. Under
|
|
704
|
+
// `set -euo pipefail`, `x="$(python3 …)"` hands the interpreter's status to the
|
|
705
|
+
// assignment and `-e` ends the script there — no verdict line, no exit-0/exit-1
|
|
706
|
+
// distinction an operator can act on. Exactly the shape of the clean-log grep defect
|
|
707
|
+
// this lane already stepped on.
|
|
708
|
+
//
|
|
709
|
+
// The oracle is a `python3` earlier on PATH that DELEGATES every other invocation to the
|
|
710
|
+
// real interpreter and fails only the stdin form the scanner uses, so nothing else in the
|
|
711
|
+
// doctor changes behaviour and the failure is attributable to the scanner alone.
|
|
712
|
+
const pyBin = path.join(root, "py-bin");
|
|
713
|
+
mkdirSync(pyBin, { recursive: true });
|
|
714
|
+
const realPython = execFileSync("bash", ["-c", "command -v python3"], { encoding: "utf8" }).trim();
|
|
715
|
+
writeFileSync(
|
|
716
|
+
path.join(pyBin, "python3"),
|
|
717
|
+
`#!/usr/bin/env bash\nfor a in "$@"; do [ "$a" = "-" ] && exit 3; done\nexec ${JSON.stringify(realPython)} "$@"\n`,
|
|
718
|
+
);
|
|
719
|
+
chmodSync(path.join(pyBin, "python3"), 0o755);
|
|
720
|
+
const out = doctor({ PATH: `${pyBin}:${process.env.PATH ?? ""}` });
|
|
721
|
+
ok(
|
|
722
|
+
"[QK:COPILOT-RECEIVE-DOCTOR-OWNS-SCANNER-FAILURE] a scanner that exits non-zero produces the doctor's OWN red verdict, not a silent mid-section death",
|
|
723
|
+
out.includes("the /proc scan for live Copilot CLIs FAILED") &&
|
|
724
|
+
out.trimEnd().endsWith("[copilot-receive-doctor] FAIL"),
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ── 10. the installer's honest inverse ───────────────────────────────────────
|
|
729
|
+
execFileSync("bash", [path.join(REPO, "run.sh"), "uninstall-copilot-receive"], { env: installEnv, stdio: "pipe" });
|
|
730
|
+
ok("uninstall removes the unit it installed and clears its state", !existsSync(unitDir) && !existsSync(stateFile));
|
|
731
|
+
|
|
732
|
+
for (const c of running) c.kill("SIGKILL");
|
|
733
|
+
rmSync(root, { recursive: true, force: true });
|
|
734
|
+
console.log(`\n[check-copilot-receive-arm] PASS (${passed} assertions)`);
|