@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,497 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-copilot-birth-hook — #82 gate: the Copilot BIRTH path, proven without Copilot.
|
|
3
|
+
*
|
|
4
|
+
* WHAT IT BINDS. The chain from the shipped unit to a real garden citizen:
|
|
5
|
+
*
|
|
6
|
+
* copilot-bridge-install.sh --assemble-only (the REAL assembler, into a temp dir)
|
|
7
|
+
* -> hooks.json baked: version 1, camelCase events, `exec` a STRING, no `args`
|
|
8
|
+
* -> the baked launcher, invoked with NO ARGV, envelope on stdin
|
|
9
|
+
* -> a v3 record with backend "copilot" in a temp store
|
|
10
|
+
* -> that record listed as a peer with liveness `unsupported`
|
|
11
|
+
*
|
|
12
|
+
* WHY IT DRIVES THE INSTALLER INSTEAD OF BUILDING THE ASSEMBLY ITSELF. A gate that
|
|
13
|
+
* re-implemented the bake would be asserting against its own copy of the logic; the
|
|
14
|
+
* shipped installer could then drift underneath it and stay green. `--assemble-only`
|
|
15
|
+
* exists for this caller and stops before the Copilot CLI is touched, so the gate runs
|
|
16
|
+
* on a host (and in CI) with no Copilot installed.
|
|
17
|
+
*
|
|
18
|
+
* WHY NO-ARGV IS THE CENTRAL CELL. Copilot's hook schema has no `args` key at all —
|
|
19
|
+
* `exec` is a single string, and an array is rejected at plugin load. So a Copilot hook
|
|
20
|
+
* ALWAYS starts with argc=0. The Claude launcher treats argc=0 as a hard error (it is
|
|
21
|
+
* the only visible symptom of an older Claude dropping `args`), which is exactly why
|
|
22
|
+
* Copilot held 0 of 409 meta-records until this unit existed. This gate fires the
|
|
23
|
+
* launcher the way Copilot does — no argv — and requires a record.
|
|
24
|
+
*
|
|
25
|
+
* WHAT IT DOES NOT PROVE, and must not be read as proving. It proves the MECHANISM,
|
|
26
|
+
* not the ADMISSION. §6 acceptance is a record minted by a real Copilot session, and a
|
|
27
|
+
* Copilot session mints on its first prompt — a billed model turn. A synthetic envelope
|
|
28
|
+
* through the real launcher is gate evidence; it is not a live citizen (cross-review,
|
|
29
|
+
* terra, 2026-08-20).
|
|
30
|
+
*
|
|
31
|
+
* Hermetic: temp dirs only, no network, no Copilot, no model turn.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import assert from "node:assert/strict";
|
|
35
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
36
|
+
import {
|
|
37
|
+
chmodSync,
|
|
38
|
+
copyFileSync,
|
|
39
|
+
existsSync,
|
|
40
|
+
mkdirSync,
|
|
41
|
+
mkdtempSync,
|
|
42
|
+
readdirSync,
|
|
43
|
+
readFileSync,
|
|
44
|
+
rmSync,
|
|
45
|
+
writeFileSync,
|
|
46
|
+
} from "node:fs";
|
|
47
|
+
import { tmpdir } from "node:os";
|
|
48
|
+
import * as path from "node:path";
|
|
49
|
+
import { fileURLToPath } from "node:url";
|
|
50
|
+
import { resolveFactList } from "../pi-extensions/lib/entwurf-facts.ts";
|
|
51
|
+
import { nativePushSupported } from "../pi-extensions/lib/entwurf-v2-contract.ts";
|
|
52
|
+
import { META_SENDER_BACKENDS, resolveTrustedMetaSenderIdentity } from "../pi-extensions/lib/meta-sender-identity.ts";
|
|
53
|
+
import { listAllMetaIdentitiesDir, processStartKey } from "../pi-extensions/lib/meta-session.ts";
|
|
54
|
+
|
|
55
|
+
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
56
|
+
const PLUGIN = "entwurf-meta-receive-copilot";
|
|
57
|
+
|
|
58
|
+
let passed = 0;
|
|
59
|
+
function ok(label: string, cond: boolean): void {
|
|
60
|
+
assert.ok(cond, label);
|
|
61
|
+
console.log(` ok ${label}`);
|
|
62
|
+
passed++;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const root = mkdtempSync(path.join(tmpdir(), "entwurf-copilot-birth."));
|
|
66
|
+
const asm = path.join(root, "asm");
|
|
67
|
+
|
|
68
|
+
// ── 1. the real assembler ────────────────────────────────────────────────────
|
|
69
|
+
// Through `run.sh`, not straight at the script: the verb dispatch is part of the
|
|
70
|
+
// install surface, and calling the script directly skipped it. check-pack-install
|
|
71
|
+
// caught exactly that — run.sh's `$@` still carried the verb name, so a strict argument
|
|
72
|
+
// parser refused its own verb (2026-08-21). The gate now covers the path an operator
|
|
73
|
+
// actually types.
|
|
74
|
+
execFileSync("bash", [path.join(REPO, "run.sh"), "install-copilot-bridge", "--assemble-only"], {
|
|
75
|
+
env: { ...process.env, ENTWURF_COPILOT_ASM: asm },
|
|
76
|
+
stdio: "pipe",
|
|
77
|
+
});
|
|
78
|
+
const unit = path.join(asm, PLUGIN);
|
|
79
|
+
const launcher = path.join(unit, "scripts", "copilot-hook-launch.sh");
|
|
80
|
+
ok(
|
|
81
|
+
"assembler produced the unit, the launcher and the capability registry",
|
|
82
|
+
[
|
|
83
|
+
path.join(asm, ".claude-plugin", "marketplace.json"),
|
|
84
|
+
path.join(unit, ".claude-plugin", "plugin.json"),
|
|
85
|
+
path.join(unit, "hooks", "hooks.json"),
|
|
86
|
+
path.join(unit, "entwurf-capabilities.json"),
|
|
87
|
+
launcher,
|
|
88
|
+
].every((p) => existsSync(p)),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// ── 2. the manifest is the COPILOT form, not the Claude one ──────────────────
|
|
92
|
+
const hooks = JSON.parse(readFileSync(path.join(unit, "hooks", "hooks.json"), "utf8")) as {
|
|
93
|
+
version?: unknown;
|
|
94
|
+
hooks?: Record<string, Array<Record<string, unknown>>>;
|
|
95
|
+
};
|
|
96
|
+
ok("hooks.json declares the literal version 1 (Copilot rejects the plugin without it)", hooks.version === 1);
|
|
97
|
+
ok(
|
|
98
|
+
"hook events are exactly the two camelCase events that fire on a first prompt",
|
|
99
|
+
JSON.stringify(Object.keys(hooks.hooks ?? {}).sort()) === JSON.stringify(["sessionStart", "userPromptSubmitted"]),
|
|
100
|
+
);
|
|
101
|
+
const leaves = Object.values(hooks.hooks ?? {}).flat();
|
|
102
|
+
ok(
|
|
103
|
+
"[QK:COPILOT-BIRTH-EXEC-IS-STRING] every hook entry's `exec` is a STRING (an array is rejected at plugin load)",
|
|
104
|
+
leaves.every((l) => typeof l.exec === "string"),
|
|
105
|
+
);
|
|
106
|
+
ok(
|
|
107
|
+
"every hook entry points at the assembled launcher",
|
|
108
|
+
leaves.every((l) => l.exec === launcher),
|
|
109
|
+
);
|
|
110
|
+
// The Claude unit's whole identity contract rides `args`. Copilot has no such key, so
|
|
111
|
+
// a stray one here would be a manifest written against the wrong vendor's schema.
|
|
112
|
+
ok(
|
|
113
|
+
"no hook entry carries `args` — Copilot's schema has no such key",
|
|
114
|
+
leaves.every((l) => !("args" in l)),
|
|
115
|
+
);
|
|
116
|
+
ok(
|
|
117
|
+
"no hook entry carries an empty `matcher` — Copilot rejects one",
|
|
118
|
+
leaves.every((l) => l.matcher !== ""),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// ── 3. the launcher is baked ─────────────────────────────────────────────────
|
|
122
|
+
const launcherText = readFileSync(launcher, "utf8");
|
|
123
|
+
// The guard's own comparison literals are split (`"__NODE_""BIN__"`) precisely so this
|
|
124
|
+
// assertion can be exact: after a bake, no whole placeholder token survives anywhere.
|
|
125
|
+
ok(
|
|
126
|
+
"launcher carries no unsubstituted placeholder",
|
|
127
|
+
!launcherText.includes("__NODE_BIN__") && !launcherText.includes("__HOOK_ENTRY__"),
|
|
128
|
+
);
|
|
129
|
+
const bakedNode = /^NODE_BIN="(.*)"$/m.exec(launcherText)?.[1] ?? "";
|
|
130
|
+
ok("launcher's baked node is an existing executable", bakedNode.length > 0 && existsSync(bakedNode));
|
|
131
|
+
|
|
132
|
+
// ── 4. FIRE, the way Copilot fires: no argv, envelope on stdin ───────────────
|
|
133
|
+
interface Fired {
|
|
134
|
+
status: number | null;
|
|
135
|
+
stdout: string;
|
|
136
|
+
stderr: string;
|
|
137
|
+
}
|
|
138
|
+
/** The child's env: this gate's own, with the store relocated and every DIRECT store
|
|
139
|
+
* override removed. `PI_CODING_AGENT_DIR` only isolates what derives from it, so an
|
|
140
|
+
* operator shell that pins `ENTWURF_META_SENDERS_DIR` (or the sessions equivalent) would
|
|
141
|
+
* send the very artifacts asserted on below into the real store and read a stale one back. */
|
|
142
|
+
function isolatedEnv(storeDir: string): NodeJS.ProcessEnv {
|
|
143
|
+
const env: NodeJS.ProcessEnv = { ...process.env, PI_CODING_AGENT_DIR: storeDir };
|
|
144
|
+
delete env.ENTWURF_META_SENDERS_DIR;
|
|
145
|
+
delete env.ENTWURF_META_SESSIONS_DIR;
|
|
146
|
+
return env;
|
|
147
|
+
}
|
|
148
|
+
function fire(envelope: unknown, storeDir: string): Fired {
|
|
149
|
+
const res = spawnSync(launcher, [], {
|
|
150
|
+
input: typeof envelope === "string" ? envelope : JSON.stringify(envelope),
|
|
151
|
+
env: isolatedEnv(storeDir),
|
|
152
|
+
encoding: "utf8",
|
|
153
|
+
});
|
|
154
|
+
return { status: res.status, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
|
|
155
|
+
}
|
|
156
|
+
function records(storeDir: string): ReturnType<typeof listAllMetaIdentitiesDir>["identities"] {
|
|
157
|
+
const dir = path.join(storeDir, "meta-sessions");
|
|
158
|
+
if (!existsSync(dir)) return [];
|
|
159
|
+
return listAllMetaIdentitiesDir(dir).identities;
|
|
160
|
+
}
|
|
161
|
+
function hookLog(storeDir: string): string {
|
|
162
|
+
const file = path.join(storeDir, "meta-bridge-hook.log");
|
|
163
|
+
return existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const store = path.join(root, "store");
|
|
167
|
+
mkdirSync(store, { recursive: true });
|
|
168
|
+
const NATIVE_ID = "cop-birth-0001";
|
|
169
|
+
const CWD = "/home/junghan/repos/gh/entwurf";
|
|
170
|
+
|
|
171
|
+
const first = fire({ sessionId: NATIVE_ID, cwd: CWD, source: "new", timestamp: 1755690000000 }, store);
|
|
172
|
+
ok("[QK:COPILOT-BIRTH-NO-ARGV-LAUNCH] a no-argv fire with the NATIVE envelope exits 0", first.status === 0);
|
|
173
|
+
ok("the hook emits the neutral response and nothing else", first.stdout.trim() === "{}");
|
|
174
|
+
let live = records(store);
|
|
175
|
+
ok("exactly one record was minted", live.length === 1);
|
|
176
|
+
ok("[QK:COPILOT-BIRTH-MINTS-COPILOT] the record's backend is copilot", live[0]?.backend === "copilot");
|
|
177
|
+
ok("the record joins on the native sessionId", live[0]?.nativeSessionId === NATIVE_ID);
|
|
178
|
+
ok("the record carries the envelope's cwd", live[0]?.cwd === CWD);
|
|
179
|
+
// Omitted, never guessed: Copilot's envelope carries neither, and both are nullable.
|
|
180
|
+
ok("model and transcriptPath are null, not invented", live[0]?.model === null && live[0]?.transcriptPath === null);
|
|
181
|
+
const bornGardenId = live[0]?.gardenId ?? "";
|
|
182
|
+
|
|
183
|
+
// ── 5. the second event of the same first prompt ATTACHES ───────────────────
|
|
184
|
+
// Measured firing order is userPromptSubmitted -> sessionStart, so the citizen is
|
|
185
|
+
// minted by one and re-seen by the other. Two records for one session would be two
|
|
186
|
+
// citizens for one Copilot window.
|
|
187
|
+
const second = fire({ hook_event_name: "SessionStart", session_id: NATIVE_ID, cwd: CWD, source: "new" }, store);
|
|
188
|
+
ok("a second fire with the CLAUDE-COMPAT envelope also exits 0", second.status === 0);
|
|
189
|
+
live = records(store);
|
|
190
|
+
ok("the store still holds exactly one record", live.length === 1);
|
|
191
|
+
ok("the second fire attached to the SAME garden id", live[0]?.gardenId === bornGardenId);
|
|
192
|
+
|
|
193
|
+
// ── 6. WHO-SENT is armed; RECEIVER state is not ─────────────────────────────
|
|
194
|
+
// The two halves used to be one assertion ("no marker of any kind"), and that
|
|
195
|
+
// conflation is the defect #82 RAIL 5b closed: the doorbell's absence is a fact about
|
|
196
|
+
// the RECEIVER rail only. A sender marker needs a shared parent, not a doorbell, so
|
|
197
|
+
// this backend can say who sent a message while still being unable to receive one.
|
|
198
|
+
//
|
|
199
|
+
// The join this gate performs is the SAME one production performs, which is why the
|
|
200
|
+
// oracle is independent of the writer: `fire()` spawns the launcher as this process's
|
|
201
|
+
// child and the launcher `exec`s the payload, so the payload's parent IS this gate.
|
|
202
|
+
// The gate therefore knows the answer (`process.pid`) before reading the file.
|
|
203
|
+
const sendersDir = path.join(store, "meta-senders");
|
|
204
|
+
const markerFile = path.join(sendersDir, "copilot", `${process.pid}.json`);
|
|
205
|
+
ok(
|
|
206
|
+
"[QK:COPILOT-BIRTH-WRITES-SENDER-MARKER] a sender marker was written under THIS process's pid — the parent the bridge child would look itself up by",
|
|
207
|
+
existsSync(markerFile),
|
|
208
|
+
);
|
|
209
|
+
const marker = JSON.parse(readFileSync(markerFile, "utf8")) as {
|
|
210
|
+
backend?: string;
|
|
211
|
+
gardenId?: string;
|
|
212
|
+
nativeSessionId?: string;
|
|
213
|
+
cwd?: string;
|
|
214
|
+
ownerPid?: number;
|
|
215
|
+
ownerStartKey?: string;
|
|
216
|
+
};
|
|
217
|
+
ok("the marker names the citizen that was just minted, not a second one", marker.gardenId === bornGardenId);
|
|
218
|
+
ok(
|
|
219
|
+
"the marker agrees with the record on backend and native id — a drift on either makes it a stale hint",
|
|
220
|
+
marker.backend === "copilot" && marker.nativeSessionId === NATIVE_ID,
|
|
221
|
+
);
|
|
222
|
+
ok("the marker's ownerPid IS the gate's own pid", marker.ownerPid === process.pid);
|
|
223
|
+
// The pid-reuse guard: a marker keyed to a pid alone would be inherited by whatever
|
|
224
|
+
// process the OS hands that number to next.
|
|
225
|
+
ok(
|
|
226
|
+
"the marker carries the owner's start-key, so a reused pid cannot inherit this citizen",
|
|
227
|
+
typeof marker.ownerStartKey === "string" && marker.ownerStartKey === processStartKey(process.pid),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// The READ half. A marker nobody looks for is invisible, and that asymmetry — writer
|
|
231
|
+
// open, reader closed — is exactly how #46 made an agy citizen send as an anonymous
|
|
232
|
+
// external host. So the resolver is run for real, not inspected.
|
|
233
|
+
ok(
|
|
234
|
+
"[QK:COPILOT-SENDER-READER-OPEN] copilot is one of the backends the resolver scans — a marker nobody looks for is invisible",
|
|
235
|
+
META_SENDER_BACKENDS.includes("copilot"),
|
|
236
|
+
);
|
|
237
|
+
// The resolver reads the RECORD store through this process's own env (the marker is only
|
|
238
|
+
// a hint; the record is the authority), while the marker root is a parameter. So the
|
|
239
|
+
// record half is pointed at the temp store for the duration of the call and put back —
|
|
240
|
+
// same isolation the agy sender gate uses, and `isolatedEnv` keeps it out of every child.
|
|
241
|
+
function withSessionsDir<T>(dir: string, fn: () => T): T {
|
|
242
|
+
const prev = process.env.ENTWURF_META_SESSIONS_DIR;
|
|
243
|
+
process.env.ENTWURF_META_SESSIONS_DIR = dir;
|
|
244
|
+
try {
|
|
245
|
+
return fn();
|
|
246
|
+
} finally {
|
|
247
|
+
if (prev === undefined) delete process.env.ENTWURF_META_SESSIONS_DIR;
|
|
248
|
+
else process.env.ENTWURF_META_SESSIONS_DIR = prev;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const trusted = withSessionsDir(path.join(store, "meta-sessions"), () =>
|
|
252
|
+
resolveTrustedMetaSenderIdentity({ ownerPids: [process.pid], sendersDir }),
|
|
253
|
+
);
|
|
254
|
+
ok(
|
|
255
|
+
"the bridge resolver joins that marker to exactly ONE identity — the citizen born above",
|
|
256
|
+
trusted?.identity.gardenId === bornGardenId && trusted?.identity.backend === "copilot",
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// The record store is the authority; the marker is only a hint it must agree with.
|
|
260
|
+
// Run in its OWN store so the live one above keeps its record for §7.
|
|
261
|
+
{
|
|
262
|
+
const orphanStore = path.join(root, "orphan-marker");
|
|
263
|
+
mkdirSync(orphanStore, { recursive: true });
|
|
264
|
+
fire({ sessionId: "cop-orphan-0001", cwd: CWD, source: "new" }, orphanStore);
|
|
265
|
+
const orphanSenders = path.join(orphanStore, "meta-senders");
|
|
266
|
+
ok(
|
|
267
|
+
"precondition: that store has its own marker too",
|
|
268
|
+
existsSync(path.join(orphanSenders, "copilot", `${process.pid}.json`)),
|
|
269
|
+
);
|
|
270
|
+
for (const f of readdirSync(path.join(orphanStore, "meta-sessions"))) {
|
|
271
|
+
rmSync(path.join(orphanStore, "meta-sessions", f));
|
|
272
|
+
}
|
|
273
|
+
ok(
|
|
274
|
+
"a marker whose record is gone resolves to NOBODY — a hint is not an identity",
|
|
275
|
+
withSessionsDir(path.join(orphanStore, "meta-sessions"), () =>
|
|
276
|
+
resolveTrustedMetaSenderIdentity({ ownerPids: [process.pid], sendersDir: orphanSenders }),
|
|
277
|
+
) === null,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// FAIL-CLOSED on provenance. Reaching the payload WITHOUT the launcher means we do not
|
|
282
|
+
// know what our parent is — an already-open session holding an older cached command is
|
|
283
|
+
// the real case. Birth still happens (a record needs no parent); only who-sent is
|
|
284
|
+
// withheld, and the log says which of the two refusals it was.
|
|
285
|
+
{
|
|
286
|
+
const noTokenStore = path.join(root, "no-provenance");
|
|
287
|
+
mkdirSync(noTokenStore, { recursive: true });
|
|
288
|
+
const entryRel = /^HOOK_ENTRY="\$PLUGIN_ROOT\/(.*)"$/m.exec(launcherText)?.[1] ?? "";
|
|
289
|
+
ok("the launcher's baked hook entry is readable from its text", entryRel.length > 0);
|
|
290
|
+
const bare = isolatedEnv(noTokenStore);
|
|
291
|
+
delete bare.ENTWURF_META_HOOK_LAUNCH;
|
|
292
|
+
const res = spawnSync(bakedNode, [path.join(path.dirname(launcher), "..", entryRel)], {
|
|
293
|
+
input: JSON.stringify({ sessionId: "cop-noprov-0001", cwd: CWD, source: "new" }),
|
|
294
|
+
env: bare,
|
|
295
|
+
encoding: "utf8",
|
|
296
|
+
});
|
|
297
|
+
ok("an unstamped launch still exits 0 — best-effort, never breaks the turn", res.status === 0);
|
|
298
|
+
ok("an unstamped launch still MINTS the citizen", records(noTokenStore).length === 1);
|
|
299
|
+
ok(
|
|
300
|
+
"an unstamped launch writes NO sender marker — an unknown parent is not an owner",
|
|
301
|
+
!readdirSync(noTokenStore).includes("meta-senders"),
|
|
302
|
+
);
|
|
303
|
+
ok(
|
|
304
|
+
"and it says so in the log the doctor reads",
|
|
305
|
+
hookLog(noTokenStore).includes("sender-marker-refused") && hookLog(noTokenStore).includes("provenance missing"),
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// BIRTH DOES NOT ARM, and that is still true now that Copilot HAS a doorbell (#82 RAIL
|
|
310
|
+
// 5). What changed is the reason, not the assertion. It used to hold because no wake
|
|
311
|
+
// surface existed anywhere in the bundle; it holds today because the surface that exists
|
|
312
|
+
// belongs to a DIFFERENT PROCESS — the forked extension, which owns the watch and can
|
|
313
|
+
// therefore honestly claim to hold one. A marker written from this hook would name the
|
|
314
|
+
// Copilot host pid as the owner of a watch that pid does not hold, and the citizen would
|
|
315
|
+
// read as deliverable for as long as the TUI stayed open, wired to nothing.
|
|
316
|
+
const storeEntries = readdirSync(store);
|
|
317
|
+
ok(
|
|
318
|
+
"[QK:COPILOT-BIRTH-DOES-NOT-ARM-RECEIVER] the birth hook creates no mailbox and no receiver marker — arming belongs to the process that holds the watch",
|
|
319
|
+
!storeEntries.includes("meta-mailbox") && !storeEntries.includes("meta-receivers"),
|
|
320
|
+
);
|
|
321
|
+
// Identity is not replyability. This citizen can say who it is the moment it is born;
|
|
322
|
+
// whether a reply LANDS is answered one rail over, by the receiver marker the extension
|
|
323
|
+
// writes when it joins. The reply rail itself is picked from nativePushSupported at the
|
|
324
|
+
// bridge, and copilot lands in self-fetch either way.
|
|
325
|
+
ok(
|
|
326
|
+
"copilot is NOT native-push — a sender marker buys who-sent, and replyability comes from the receiver marker instead",
|
|
327
|
+
nativePushSupported("copilot") === false,
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
// ── 7. the citizen is a PEER, and an honest one ─────────────────────────────
|
|
331
|
+
const facts = resolveFactList(live, []);
|
|
332
|
+
const peer = facts.peers.find((p) => p.gardenId === bornGardenId);
|
|
333
|
+
ok("the minted citizen appears in the peer fact list", peer !== undefined);
|
|
334
|
+
ok("its liveness is `unsupported` — no control-socket probe exists for this backend", peer?.liveness === "unsupported");
|
|
335
|
+
|
|
336
|
+
// ── 8. negatives: every refusal is a REFUSAL, not a guessed record ──────────
|
|
337
|
+
function refuses(label: string, envelope: unknown, expectInLog: string): void {
|
|
338
|
+
const negStore = path.join(root, `neg-${label.replace(/[^a-z0-9]+/gi, "-")}`);
|
|
339
|
+
mkdirSync(negStore, { recursive: true });
|
|
340
|
+
const res = fire(envelope, negStore);
|
|
341
|
+
ok(`${label}: exits 0 (best-effort, never breaks the operator's turn)`, res.status === 0);
|
|
342
|
+
ok(`${label}: writes NO record`, records(negStore).length === 0);
|
|
343
|
+
ok(`${label}: logs an ERROR the doctor can read (${expectInLog})`, hookLog(negStore).includes(expectInLog));
|
|
344
|
+
}
|
|
345
|
+
// The one a naive `sessionId ?? session_id` would swallow: two identities for one
|
|
346
|
+
// session means the envelope is not trustworthy, so neither id may be minted.
|
|
347
|
+
refuses(
|
|
348
|
+
"[QK:COPILOT-BIRTH-ID-DISAGREEMENT] disagreeing sessionId/session_id",
|
|
349
|
+
{ sessionId: "a-1", session_id: "b-2", cwd: CWD },
|
|
350
|
+
"disagree",
|
|
351
|
+
);
|
|
352
|
+
refuses("missing cwd", { sessionId: NATIVE_ID }, "cwd missing");
|
|
353
|
+
refuses("no session id under either key", { cwd: CWD, source: "new" }, "no sessionId/session_id");
|
|
354
|
+
refuses("malformed envelope", "{not json", "envelope parse failed");
|
|
355
|
+
// Agreement is NOT a refusal — the compat translator emitting both keys is normal.
|
|
356
|
+
const agreeStore = path.join(root, "agree");
|
|
357
|
+
mkdirSync(agreeStore, { recursive: true });
|
|
358
|
+
const agreeing = fire({ sessionId: NATIVE_ID, session_id: NATIVE_ID, cwd: CWD }, agreeStore);
|
|
359
|
+
ok(
|
|
360
|
+
"both keys AGREEING is accepted (that is the compat translator, not a defect)",
|
|
361
|
+
agreeing.status === 0 && records(agreeStore).length === 1,
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
// ── 9. an UNBAKED launcher refuses loudly instead of exec'ing a placeholder ──
|
|
365
|
+
const rawUnit = path.join(root, "raw");
|
|
366
|
+
mkdirSync(path.join(rawUnit, "scripts"), { recursive: true });
|
|
367
|
+
const rawLauncher = path.join(rawUnit, "scripts", "copilot-hook-launch.sh");
|
|
368
|
+
copyFileSync(path.join(REPO, "pi", "meta-bridge-copilot", PLUGIN, "scripts", "copilot-hook-launch.sh"), rawLauncher);
|
|
369
|
+
chmodSync(rawLauncher, 0o755);
|
|
370
|
+
const rawStore = path.join(root, "raw-store");
|
|
371
|
+
mkdirSync(rawStore, { recursive: true });
|
|
372
|
+
const raw = spawnSync(rawLauncher, [], {
|
|
373
|
+
input: JSON.stringify({ sessionId: NATIVE_ID, cwd: CWD }),
|
|
374
|
+
env: { ...process.env, PI_CODING_AGENT_DIR: rawStore },
|
|
375
|
+
encoding: "utf8",
|
|
376
|
+
});
|
|
377
|
+
ok("the committed (unbaked) launcher exits non-zero", raw.status !== 0);
|
|
378
|
+
ok("it names the install verb rather than failing silently", (raw.stderr ?? "").includes("install-copilot-bridge"));
|
|
379
|
+
ok("it wrote no record", records(rawStore).length === 0);
|
|
380
|
+
|
|
381
|
+
// ── 10. the shipped skeleton keeps its placeholders ─────────────────────────
|
|
382
|
+
// If the committed unit were already baked to some host's node path, every other
|
|
383
|
+
// host's install would ship a launcher pointing at a binary it does not have.
|
|
384
|
+
const shippedHooks = readFileSync(path.join(REPO, "pi", "meta-bridge-copilot", PLUGIN, "hooks", "hooks.json"), "utf8");
|
|
385
|
+
ok("the committed hooks.json still carries __COPILOT_LAUNCHER__", shippedHooks.includes("__COPILOT_LAUNCHER__"));
|
|
386
|
+
const shippedLauncher = readFileSync(
|
|
387
|
+
path.join(REPO, "pi", "meta-bridge-copilot", PLUGIN, "scripts", "copilot-hook-launch.sh"),
|
|
388
|
+
"utf8",
|
|
389
|
+
);
|
|
390
|
+
ok(
|
|
391
|
+
"the committed launcher still carries both placeholders",
|
|
392
|
+
shippedLauncher.includes('NODE_BIN="__NODE_BIN__"') && shippedLauncher.includes("__HOOK_ENTRY__"),
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
// ── 11. the install path, driven against a FAKE copilot ─────────────────────
|
|
396
|
+
// The one part of this lane that cannot be exercised for real without touching the
|
|
397
|
+
// operator's Copilot — and the place cross-review named the strongest unguarded
|
|
398
|
+
// defect: an unqualified stale-unit removal that treats a CLI failure as an absence,
|
|
399
|
+
// or that reaches a same-named plugin from somebody else's marketplace.
|
|
400
|
+
const OURS = "entwurf-meta-receive-copilot@meta-bridge-copilot-local";
|
|
401
|
+
const STALE = "entwurf-meta-receive@meta-bridge-local";
|
|
402
|
+
const FOREIGN = "entwurf-meta-receive@someone-elses-marketplace";
|
|
403
|
+
|
|
404
|
+
interface FakeRun {
|
|
405
|
+
status: number | null;
|
|
406
|
+
stdout: string;
|
|
407
|
+
stderr: string;
|
|
408
|
+
/** every `copilot …` argv the installer issued, in order */
|
|
409
|
+
calls: string[];
|
|
410
|
+
/** the plugin ids the fake still holds when the installer is done */
|
|
411
|
+
installed: string[];
|
|
412
|
+
}
|
|
413
|
+
function runInstall(opts: {
|
|
414
|
+
installed: string[];
|
|
415
|
+
uninstallFails?: boolean;
|
|
416
|
+
listFails?: boolean;
|
|
417
|
+
label: string;
|
|
418
|
+
}): FakeRun {
|
|
419
|
+
const home = path.join(root, `install-${opts.label}`);
|
|
420
|
+
const bin = path.join(home, "bin");
|
|
421
|
+
mkdirSync(bin, { recursive: true });
|
|
422
|
+
const state = path.join(home, "installed.txt");
|
|
423
|
+
const log = path.join(home, "calls.log");
|
|
424
|
+
writeFileSync(state, opts.installed.join("\n") + (opts.installed.length ? "\n" : ""));
|
|
425
|
+
writeFileSync(log, "");
|
|
426
|
+
// A fake that ANSWERS like the measured CLI: `plugin list` prints qualified ids,
|
|
427
|
+
// `plugin uninstall <id>` removes exactly that id.
|
|
428
|
+
writeFileSync(
|
|
429
|
+
path.join(bin, "copilot"),
|
|
430
|
+
[
|
|
431
|
+
"#!/usr/bin/env bash",
|
|
432
|
+
`STATE=${JSON.stringify(state)}`,
|
|
433
|
+
`LOG=${JSON.stringify(log)}`,
|
|
434
|
+
'echo "$*" >> "$LOG"',
|
|
435
|
+
'case "$1 $2" in',
|
|
436
|
+
opts.listFails
|
|
437
|
+
? ' "plugin list") echo "not authenticated" >&2; exit 1 ;;'
|
|
438
|
+
: ' "plugin list") echo "Installed plugins:"; sed "s/^/ • /" "$STATE"; exit 0 ;;',
|
|
439
|
+
' "plugin uninstall")',
|
|
440
|
+
opts.uninstallFails
|
|
441
|
+
? ' echo "boom" >&2; exit 1 ;;'
|
|
442
|
+
: ' grep -Fvx "$3" "$STATE" > "$STATE.tmp" || true; mv "$STATE.tmp" "$STATE"; exit 0 ;;',
|
|
443
|
+
' "plugin install") echo "$3" >> "$STATE"; exit 0 ;;',
|
|
444
|
+
' "plugin marketplace") exit 0 ;;',
|
|
445
|
+
"esac",
|
|
446
|
+
"exit 0",
|
|
447
|
+
].join("\n"),
|
|
448
|
+
);
|
|
449
|
+
chmodSync(path.join(bin, "copilot"), 0o755);
|
|
450
|
+
const res = spawnSync("bash", [path.join(REPO, "run.sh"), "install-copilot-bridge"], {
|
|
451
|
+
env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, ENTWURF_COPILOT_ASM: path.join(home, "asm") },
|
|
452
|
+
encoding: "utf8",
|
|
453
|
+
});
|
|
454
|
+
return {
|
|
455
|
+
status: res.status,
|
|
456
|
+
stdout: res.stdout ?? "",
|
|
457
|
+
stderr: res.stderr ?? "",
|
|
458
|
+
calls: readFileSync(log, "utf8").split("\n").filter(Boolean),
|
|
459
|
+
installed: readFileSync(state, "utf8").split("\n").filter(Boolean),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const withStale = runInstall({ installed: [STALE], label: "stale" });
|
|
464
|
+
ok(
|
|
465
|
+
"[QK:COPILOT-INSTALL-QUALIFIED-STALE] install removes the stale Claude unit by its QUALIFIED id",
|
|
466
|
+
withStale.calls.includes(`plugin uninstall ${STALE}`),
|
|
467
|
+
);
|
|
468
|
+
ok("install then registers our unit", withStale.installed.includes(OURS) && withStale.status === 0);
|
|
469
|
+
ok("the stale unit is gone afterwards", !withStale.installed.includes(STALE));
|
|
470
|
+
|
|
471
|
+
const withForeign = runInstall({ installed: [FOREIGN], label: "foreign" });
|
|
472
|
+
ok(
|
|
473
|
+
"[QK:COPILOT-INSTALL-FOREIGN-UNTOUCHED] a same-named plugin from ANOTHER marketplace is left alone",
|
|
474
|
+
withForeign.installed.includes(FOREIGN) &&
|
|
475
|
+
!withForeign.calls.some((c) => c.startsWith(`plugin uninstall ${FOREIGN}`)),
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
const uninstallBroken = runInstall({ installed: [STALE], uninstallFails: true, label: "cli-error" });
|
|
479
|
+
ok(
|
|
480
|
+
"[QK:COPILOT-INSTALL-UNINSTALL-FAILURE-IS-FATAL] a FAILING uninstall is not read as an absence — the install refuses",
|
|
481
|
+
uninstallBroken.status !== 0 && !uninstallBroken.installed.includes(OURS),
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
const listBroken = runInstall({ installed: [STALE], listFails: true, label: "list-error" });
|
|
485
|
+
ok(
|
|
486
|
+
"[QK:COPILOT-INSTALL-LIST-FAILURE-IS-FATAL] a FAILING plugin list is not read as an empty host — the install refuses",
|
|
487
|
+
listBroken.status !== 0 && !listBroken.installed.includes(OURS),
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
const clean = runInstall({ installed: [], label: "clean" });
|
|
491
|
+
ok(
|
|
492
|
+
"a host with no stale unit installs cleanly and says so",
|
|
493
|
+
clean.status === 0 && clean.stdout.includes("nothing to remove"),
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
writeFileSync(path.join(root, "gate.ok"), "");
|
|
497
|
+
console.log(`[check-copilot-birth-hook] ${passed} assertions ok`);
|