@junghanacs/entwurf 0.14.2 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +9 -5
- package/CHANGELOG.md +149 -0
- package/DELIVERY.md +72 -59
- package/README.md +120 -20
- package/VERIFY.md +10 -7
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +2 -2
- package/docs/external-mcp-host.md +26 -4
- package/docs/setup-clean-host.md +118 -18
- 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 +1079 -186
- package/scripts/agy-bridge.sh +4 -2
- package/scripts/check-copilot-birth-hook.ts +1174 -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 +104 -27
- package/scripts/check-gate-qualification.ts +10 -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/check-setup-qualification.sh +126 -0
- package/scripts/copilot-bridge-doctor.sh +242 -0
- package/scripts/copilot-bridge-install.sh +384 -0
- package/scripts/copilot-bridge-oracle.sh +223 -0
- package/scripts/copilot-bridge-uninstall.sh +137 -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 +53 -9
- package/scripts/fake-copilot-vendor.sh +67 -0
- package/scripts/meta-bridge-hook-log.sh +17 -5
- package/scripts/mutants/capability-cache.json +64 -0
- package/scripts/mutants/copilot-birth.json +244 -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/mutants/pi-package-ownership.json +92 -0
- package/scripts/mutants/setup-verdict.json +148 -0
- package/scripts/mutants/source-install.json +32 -0
- package/scripts/pi_settings_io.py +22 -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/register-pi-package.py +525 -7
- package/scripts/register-pi-provider.py +162 -12
- package/scripts/smoke-agy-install-state.sh +86 -30
- package/scripts/smoke-agy-statusline-state.sh +9 -6
- 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/smoke-setup-verdict.sh +291 -0
- package/scripts/smoke-user-scope-citizen.sh +388 -1
- package/scripts/tsconfig.json +1 -0
|
@@ -0,0 +1,1174 @@
|
|
|
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
|
+
symlinkSync,
|
|
46
|
+
writeFileSync,
|
|
47
|
+
} from "node:fs";
|
|
48
|
+
import { tmpdir } from "node:os";
|
|
49
|
+
import * as path from "node:path";
|
|
50
|
+
import { fileURLToPath } from "node:url";
|
|
51
|
+
import { resolveFactList } from "../pi-extensions/lib/entwurf-facts.ts";
|
|
52
|
+
import { nativePushSupported } from "../pi-extensions/lib/entwurf-v2-contract.ts";
|
|
53
|
+
import { META_SENDER_BACKENDS, resolveTrustedMetaSenderIdentity } from "../pi-extensions/lib/meta-sender-identity.ts";
|
|
54
|
+
import { listAllMetaIdentitiesDir, processStartKey } from "../pi-extensions/lib/meta-session.ts";
|
|
55
|
+
|
|
56
|
+
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
57
|
+
const PLUGIN = "entwurf-meta-receive-copilot";
|
|
58
|
+
|
|
59
|
+
let passed = 0;
|
|
60
|
+
function ok(label: string, cond: boolean): void {
|
|
61
|
+
assert.ok(cond, label);
|
|
62
|
+
console.log(` ok ${label}`);
|
|
63
|
+
passed++;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const root = mkdtempSync(path.join(tmpdir(), "entwurf-copilot-birth."));
|
|
67
|
+
const asm = path.join(root, "asm");
|
|
68
|
+
|
|
69
|
+
// ── 1. the real assembler ────────────────────────────────────────────────────
|
|
70
|
+
// Through `run.sh`, not straight at the script: the verb dispatch is part of the
|
|
71
|
+
// install surface, and calling the script directly skipped it. check-pack-install
|
|
72
|
+
// caught exactly that — run.sh's `$@` still carried the verb name, so a strict argument
|
|
73
|
+
// parser refused its own verb (2026-08-21). The gate now covers the path an operator
|
|
74
|
+
// actually types.
|
|
75
|
+
execFileSync("bash", [path.join(REPO, "run.sh"), "install-copilot-bridge", "--assemble-only"], {
|
|
76
|
+
env: { ...process.env, ENTWURF_COPILOT_ASM: asm },
|
|
77
|
+
stdio: "pipe",
|
|
78
|
+
});
|
|
79
|
+
const unit = path.join(asm, PLUGIN);
|
|
80
|
+
const launcher = path.join(unit, "scripts", "copilot-hook-launch.sh");
|
|
81
|
+
ok(
|
|
82
|
+
"assembler produced the unit, the launcher and the capability registry",
|
|
83
|
+
[
|
|
84
|
+
path.join(asm, ".claude-plugin", "marketplace.json"),
|
|
85
|
+
path.join(unit, ".claude-plugin", "plugin.json"),
|
|
86
|
+
path.join(unit, "hooks", "hooks.json"),
|
|
87
|
+
path.join(unit, "entwurf-capabilities.json"),
|
|
88
|
+
launcher,
|
|
89
|
+
].every((p) => existsSync(p)),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// ── 2. the manifest is the COPILOT form, not the Claude one ──────────────────
|
|
93
|
+
const hooks = JSON.parse(readFileSync(path.join(unit, "hooks", "hooks.json"), "utf8")) as {
|
|
94
|
+
version?: unknown;
|
|
95
|
+
hooks?: Record<string, Array<Record<string, unknown>>>;
|
|
96
|
+
};
|
|
97
|
+
ok("hooks.json declares the literal version 1 (Copilot rejects the plugin without it)", hooks.version === 1);
|
|
98
|
+
ok(
|
|
99
|
+
"hook events are exactly the two camelCase events that fire on a first prompt",
|
|
100
|
+
JSON.stringify(Object.keys(hooks.hooks ?? {}).sort()) === JSON.stringify(["sessionStart", "userPromptSubmitted"]),
|
|
101
|
+
);
|
|
102
|
+
const leaves = Object.values(hooks.hooks ?? {}).flat();
|
|
103
|
+
ok(
|
|
104
|
+
"[QK:COPILOT-BIRTH-EXEC-IS-STRING] every hook entry's `exec` is a STRING (an array is rejected at plugin load)",
|
|
105
|
+
leaves.every((l) => typeof l.exec === "string"),
|
|
106
|
+
);
|
|
107
|
+
ok(
|
|
108
|
+
"every hook entry points at the assembled launcher",
|
|
109
|
+
leaves.every((l) => l.exec === launcher),
|
|
110
|
+
);
|
|
111
|
+
// The Claude unit's whole identity contract rides `args`. Copilot has no such key, so
|
|
112
|
+
// a stray one here would be a manifest written against the wrong vendor's schema.
|
|
113
|
+
ok(
|
|
114
|
+
"no hook entry carries `args` — Copilot's schema has no such key",
|
|
115
|
+
leaves.every((l) => !("args" in l)),
|
|
116
|
+
);
|
|
117
|
+
ok(
|
|
118
|
+
"no hook entry carries an empty `matcher` — Copilot rejects one",
|
|
119
|
+
leaves.every((l) => l.matcher !== ""),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// ── 3. the launcher is baked ─────────────────────────────────────────────────
|
|
123
|
+
const launcherText = readFileSync(launcher, "utf8");
|
|
124
|
+
// The guard's own comparison literals are split (`"__NODE_""BIN__"`) precisely so this
|
|
125
|
+
// assertion can be exact: after a bake, no whole placeholder token survives anywhere.
|
|
126
|
+
ok(
|
|
127
|
+
"launcher carries no unsubstituted placeholder",
|
|
128
|
+
!launcherText.includes("__NODE_BIN__") && !launcherText.includes("__HOOK_ENTRY__"),
|
|
129
|
+
);
|
|
130
|
+
const bakedNode = /^NODE_BIN="(.*)"$/m.exec(launcherText)?.[1] ?? "";
|
|
131
|
+
ok("launcher's baked node is an existing executable", bakedNode.length > 0 && existsSync(bakedNode));
|
|
132
|
+
|
|
133
|
+
// ── 4. FIRE, the way Copilot fires: no argv, envelope on stdin ───────────────
|
|
134
|
+
interface Fired {
|
|
135
|
+
status: number | null;
|
|
136
|
+
stdout: string;
|
|
137
|
+
stderr: string;
|
|
138
|
+
}
|
|
139
|
+
/** The child's env: this gate's own, with the store relocated and every DIRECT store
|
|
140
|
+
* override removed. `PI_CODING_AGENT_DIR` only isolates what derives from it, so an
|
|
141
|
+
* operator shell that pins `ENTWURF_META_SENDERS_DIR` (or the sessions equivalent) would
|
|
142
|
+
* send the very artifacts asserted on below into the real store and read a stale one back. */
|
|
143
|
+
function isolatedEnv(storeDir: string): NodeJS.ProcessEnv {
|
|
144
|
+
const env: NodeJS.ProcessEnv = { ...process.env, PI_CODING_AGENT_DIR: storeDir };
|
|
145
|
+
delete env.ENTWURF_META_SENDERS_DIR;
|
|
146
|
+
delete env.ENTWURF_META_SESSIONS_DIR;
|
|
147
|
+
return env;
|
|
148
|
+
}
|
|
149
|
+
function fire(envelope: unknown, storeDir: string): Fired {
|
|
150
|
+
const res = spawnSync(launcher, [], {
|
|
151
|
+
input: typeof envelope === "string" ? envelope : JSON.stringify(envelope),
|
|
152
|
+
env: isolatedEnv(storeDir),
|
|
153
|
+
encoding: "utf8",
|
|
154
|
+
});
|
|
155
|
+
return { status: res.status, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
|
|
156
|
+
}
|
|
157
|
+
function records(storeDir: string): ReturnType<typeof listAllMetaIdentitiesDir>["identities"] {
|
|
158
|
+
const dir = path.join(storeDir, "meta-sessions");
|
|
159
|
+
if (!existsSync(dir)) return [];
|
|
160
|
+
return listAllMetaIdentitiesDir(dir).identities;
|
|
161
|
+
}
|
|
162
|
+
function hookLog(storeDir: string): string {
|
|
163
|
+
const file = path.join(storeDir, "meta-bridge-hook.log");
|
|
164
|
+
return existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const store = path.join(root, "store");
|
|
168
|
+
mkdirSync(store, { recursive: true });
|
|
169
|
+
const NATIVE_ID = "cop-birth-0001";
|
|
170
|
+
const CWD = "/home/junghan/repos/gh/entwurf";
|
|
171
|
+
|
|
172
|
+
const first = fire({ sessionId: NATIVE_ID, cwd: CWD, source: "new", timestamp: 1755690000000 }, store);
|
|
173
|
+
ok("[QK:COPILOT-BIRTH-NO-ARGV-LAUNCH] a no-argv fire with the NATIVE envelope exits 0", first.status === 0);
|
|
174
|
+
ok("the hook emits the neutral response and nothing else", first.stdout.trim() === "{}");
|
|
175
|
+
let live = records(store);
|
|
176
|
+
ok("exactly one record was minted", live.length === 1);
|
|
177
|
+
ok("[QK:COPILOT-BIRTH-MINTS-COPILOT] the record's backend is copilot", live[0]?.backend === "copilot");
|
|
178
|
+
ok("the record joins on the native sessionId", live[0]?.nativeSessionId === NATIVE_ID);
|
|
179
|
+
ok("the record carries the envelope's cwd", live[0]?.cwd === CWD);
|
|
180
|
+
// Omitted, never guessed: Copilot's envelope carries neither, and both are nullable.
|
|
181
|
+
ok("model and transcriptPath are null, not invented", live[0]?.model === null && live[0]?.transcriptPath === null);
|
|
182
|
+
const bornGardenId = live[0]?.gardenId ?? "";
|
|
183
|
+
|
|
184
|
+
// ── 5. the second event of the same first prompt ATTACHES ───────────────────
|
|
185
|
+
// Measured firing order is userPromptSubmitted -> sessionStart, so the citizen is
|
|
186
|
+
// minted by one and re-seen by the other. Two records for one session would be two
|
|
187
|
+
// citizens for one Copilot window.
|
|
188
|
+
const second = fire({ hook_event_name: "SessionStart", session_id: NATIVE_ID, cwd: CWD, source: "new" }, store);
|
|
189
|
+
ok("a second fire with the CLAUDE-COMPAT envelope also exits 0", second.status === 0);
|
|
190
|
+
live = records(store);
|
|
191
|
+
ok("the store still holds exactly one record", live.length === 1);
|
|
192
|
+
ok("the second fire attached to the SAME garden id", live[0]?.gardenId === bornGardenId);
|
|
193
|
+
|
|
194
|
+
// ── 6. WHO-SENT is armed; RECEIVER state is not ─────────────────────────────
|
|
195
|
+
// The two halves used to be one assertion ("no marker of any kind"), and that
|
|
196
|
+
// conflation is the defect #82 RAIL 5b closed: the doorbell's absence is a fact about
|
|
197
|
+
// the RECEIVER rail only. A sender marker needs a shared parent, not a doorbell, so
|
|
198
|
+
// this backend can say who sent a message while still being unable to receive one.
|
|
199
|
+
//
|
|
200
|
+
// The join this gate performs is the SAME one production performs, which is why the
|
|
201
|
+
// oracle is independent of the writer: `fire()` spawns the launcher as this process's
|
|
202
|
+
// child and the launcher `exec`s the payload, so the payload's parent IS this gate.
|
|
203
|
+
// The gate therefore knows the answer (`process.pid`) before reading the file.
|
|
204
|
+
const sendersDir = path.join(store, "meta-senders");
|
|
205
|
+
const markerFile = path.join(sendersDir, "copilot", `${process.pid}.json`);
|
|
206
|
+
ok(
|
|
207
|
+
"[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",
|
|
208
|
+
existsSync(markerFile),
|
|
209
|
+
);
|
|
210
|
+
const marker = JSON.parse(readFileSync(markerFile, "utf8")) as {
|
|
211
|
+
backend?: string;
|
|
212
|
+
gardenId?: string;
|
|
213
|
+
nativeSessionId?: string;
|
|
214
|
+
cwd?: string;
|
|
215
|
+
ownerPid?: number;
|
|
216
|
+
ownerStartKey?: string;
|
|
217
|
+
};
|
|
218
|
+
ok("the marker names the citizen that was just minted, not a second one", marker.gardenId === bornGardenId);
|
|
219
|
+
ok(
|
|
220
|
+
"the marker agrees with the record on backend and native id — a drift on either makes it a stale hint",
|
|
221
|
+
marker.backend === "copilot" && marker.nativeSessionId === NATIVE_ID,
|
|
222
|
+
);
|
|
223
|
+
ok("the marker's ownerPid IS the gate's own pid", marker.ownerPid === process.pid);
|
|
224
|
+
// The pid-reuse guard: a marker keyed to a pid alone would be inherited by whatever
|
|
225
|
+
// process the OS hands that number to next.
|
|
226
|
+
ok(
|
|
227
|
+
"the marker carries the owner's start-key, so a reused pid cannot inherit this citizen",
|
|
228
|
+
typeof marker.ownerStartKey === "string" && marker.ownerStartKey === processStartKey(process.pid),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// The READ half. A marker nobody looks for is invisible, and that asymmetry — writer
|
|
232
|
+
// open, reader closed — is exactly how #46 made an agy citizen send as an anonymous
|
|
233
|
+
// external host. So the resolver is run for real, not inspected.
|
|
234
|
+
ok(
|
|
235
|
+
"[QK:COPILOT-SENDER-READER-OPEN] copilot is one of the backends the resolver scans — a marker nobody looks for is invisible",
|
|
236
|
+
META_SENDER_BACKENDS.includes("copilot"),
|
|
237
|
+
);
|
|
238
|
+
// The resolver reads the RECORD store through this process's own env (the marker is only
|
|
239
|
+
// a hint; the record is the authority), while the marker root is a parameter. So the
|
|
240
|
+
// record half is pointed at the temp store for the duration of the call and put back —
|
|
241
|
+
// same isolation the agy sender gate uses, and `isolatedEnv` keeps it out of every child.
|
|
242
|
+
function withSessionsDir<T>(dir: string, fn: () => T): T {
|
|
243
|
+
const prev = process.env.ENTWURF_META_SESSIONS_DIR;
|
|
244
|
+
process.env.ENTWURF_META_SESSIONS_DIR = dir;
|
|
245
|
+
try {
|
|
246
|
+
return fn();
|
|
247
|
+
} finally {
|
|
248
|
+
if (prev === undefined) delete process.env.ENTWURF_META_SESSIONS_DIR;
|
|
249
|
+
else process.env.ENTWURF_META_SESSIONS_DIR = prev;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const trusted = withSessionsDir(path.join(store, "meta-sessions"), () =>
|
|
253
|
+
resolveTrustedMetaSenderIdentity({ ownerPids: [process.pid], sendersDir }),
|
|
254
|
+
);
|
|
255
|
+
ok(
|
|
256
|
+
"the bridge resolver joins that marker to exactly ONE identity — the citizen born above",
|
|
257
|
+
trusted?.identity.gardenId === bornGardenId && trusted?.identity.backend === "copilot",
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
// The record store is the authority; the marker is only a hint it must agree with.
|
|
261
|
+
// Run in its OWN store so the live one above keeps its record for §7.
|
|
262
|
+
{
|
|
263
|
+
const orphanStore = path.join(root, "orphan-marker");
|
|
264
|
+
mkdirSync(orphanStore, { recursive: true });
|
|
265
|
+
fire({ sessionId: "cop-orphan-0001", cwd: CWD, source: "new" }, orphanStore);
|
|
266
|
+
const orphanSenders = path.join(orphanStore, "meta-senders");
|
|
267
|
+
ok(
|
|
268
|
+
"precondition: that store has its own marker too",
|
|
269
|
+
existsSync(path.join(orphanSenders, "copilot", `${process.pid}.json`)),
|
|
270
|
+
);
|
|
271
|
+
for (const f of readdirSync(path.join(orphanStore, "meta-sessions"))) {
|
|
272
|
+
rmSync(path.join(orphanStore, "meta-sessions", f));
|
|
273
|
+
}
|
|
274
|
+
ok(
|
|
275
|
+
"a marker whose record is gone resolves to NOBODY — a hint is not an identity",
|
|
276
|
+
withSessionsDir(path.join(orphanStore, "meta-sessions"), () =>
|
|
277
|
+
resolveTrustedMetaSenderIdentity({ ownerPids: [process.pid], sendersDir: orphanSenders }),
|
|
278
|
+
) === null,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// FAIL-CLOSED on provenance. Reaching the payload WITHOUT the launcher means we do not
|
|
283
|
+
// know what our parent is — an already-open session holding an older cached command is
|
|
284
|
+
// the real case. Birth still happens (a record needs no parent); only who-sent is
|
|
285
|
+
// withheld, and the log says which of the two refusals it was.
|
|
286
|
+
{
|
|
287
|
+
const noTokenStore = path.join(root, "no-provenance");
|
|
288
|
+
mkdirSync(noTokenStore, { recursive: true });
|
|
289
|
+
const entryRel = /^HOOK_ENTRY="\$PLUGIN_ROOT\/(.*)"$/m.exec(launcherText)?.[1] ?? "";
|
|
290
|
+
ok("the launcher's baked hook entry is readable from its text", entryRel.length > 0);
|
|
291
|
+
const bare = isolatedEnv(noTokenStore);
|
|
292
|
+
delete bare.ENTWURF_META_HOOK_LAUNCH;
|
|
293
|
+
const res = spawnSync(bakedNode, [path.join(path.dirname(launcher), "..", entryRel)], {
|
|
294
|
+
input: JSON.stringify({ sessionId: "cop-noprov-0001", cwd: CWD, source: "new" }),
|
|
295
|
+
env: bare,
|
|
296
|
+
encoding: "utf8",
|
|
297
|
+
});
|
|
298
|
+
ok("an unstamped launch still exits 0 — best-effort, never breaks the turn", res.status === 0);
|
|
299
|
+
ok("an unstamped launch still MINTS the citizen", records(noTokenStore).length === 1);
|
|
300
|
+
ok(
|
|
301
|
+
"an unstamped launch writes NO sender marker — an unknown parent is not an owner",
|
|
302
|
+
!readdirSync(noTokenStore).includes("meta-senders"),
|
|
303
|
+
);
|
|
304
|
+
ok(
|
|
305
|
+
"and it says so in the log the doctor reads",
|
|
306
|
+
hookLog(noTokenStore).includes("sender-marker-refused") && hookLog(noTokenStore).includes("provenance missing"),
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// BIRTH DOES NOT ARM, and that is still true now that Copilot HAS a doorbell (#82 RAIL
|
|
311
|
+
// 5). What changed is the reason, not the assertion. It used to hold because no wake
|
|
312
|
+
// surface existed anywhere in the bundle; it holds today because the surface that exists
|
|
313
|
+
// belongs to a DIFFERENT PROCESS — the forked extension, which owns the watch and can
|
|
314
|
+
// therefore honestly claim to hold one. A marker written from this hook would name the
|
|
315
|
+
// Copilot host pid as the owner of a watch that pid does not hold, and the citizen would
|
|
316
|
+
// read as deliverable for as long as the TUI stayed open, wired to nothing.
|
|
317
|
+
const storeEntries = readdirSync(store);
|
|
318
|
+
ok(
|
|
319
|
+
"[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",
|
|
320
|
+
!storeEntries.includes("meta-mailbox") && !storeEntries.includes("meta-receivers"),
|
|
321
|
+
);
|
|
322
|
+
// Identity is not replyability. This citizen can say who it is the moment it is born;
|
|
323
|
+
// whether a reply LANDS is answered one rail over, by the receiver marker the extension
|
|
324
|
+
// writes when it joins. The reply rail itself is picked from nativePushSupported at the
|
|
325
|
+
// bridge, and copilot lands in self-fetch either way.
|
|
326
|
+
ok(
|
|
327
|
+
"copilot is NOT native-push — a sender marker buys who-sent, and replyability comes from the receiver marker instead",
|
|
328
|
+
nativePushSupported("copilot") === false,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
// ── 7. the citizen is a PEER, and an honest one ─────────────────────────────
|
|
332
|
+
const facts = resolveFactList(live, []);
|
|
333
|
+
const peer = facts.peers.find((p) => p.gardenId === bornGardenId);
|
|
334
|
+
ok("the minted citizen appears in the peer fact list", peer !== undefined);
|
|
335
|
+
ok("its liveness is `unsupported` — no control-socket probe exists for this backend", peer?.liveness === "unsupported");
|
|
336
|
+
|
|
337
|
+
// ── 8. negatives: every refusal is a REFUSAL, not a guessed record ──────────
|
|
338
|
+
function refuses(label: string, envelope: unknown, expectInLog: string): void {
|
|
339
|
+
const negStore = path.join(root, `neg-${label.replace(/[^a-z0-9]+/gi, "-")}`);
|
|
340
|
+
mkdirSync(negStore, { recursive: true });
|
|
341
|
+
const res = fire(envelope, negStore);
|
|
342
|
+
ok(`${label}: exits 0 (best-effort, never breaks the operator's turn)`, res.status === 0);
|
|
343
|
+
ok(`${label}: writes NO record`, records(negStore).length === 0);
|
|
344
|
+
ok(`${label}: logs an ERROR the doctor can read (${expectInLog})`, hookLog(negStore).includes(expectInLog));
|
|
345
|
+
}
|
|
346
|
+
// The one a naive `sessionId ?? session_id` would swallow: two identities for one
|
|
347
|
+
// session means the envelope is not trustworthy, so neither id may be minted.
|
|
348
|
+
refuses(
|
|
349
|
+
"[QK:COPILOT-BIRTH-ID-DISAGREEMENT] disagreeing sessionId/session_id",
|
|
350
|
+
{ sessionId: "a-1", session_id: "b-2", cwd: CWD },
|
|
351
|
+
"disagree",
|
|
352
|
+
);
|
|
353
|
+
refuses("missing cwd", { sessionId: NATIVE_ID }, "cwd missing");
|
|
354
|
+
refuses("no session id under either key", { cwd: CWD, source: "new" }, "no sessionId/session_id");
|
|
355
|
+
refuses("malformed envelope", "{not json", "envelope parse failed");
|
|
356
|
+
// Agreement is NOT a refusal — the compat translator emitting both keys is normal.
|
|
357
|
+
const agreeStore = path.join(root, "agree");
|
|
358
|
+
mkdirSync(agreeStore, { recursive: true });
|
|
359
|
+
const agreeing = fire({ sessionId: NATIVE_ID, session_id: NATIVE_ID, cwd: CWD }, agreeStore);
|
|
360
|
+
ok(
|
|
361
|
+
"both keys AGREEING is accepted (that is the compat translator, not a defect)",
|
|
362
|
+
agreeing.status === 0 && records(agreeStore).length === 1,
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
// ── 9. an UNBAKED launcher refuses loudly instead of exec'ing a placeholder ──
|
|
366
|
+
const rawUnit = path.join(root, "raw");
|
|
367
|
+
mkdirSync(path.join(rawUnit, "scripts"), { recursive: true });
|
|
368
|
+
const rawLauncher = path.join(rawUnit, "scripts", "copilot-hook-launch.sh");
|
|
369
|
+
copyFileSync(path.join(REPO, "pi", "meta-bridge-copilot", PLUGIN, "scripts", "copilot-hook-launch.sh"), rawLauncher);
|
|
370
|
+
chmodSync(rawLauncher, 0o755);
|
|
371
|
+
const rawStore = path.join(root, "raw-store");
|
|
372
|
+
mkdirSync(rawStore, { recursive: true });
|
|
373
|
+
const raw = spawnSync(rawLauncher, [], {
|
|
374
|
+
input: JSON.stringify({ sessionId: NATIVE_ID, cwd: CWD }),
|
|
375
|
+
env: { ...process.env, PI_CODING_AGENT_DIR: rawStore },
|
|
376
|
+
encoding: "utf8",
|
|
377
|
+
});
|
|
378
|
+
ok("the committed (unbaked) launcher exits non-zero", raw.status !== 0);
|
|
379
|
+
ok("it names the install verb rather than failing silently", (raw.stderr ?? "").includes("install-copilot-bridge"));
|
|
380
|
+
ok("it wrote no record", records(rawStore).length === 0);
|
|
381
|
+
|
|
382
|
+
// ── 10. the shipped skeleton keeps its placeholders ─────────────────────────
|
|
383
|
+
// If the committed unit were already baked to some host's node path, every other
|
|
384
|
+
// host's install would ship a launcher pointing at a binary it does not have.
|
|
385
|
+
const shippedHooks = readFileSync(path.join(REPO, "pi", "meta-bridge-copilot", PLUGIN, "hooks", "hooks.json"), "utf8");
|
|
386
|
+
ok("the committed hooks.json still carries __COPILOT_LAUNCHER__", shippedHooks.includes("__COPILOT_LAUNCHER__"));
|
|
387
|
+
const shippedLauncher = readFileSync(
|
|
388
|
+
path.join(REPO, "pi", "meta-bridge-copilot", PLUGIN, "scripts", "copilot-hook-launch.sh"),
|
|
389
|
+
"utf8",
|
|
390
|
+
);
|
|
391
|
+
ok(
|
|
392
|
+
"the committed launcher still carries both placeholders",
|
|
393
|
+
shippedLauncher.includes('NODE_BIN="__NODE_BIN__"') && shippedLauncher.includes("__HOOK_ENTRY__"),
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
// ── 11. the install path, driven against a FAKE copilot ─────────────────────
|
|
397
|
+
// The one part of this lane that cannot be exercised for real without touching the
|
|
398
|
+
// operator's Copilot — and the place cross-review named the strongest unguarded
|
|
399
|
+
// defect: an unqualified stale-unit removal that treats a CLI failure as an absence,
|
|
400
|
+
// or that reaches a same-named plugin from somebody else's marketplace.
|
|
401
|
+
const OURS = "entwurf-meta-receive-copilot@meta-bridge-copilot-local";
|
|
402
|
+
const STALE = "entwurf-meta-receive@meta-bridge-local";
|
|
403
|
+
const FOREIGN = "entwurf-meta-receive@someone-elses-marketplace";
|
|
404
|
+
const MKT = "meta-bridge-copilot-local";
|
|
405
|
+
const SHIPPED_VERSION = (
|
|
406
|
+
JSON.parse(
|
|
407
|
+
readFileSync(path.join(REPO, "pi", "meta-bridge-copilot", PLUGIN, ".claude-plugin", "plugin.json"), "utf8"),
|
|
408
|
+
) as {
|
|
409
|
+
version: string;
|
|
410
|
+
}
|
|
411
|
+
).version;
|
|
412
|
+
|
|
413
|
+
interface FakeHost {
|
|
414
|
+
home: string;
|
|
415
|
+
env: NodeJS.ProcessEnv;
|
|
416
|
+
asm: string;
|
|
417
|
+
/** package ownership state file inside the sandboxed XDG root */
|
|
418
|
+
stateFile: string;
|
|
419
|
+
log: string;
|
|
420
|
+
pluginState: string;
|
|
421
|
+
mktState: string;
|
|
422
|
+
}
|
|
423
|
+
interface FakeOpts {
|
|
424
|
+
installed?: string[];
|
|
425
|
+
/** registered marketplaces as [name, localPath] rows */
|
|
426
|
+
marketplaces?: Array<[string, string]>;
|
|
427
|
+
/** verbatim `plugin list` body override — for malformed/garbled listing cells */
|
|
428
|
+
pluginListRaw?: string;
|
|
429
|
+
uninstallFails?: boolean;
|
|
430
|
+
listFails?: boolean;
|
|
431
|
+
mktListFails?: boolean;
|
|
432
|
+
mktRemoveFails?: boolean;
|
|
433
|
+
}
|
|
434
|
+
interface FakeRun {
|
|
435
|
+
status: number | null;
|
|
436
|
+
stdout: string;
|
|
437
|
+
stderr: string;
|
|
438
|
+
/** every `copilot …` argv the driven surface issued, in order */
|
|
439
|
+
calls: string[];
|
|
440
|
+
/** the plugin ids the fake still holds when the surface is done */
|
|
441
|
+
installed: string[];
|
|
442
|
+
/** the marketplace rows the fake still holds */
|
|
443
|
+
marketplaces: string[];
|
|
444
|
+
}
|
|
445
|
+
/** A fake that ANSWERS like the MEASURED CLI (copilot 1.0.80, 2026-08-27): `plugin
|
|
446
|
+
* list` prints qualified ids with a `(vX)` suffix, `plugin marketplace list` prints a
|
|
447
|
+
* registered local marketplace as `<name> (Local: <abs path>)`, `plugin uninstall <id>`
|
|
448
|
+
* removes exactly that id — and `--force` anywhere is refused loudly, because the real
|
|
449
|
+
* `marketplace remove --force` uninstalls that marketplace's plugins as a side effect
|
|
450
|
+
* and no entwurf surface may reach for it. Marketplace subverbs are modeled SEPARATELY
|
|
451
|
+
* (the old generic `exit 0` arm could not kill an inverse that removed a foreign
|
|
452
|
+
* marketplace). */
|
|
453
|
+
function makeFakeHost(label: string, opts: FakeOpts): FakeHost {
|
|
454
|
+
const home = path.join(root, `fake-${label}`);
|
|
455
|
+
const bin = path.join(home, "bin");
|
|
456
|
+
mkdirSync(bin, { recursive: true });
|
|
457
|
+
const xdg = path.join(home, "xdg");
|
|
458
|
+
mkdirSync(xdg, { recursive: true });
|
|
459
|
+
const pluginState = path.join(home, "installed.txt");
|
|
460
|
+
const mktState = path.join(home, "marketplaces.txt");
|
|
461
|
+
const rawList = path.join(home, "rawlist.txt");
|
|
462
|
+
const log = path.join(home, "calls.log");
|
|
463
|
+
const installed = opts.installed ?? [];
|
|
464
|
+
const marketplaces = opts.marketplaces ?? [];
|
|
465
|
+
if (opts.pluginListRaw !== undefined) writeFileSync(rawList, `${opts.pluginListRaw}\n`);
|
|
466
|
+
writeFileSync(pluginState, installed.join("\n") + (installed.length ? "\n" : ""));
|
|
467
|
+
writeFileSync(mktState, marketplaces.map(([n, p]) => `${n}\t${p}`).join("\n") + (marketplaces.length ? "\n" : ""));
|
|
468
|
+
writeFileSync(log, "");
|
|
469
|
+
writeFileSync(
|
|
470
|
+
path.join(bin, "copilot"),
|
|
471
|
+
[
|
|
472
|
+
"#!/usr/bin/env bash",
|
|
473
|
+
`STATE=${JSON.stringify(pluginState)}`,
|
|
474
|
+
`MKTS=${JSON.stringify(mktState)}`,
|
|
475
|
+
`LOG=${JSON.stringify(log)}`,
|
|
476
|
+
`VER=${JSON.stringify(SHIPPED_VERSION)}`,
|
|
477
|
+
'echo "$*" >> "$LOG"',
|
|
478
|
+
'for a in "$@"; do [ "$a" = "--force" ] && { echo "fake copilot: --force is forbidden here" >&2; exit 99; }; done',
|
|
479
|
+
'case "$1 $2 $3" in',
|
|
480
|
+
opts.mktListFails
|
|
481
|
+
? ' "plugin marketplace list") echo "not authenticated" >&2; exit 1 ;;'
|
|
482
|
+
: [
|
|
483
|
+
' "plugin marketplace list")',
|
|
484
|
+
' echo "Included with GitHub Copilot:"',
|
|
485
|
+
' while IFS=$\'\\t\' read -r n p; do [ -n "$n" ] && echo " • $n (Local: $p)"; done < "$MKTS"',
|
|
486
|
+
" exit 0 ;;",
|
|
487
|
+
].join("\n"),
|
|
488
|
+
' "plugin marketplace add") printf "%s\\t%s\\n" ' + JSON.stringify(MKT) + ' "$4" >> "$MKTS"; exit 0 ;;',
|
|
489
|
+
opts.mktRemoveFails
|
|
490
|
+
? ' "plugin marketplace remove") echo "mkt boom" >&2; exit 1 ;;'
|
|
491
|
+
: ' "plugin marketplace remove") awk -F"\\t" -v n="$4" \'$1 != n\' "$MKTS" > "$MKTS.tmp"; mv "$MKTS.tmp" "$MKTS"; exit 0 ;;',
|
|
492
|
+
"esac",
|
|
493
|
+
'case "$1 $2" in',
|
|
494
|
+
opts.listFails
|
|
495
|
+
? ' "plugin list") echo "not authenticated" >&2; exit 1 ;;'
|
|
496
|
+
: opts.pluginListRaw !== undefined
|
|
497
|
+
? ` "plugin list") echo "Installed plugins:"; cat ${JSON.stringify(rawList)}; exit 0 ;;`
|
|
498
|
+
: ' "plugin list") echo "Installed plugins:"; sed "s/^/ • /;s/$/ (v$VER)/" "$STATE"; exit 0 ;;',
|
|
499
|
+
' "plugin uninstall")',
|
|
500
|
+
opts.uninstallFails
|
|
501
|
+
? ' echo "boom" >&2; exit 1 ;;'
|
|
502
|
+
: ' grep -Fvx "$3" "$STATE" > "$STATE.tmp"; mv "$STATE.tmp" "$STATE"; exit 0 ;;',
|
|
503
|
+
' "plugin install") echo "$3" >> "$STATE"; exit 0 ;;',
|
|
504
|
+
"esac",
|
|
505
|
+
"exit 0",
|
|
506
|
+
].join("\n"),
|
|
507
|
+
);
|
|
508
|
+
chmodSync(path.join(bin, "copilot"), 0o755);
|
|
509
|
+
return {
|
|
510
|
+
home,
|
|
511
|
+
asm: path.join(home, "asm"),
|
|
512
|
+
stateFile: path.join(xdg, "entwurf", "copilot-bridge", "install-state.json"),
|
|
513
|
+
log,
|
|
514
|
+
pluginState,
|
|
515
|
+
mktState,
|
|
516
|
+
env: {
|
|
517
|
+
...process.env,
|
|
518
|
+
PATH: `${bin}:${process.env.PATH}`,
|
|
519
|
+
XDG_DATA_HOME: xdg,
|
|
520
|
+
// The doctor reads the record store and hook log from the agent dir; keep the
|
|
521
|
+
// gate hermetic rather than letting it read the operator's real host state.
|
|
522
|
+
PI_CODING_AGENT_DIR: path.join(home, "agent"),
|
|
523
|
+
ENTWURF_COPILOT_ASM: path.join(home, "asm"),
|
|
524
|
+
},
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
function runVerb(host: FakeHost, verb: string): FakeRun {
|
|
528
|
+
const res = spawnSync("bash", [path.join(REPO, "run.sh"), verb], { env: host.env, encoding: "utf8" });
|
|
529
|
+
return {
|
|
530
|
+
status: res.status,
|
|
531
|
+
stdout: res.stdout ?? "",
|
|
532
|
+
stderr: res.stderr ?? "",
|
|
533
|
+
calls: readFileSync(host.log, "utf8").split("\n").filter(Boolean),
|
|
534
|
+
installed: readFileSync(host.pluginState, "utf8").split("\n").filter(Boolean),
|
|
535
|
+
marketplaces: readFileSync(host.mktState, "utf8").split("\n").filter(Boolean),
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
function runInstall(opts: FakeOpts & { label: string }): FakeRun & { host: FakeHost } {
|
|
539
|
+
const host = makeFakeHost(`install-${opts.label}`, opts);
|
|
540
|
+
return { ...runVerb(host, "install-copilot-bridge"), host };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const withStale = runInstall({ installed: [STALE], label: "stale" });
|
|
544
|
+
ok(
|
|
545
|
+
"[QK:COPILOT-INSTALL-QUALIFIED-STALE] install removes the stale Claude unit by its QUALIFIED id",
|
|
546
|
+
withStale.calls.includes(`plugin uninstall ${STALE}`),
|
|
547
|
+
);
|
|
548
|
+
ok("install then registers our unit", withStale.installed.includes(OURS) && withStale.status === 0);
|
|
549
|
+
ok("the stale unit is gone afterwards", !withStale.installed.includes(STALE));
|
|
550
|
+
|
|
551
|
+
const withForeign = runInstall({ installed: [FOREIGN], label: "foreign" });
|
|
552
|
+
ok(
|
|
553
|
+
"[QK:COPILOT-INSTALL-FOREIGN-UNTOUCHED] a same-named plugin from ANOTHER marketplace is left alone",
|
|
554
|
+
withForeign.installed.includes(FOREIGN) &&
|
|
555
|
+
!withForeign.calls.some((c) => c.startsWith(`plugin uninstall ${FOREIGN}`)),
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
const uninstallBroken = runInstall({ installed: [STALE], uninstallFails: true, label: "cli-error" });
|
|
559
|
+
ok(
|
|
560
|
+
"[QK:COPILOT-INSTALL-UNINSTALL-FAILURE-IS-FATAL] a FAILING uninstall is not read as an absence — the install refuses",
|
|
561
|
+
uninstallBroken.status !== 0 && !uninstallBroken.installed.includes(OURS),
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
const listBroken = runInstall({ installed: [STALE], listFails: true, label: "list-error" });
|
|
565
|
+
ok(
|
|
566
|
+
"[QK:COPILOT-INSTALL-LIST-FAILURE-IS-FATAL] a FAILING plugin list is not read as an empty host — the install refuses",
|
|
567
|
+
listBroken.status !== 0 && !listBroken.installed.includes(OURS),
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
const clean = runInstall({ installed: [], label: "clean" });
|
|
571
|
+
ok(
|
|
572
|
+
"a host with no stale unit installs cleanly and says so",
|
|
573
|
+
clean.status === 0 && clean.stdout.includes("nothing to remove"),
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
// ── 12. install ownership state (#86 C3a) ───────────────────────────────────
|
|
577
|
+
// The birth unit gets the same discipline the other three Copilot units have: a
|
|
578
|
+
// package-owned install-state written after the assembly publish and before any
|
|
579
|
+
// vendor mutation, adoption that is explicit and narrow, and vendor-list failures
|
|
580
|
+
// that read as UNKNOWN, never as absence.
|
|
581
|
+
ok(
|
|
582
|
+
"a fresh install writes the ownership state with the exact schema",
|
|
583
|
+
(() => {
|
|
584
|
+
if (!existsSync(clean.host.stateFile)) return false;
|
|
585
|
+
const s = JSON.parse(readFileSync(clean.host.stateFile, "utf8")) as Record<string, unknown>;
|
|
586
|
+
return (
|
|
587
|
+
s.schemaVersion === 1 &&
|
|
588
|
+
s.qualifiedId === OURS &&
|
|
589
|
+
s.marketplaceName === MKT &&
|
|
590
|
+
s.assemblyPath === clean.host.asm &&
|
|
591
|
+
s.pluginVersion === SHIPPED_VERSION &&
|
|
592
|
+
s.ownedMarketplace === true &&
|
|
593
|
+
s.ownedAssembly === true &&
|
|
594
|
+
typeof s.installedAt === "string"
|
|
595
|
+
);
|
|
596
|
+
})(),
|
|
597
|
+
);
|
|
598
|
+
ok(
|
|
599
|
+
"the vendor sequence ran loud and in the bounded order (add before install, no blind uninstall)",
|
|
600
|
+
clean.calls.some((c) => c.startsWith("plugin marketplace add ")) &&
|
|
601
|
+
clean.calls.indexOf(`plugin install ${OURS}`) >
|
|
602
|
+
clean.calls.findIndex((c) => c.startsWith("plugin marketplace add ")),
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
const mktListBroken = runInstall({ installed: [], mktListFails: true, label: "mkt-list-error" });
|
|
606
|
+
ok(
|
|
607
|
+
"a FAILING marketplace list is not read as an empty host — the install refuses with zero writes",
|
|
608
|
+
mktListBroken.status !== 0 && !mktListBroken.installed.includes(OURS) && !existsSync(mktListBroken.host.stateFile),
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
// Reinstall over an existing state: the old blind `|| true` pair is gone, so a failing
|
|
612
|
+
// vendor step is an honest partial failure with the state retained for the rerun. The
|
|
613
|
+
// first install on each host is clean by construction (nothing to uninstall/remove), so
|
|
614
|
+
// the failure mode only fires on the RE-install, which is the path the blind pair hid.
|
|
615
|
+
{
|
|
616
|
+
const host = makeFakeHost("reinstall-uninstall-broken", { uninstallFails: true });
|
|
617
|
+
const first = runVerb(host, "install-copilot-bridge");
|
|
618
|
+
ok("precondition: the first install on the uninstall-broken host is green", first.status === 0);
|
|
619
|
+
const second = runVerb(host, "install-copilot-bridge");
|
|
620
|
+
ok(
|
|
621
|
+
"a reinstall whose exact-id uninstall FAILS stops loud with the state retained for repair",
|
|
622
|
+
second.status !== 0 && existsSync(host.stateFile) && second.installed.includes(OURS),
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
{
|
|
626
|
+
const host = makeFakeHost("reinstall-mkt-remove-broken", { mktRemoveFails: true });
|
|
627
|
+
const first = runVerb(host, "install-copilot-bridge");
|
|
628
|
+
ok("precondition: the first install on the remove-broken host is green", first.status === 0);
|
|
629
|
+
const second = runVerb(host, "install-copilot-bridge");
|
|
630
|
+
ok(
|
|
631
|
+
"a reinstall whose marketplace remove FAILS stops loud (never retried with --force) with the state retained",
|
|
632
|
+
second.status !== 0 && existsSync(host.stateFile) && !second.calls.some((c) => c.includes("--force")),
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
{
|
|
636
|
+
// No state + marketplace pre-registered at our path + no valid assembly → the
|
|
637
|
+
// legacy-marketplace adoption clause refuses before any write.
|
|
638
|
+
const host = makeFakeHost("mkt-no-assembly", {
|
|
639
|
+
marketplaces: [[MKT, path.join(root, "fake-mkt-no-assembly", "asm")]],
|
|
640
|
+
});
|
|
641
|
+
const refuse = runVerb(host, "install-copilot-bridge");
|
|
642
|
+
ok(
|
|
643
|
+
"a no-state marketplace registration over an INVALID assembly refuses with zero writes",
|
|
644
|
+
refuse.status !== 0 && !existsSync(host.stateFile) && !existsSync(host.asm),
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Legacy adoption, both shapes, driven through the honest route: install once, then
|
|
649
|
+
// strip the state and run again.
|
|
650
|
+
{
|
|
651
|
+
const adopt = runInstall({ installed: [], label: "adopt" });
|
|
652
|
+
ok("precondition: the adoption host installed green", adopt.status === 0 && existsSync(adopt.host.stateFile));
|
|
653
|
+
rmSync(adopt.host.stateFile);
|
|
654
|
+
const second = runVerb(adopt.host, "install-copilot-bridge");
|
|
655
|
+
ok(
|
|
656
|
+
"a legacy no-state installation with the exact QUALIFIED, our marketplace path and a valid assembly is ADOPTED and reported",
|
|
657
|
+
second.status === 0 &&
|
|
658
|
+
second.stdout.includes("adopting the legacy no-state installation") &&
|
|
659
|
+
existsSync(adopt.host.stateFile),
|
|
660
|
+
);
|
|
661
|
+
// Now break the assembly and strip the state again: adoption must refuse, and the
|
|
662
|
+
// refusal is zero-write (no state minted, the broken assembly untouched).
|
|
663
|
+
rmSync(adopt.host.stateFile);
|
|
664
|
+
rmSync(path.join(adopt.host.asm, PLUGIN, "hooks", "hooks.json"));
|
|
665
|
+
const invalid = runVerb(adopt.host, "install-copilot-bridge");
|
|
666
|
+
ok(
|
|
667
|
+
"a legacy no-state installation whose assembly FAILS the structural oracle refuses with zero writes",
|
|
668
|
+
invalid.status !== 0 &&
|
|
669
|
+
!existsSync(adopt.host.stateFile) &&
|
|
670
|
+
!existsSync(path.join(adopt.host.asm, PLUGIN, "hooks", "hooks.json")),
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// ── 13. the package-owned INVERSE (#86 C3a) ─────────────────────────────────
|
|
675
|
+
{
|
|
676
|
+
const host = makeFakeHost("inverse-no-state", {
|
|
677
|
+
installed: [OURS],
|
|
678
|
+
marketplaces: [[MKT, path.join(root, "fake-inverse-no-state", "asm")]],
|
|
679
|
+
});
|
|
680
|
+
const res = runVerb(host, "uninstall-copilot-bridge");
|
|
681
|
+
ok(
|
|
682
|
+
"[QK:COPILOT-BIRTH-INVERSE-NO-STATE] with NO ownership state the inverse refuses and mutates NOTHING — a blind best-effort remove is exactly the defect",
|
|
683
|
+
res.status !== 0 &&
|
|
684
|
+
res.installed.includes(OURS) &&
|
|
685
|
+
res.marketplaces.length === 1 &&
|
|
686
|
+
!res.calls.some((c) => c.startsWith("plugin uninstall") || c.startsWith("plugin marketplace remove")),
|
|
687
|
+
);
|
|
688
|
+
ok("the no-state refusal names the adoption repair", (res.stderr ?? "").includes("install-copilot-bridge"));
|
|
689
|
+
}
|
|
690
|
+
{
|
|
691
|
+
const host = makeFakeHost("inverse-corrupt", { installed: [OURS] });
|
|
692
|
+
mkdirSync(path.dirname(host.stateFile), { recursive: true });
|
|
693
|
+
writeFileSync(host.stateFile, "{not json");
|
|
694
|
+
const res = runVerb(host, "uninstall-copilot-bridge");
|
|
695
|
+
ok(
|
|
696
|
+
"a CORRUPT state refuses with zero vendor mutation and the state retained for inspection",
|
|
697
|
+
res.status !== 0 && res.installed.includes(OURS) && existsSync(host.stateFile) && res.calls.length === 0,
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
{
|
|
701
|
+
const host = makeFakeHost("inverse-drift", { installed: [OURS] });
|
|
702
|
+
mkdirSync(path.dirname(host.stateFile), { recursive: true });
|
|
703
|
+
writeFileSync(
|
|
704
|
+
host.stateFile,
|
|
705
|
+
JSON.stringify({
|
|
706
|
+
schemaVersion: 1,
|
|
707
|
+
qualifiedId: OURS,
|
|
708
|
+
marketplaceName: MKT,
|
|
709
|
+
assemblyPath: "/somewhere/else/.assembled",
|
|
710
|
+
pluginVersion: SHIPPED_VERSION,
|
|
711
|
+
ownedMarketplace: true,
|
|
712
|
+
ownedAssembly: true,
|
|
713
|
+
installedAt: "2026-08-27T00:00:00Z",
|
|
714
|
+
}),
|
|
715
|
+
);
|
|
716
|
+
const res = runVerb(host, "uninstall-copilot-bridge");
|
|
717
|
+
ok(
|
|
718
|
+
"a state whose assemblyPath DRIFTED from this host's effective assembly refuses with zero vendor mutation",
|
|
719
|
+
res.status !== 0 && res.installed.includes(OURS) && res.calls.length === 0,
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
function writeBoundState(host: FakeHost): void {
|
|
723
|
+
mkdirSync(path.dirname(host.stateFile), { recursive: true });
|
|
724
|
+
writeFileSync(
|
|
725
|
+
host.stateFile,
|
|
726
|
+
JSON.stringify({
|
|
727
|
+
schemaVersion: 1,
|
|
728
|
+
qualifiedId: OURS,
|
|
729
|
+
marketplaceName: MKT,
|
|
730
|
+
assemblyPath: host.asm,
|
|
731
|
+
pluginVersion: SHIPPED_VERSION,
|
|
732
|
+
ownedMarketplace: true,
|
|
733
|
+
ownedAssembly: true,
|
|
734
|
+
installedAt: "2026-08-27T00:00:00Z",
|
|
735
|
+
}),
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
{
|
|
739
|
+
const host = makeFakeHost("inverse-foreign-mkt", {
|
|
740
|
+
installed: [OURS],
|
|
741
|
+
marketplaces: [[MKT, "/somebody/elses/marketplace-root"]],
|
|
742
|
+
});
|
|
743
|
+
writeBoundState(host);
|
|
744
|
+
const res = runVerb(host, "uninstall-copilot-bridge");
|
|
745
|
+
ok(
|
|
746
|
+
"[QK:COPILOT-BIRTH-INVERSE-FOREIGN] a marketplace with OUR name at ANOTHER path refuses BEFORE the plugin uninstall — zero vendor writes, everything preserved (that qualified id could be THAT marketplace's plugin)",
|
|
747
|
+
res.status !== 0 &&
|
|
748
|
+
res.installed.includes(OURS) &&
|
|
749
|
+
!res.calls.includes(`plugin uninstall ${OURS}`) &&
|
|
750
|
+
res.marketplaces.some((row) => row.includes("/somebody/elses/marketplace-root")) &&
|
|
751
|
+
!res.calls.some((c) => c.startsWith("plugin marketplace remove")) &&
|
|
752
|
+
existsSync(host.stateFile),
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
{
|
|
756
|
+
// ownedMarketplace=false while OUR marketplace IS registered at the recorded path:
|
|
757
|
+
// completing the inverse would delete the backing assembly/state under a
|
|
758
|
+
// registration it must preserve — so the whole operation refuses up front.
|
|
759
|
+
const host = makeFakeHost("inverse-unowned-mkt", { installed: [OURS] });
|
|
760
|
+
mkdirSync(path.dirname(host.stateFile), { recursive: true });
|
|
761
|
+
writeFileSync(host.mktState, `${MKT}\t${host.asm}\n`);
|
|
762
|
+
mkdirSync(path.join(host.asm, PLUGIN), { recursive: true });
|
|
763
|
+
writeFileSync(
|
|
764
|
+
host.stateFile,
|
|
765
|
+
JSON.stringify({
|
|
766
|
+
schemaVersion: 1,
|
|
767
|
+
qualifiedId: OURS,
|
|
768
|
+
marketplaceName: MKT,
|
|
769
|
+
assemblyPath: host.asm,
|
|
770
|
+
pluginVersion: SHIPPED_VERSION,
|
|
771
|
+
ownedMarketplace: false,
|
|
772
|
+
ownedAssembly: true,
|
|
773
|
+
installedAt: "2026-08-27T00:00:00Z",
|
|
774
|
+
}),
|
|
775
|
+
);
|
|
776
|
+
const res = runVerb(host, "uninstall-copilot-bridge");
|
|
777
|
+
ok(
|
|
778
|
+
"a REGISTERED marketplace the state does not own refuses the WHOLE inverse before any vendor write — no dangling registration over removed backing",
|
|
779
|
+
res.status !== 0 &&
|
|
780
|
+
res.installed.includes(OURS) &&
|
|
781
|
+
!res.calls.includes(`plugin uninstall ${OURS}`) &&
|
|
782
|
+
res.marketplaces.length === 1 &&
|
|
783
|
+
existsSync(path.join(host.asm, PLUGIN)) &&
|
|
784
|
+
existsSync(host.stateFile),
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
{
|
|
788
|
+
// State schema is fail-closed EXACTLY: representative missing-key, wrong-typed
|
|
789
|
+
// bool, and unknown-key states each refuse with zero vendor mutation.
|
|
790
|
+
const badStates: Array<[string, Record<string, unknown>]> = [
|
|
791
|
+
[
|
|
792
|
+
"missing-key",
|
|
793
|
+
{
|
|
794
|
+
schemaVersion: 1,
|
|
795
|
+
qualifiedId: OURS,
|
|
796
|
+
marketplaceName: MKT,
|
|
797
|
+
assemblyPath: "PLACEHOLDER",
|
|
798
|
+
pluginVersion: SHIPPED_VERSION,
|
|
799
|
+
ownedMarketplace: true,
|
|
800
|
+
ownedAssembly: true,
|
|
801
|
+
},
|
|
802
|
+
],
|
|
803
|
+
[
|
|
804
|
+
"wrong-bool",
|
|
805
|
+
{
|
|
806
|
+
schemaVersion: 1,
|
|
807
|
+
qualifiedId: OURS,
|
|
808
|
+
marketplaceName: MKT,
|
|
809
|
+
assemblyPath: "PLACEHOLDER",
|
|
810
|
+
pluginVersion: SHIPPED_VERSION,
|
|
811
|
+
ownedMarketplace: "true",
|
|
812
|
+
ownedAssembly: true,
|
|
813
|
+
installedAt: "2026-08-27T00:00:00Z",
|
|
814
|
+
},
|
|
815
|
+
],
|
|
816
|
+
[
|
|
817
|
+
"unknown-key",
|
|
818
|
+
{
|
|
819
|
+
schemaVersion: 1,
|
|
820
|
+
qualifiedId: OURS,
|
|
821
|
+
marketplaceName: MKT,
|
|
822
|
+
assemblyPath: "PLACEHOLDER",
|
|
823
|
+
pluginVersion: SHIPPED_VERSION,
|
|
824
|
+
ownedMarketplace: true,
|
|
825
|
+
ownedAssembly: true,
|
|
826
|
+
installedAt: "2026-08-27T00:00:00Z",
|
|
827
|
+
extra: 1,
|
|
828
|
+
},
|
|
829
|
+
],
|
|
830
|
+
];
|
|
831
|
+
for (const [label, state] of badStates) {
|
|
832
|
+
const host = makeFakeHost(`inverse-state-${label}`, { installed: [OURS] });
|
|
833
|
+
mkdirSync(path.dirname(host.stateFile), { recursive: true });
|
|
834
|
+
state.assemblyPath = host.asm;
|
|
835
|
+
writeFileSync(host.stateFile, JSON.stringify(state));
|
|
836
|
+
const res = runVerb(host, "uninstall-copilot-bridge");
|
|
837
|
+
ok(
|
|
838
|
+
`a ${label} ownership state refuses fail-closed with zero vendor mutation (flags are never coerced)`,
|
|
839
|
+
res.status !== 0 && res.installed.includes(OURS) && res.calls.length === 0 && existsSync(host.stateFile),
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
{
|
|
844
|
+
// EXACT-row adversary: a longer qualified id that CONTAINS ours authorizes nothing —
|
|
845
|
+
// not the inverse's uninstall branch, not install adoption, not the doctor's
|
|
846
|
+
// registration read.
|
|
847
|
+
const ADVERSARY = `${OURS}-extra`;
|
|
848
|
+
const invHost = makeFakeHost("adversary-inverse", { installed: [ADVERSARY] });
|
|
849
|
+
writeBoundState(invHost);
|
|
850
|
+
const inv = runVerb(invHost, "uninstall-copilot-bridge");
|
|
851
|
+
ok(
|
|
852
|
+
"inverse: a longer row containing our qualified id is NOT our plugin — named already-absent, adversary survives, no uninstall argv",
|
|
853
|
+
inv.status === 0 &&
|
|
854
|
+
inv.stdout.includes("already absent") &&
|
|
855
|
+
inv.installed.includes(ADVERSARY) &&
|
|
856
|
+
!inv.calls.some((c) => c.startsWith("plugin uninstall")),
|
|
857
|
+
);
|
|
858
|
+
const adoptHost = makeFakeHost("adversary-adopt", { installed: [ADVERSARY] });
|
|
859
|
+
const adopt = runVerb(adoptHost, "install-copilot-bridge");
|
|
860
|
+
ok(
|
|
861
|
+
"install: the adversary row does not trigger the adoption branch — the host reads as fresh and the adversary survives",
|
|
862
|
+
adopt.status === 0 && !adopt.stdout.includes("adopting") && adopt.installed.includes(ADVERSARY),
|
|
863
|
+
);
|
|
864
|
+
const docHost = makeFakeHost("adversary-doctor", { installed: [ADVERSARY] });
|
|
865
|
+
const doc = runVerb(docHost, "doctor-copilot-bridge");
|
|
866
|
+
ok(
|
|
867
|
+
"doctor: the adversary row does not read as our registration",
|
|
868
|
+
doc.status !== 0 && doc.stdout.includes("NOT installed"),
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
{
|
|
872
|
+
// ASM delete-safety representative: a symlinked recorded assembly refuses the whole
|
|
873
|
+
// inverse before any vendor write.
|
|
874
|
+
const host = makeFakeHost("inverse-asm-symlink", { installed: [OURS] });
|
|
875
|
+
const realDir = path.join(host.home, "real-asm");
|
|
876
|
+
mkdirSync(realDir, { recursive: true });
|
|
877
|
+
rmSync(host.asm, { recursive: true, force: true });
|
|
878
|
+
symlinkSync(realDir, host.asm);
|
|
879
|
+
writeBoundState(host);
|
|
880
|
+
const res = runVerb(host, "uninstall-copilot-bridge");
|
|
881
|
+
ok(
|
|
882
|
+
"a SYMLINKED recorded assembly refuses the whole inverse before any vendor write (read-only lists are the only argv)",
|
|
883
|
+
res.status !== 0 &&
|
|
884
|
+
res.installed.includes(OURS) &&
|
|
885
|
+
res.calls.every((c) => c === "plugin list" || c === "plugin marketplace list") &&
|
|
886
|
+
existsSync(realDir) &&
|
|
887
|
+
existsSync(host.stateFile),
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
{
|
|
891
|
+
// --assemble-only is gate-only and must never rebuild the LIVE assembly: no
|
|
892
|
+
// override (or the default path) refuses before any assembly mutation.
|
|
893
|
+
const host = makeFakeHost("assemble-only-default", {});
|
|
894
|
+
const env = { ...host.env };
|
|
895
|
+
delete env.ENTWURF_COPILOT_ASM;
|
|
896
|
+
const res = spawnSync("bash", [path.join(REPO, "run.sh"), "install-copilot-bridge", "--assemble-only"], {
|
|
897
|
+
env,
|
|
898
|
+
encoding: "utf8",
|
|
899
|
+
});
|
|
900
|
+
const liveAsm = path.join(env.XDG_DATA_HOME as string, "entwurf", "meta-bridge-copilot", ".assembled");
|
|
901
|
+
ok(
|
|
902
|
+
"--assemble-only without an explicit temp ENTWURF_COPILOT_ASM refuses BEFORE any assembly mutation",
|
|
903
|
+
res.status !== 0 && (res.stderr ?? "").includes("ENTWURF_COPILOT_ASM") && !existsSync(liveAsm),
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
{
|
|
907
|
+
const host = makeFakeHost("inverse-happy", { installed: [OURS, FOREIGN] });
|
|
908
|
+
// A real assembly to remove: the honest route again — this is the same host shape a
|
|
909
|
+
// clean install leaves behind, minus the vendor rows the fake seeds directly.
|
|
910
|
+
writeBoundState(host);
|
|
911
|
+
mkdirSync(path.join(host.asm, PLUGIN), { recursive: true });
|
|
912
|
+
writeFileSync(host.mktState, `${MKT}\t${host.asm}\n`);
|
|
913
|
+
const res = runVerb(host, "uninstall-copilot-bridge");
|
|
914
|
+
ok(
|
|
915
|
+
"the full inverse removes the exact plugin, the exact marketplace, the recorded assembly, then the state LAST",
|
|
916
|
+
res.status === 0 &&
|
|
917
|
+
!res.installed.includes(OURS) &&
|
|
918
|
+
res.marketplaces.length === 0 &&
|
|
919
|
+
!existsSync(host.asm) &&
|
|
920
|
+
!existsSync(host.stateFile),
|
|
921
|
+
);
|
|
922
|
+
ok(
|
|
923
|
+
"the inverse used only EXACT qualified/marketplace argv — never a bare id, never --force",
|
|
924
|
+
res.calls.includes(`plugin uninstall ${OURS}`) &&
|
|
925
|
+
res.calls.includes(`plugin marketplace remove ${MKT}`) &&
|
|
926
|
+
!res.calls.includes(`plugin uninstall ${PLUGIN}`) &&
|
|
927
|
+
!res.calls.some((c) => c.includes("--force")),
|
|
928
|
+
);
|
|
929
|
+
ok("a same-named FOREIGN plugin survives the inverse untouched", res.installed.includes(FOREIGN));
|
|
930
|
+
}
|
|
931
|
+
{
|
|
932
|
+
const host = makeFakeHost("inverse-retry", { installed: [], marketplaces: [] });
|
|
933
|
+
writeBoundState(host);
|
|
934
|
+
const res = runVerb(host, "uninstall-copilot-bridge");
|
|
935
|
+
ok(
|
|
936
|
+
"a RETRY over already-absent vendor resources names each absence and still clears the state (rerun-repair)",
|
|
937
|
+
res.status === 0 && res.stdout.includes("already absent") && !existsSync(host.stateFile),
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
{
|
|
941
|
+
const host = makeFakeHost("inverse-list-broken", { installed: [OURS], listFails: true });
|
|
942
|
+
writeBoundState(host);
|
|
943
|
+
const res = runVerb(host, "uninstall-copilot-bridge");
|
|
944
|
+
ok(
|
|
945
|
+
"[QK:COPILOT-BIRTH-NO-FALSE-ABSENCE] a FAILING plugin list is UNKNOWN, not an empty host — the inverse refuses with the state retained and no mutation",
|
|
946
|
+
res.status !== 0 &&
|
|
947
|
+
(res.stderr ?? "").includes("UNKNOWN") &&
|
|
948
|
+
existsSync(host.stateFile) &&
|
|
949
|
+
!res.calls.some((c) => c.startsWith("plugin uninstall") || c.startsWith("plugin marketplace remove")),
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
{
|
|
954
|
+
// Version drift (final amendment): the vendor lists our exact id at a version the
|
|
955
|
+
// state did not record — the inverse must refuse fail-closed with everything
|
|
956
|
+
// preserved, and the doctor must name it.
|
|
957
|
+
const invHost = makeFakeHost("inverse-version-drift", { installed: [OURS] });
|
|
958
|
+
mkdirSync(path.dirname(invHost.stateFile), { recursive: true });
|
|
959
|
+
mkdirSync(path.join(invHost.asm, PLUGIN), { recursive: true });
|
|
960
|
+
writeFileSync(invHost.mktState, `${MKT}\t${invHost.asm}\n`);
|
|
961
|
+
writeFileSync(
|
|
962
|
+
invHost.stateFile,
|
|
963
|
+
JSON.stringify({
|
|
964
|
+
schemaVersion: 1,
|
|
965
|
+
qualifiedId: OURS,
|
|
966
|
+
marketplaceName: MKT,
|
|
967
|
+
assemblyPath: invHost.asm,
|
|
968
|
+
pluginVersion: "0.0.9-drift",
|
|
969
|
+
ownedMarketplace: true,
|
|
970
|
+
ownedAssembly: true,
|
|
971
|
+
installedAt: "2026-08-27T00:00:00Z",
|
|
972
|
+
}),
|
|
973
|
+
);
|
|
974
|
+
const inv = runVerb(invHost, "uninstall-copilot-bridge");
|
|
975
|
+
ok(
|
|
976
|
+
"inverse: an exact row at a version the state did not record is VERSION-DRIFT — fail-closed, no vendor mutation, plugin/marketplace/assembly/state all preserved",
|
|
977
|
+
inv.status !== 0 &&
|
|
978
|
+
(inv.stderr ?? "").includes("version-drift") &&
|
|
979
|
+
inv.installed.includes(OURS) &&
|
|
980
|
+
inv.marketplaces.length === 1 &&
|
|
981
|
+
existsSync(path.join(invHost.asm, PLUGIN)) &&
|
|
982
|
+
existsSync(invHost.stateFile) &&
|
|
983
|
+
!inv.calls.some((c) => c.startsWith("plugin uninstall") || c.startsWith("plugin marketplace remove")),
|
|
984
|
+
);
|
|
985
|
+
const docHost = makeFakeHost("doctor-version-drift", { installed: [OURS] });
|
|
986
|
+
const doctorAsm = path.join(docHost.env.XDG_DATA_HOME as string, "entwurf", "meta-bridge-copilot", ".assembled");
|
|
987
|
+
mkdirSync(path.dirname(docHost.stateFile), { recursive: true });
|
|
988
|
+
writeFileSync(docHost.mktState, `${MKT}\t${doctorAsm}\n`);
|
|
989
|
+
writeFileSync(
|
|
990
|
+
docHost.stateFile,
|
|
991
|
+
JSON.stringify({
|
|
992
|
+
schemaVersion: 1,
|
|
993
|
+
qualifiedId: OURS,
|
|
994
|
+
marketplaceName: MKT,
|
|
995
|
+
assemblyPath: doctorAsm,
|
|
996
|
+
pluginVersion: "0.0.9-drift",
|
|
997
|
+
ownedMarketplace: true,
|
|
998
|
+
ownedAssembly: true,
|
|
999
|
+
installedAt: "2026-08-27T00:00:00Z",
|
|
1000
|
+
}),
|
|
1001
|
+
);
|
|
1002
|
+
const doc = runVerb(docHost, "doctor-copilot-bridge");
|
|
1003
|
+
ok(
|
|
1004
|
+
"doctor: the same drift is a named RED (`version drift`)",
|
|
1005
|
+
doc.status !== 0 && doc.stdout.includes("version drift"),
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// ── 13b. C3a corrective amendment (B review defects, 2026-08-27) ─────────────
|
|
1010
|
+
{
|
|
1011
|
+
// B defect 2: TWO marketplace rows with our exact name. The retired grep|head -1
|
|
1012
|
+
// grammar silently took the FIRST — here deliberately the one at OUR assembly, so
|
|
1013
|
+
// the old parser would proceed. Every surface must refuse the ambiguity instead.
|
|
1014
|
+
const invHost = makeFakeHost("inverse-dup-mkt", { installed: [OURS] });
|
|
1015
|
+
writeFileSync(invHost.mktState, `${MKT}\t${invHost.asm}\n${MKT}\t/somebody/elses/dup-root\n`);
|
|
1016
|
+
writeBoundState(invHost);
|
|
1017
|
+
const inv = runVerb(invHost, "uninstall-copilot-bridge");
|
|
1018
|
+
ok(
|
|
1019
|
+
"[QK:COPILOT-MKT-DUPLICATE-REFUSED] inverse: duplicate same-named marketplace rows are ambiguity — the whole inverse refuses before any vendor write, never 'the first row'",
|
|
1020
|
+
inv.status !== 0 &&
|
|
1021
|
+
(inv.stderr ?? "").includes("multiple marketplace rows") &&
|
|
1022
|
+
inv.installed.includes(OURS) &&
|
|
1023
|
+
inv.marketplaces.length === 2 &&
|
|
1024
|
+
!inv.calls.some((c) => c.startsWith("plugin uninstall") || c.startsWith("plugin marketplace remove")) &&
|
|
1025
|
+
existsSync(invHost.stateFile),
|
|
1026
|
+
);
|
|
1027
|
+
const instHost = makeFakeHost("install-dup-mkt", {});
|
|
1028
|
+
writeFileSync(instHost.mktState, `${MKT}\t${instHost.asm}\n${MKT}\t/somebody/elses/dup-root\n`);
|
|
1029
|
+
const inst = runVerb(instHost, "install-copilot-bridge");
|
|
1030
|
+
ok(
|
|
1031
|
+
"install: the same duplicate marketplace listing refuses before any write (no state, no assembly)",
|
|
1032
|
+
inst.status !== 0 && !existsSync(instHost.stateFile) && !existsSync(instHost.asm),
|
|
1033
|
+
);
|
|
1034
|
+
const docHost = makeFakeHost("doctor-dup-mkt", { installed: [OURS] });
|
|
1035
|
+
writeFileSync(docHost.mktState, `${MKT}\t/first/root\n${MKT}\t/second/root\n`);
|
|
1036
|
+
const doc = runVerb(docHost, "doctor-copilot-bridge");
|
|
1037
|
+
ok(
|
|
1038
|
+
"doctor: duplicate same-named marketplace rows are a RED ownership fact",
|
|
1039
|
+
doc.status !== 0 && doc.stdout.includes("duplicated") && doc.stdout.includes("ownership axis: FAIL"),
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
{
|
|
1043
|
+
// B defect 3: a TRUNCATED exact row (`(v0.1` — no closing paren) used to slip the
|
|
1044
|
+
// startswith/endswith pair and round to ABSENT; absence licenses the inverse to
|
|
1045
|
+
// continue as retry-safe. Malformed must be malformed on every surface.
|
|
1046
|
+
const raw = ` • ${OURS} (v0.1`;
|
|
1047
|
+
const invHost = makeFakeHost("inverse-truncated-row", { pluginListRaw: raw });
|
|
1048
|
+
writeBoundState(invHost);
|
|
1049
|
+
mkdirSync(path.join(invHost.asm, PLUGIN), { recursive: true });
|
|
1050
|
+
writeFileSync(invHost.mktState, `${MKT}\t${invHost.asm}\n`);
|
|
1051
|
+
const inv = runVerb(invHost, "uninstall-copilot-bridge");
|
|
1052
|
+
ok(
|
|
1053
|
+
"[QK:COPILOT-MALFORMED-ROW-NOT-ABSENT] inverse: a truncated exact row is MALFORMED — refuse with zero vendor mutation, never 'already absent'",
|
|
1054
|
+
inv.status !== 0 &&
|
|
1055
|
+
(inv.stderr ?? "").includes("malformed") &&
|
|
1056
|
+
!inv.stdout.includes("already absent") &&
|
|
1057
|
+
!inv.calls.some((c) => c.startsWith("plugin uninstall") || c.startsWith("plugin marketplace remove")) &&
|
|
1058
|
+
existsSync(path.join(invHost.asm, PLUGIN)) &&
|
|
1059
|
+
existsSync(invHost.stateFile),
|
|
1060
|
+
);
|
|
1061
|
+
const instHost = makeFakeHost("install-truncated-row", { pluginListRaw: raw });
|
|
1062
|
+
const inst = runVerb(instHost, "install-copilot-bridge");
|
|
1063
|
+
ok(
|
|
1064
|
+
"install: the same truncated row refuses before any write (no state minted)",
|
|
1065
|
+
inst.status !== 0 && (inst.stderr ?? "").includes("malformed") && !existsSync(instHost.stateFile),
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
{
|
|
1069
|
+
// B defect 4: a pluginVersion carrying whitespace would be truncated by the
|
|
1070
|
+
// space-separated fact transport (`cut -d' ' -f3`) into a FABRICATED version and a
|
|
1071
|
+
// misleading downstream reason. The shared state validator refuses it as corrupt.
|
|
1072
|
+
const wsState = (asmPath: string): string =>
|
|
1073
|
+
JSON.stringify({
|
|
1074
|
+
schemaVersion: 1,
|
|
1075
|
+
qualifiedId: OURS,
|
|
1076
|
+
marketplaceName: MKT,
|
|
1077
|
+
assemblyPath: asmPath,
|
|
1078
|
+
pluginVersion: `${SHIPPED_VERSION} extra`,
|
|
1079
|
+
ownedMarketplace: true,
|
|
1080
|
+
ownedAssembly: true,
|
|
1081
|
+
installedAt: "2026-08-27T00:00:00Z",
|
|
1082
|
+
});
|
|
1083
|
+
const invHost = makeFakeHost("inverse-ws-version", { installed: [OURS] });
|
|
1084
|
+
mkdirSync(path.dirname(invHost.stateFile), { recursive: true });
|
|
1085
|
+
writeFileSync(invHost.stateFile, wsState(invHost.asm));
|
|
1086
|
+
const inv = runVerb(invHost, "uninstall-copilot-bridge");
|
|
1087
|
+
ok(
|
|
1088
|
+
"[QK:COPILOT-STATE-VERSION-WHITESPACE] inverse: a whitespace-carrying pluginVersion is a CORRUPT state named for its transport reason — zero vendor mutation, never a fabricated version verdict",
|
|
1089
|
+
inv.status !== 0 &&
|
|
1090
|
+
(inv.stderr ?? "").includes("whitespace") &&
|
|
1091
|
+
!(inv.stderr ?? "").includes("version-drift") &&
|
|
1092
|
+
inv.installed.includes(OURS) &&
|
|
1093
|
+
inv.calls.length === 0 &&
|
|
1094
|
+
existsSync(invHost.stateFile),
|
|
1095
|
+
);
|
|
1096
|
+
const docHost = makeFakeHost("doctor-ws-version", { installed: [OURS] });
|
|
1097
|
+
const doctorAsm = path.join(docHost.env.XDG_DATA_HOME as string, "entwurf", "meta-bridge-copilot", ".assembled");
|
|
1098
|
+
mkdirSync(path.dirname(docHost.stateFile), { recursive: true });
|
|
1099
|
+
writeFileSync(docHost.mktState, `${MKT}\t${doctorAsm}\n`);
|
|
1100
|
+
writeFileSync(docHost.stateFile, wsState(doctorAsm));
|
|
1101
|
+
const doc = runVerb(docHost, "doctor-copilot-bridge");
|
|
1102
|
+
ok(
|
|
1103
|
+
"doctor: the same whitespace version is a corrupt-state RED, not a fabricated 'version drift'",
|
|
1104
|
+
doc.status !== 0 && doc.stdout.includes("whitespace") && !doc.stdout.includes("version drift"),
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
{
|
|
1108
|
+
// The doctor's structural verdict IS the shared oracle (B defect 1's doctor half):
|
|
1109
|
+
// a host the real installer just set up reads green THROUGH the oracle, and one
|
|
1110
|
+
// broken oracle fact turns the same host red with the oracle's own reason. The
|
|
1111
|
+
// install here runs WITHOUT the ENTWURF_COPILOT_ASM seam so the assembly lands at
|
|
1112
|
+
// the XDG-derived path the doctor actually reads.
|
|
1113
|
+
const host = makeFakeHost("doctor-oracle", {});
|
|
1114
|
+
const envNoSeam = { ...host.env };
|
|
1115
|
+
delete envNoSeam.ENTWURF_COPILOT_ASM;
|
|
1116
|
+
const inst = spawnSync("bash", [path.join(REPO, "run.sh"), "install-copilot-bridge"], {
|
|
1117
|
+
env: envNoSeam,
|
|
1118
|
+
encoding: "utf8",
|
|
1119
|
+
});
|
|
1120
|
+
ok("precondition: a full install at the doctor's own XDG assembly path is green", inst.status === 0);
|
|
1121
|
+
const doctorAsm = path.join(host.env.XDG_DATA_HOME as string, "entwurf", "meta-bridge-copilot", ".assembled");
|
|
1122
|
+
const green = runVerb(host, "doctor-copilot-bridge");
|
|
1123
|
+
ok(
|
|
1124
|
+
"doctor: the freshly installed host is GREEN through the shared structural oracle",
|
|
1125
|
+
green.status === 0 && green.stdout.includes("shared structural oracle"),
|
|
1126
|
+
);
|
|
1127
|
+
rmSync(path.join(doctorAsm, PLUGIN, "hooks", "hooks.json"));
|
|
1128
|
+
const red = runVerb(host, "doctor-copilot-bridge");
|
|
1129
|
+
ok(
|
|
1130
|
+
"[QK:COPILOT-DOCTOR-ORACLE-CONSUMED] doctor: one broken oracle fact turns the same host RED with the oracle's reason",
|
|
1131
|
+
red.status !== 0 && red.stdout.includes("structural oracle") && red.stdout.includes("hooks.json"),
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// ── 14. the doctor's ownership axis (#86 C3a) ───────────────────────────────
|
|
1136
|
+
{
|
|
1137
|
+
const host = makeFakeHost("doctor-list-broken", { installed: [], listFails: true });
|
|
1138
|
+
const res = runVerb(host, "doctor-copilot-bridge");
|
|
1139
|
+
ok(
|
|
1140
|
+
"the doctor reads a FAILING plugin list as UNKNOWN (red), never as an empty host",
|
|
1141
|
+
res.status !== 0 && res.stdout.includes("UNKNOWN"),
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
{
|
|
1145
|
+
const host = makeFakeHost("doctor-ownership-drift", {
|
|
1146
|
+
installed: [OURS],
|
|
1147
|
+
marketplaces: [[MKT, "/somebody/elses/marketplace-root"]],
|
|
1148
|
+
});
|
|
1149
|
+
// The doctor derives its assembly path from XDG (no ENTWURF_COPILOT_ASM seam), so
|
|
1150
|
+
// bind the state to that derived path — the drift under test is the MARKETPLACE's.
|
|
1151
|
+
const doctorAsm = path.join(host.env.XDG_DATA_HOME as string, "entwurf", "meta-bridge-copilot", ".assembled");
|
|
1152
|
+
mkdirSync(path.dirname(host.stateFile), { recursive: true });
|
|
1153
|
+
writeFileSync(
|
|
1154
|
+
host.stateFile,
|
|
1155
|
+
JSON.stringify({
|
|
1156
|
+
schemaVersion: 1,
|
|
1157
|
+
qualifiedId: OURS,
|
|
1158
|
+
marketplaceName: MKT,
|
|
1159
|
+
assemblyPath: doctorAsm,
|
|
1160
|
+
pluginVersion: SHIPPED_VERSION,
|
|
1161
|
+
ownedMarketplace: true,
|
|
1162
|
+
ownedAssembly: true,
|
|
1163
|
+
installedAt: "2026-08-27T00:00:00Z",
|
|
1164
|
+
}),
|
|
1165
|
+
);
|
|
1166
|
+
const res = runVerb(host, "doctor-copilot-bridge");
|
|
1167
|
+
ok(
|
|
1168
|
+
"the doctor FAILs a marketplace registered under our name at another path (ownership drift) and prints the axes separately",
|
|
1169
|
+
res.status !== 0 && res.stdout.includes("ownership drift") && res.stdout.includes("ownership axis: FAIL"),
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
writeFileSync(path.join(root, "gate.ok"), "");
|
|
1174
|
+
console.log(`[check-copilot-birth-hook] ${passed} assertions ok`);
|