@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.
Files changed (72) hide show
  1. package/AGENTS.md +8 -5
  2. package/CHANGELOG.md +79 -0
  3. package/DELIVERY.md +72 -59
  4. package/README.md +55 -4
  5. package/VERIFY.md +2 -2
  6. package/demo/README.md +1 -1
  7. package/docs/acp-backend-rail.md +1 -1
  8. package/docs/external-mcp-host.md +26 -4
  9. package/docs/setup-clean-host.md +3 -3
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +8 -6
  11. package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/copilot-fresh-preflight.js +253 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +10 -2
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +102 -28
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +77 -11
  16. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-copilot.js +288 -0
  17. package/mcp/entwurf-bridge/src/index.ts +10 -6
  18. package/mcp/entwurf-bridge/tsconfig.build.json +2 -1
  19. package/package.json +22 -11
  20. package/pi/copilot-receive/entwurf-receive/extension.mjs +323 -0
  21. package/pi/entwurf-capabilities.json +1 -0
  22. package/pi/meta-bridge-copilot/.claude-plugin/marketplace.json +11 -0
  23. package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/.claude-plugin/plugin.json +5 -0
  24. package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/hooks/hooks.json +7 -0
  25. package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/scripts/copilot-hook-launch.sh +85 -0
  26. package/pi-extensions/entwurf-control.ts +17 -8
  27. package/pi-extensions/lib/copilot-fresh-preflight.ts +251 -0
  28. package/pi-extensions/lib/meta-sender-identity.ts +10 -2
  29. package/pi-extensions/lib/meta-session.ts +102 -29
  30. package/pi-extensions/lib/mux-fresh-call.ts +82 -11
  31. package/pi-extensions/meta-bridge-hook-copilot.ts +323 -0
  32. package/run.sh +334 -16
  33. package/scripts/check-copilot-birth-hook.ts +497 -0
  34. package/scripts/check-copilot-launch.ts +395 -0
  35. package/scripts/check-copilot-receive-arm.ts +734 -0
  36. package/scripts/check-copilot-statusline.ts +122 -0
  37. package/scripts/check-entwurf-capabilities.ts +25 -6
  38. package/scripts/check-fresh-cut-gate.sh +92 -23
  39. package/scripts/check-gate-qualification.ts +7 -1
  40. package/scripts/check-install-container.sh +2 -2
  41. package/scripts/check-install-surface.ts +1 -1
  42. package/scripts/check-meta-capability-source.ts +89 -5
  43. package/scripts/check-meta-doctor-oracle.sh +26 -0
  44. package/scripts/check-meta-manifest-schema.py +38 -0
  45. package/scripts/check-meta-receiver-marker.ts +25 -2
  46. package/scripts/check-meta-session.ts +32 -0
  47. package/scripts/copilot-bridge-doctor.sh +209 -0
  48. package/scripts/copilot-bridge-install.sh +224 -0
  49. package/scripts/copilot-launch.sh +236 -0
  50. package/scripts/copilot-mcp-bridge.sh +177 -0
  51. package/scripts/copilot-mcp-config.py +221 -0
  52. package/scripts/copilot-receive-bridge.sh +483 -0
  53. package/scripts/copilot-statusline-bridge.sh +149 -0
  54. package/scripts/copilot-statusline-config.py +201 -0
  55. package/scripts/copilot-statusline.sh +68 -0
  56. package/scripts/dev-bin.sh +4 -1
  57. package/scripts/meta-bridge-hook-log.sh +17 -5
  58. package/scripts/mutants/capability-cache.json +64 -0
  59. package/scripts/mutants/copilot-birth.json +155 -0
  60. package/scripts/mutants/copilot-launch.json +187 -0
  61. package/scripts/mutants/copilot-receive.json +263 -0
  62. package/scripts/mutants/fresh-cut.json +17 -0
  63. package/scripts/mutants/mux-fresh-call.json +216 -2
  64. package/scripts/mutants/pack-install.json +17 -0
  65. package/scripts/raw-async-delivery/README.md +170 -125
  66. package/scripts/raw-async-delivery/copilot-enqueue-addressed.sh +35 -0
  67. package/scripts/raw-async-delivery/copilot-extension-receive/extension.mjs +123 -0
  68. package/scripts/smoke-copilot-mcp-state.sh +153 -0
  69. package/scripts/smoke-copilot-statusline-state.sh +131 -0
  70. package/scripts/smoke-mux-fresh-call-live.ts +2 -0
  71. package/scripts/smoke-mux-lifecycle-live.ts +3 -1
  72. package/scripts/tsconfig.json +1 -0
@@ -0,0 +1,395 @@
1
+ /**
2
+ * check-copilot-launch — deterministic gate for the MANAGED Copilot launch
3
+ * (`entwurf copilot`, #82 RAIL 7). Hermetic: no GitHub Copilot CLI, no network, no
4
+ * model turn, no write outside its own temp root.
5
+ *
6
+ * WHAT IS UNDER TEST is a process replacement, so the oracle is a FAKE VENDOR: a real
7
+ * executable placed on a sandbox PATH that reports the argv, environment, pid and cwd it
8
+ * was actually handed, then exits with a status this gate chooses. Everything below is
9
+ * asserted from that report — never from reading the launcher's source. The launcher is
10
+ * driven through its PUBLIC address (`run.sh copilot`), because the dispatcher's own
11
+ * argv handling is part of the contract: the verb must not reach the vendor.
12
+ *
13
+ * WHY THE SANDBOX IS TOTAL. HOME, XDG_DATA_HOME and PATH are all redirected, so the
14
+ * receiver install-state, the extensions root and the resolved binary are the fixture's
15
+ * and never the operator's. The fake vendor is installed under the REAL name `copilot`
16
+ * on that sandbox PATH — there is no "which command stands in for the vendor" switch,
17
+ * because a production env seam that can redirect an exec is an authority and not a test
18
+ * convenience. What the launcher resolves here is what it resolves anywhere: the first
19
+ * `copilot` on PATH.
20
+ */
21
+
22
+ import assert from "node:assert/strict";
23
+ import { spawnSync } from "node:child_process";
24
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
25
+ import { tmpdir } from "node:os";
26
+ import * as path from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+
29
+ const REPO = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
30
+
31
+ let passed = 0;
32
+ function ok(label: string, cond: boolean): void {
33
+ assert.ok(cond, label);
34
+ console.log(` ok ${label}`);
35
+ passed++;
36
+ }
37
+
38
+ const root = mkdtempSync(path.join(tmpdir(), "entwurf-copilot-launch."));
39
+ try {
40
+ const home = path.join(root, "home");
41
+ const xdg = path.join(root, "xdg");
42
+ const bin = path.join(root, "bin");
43
+ const unitDir = path.join(home, ".copilot", "extensions", "entwurf-receive");
44
+ const stateDir = path.join(xdg, "entwurf", "copilot-receive");
45
+ const stateFile = path.join(stateDir, "install-state.json");
46
+ for (const d of [bin, unitDir, stateDir]) mkdirSync(d, { recursive: true });
47
+
48
+ // The fake vendor. `printf '%s\n'` per element keeps empty strings and embedded
49
+ // spaces visible as themselves, which is the only way to assert byte preservation.
50
+ const vendor = path.join(bin, "copilot");
51
+ writeFileSync(
52
+ vendor,
53
+ `#!/usr/bin/env bash
54
+ echo "CWD=$PWD"
55
+ echo "PID=$$"
56
+ echo "FLAG=[\${COPILOT_CLI_ENABLED_FEATURE_FLAGS-<unset>}]"
57
+ # The identity carriers, reported with the SET/UNSET distinction intact: the bridge's
58
+ # authoritative-self resolution needs a COMPLETE pair, so "present but empty" and "absent"
59
+ # are different facts and an empty-string report would hide one of them.
60
+ echo "PISESSION=[\${PI_SESSION_ID-<unset>}]"
61
+ echo "PIAGENT=[\${PI_AGENT_ID-<unset>}]"
62
+ echo "KEEP=\${ENTWURF_FIXTURE_KEEP-0}"
63
+ for a in "$@"; do printf 'ARG<%s>\\n' "$a"; done
64
+ exit "\${FAKE_COPILOT_EXIT:-0}"
65
+ `,
66
+ );
67
+ chmodSync(vendor, 0o755);
68
+ writeFileSync(path.join(unitDir, "extension.mjs"), "// fixture\n");
69
+
70
+ const goodState = JSON.stringify({ schemaVersion: 1, unit: "entwurf-receive", path: unitDir });
71
+ const writeState = (body: string): void => writeFileSync(stateFile, body);
72
+ writeState(goodState);
73
+
74
+ interface Run {
75
+ status: number | null;
76
+ out: string;
77
+ args: string[];
78
+ flag: string;
79
+ cwd: string;
80
+ pid: string;
81
+ piSession: string;
82
+ piAgent: string;
83
+ }
84
+ // A PATH with no `copilot` anywhere on it — built by dropping every real PATH entry that
85
+ // actually holds one, rather than by emptying PATH (the launcher still needs python3,
86
+ // readlink and friends). This is how "the vendor is missing" is expressed now that the
87
+ // gate cannot simply point the launcher at a name that does not exist.
88
+ const pathWithoutVendor = (process.env.PATH ?? "")
89
+ .split(":")
90
+ .filter((d) => d !== "" && !existsSync(path.join(d, "copilot")))
91
+ .join(":");
92
+
93
+ function launch(args: string[], extraEnv: Record<string, string> = {}, cwd = root): Run {
94
+ const r = spawnSync("bash", [path.join(REPO, "run.sh"), "copilot", ...args], {
95
+ cwd,
96
+ encoding: "utf8",
97
+ env: {
98
+ ...process.env,
99
+ HOME: home,
100
+ XDG_DATA_HOME: xdg,
101
+ PATH: `${bin}:${process.env.PATH ?? ""}`,
102
+ COPILOT_CLI_ENABLED_FEATURE_FLAGS: undefined as unknown as string,
103
+ ENTWURF_COPILOT_LAUNCH_ACTIVE: undefined as unknown as string,
104
+ ...extraEnv,
105
+ },
106
+ });
107
+ const out = `${r.stdout ?? ""}${r.stderr ?? ""}`;
108
+ const argv: string[] = [];
109
+ for (const line of out.split("\n")) {
110
+ const m = /^ARG<([\s\S]*)>$/.exec(line);
111
+ if (m) argv.push(m[1]);
112
+ }
113
+ return {
114
+ status: r.status,
115
+ out,
116
+ args: argv,
117
+ // Line-anchored, NOT `[\s\S]*`: with more reported lines below, a greedy
118
+ // any-character match runs past this line's `]` and captures everything up to the
119
+ // LAST bracketed line in the report.
120
+ flag: /^FLAG=\[(.*)\]$/m.exec(out)?.[1] ?? "<no-launch>",
121
+ cwd: /^CWD=(.*)$/m.exec(out)?.[1] ?? "",
122
+ pid: /^PID=(.*)$/m.exec(out)?.[1] ?? "",
123
+ piSession: /^PISESSION=\[(.*)\]$/m.exec(out)?.[1] ?? "<no-launch>",
124
+ piAgent: /^PIAGENT=\[(.*)\]$/m.exec(out)?.[1] ?? "<no-launch>",
125
+ };
126
+ }
127
+
128
+ // ── 1. the promise is refused unless the receiver behind it exists ──────────
129
+ // Setting the scan flag is a PROMISE of a doorbell. Making it while no unit is
130
+ // installed produces a session that looks managed and can never be delivered to.
131
+ {
132
+ rmSync(stateFile);
133
+ const r = launch([]);
134
+ ok(
135
+ "[QK:COPILOT-LAUNCH-REQUIRES-RECEIVER] with no receiver install-state the launch REFUSES, names the exact repair, and never reaches the vendor",
136
+ r.status !== 0 && r.out.includes("entwurf install-copilot-receive") && r.flag === "<no-launch>",
137
+ );
138
+ writeState(goodState);
139
+ }
140
+ {
141
+ // A state file naming a different extensions root than this environment scans means
142
+ // the CLI would read a directory nobody installed into — a green artifact check with
143
+ // an inert session behind it.
144
+ //
145
+ // The decoy is a COMPLETE, VALID unit at the wrong place, on purpose. Pointing at a
146
+ // path that merely does not exist would be caught by the directory check further
147
+ // down, and this cell would pass even with the path-equality guard removed — the
148
+ // assertion would be over-determined and the claim untested.
149
+ const decoy = path.join(root, "other-extensions-root", "entwurf-receive");
150
+ mkdirSync(decoy, { recursive: true });
151
+ writeFileSync(path.join(decoy, "extension.mjs"), "// decoy\n");
152
+ writeState(JSON.stringify({ schemaVersion: 1, unit: "entwurf-receive", path: decoy }));
153
+ const r = launch([]);
154
+ ok(
155
+ "[QK:COPILOT-LAUNCH-REFUSES-PATH-MISMATCH] an install-state pointing at a different extensions root refuses instead of launching",
156
+ r.status !== 0 && r.flag === "<no-launch>" && r.out.includes("entwurf install-copilot-receive"),
157
+ );
158
+ writeState(goodState);
159
+ }
160
+ {
161
+ writeState("{ not json");
162
+ const r = launch([]);
163
+ ok(
164
+ "an unreadable install-state refuses rather than guessing (over-determined: the empty-path guard catches it too, so this carries no claim of its own)",
165
+ r.status !== 0 && r.flag === "<no-launch>",
166
+ );
167
+ writeState(goodState);
168
+ }
169
+
170
+ // ── 2. the flag this whole surface exists to set ────────────────────────────
171
+ {
172
+ const r = launch([]);
173
+ ok(
174
+ "[QK:COPILOT-LAUNCH-SETS-EXTENSIONS-TOKEN] the vendor is launched with the EXTENSIONS scan token — the flag no installer can set, because it belongs to a launch",
175
+ r.status === 0 && r.flag === "EXTENSIONS",
176
+ );
177
+ }
178
+ {
179
+ // The operator's own tokens are theirs: kept, in order, deduplicated, with blank
180
+ // fields dropped and EXTENSIONS appended exactly once.
181
+ const r = launch([], { COPILOT_CLI_ENABLED_FEATURE_FLAGS: " FOO ,,BAR,FOO," });
182
+ const already = launch([], { COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS,ZED" });
183
+ ok(
184
+ `[QK:COPILOT-LAUNCH-PRESERVES-OPERATOR-TOKENS] existing feature-flag tokens survive in order, deduplicated, and EXTENSIONS is added exactly once (got "${r.flag}" / "${already.flag}")`,
185
+ r.flag === "FOO,BAR,EXTENSIONS" && already.flag === "EXTENSIONS,ZED",
186
+ );
187
+ }
188
+
189
+ // ── 3. injected defaults, and the terminator that stops them ────────────────
190
+ {
191
+ const r = launch([]);
192
+ ok(
193
+ `[QK:COPILOT-LAUNCH-INJECTS-MANAGED-PROFILE] a bare managed launch adds --model auto and --yolo, and nothing else (got ${JSON.stringify(r.args)})`,
194
+ JSON.stringify(r.args) === JSON.stringify(["--model", "auto", "--yolo"]),
195
+ );
196
+ }
197
+ {
198
+ const r = launch(["-p", "hi", "--", "--yolo", "", "a b"]);
199
+ ok(
200
+ `[QK:COPILOT-LAUNCH-DEFAULTS-BEFORE-TERMINATOR] defaults land BEFORE the first literal --, and everything after it crosses untouched (got ${JSON.stringify(r.args)})`,
201
+ JSON.stringify(r.args) === JSON.stringify(["-p", "hi", "--model", "auto", "--yolo", "--", "--yolo", "", "a b"]),
202
+ );
203
+ }
204
+ {
205
+ // The `--yolo` AFTER the terminator in the case above is data, not policy: if the
206
+ // scan read past the terminator it would have suppressed the injected one.
207
+ const r = launch(["--", "--allow-all"]);
208
+ ok(
209
+ "[QK:COPILOT-LAUNCH-TERMINATOR-IS-NOT-POLICY] a policy-looking token after the terminator is data — it does not suppress the managed default",
210
+ r.args.includes("--yolo") && r.args.indexOf("--yolo") < r.args.indexOf("--"),
211
+ );
212
+ }
213
+ {
214
+ const weird = ["", " ", "a\tb", "--not-a-flag=x y"];
215
+ const r = launch(weird);
216
+ ok(
217
+ `[QK:COPILOT-LAUNCH-PRESERVES-ARGV-BYTES] every operator argument crosses byte-identical, empties and whitespace included (got ${JSON.stringify(r.args)})`,
218
+ JSON.stringify(r.args.slice(0, weird.length)) === JSON.stringify(weird),
219
+ );
220
+ }
221
+
222
+ // ── 4. explicit permission / surface policy wins over the managed default ───
223
+ // Measured against GitHub Copilot CLI 1.0.80 `--help`. The narrowing flags are in this
224
+ // set on purpose: appending `--yolo` (all tools + paths + URLs) beside an operator's
225
+ // `--allow-url=…` would silently widen exactly what they were narrowing.
226
+ {
227
+ const overrides = [
228
+ "--yolo",
229
+ "--allow-all",
230
+ "--allow-all-tools",
231
+ "--allow-all-paths",
232
+ "--allow-all-urls",
233
+ "--allow-tool",
234
+ "--deny-tool",
235
+ "--allow-url",
236
+ "--deny-url",
237
+ "--available-tools",
238
+ "--excluded-tools",
239
+ ];
240
+ const suppressed = overrides.every((flag) => {
241
+ const split = launch([flag, "value"]);
242
+ const joined = launch([`${flag}=value`]);
243
+ return (
244
+ split.args.filter((a) => a === "--yolo").length === (flag === "--yolo" ? 1 : 0) &&
245
+ !joined.args.includes("--yolo")
246
+ );
247
+ });
248
+ ok(
249
+ `[QK:COPILOT-LAUNCH-NO-DEFAULT-OVER-EXPLICIT-POLICY] any of the ${overrides.length} explicit permission/surface policy flags, in either --flag value or --flag=value form, suppresses the injected --yolo`,
250
+ suppressed,
251
+ );
252
+ }
253
+ {
254
+ // The two deliberate NON-members. `--allow-all-mcp-server-instructions` is prompt
255
+ // content, not authorization, and shares a prefix with a real override — exact
256
+ // matching is what keeps them apart. `--autopilot` is a mode.
257
+ const mcp = launch(["--allow-all-mcp-server-instructions"]);
258
+ const auto = launch(["--autopilot"]);
259
+ ok(
260
+ "[QK:COPILOT-LAUNCH-PREFIX-IS-NOT-POLICY] --allow-all-mcp-server-instructions and --autopilot are NOT permission policy, so the managed default still applies",
261
+ mcp.args.includes("--yolo") && auto.args.includes("--yolo"),
262
+ );
263
+ }
264
+ {
265
+ const split = launch(["--model", "gpt-5.4"]);
266
+ const joined = launch(["--model=gpt-5.4"]);
267
+ ok(
268
+ `[QK:COPILOT-LAUNCH-KEEPS-OPERATOR-MODEL] an explicit --model is never overridden by the injected default, in either form (got ${JSON.stringify(split.args)} / ${JSON.stringify(joined.args)})`,
269
+ split.args.includes("gpt-5.4") &&
270
+ !split.args.includes("auto") &&
271
+ joined.args.includes("--model=gpt-5.4") &&
272
+ !joined.args.includes("auto"),
273
+ );
274
+ }
275
+
276
+ // ── 5. it becomes the vendor; it does not supervise one ────────────────────
277
+ {
278
+ const sub = path.join(root, "elsewhere-cwd");
279
+ mkdirSync(sub, { recursive: true });
280
+ const r = launch([], {}, sub);
281
+ ok(
282
+ "[QK:COPILOT-LAUNCH-KEEPS-CALLER-CWD] the vendor starts in the caller's own directory — the launch is not a repo-scoped subshell",
283
+ r.cwd === sub,
284
+ );
285
+ }
286
+ {
287
+ // exec, not fork: run.sh replaces itself with the leaf and the leaf with the vendor,
288
+ // so the vendor IS the process the operator's shell is waiting on. A supervising
289
+ // parent would show a different pid here and would own the tty.
290
+ const r = spawnSync("bash", ["-c", `echo "SHELL_PID=$$"; exec bash "${path.join(REPO, "run.sh")}" copilot`], {
291
+ cwd: root,
292
+ encoding: "utf8",
293
+ env: {
294
+ ...process.env,
295
+ HOME: home,
296
+ XDG_DATA_HOME: xdg,
297
+ PATH: `${bin}:${process.env.PATH ?? ""}`,
298
+ COPILOT_CLI_ENABLED_FEATURE_FLAGS: undefined as unknown as string,
299
+ ENTWURF_COPILOT_LAUNCH_ACTIVE: undefined as unknown as string,
300
+ },
301
+ });
302
+ const shellPid = /^SHELL_PID=(\d+)$/m.exec(r.stdout ?? "")?.[1];
303
+ const vendorPid = /^PID=(\d+)$/m.exec(r.stdout ?? "")?.[1];
304
+ ok(
305
+ `[QK:COPILOT-LAUNCH-EXECS-NOT-FORKS] the vendor inherits the caller's own process, not a child of it (shell ${shellPid} === vendor ${vendorPid})`,
306
+ Boolean(shellPid) && shellPid === vendorPid,
307
+ );
308
+ }
309
+
310
+ {
311
+ const r = launch([], { FAKE_COPILOT_EXIT: "7" });
312
+ ok("[QK:COPILOT-LAUNCH-PASSES-EXIT-STATUS] the vendor's exit status is the launch's exit status", r.status === 7);
313
+ }
314
+
315
+ // ── 6. the loop that would hang a terminal ─────────────────────────────────
316
+ {
317
+ // A `copilot` earlier on PATH that calls `entwurf copilot` back would spin forever,
318
+ // and the symptom would be a hung terminal rather than an error. The fixture is the
319
+ // real accident shape: a SHADOWING `copilot`, in a directory ahead of the fake
320
+ // vendor's, whose body re-enters the managed launch.
321
+ const loopBin = path.join(root, "loop-bin");
322
+ mkdirSync(loopBin, { recursive: true });
323
+ const loopy = path.join(loopBin, "copilot");
324
+ writeFileSync(loopy, `#!/usr/bin/env bash\nexec bash "${path.join(REPO, "run.sh")}" copilot "$@"\n`);
325
+ chmodSync(loopy, 0o755);
326
+ // The claim is asserted against the FENCE itself — a launch entered with the
327
+ // sentinel already set — not against a live loop. Asserting it by spinning one up
328
+ // would mean the mutation that removes the fence hangs the gate instead of failing
329
+ // a cell, and a hang is red for the wrong reason.
330
+ const fenced = launch([], { ENTWURF_COPILOT_LAUNCH_ACTIVE: "1" });
331
+ ok(
332
+ "[QK:COPILOT-LAUNCH-REFUSES-RECURSION] a launch entered from inside its own exec chain is refused, and never reaches the vendor",
333
+ fenced.status !== 0 && fenced.flag === "<no-launch>" && /recursive managed launch/.test(fenced.out),
334
+ );
335
+ const r = launch([], { PATH: `${loopBin}:${bin}:${process.env.PATH ?? ""}` });
336
+ ok(
337
+ "end to end, a PATH entry that resolves back into the managed launch terminates with a refusal instead of spinning",
338
+ r.status !== 0 && /recursive managed launch|launch loop/.test(r.out),
339
+ );
340
+ }
341
+ {
342
+ const r = launch([], { PATH: pathWithoutVendor });
343
+ ok(
344
+ "[QK:COPILOT-LAUNCH-REFUSES-ABSENT-VENDOR] a missing vendor executable is named as missing, not silently skipped",
345
+ r.status !== 0 && r.out.includes("executable found on PATH"),
346
+ );
347
+ }
348
+ // ── foreign identity carriers ──────────────────────────────────────────────
349
+ // The bridge's authoritative-self resolution believes a COMPLETE PI_SESSION_ID +
350
+ // PI_AGENT_ID pair before it looks for a native sender marker. Inherited from a pi
351
+ // citizen's bash, they make this Copilot session — and every MCP/extension child that
352
+ // inherits from it — speak under the PARENT pi garden id, with a perfectly correct
353
+ // marker sitting right there. So the launch must strip them, and the vendor's own
354
+ // report of its environment is the only honest oracle for that.
355
+ // The two cells below split the pair ON PURPOSE, one carrier each, and the split is what
356
+ // makes their two defects tell themselves apart. A cell asserting both halves would fail
357
+ // under EITHER defect, so the half-cleared launcher would be caught by whichever cell ran
358
+ // first and the report would name the wrong claim — red for a true reason it did not test.
359
+ const bothIn = { PI_SESSION_ID: "0199aaaa-bbbb-7ccc-8ddd-eeeeffff0000", PI_AGENT_ID: "pi-agent-fixture" };
360
+ {
361
+ const r = launch([], bothIn);
362
+ ok(
363
+ `[QK:COPILOT-LAUNCH-STRIPS-PI-IDENTITY] a managed launch entered with inherited PI identity carriers hands the vendor no PI_SESSION_ID (got "${r.piSession}")`,
364
+ r.flag !== "<no-launch>" && r.piSession === "<unset>",
365
+ );
366
+ }
367
+ {
368
+ // The AGENT half, asserted both when the pair arrived whole and when only the agent id
369
+ // did. Clearing one is not a repair: an incomplete pair merely changes the wording of
370
+ // the failure while leaving a carrier for a future partial reader to believe.
371
+ const both = launch([], bothIn);
372
+ const onlyAgent = launch([], { PI_AGENT_ID: "pi-agent-fixture" });
373
+ ok(
374
+ `[QK:COPILOT-LAUNCH-STRIPS-BOTH-CARRIERS] PI_AGENT_ID does not survive either, whole pair or alone — a half-cleared pair is not a repair (got "${both.piAgent}" / "${onlyAgent.piAgent}")`,
375
+ both.piAgent === "<unset>" && onlyAgent.piAgent === "<unset>",
376
+ );
377
+ }
378
+ {
379
+ // The strip is identity-only. A launch that also swallowed the operator's own
380
+ // environment would be a different, quieter defect, so the flag and argv axes are
381
+ // re-asserted in the SAME run that carries the carriers in.
382
+ const r = launch(["--model", "gpt-5.6-terra"], { PI_SESSION_ID: "x", PI_AGENT_ID: "y", ENTWURF_FIXTURE_KEEP: "1" });
383
+ ok(
384
+ "the strip touches ONLY the two identity carriers — the scan flag, the operator's argv and unrelated environment all survive it",
385
+ r.flag === "EXTENSIONS" &&
386
+ r.args.includes("--model") &&
387
+ r.args.includes("gpt-5.6-terra") &&
388
+ r.out.includes("KEEP=1"),
389
+ );
390
+ }
391
+
392
+ console.log(`\n[check-copilot-launch] PASS (${passed} assertions)`);
393
+ } finally {
394
+ rmSync(root, { recursive: true, force: true });
395
+ }