@cairnkeep/cli 2.9.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +66 -1
  2. package/README.md +83 -29
  3. package/bin/cairn +6 -165
  4. package/bin/cairn-container +8 -0
  5. package/docs/building-an-overlay.md +42 -5
  6. package/docs/containers.md +10 -0
  7. package/docs/harness-compatibility.md +64 -3
  8. package/docs/learning/CURRICULUM-MAP.md +13 -6
  9. package/docs/learning/FEATURE-GUIDE.md +14 -3
  10. package/docs/learning/README.md +5 -2
  11. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  12. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  13. package/docs/learning/lessons/L02-installation.md +24 -8
  14. package/docs/learning/lessons/L03-first-project.md +34 -15
  15. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  16. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  17. package/docs/learning/lessons/L21-context-packs.md +1 -1
  18. package/docs/learning/lessons/L22-native-windows.md +83 -0
  19. package/docs/learning/lessons/L23-guided-setup.md +142 -0
  20. package/docs/learning/tracks/operator.md +8 -3
  21. package/docs/learning/tracks/quickstart.md +9 -4
  22. package/docs/native-windows.md +136 -0
  23. package/docs/operating.md +142 -20
  24. package/docs/privacy-and-data-flow.md +41 -1
  25. package/docs/quickstart.md +86 -0
  26. package/docs/storage.md +14 -2
  27. package/examples/eval/bundled-fake.json +1 -1
  28. package/mcp-memory-server/dist/artifact-store-windows-worker.js +74 -0
  29. package/mcp-memory-server/dist/artifact-store.js +196 -55
  30. package/mcp-memory-server/dist/capability-cli.js +2 -1
  31. package/mcp-memory-server/dist/capability-config.js +4 -3
  32. package/mcp-memory-server/dist/capability-harness.js +34 -7
  33. package/mcp-memory-server/dist/context-pack.js +84 -25
  34. package/mcp-memory-server/dist/eval-cli.js +6 -5
  35. package/mcp-memory-server/dist/eval-plan.js +35 -13
  36. package/mcp-memory-server/dist/eval-process.js +11 -4
  37. package/mcp-memory-server/dist/eval-report.js +7 -5
  38. package/mcp-memory-server/dist/graph-cli.js +7 -1
  39. package/mcp-memory-server/dist/index.js +19 -20
  40. package/mcp-memory-server/dist/mcp-tool-profile.js +6 -4
  41. package/mcp-memory-server/dist/note-store.js +12 -3
  42. package/mcp-memory-server/dist/pi-mcp-bridge.js +185 -0
  43. package/mcp-memory-server/dist/platform-security.js +102 -0
  44. package/mcp-memory-server/dist/skill-store.js +27 -12
  45. package/mcp-memory-server/package.json +2 -1
  46. package/package.json +6 -4
  47. package/pi/extensions/cairnkeep-memory.ts +61 -0
  48. package/schemas/cairnkeep-setup-policy.schema.json +55 -0
  49. package/schemas/cairnkeep-setup.schema.json +36 -0
  50. package/scripts/bootstrap.sh +7 -15
  51. package/scripts/cairn-cli.mjs +119 -0
  52. package/scripts/cairn-container-cli.mjs +121 -0
  53. package/scripts/cairn-hook.mjs +199 -0
  54. package/scripts/completion.sh +17 -6
  55. package/scripts/doctor.sh +58 -0
  56. package/scripts/fix-permissions.mjs +12 -0
  57. package/scripts/harness-registry.mjs +126 -0
  58. package/scripts/memory-wiki-audit.mjs +95 -0
  59. package/scripts/phase26-test-manifest.mjs +26 -0
  60. package/scripts/run-repository-tests.mjs +76 -0
  61. package/scripts/setup-core.mjs +312 -0
  62. package/scripts/setup-reconcile.mjs +332 -0
  63. package/scripts/setup.mjs +342 -0
  64. package/scripts/sync-pi-assets.sh +19 -4
  65. package/scripts/test-bootstrap-untracked.sh +2 -1
  66. package/scripts/test-completion.sh +41 -0
  67. package/scripts/test-doctor.sh +66 -0
  68. package/scripts/test-harness-registry.sh +94 -0
  69. package/scripts/test-learning-docs.sh +52 -3
  70. package/scripts/test-package-install.sh +69 -0
  71. package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
  72. package/scripts/test-phase18-capability-lifecycle.sh +1 -1
  73. package/scripts/test-phase19-eval-lifecycle.sh +12 -2
  74. package/scripts/test-pi-lifecycle.mjs +190 -0
  75. package/scripts/test-setup-compatibility.mjs +187 -0
  76. package/scripts/test-setup-output.sh +131 -0
  77. package/scripts/test-setup-overlay.sh +113 -0
  78. package/scripts/test-setup-preflight.mjs +281 -0
  79. package/scripts/test-setup-reconcile.mjs +257 -0
  80. package/scripts/test-uninstall.sh +50 -0
  81. package/scripts/test-windows-native.mjs +159 -0
  82. package/scripts/uninstall.sh +73 -6
  83. package/scripts/verify-pi-mcp-bridge.mjs +588 -0
  84. package/scripts/windows-platform.mjs +733 -0
  85. package/templates/codex-config.toml.template +5 -0
  86. package/templates/start-codex.sh.template +30 -0
@@ -0,0 +1,588 @@
1
+ #!/usr/bin/env node
2
+
3
+ import assert from "node:assert/strict";
4
+ import { spawn } from "node:child_process";
5
+ import {
6
+ accessSync,
7
+ chmodSync,
8
+ constants,
9
+ existsSync,
10
+ mkdtempSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ realpathSync,
14
+ rmSync,
15
+ statSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { basename, dirname, join, resolve } from "node:path";
20
+ import { fileURLToPath, pathToFileURL } from "node:url";
21
+
22
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
23
+ const selfPath = fileURLToPath(import.meta.url);
24
+ const serverRoot = join(root, "mcp-memory-server");
25
+ const serverEntry = join(serverRoot, "dist", "index.js");
26
+ const bridgeEntry = join(serverRoot, "dist", "pi-mcp-bridge.js");
27
+ const extensionTemplate = join(root, "pi", "extensions", "cairnkeep-memory.ts");
28
+ const MAX_OUTPUT_BYTES = 64 * 1024;
29
+ const PROCESS_TIMEOUT_MS = 15_000;
30
+ const ORPHAN_TIMEOUT_MS = 3_000;
31
+ const PROFILES = Object.freeze([
32
+ Object.freeze({ name: "full", allowed: null }),
33
+ Object.freeze({ name: "read-only", allowed: null }),
34
+ Object.freeze({ name: "custom", allowed: "memory_list,context_explore" }),
35
+ ]);
36
+
37
+ class GateError extends Error {
38
+ constructor(code) {
39
+ super(code);
40
+ this.code = code;
41
+ }
42
+ }
43
+
44
+ function output(record) {
45
+ process.stdout.write(`${JSON.stringify(record)}\n`);
46
+ }
47
+
48
+ function fail(code) {
49
+ throw new GateError(code);
50
+ }
51
+
52
+ function parseArgs(argv) {
53
+ const options = { requiredRelease: false, selfTest: false, json: false };
54
+ for (let index = 0; index < argv.length; index += 1) {
55
+ const arg = argv[index];
56
+ if (arg === "--required-release") options.requiredRelease = true;
57
+ else if (arg === "--self-test") options.selfTest = true;
58
+ else if (arg === "--json") options.json = true;
59
+ else if (["--pi-0-84-1", "--pi-current", "--pi-current-version"].includes(arg)) {
60
+ const value = argv[index + 1];
61
+ if (!value || value.startsWith("--")) fail("invalid-arguments");
62
+ index += 1;
63
+ if (arg === "--pi-0-84-1") options.minimumPath = value;
64
+ else if (arg === "--pi-current") options.currentPath = value;
65
+ else options.currentVersion = value;
66
+ } else fail("invalid-arguments");
67
+ }
68
+ if (options.selfTest && (options.requiredRelease || options.minimumPath || options.currentPath || options.currentVersion)) {
69
+ fail("invalid-arguments");
70
+ }
71
+ options.minimumPath ??= process.env.CAIRN_PI_0841_BIN;
72
+ options.currentPath ??= process.env.CAIRN_PI_CURRENT_BIN;
73
+ options.currentVersion ??= process.env.CAIRN_PI_CURRENT_VERSION;
74
+ return options;
75
+ }
76
+
77
+ function semver(value) {
78
+ if (typeof value !== "string" || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(value)) fail("invalid-current-version");
79
+ return value;
80
+ }
81
+
82
+ function executable(path) {
83
+ if (typeof path !== "string" || path.length === 0) fail("invalid-executable");
84
+ let resolved;
85
+ try {
86
+ resolved = realpathSync(resolve(path));
87
+ if (!statSync(resolved).isFile()) fail("invalid-executable");
88
+ accessSync(resolved, constants.X_OK);
89
+ } catch (error) {
90
+ if (error instanceof GateError) throw error;
91
+ fail("invalid-executable");
92
+ }
93
+ return resolved;
94
+ }
95
+
96
+ function boundedAppend(current, chunk) {
97
+ const next = Buffer.concat([current, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
98
+ return next.byteLength <= MAX_OUTPUT_BYTES ? next : next.subarray(next.byteLength - MAX_OUTPUT_BYTES);
99
+ }
100
+
101
+ function spawnCaptured(command, args, options = {}) {
102
+ return new Promise((resolvePromise, rejectPromise) => {
103
+ const child = spawn(command, args, {
104
+ cwd: options.cwd,
105
+ env: options.env,
106
+ shell: false,
107
+ stdio: ["ignore", "pipe", "pipe"],
108
+ });
109
+ let stdout = Buffer.alloc(0);
110
+ let stderr = Buffer.alloc(0);
111
+ let timedOut = false;
112
+ const timer = setTimeout(() => {
113
+ timedOut = true;
114
+ child.kill("SIGTERM");
115
+ setTimeout(() => child.kill("SIGKILL"), 500).unref();
116
+ }, options.timeoutMs ?? PROCESS_TIMEOUT_MS);
117
+ child.stdout.on("data", (chunk) => { stdout = boundedAppend(stdout, chunk); });
118
+ child.stderr.on("data", (chunk) => { stderr = boundedAppend(stderr, chunk); });
119
+ child.once("error", (error) => {
120
+ clearTimeout(timer);
121
+ rejectPromise(error);
122
+ });
123
+ child.once("close", (status, signal) => {
124
+ clearTimeout(timer);
125
+ resolvePromise({ status, signal, timedOut, stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8") });
126
+ });
127
+ });
128
+ }
129
+
130
+ function cleanEnvironment(extra = {}) {
131
+ const env = { ...process.env };
132
+ for (const name of [
133
+ "CAIRN_MCP_TOOL_PROFILE",
134
+ "CAIRN_MCP_ALLOWED_TOOLS",
135
+ "CAIRN_TYPED_MEMORY_NODES",
136
+ "CAIRN_ARTIFACT_STORE",
137
+ "CAIRN_ARTIFACT_HTTP",
138
+ "CAIRN_CONTEXT_PACKS",
139
+ "CAIRN_CONTEXT_PACK_HTTP",
140
+ "CAIRN_CAPABILITY_CONTRACT",
141
+ "MCP_HTTP_PORT",
142
+ "CAIRN_PI_ACCEPTANCE_EVIDENCE",
143
+ "CAIRN_PI_ACCEPTANCE_PIDS",
144
+ "CAIRN_PI_ACCEPTANCE_BRIDGE",
145
+ "CAIRN_PI_ACCEPTANCE_ORACLE",
146
+ "CAIRN_PI_ACCEPTANCE_PROFILE",
147
+ "CAIRN_PI_ACCEPTANCE_SELFTEST_FAULT",
148
+ ]) delete env[name];
149
+ return { ...env, ...extra };
150
+ }
151
+
152
+ async function loadSdk() {
153
+ const sdk = join(serverRoot, "node_modules", "@modelcontextprotocol", "sdk", "dist", "esm");
154
+ const [{ Client }, { StdioClientTransport }] = await Promise.all([
155
+ import(pathToFileURL(join(sdk, "client", "index.js")).href),
156
+ import(pathToFileURL(join(sdk, "client", "stdio.js")).href),
157
+ ]);
158
+ return { Client, StdioClientTransport };
159
+ }
160
+
161
+ function profileEnvironment(sandbox, profile) {
162
+ return cleanEnvironment({
163
+ CAIRN_AGENTFS_BASE_DIR: join(sandbox, "memory"),
164
+ CAIRN_MCP_TOOL_PROFILE: profile.name,
165
+ CAIRN_EXPLORE_BINARY: join(sandbox, "delayed-explore.mjs"),
166
+ CAIRN_EXPLORE_CACHE: "0",
167
+ ...(profile.allowed ? { CAIRN_MCP_ALLOWED_TOOLS: profile.allowed } : {}),
168
+ });
169
+ }
170
+
171
+ async function directCatalog(sdk, sandbox, profile) {
172
+ const transport = new sdk.StdioClientTransport({
173
+ command: process.execPath,
174
+ args: [serverEntry],
175
+ cwd: sandbox,
176
+ env: profileEnvironment(sandbox, profile),
177
+ stderr: "pipe",
178
+ });
179
+ const client = new sdk.Client({ name: "cairnkeep-pi-release-oracle", version: "1" }, { capabilities: {} });
180
+ await client.connect(transport, { timeout: 5_000, maxTotalTimeout: 5_000 });
181
+ try {
182
+ const tools = [];
183
+ let cursor;
184
+ do {
185
+ const page = await client.listTools(cursor ? { cursor } : undefined, { timeout: 5_000 });
186
+ tools.push(...page.tools);
187
+ cursor = page.nextCursor;
188
+ } while (cursor);
189
+ return tools;
190
+ } finally {
191
+ await client.close();
192
+ }
193
+ }
194
+
195
+ async function buildCatalogs(sandbox) {
196
+ if (!existsSync(serverEntry) || !existsSync(bridgeEntry)) fail("server-build-missing");
197
+ const sdk = await loadSdk();
198
+ const catalogs = new Map();
199
+ for (const profile of PROFILES) catalogs.set(profile.name, await directCatalog(sdk, sandbox, profile));
200
+ return catalogs;
201
+ }
202
+
203
+ function captureToolRegistrations(pi, registeredTools) {
204
+ return new Proxy(pi, {
205
+ get(target, property, receiver) {
206
+ if (property === "registerTool") {
207
+ return (tool) => {
208
+ registeredTools.set(tool.name, tool);
209
+ target.registerTool(tool);
210
+ };
211
+ }
212
+ return Reflect.get(target, property, receiver);
213
+ },
214
+ });
215
+ }
216
+
217
+ const OBSERVER_SOURCE = String.raw`
218
+ import cairnMemoryExtension from @@CAIRN_MEMORY_EXTENSION@@;
219
+ import { writeFileSync } from "node:fs";
220
+ import { pathToFileURL } from "node:url";
221
+
222
+ const evidencePath = process.env.CAIRN_PI_ACCEPTANCE_EVIDENCE;
223
+ const bridgePath = process.env.CAIRN_PI_ACCEPTANCE_BRIDGE;
224
+ const write = (value) => writeFileSync(evidencePath, JSON.stringify(value));
225
+ const serial = (value) => JSON.parse(JSON.stringify(value));
226
+ @@CAPTURE_TOOL_REGISTRATIONS@@
227
+
228
+ export default function acceptanceObserver(pi) {
229
+ const registeredTools = new Map();
230
+ cairnMemoryExtension(captureToolRegistrations(pi, registeredTools));
231
+ pi.on("session_start", async (_event, ctx) => {
232
+ let acceptanceBridge;
233
+ let stage = "tools";
234
+ try {
235
+ const registered = Array.from(registeredTools.values());
236
+ const visibleRegistered = pi.getAllTools().filter(({ name }) => registeredTools.has(name));
237
+ const read = registered.find(({ name }) => name === "memory_list");
238
+ const delayed = registered.find(({ name }) => name === "context_explore");
239
+ if (!read || !delayed || typeof read.execute !== "function" || typeof delayed.execute !== "function") throw new Error("tools unavailable");
240
+
241
+ stage = "bridge-import";
242
+ const bridgeModule = await import(pathToFileURL(bridgePath).href);
243
+ stage = "bridge-connect";
244
+ acceptanceBridge = await bridgeModule.connectCairnPiBridge({ cwd: ctx.cwd, env: { ...process.env } });
245
+ stage = "catalog";
246
+ const trustedCatalog = await acceptanceBridge.listAllTools();
247
+
248
+ stage = "read-call";
249
+ const first = await read.execute("acceptance-read-1", { scope: "project" }, new AbortController().signal);
250
+ stage = "cancel-call";
251
+ const controller = new AbortController();
252
+ const pending = delayed.execute("acceptance-cancel", {
253
+ query: "synthetic cancellation probe",
254
+ repo_root: ctx.cwd,
255
+ timeout_seconds: 1,
256
+ }, controller.signal);
257
+ setTimeout(() => controller.abort(new Error("cancelled")), 50);
258
+ let cancellation = false;
259
+ try { await pending; } catch { cancellation = true; }
260
+ stage = "post-cancel-call";
261
+ const after = await read.execute("acceptance-read-2", { scope: "project" }, new AbortController().signal);
262
+ stage = "bridge-close";
263
+ await acceptanceBridge.close();
264
+ acceptanceBridge = undefined;
265
+
266
+ stage = "evidence";
267
+ write({
268
+ status: "PASS",
269
+ registered: registered.map((tool) => ({
270
+ name: tool.name,
271
+ description: tool.description,
272
+ inputSchema: serial(tool.parameters),
273
+ nativeAnnotations: Object.hasOwn(tool, "annotations"),
274
+ })),
275
+ visibleRegistered: visibleRegistered.map((tool) => ({
276
+ name: tool.name,
277
+ description: tool.description,
278
+ inputSchema: serial(tool.parameters),
279
+ exposesExecute: typeof tool.execute === "function",
280
+ })),
281
+ trustedCatalog: serial(trustedCatalog),
282
+ callDetails: serial(first.details),
283
+ callPassed: Array.isArray(first.content),
284
+ cancellation,
285
+ sessionUsable: Array.isArray(after.content),
286
+ shutdownRequested: true,
287
+ });
288
+ } catch {
289
+ try { await acceptanceBridge?.close(); } catch {}
290
+ write({ status: "FAIL", reason: "observer-" + stage + "-failed" });
291
+ } finally {
292
+ ctx.shutdown();
293
+ }
294
+ });
295
+ }
296
+ `;
297
+
298
+ function writeRuntimeFixtures(sandbox) {
299
+ const piRoot = join(sandbox, "pi-root");
300
+ const extensionDir = join(piRoot, "extensions");
301
+ mkdirSync(extensionDir, { recursive: true });
302
+ const installedExtension = join(extensionDir, "cairnkeep-memory.ts");
303
+ const rendered = readFileSync(extensionTemplate, "utf8").replaceAll("@@INFRA_ROOT@@", root.replaceAll("\\", "/"));
304
+ writeFileSync(installedExtension, rendered);
305
+ const observer = join(sandbox, "cairnkeep-acceptance.ts");
306
+ writeFileSync(observer, OBSERVER_SOURCE
307
+ .replace("@@CAIRN_MEMORY_EXTENSION@@", JSON.stringify(installedExtension))
308
+ .replace("@@CAPTURE_TOOL_REGISTRATIONS@@", captureToolRegistrations.toString()));
309
+ const delayed = join(sandbox, "delayed-explore.mjs");
310
+ writeFileSync(delayed, "#!/usr/bin/env node\nsetTimeout(() => process.stdout.write(JSON.stringify({citations:[],stats:{turns:0,tool_calls:0}})), 10000);\n");
311
+ chmodSync(delayed, 0o755);
312
+ return { piRoot, observer };
313
+ }
314
+
315
+ function writeCairnWrapper(sandbox) {
316
+ const bin = join(sandbox, "bin");
317
+ mkdirSync(bin, { recursive: true });
318
+ const wrapper = join(bin, "cairn");
319
+ writeFileSync(wrapper, `#!/usr/bin/env node
320
+ import { appendFileSync } from "node:fs";
321
+ import { pathToFileURL } from "node:url";
322
+ if (process.argv.length !== 3 || process.argv[2] !== "memory-server") process.exit(64);
323
+ appendFileSync(process.env.CAIRN_PI_ACCEPTANCE_PIDS, String(process.pid) + "\\n");
324
+ await import(pathToFileURL(${JSON.stringify(serverEntry)}).href);
325
+ `);
326
+ chmodSync(wrapper, 0o755);
327
+ return bin;
328
+ }
329
+
330
+ function validateCatalogEvidence(expected, evidence) {
331
+ if (evidence?.status !== "PASS") {
332
+ const reason = typeof evidence?.reason === "string" && /^observer-(tools|bridge-import|bridge-connect|catalog|read-call|cancel-call|post-cancel-call|bridge-close|evidence)-failed$/.test(evidence.reason)
333
+ ? evidence.reason
334
+ : "pi-observer-failed";
335
+ fail(reason);
336
+ }
337
+ if (!Array.isArray(evidence.registered) || !Array.isArray(evidence.visibleRegistered) || !Array.isArray(evidence.trustedCatalog)) fail("pi-evidence-invalid");
338
+ assert.deepEqual(evidence.trustedCatalog, expected, "trusted Pi bridge catalog drifted");
339
+ assert.deepEqual(evidence.registered.map(({ name }) => name), expected.map(({ name }) => name), "Pi registered tool order drifted");
340
+ assert.deepEqual(evidence.visibleRegistered.map(({ name }) => name), expected.map(({ name }) => name), "Pi public tool order drifted");
341
+ for (let index = 0; index < expected.length; index += 1) {
342
+ assert.equal(evidence.registered[index].description, expected[index].description, `${expected[index].name} description drifted`);
343
+ assert.deepEqual(evidence.registered[index].inputSchema, expected[index].inputSchema, `${expected[index].name} input schema drifted`);
344
+ assert.equal(evidence.registered[index].nativeAnnotations, false, `${expected[index].name} claimed unsupported native annotations`);
345
+ assert.equal(evidence.visibleRegistered[index].description, expected[index].description, `${expected[index].name} public description drifted`);
346
+ assert.deepEqual(evidence.visibleRegistered[index].inputSchema, expected[index].inputSchema, `${expected[index].name} public input schema drifted`);
347
+ assert.equal(evidence.visibleRegistered[index].exposesExecute, false, `${expected[index].name} public metadata unexpectedly exposed execute`);
348
+ assert.deepEqual(evidence.trustedCatalog[index].outputSchema, expected[index].outputSchema, `${expected[index].name} output schema drifted`);
349
+ assert.deepEqual(evidence.trustedCatalog[index].annotations, expected[index].annotations, `${expected[index].name} annotations drifted`);
350
+ }
351
+ const read = expected.find(({ name }) => name === "memory_list");
352
+ if (!read || !evidence.callDetails) fail("pi-read-evidence-missing");
353
+ assert.deepEqual(evidence.callDetails.tool, read, "trusted call tool metadata drifted");
354
+ assert.deepEqual(evidence.callDetails.annotations, read.annotations, "trusted call annotations drifted");
355
+ assert.deepEqual(evidence.callDetails.outputSchema, read.outputSchema, "trusted call output schema drifted");
356
+ if (!evidence.callPassed) fail("pi-read-call-failed");
357
+ if (!evidence.cancellation) fail("pi-cancellation-failed");
358
+ if (!evidence.sessionUsable) fail("pi-session-died-after-cancel");
359
+ if (!evidence.shutdownRequested) fail("pi-shutdown-not-requested");
360
+ }
361
+
362
+ function pidAlive(pid) {
363
+ try {
364
+ process.kill(pid, 0);
365
+ return true;
366
+ } catch (error) {
367
+ return error?.code !== "ESRCH";
368
+ }
369
+ }
370
+
371
+ async function requireNoOrphans(pidFile) {
372
+ const pids = existsSync(pidFile)
373
+ ? readFileSync(pidFile, "utf8").split(/\s+/).filter(Boolean).map(Number).filter(Number.isSafeInteger)
374
+ : [];
375
+ if (pids.length === 0) fail("pi-child-evidence-missing");
376
+ const deadline = Date.now() + ORPHAN_TIMEOUT_MS;
377
+ while (Date.now() < deadline && pids.some(pidAlive)) await new Promise((resolvePromise) => setTimeout(resolvePromise, 50));
378
+ const alive = pids.filter(pidAlive);
379
+ for (const pid of alive) {
380
+ try { process.kill(pid, "SIGKILL"); } catch {}
381
+ }
382
+ if (alive.length) fail("pi-memory-child-orphaned");
383
+ }
384
+
385
+ async function reportedVersion(path, expected) {
386
+ const result = await spawnCaptured(path, ["--version"], { cwd: root, env: cleanEnvironment(), timeoutMs: 5_000 });
387
+ if (result.timedOut || result.status !== 0) fail("pi-version-check-failed");
388
+ const versions = `${result.stdout}\n${result.stderr}`.match(/\b\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\b/g) ?? [];
389
+ if (!versions.includes(expected)) fail("pi-version-mismatch");
390
+ }
391
+
392
+ async function runProfile(executablePath, version, profile, expected, parent, extraEnv = {}) {
393
+ const sandbox = join(parent, `${version.replaceAll(/[^0-9A-Za-z]/g, "-")}-${profile.name}`);
394
+ mkdirSync(sandbox, { recursive: true });
395
+ const { piRoot, observer } = writeRuntimeFixtures(sandbox);
396
+ const wrapperBin = writeCairnWrapper(sandbox);
397
+ const evidencePath = join(sandbox, "evidence.json");
398
+ const pidFile = join(sandbox, "children.txt");
399
+ const oraclePath = join(sandbox, "oracle.json");
400
+ writeFileSync(oraclePath, JSON.stringify(expected));
401
+ const env = {
402
+ ...profileEnvironment(sandbox, profile),
403
+ ...extraEnv,
404
+ PI_CODING_AGENT_DIR: piRoot,
405
+ PATH: `${wrapperBin}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`,
406
+ CAIRN_PI_ACCEPTANCE_EVIDENCE: evidencePath,
407
+ CAIRN_PI_ACCEPTANCE_PIDS: pidFile,
408
+ CAIRN_PI_ACCEPTANCE_BRIDGE: bridgeEntry,
409
+ CAIRN_PI_ACCEPTANCE_ORACLE: oraclePath,
410
+ CAIRN_PI_ACCEPTANCE_PROFILE: profile.name,
411
+ };
412
+ const args = [
413
+ "--mode", "rpc", "--no-session", "--no-extensions",
414
+ "--extension", observer,
415
+ "--no-skills", "--no-prompt-templates", "--no-context-files",
416
+ "--no-builtin-tools", "--approve",
417
+ ];
418
+ const result = await spawnCaptured(executablePath, args, { cwd: sandbox, env });
419
+ if (result.timedOut) fail("pi-runtime-timeout");
420
+ if (result.status !== 0) fail("pi-runtime-failed");
421
+ if (!existsSync(evidencePath)) fail("pi-evidence-missing");
422
+ let evidence;
423
+ try { evidence = JSON.parse(readFileSync(evidencePath, "utf8")); } catch { fail("pi-evidence-invalid"); }
424
+ try { validateCatalogEvidence(expected, evidence); } catch (error) {
425
+ if (error instanceof GateError) throw error;
426
+ fail("pi-catalog-mismatch");
427
+ }
428
+ await requireNoOrphans(pidFile);
429
+ return Object.freeze({ profile: profile.name, tool_count: expected.length, catalog: true, trusted_details: true, call: true, cancellation: true, shutdown: true, orphan_free: true });
430
+ }
431
+
432
+ async function runExecutable(executablePath, version, catalogs, parent, extraEnv = {}) {
433
+ await reportedVersion(executablePath, version);
434
+ const profiles = [];
435
+ for (const profile of PROFILES) profiles.push(await runProfile(executablePath, version, profile, catalogs.get(profile.name), parent, extraEnv));
436
+ return Object.freeze({ version, profiles });
437
+ }
438
+
439
+ function fakeExecutableSource(version) {
440
+ return `#!/usr/bin/env node
441
+ import { readFileSync, writeFileSync, appendFileSync } from "node:fs";
442
+ import { spawn } from "node:child_process";
443
+ if (process.argv.includes("--version")) { console.log(${JSON.stringify(version)}); process.exit(0); }
444
+ const expected = JSON.parse(readFileSync(process.env.CAIRN_PI_ACCEPTANCE_ORACLE, "utf8"));
445
+ const fault = process.env.CAIRN_PI_ACCEPTANCE_SELFTEST_FAULT || "";
446
+ if (fault === "shutdown") process.exit(9);
447
+ const child = spawn(process.execPath, ["-e", "setTimeout(() => {}, " + (fault === "orphan" ? "10000" : "20") + ")"], { stdio: "ignore", detached: fault === "orphan" });
448
+ appendFileSync(process.env.CAIRN_PI_ACCEPTANCE_PIDS, String(child.pid) + "\\n");
449
+ if (fault === "orphan") child.unref(); else await new Promise((resolvePromise) => child.once("exit", resolvePromise));
450
+ const registered = expected.map((tool) => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema, nativeAnnotations: false }));
451
+ if (fault === "profile" && process.env.CAIRN_PI_ACCEPTANCE_PROFILE === "read-only") registered[0].name = "drifted_tool";
452
+ const visibleRegistered = registered.map(({ name, description, inputSchema }) => ({ name, description, inputSchema, exposesExecute: false }));
453
+ const read = expected.find(({ name }) => name === "memory_list");
454
+ writeFileSync(process.env.CAIRN_PI_ACCEPTANCE_EVIDENCE, JSON.stringify({
455
+ status: "PASS", registered, visibleRegistered, trustedCatalog: expected,
456
+ callDetails: { tool: read, annotations: read?.annotations, outputSchema: read?.outputSchema },
457
+ callPassed: true, cancellation: fault !== "cancellation", sessionUsable: true,
458
+ shutdownRequested: true,
459
+ }));
460
+ `;
461
+ }
462
+
463
+ function writeFakeExecutable(directory, name, version) {
464
+ const path = join(directory, name);
465
+ writeFileSync(path, fakeExecutableSource(version));
466
+ chmodSync(path, 0o755);
467
+ return path;
468
+ }
469
+
470
+ async function expectGate(code, action) {
471
+ await assert.rejects(action, (error) => error instanceof GateError && error.code === code);
472
+ }
473
+
474
+ async function selfTest() {
475
+ const sandbox = mkdtempSync(join(tmpdir(), "cairn-pi-release-self-test-"));
476
+ try {
477
+ const captured = new Map();
478
+ const definitions = new Map();
479
+ const metadataOnlyPi = {
480
+ registerTool(tool) { definitions.set(tool.name, tool); },
481
+ getAllTools() {
482
+ return Array.from(definitions.values(), ({ name, description, parameters }) => ({ name, description, parameters }));
483
+ },
484
+ };
485
+ const acceptancePi = captureToolRegistrations(metadataOnlyPi, captured);
486
+ const execute = async () => ({ content: [] });
487
+ acceptancePi.registerTool({ name: "fixture_tool", description: "Fixture", parameters: { type: "object" }, execute });
488
+ assert.equal(metadataOnlyPi.getAllTools()[0].execute, undefined);
489
+ assert.equal(captured.get("fixture_tool").execute, execute);
490
+
491
+ const skipped = await spawnCaptured(process.execPath, [selfPath], { cwd: root, env: cleanEnvironment(), timeoutMs: 5_000 });
492
+ assert.equal(skipped.status, 0);
493
+ assert.deepEqual(JSON.parse(skipped.stdout), { schema_version: 1, status: "SKIP", reason: "real-pi-fixtures-unavailable" });
494
+ const requiredMissing = await spawnCaptured(process.execPath, [selfPath, "--required-release"], { cwd: root, env: cleanEnvironment(), timeoutMs: 5_000 });
495
+ assert.notEqual(requiredMissing.status, 0);
496
+ assert.equal(requiredMissing.stdout.includes("SKIP"), false);
497
+ assert.deepEqual(JSON.parse(requiredMissing.stdout), { schema_version: 1, status: "FAIL", reason: "required-fixtures-missing" });
498
+ const minimum = writeFakeExecutable(sandbox, "pi-minimum", "0.84.1");
499
+ const equalCurrent = writeFakeExecutable(sandbox, "pi-current-equal", "0.84.1");
500
+ const current = writeFakeExecutable(sandbox, "pi-current", "0.99.0");
501
+ const equalRelease = await spawnCaptured(process.execPath, [
502
+ selfPath,
503
+ "--required-release",
504
+ "--pi-0-84-1", minimum,
505
+ "--pi-current", equalCurrent,
506
+ "--pi-current-version", "0.84.1",
507
+ ], { cwd: root, env: cleanEnvironment() });
508
+ assert.equal(equalRelease.status, 0);
509
+ const equalEvidence = JSON.parse(equalRelease.stdout);
510
+ assert.equal(equalEvidence.status, "PASS");
511
+ assert.equal(equalEvidence.mode, "required-release");
512
+ assert.equal(equalEvidence.versions_equal, true);
513
+ assert.equal(equalEvidence.minimum.version, "0.84.1");
514
+ assert.equal(equalEvidence.current.version, "0.84.1");
515
+ for (const forbidden of [sandbox, process.env.USER, process.env.HOSTNAME, basename(root)]) {
516
+ if (forbidden && equalRelease.stdout.includes(forbidden)) fail("self-test-evidence-disclosure");
517
+ }
518
+ const duplicateFixture = await spawnCaptured(process.execPath, [
519
+ selfPath,
520
+ "--required-release",
521
+ "--pi-0-84-1", minimum,
522
+ "--pi-current", minimum,
523
+ "--pi-current-version", "0.84.1",
524
+ ], { cwd: root, env: cleanEnvironment() });
525
+ assert.notEqual(duplicateFixture.status, 0);
526
+ assert.deepEqual(JSON.parse(duplicateFixture.stdout), { schema_version: 1, status: "FAIL", reason: "fixtures-not-distinct" });
527
+ const catalogs = await buildCatalogs(sandbox);
528
+ const pass = await Promise.all([
529
+ runExecutable(minimum, "0.84.1", catalogs, join(sandbox, "pass-minimum")),
530
+ runExecutable(current, "0.99.0", catalogs, join(sandbox, "pass-current")),
531
+ ]);
532
+ assert.equal(pass.length, 2);
533
+ await expectGate("pi-version-mismatch", () => reportedVersion(current, "0.99.1"));
534
+ await expectGate("pi-catalog-mismatch", () => runExecutable(minimum, "0.84.1", catalogs, join(sandbox, "profile-fault"), { CAIRN_PI_ACCEPTANCE_SELFTEST_FAULT: "profile" }));
535
+ await expectGate("pi-cancellation-failed", () => runProfile(minimum, "0.84.1", PROFILES[0], catalogs.get("full"), join(sandbox, "cancel-fault"), { CAIRN_PI_ACCEPTANCE_SELFTEST_FAULT: "cancellation" }));
536
+ await expectGate("pi-runtime-failed", () => runProfile(minimum, "0.84.1", PROFILES[0], catalogs.get("full"), join(sandbox, "shutdown-fault"), { CAIRN_PI_ACCEPTANCE_SELFTEST_FAULT: "shutdown" }));
537
+ await expectGate("pi-memory-child-orphaned", () => runProfile(minimum, "0.84.1", PROFILES[0], catalogs.get("full"), join(sandbox, "orphan-fault"), { CAIRN_PI_ACCEPTANCE_SELFTEST_FAULT: "orphan" }));
538
+ const sanitized = JSON.stringify({ schema_version: 1, status: "PASS", mode: "self-test", checks: ["skip", "required-input", "version", "equal-version-fixtures", "distinct-fixtures", "metadata-only-pi-api", "profiles", "trusted-details", "cancellation", "shutdown", "orphan"] });
539
+ for (const forbidden of [sandbox, process.env.USER, process.env.HOSTNAME, basename(root)]) {
540
+ if (forbidden && sanitized.includes(forbidden)) fail("self-test-evidence-disclosure");
541
+ }
542
+ output(JSON.parse(sanitized));
543
+ } finally {
544
+ rmSync(sandbox, { recursive: true, force: true });
545
+ }
546
+ }
547
+
548
+ async function main() {
549
+ const options = parseArgs(process.argv.slice(2));
550
+ if (options.selfTest) {
551
+ await selfTest();
552
+ return;
553
+ }
554
+ const complete = Boolean(options.minimumPath && options.currentPath && options.currentVersion);
555
+ if (!complete) {
556
+ if (options.requiredRelease) fail("required-fixtures-missing");
557
+ output({ schema_version: 1, status: "SKIP", reason: options.minimumPath || options.currentPath || options.currentVersion ? "real-pi-fixtures-incomplete" : "real-pi-fixtures-unavailable" });
558
+ return;
559
+ }
560
+ const currentVersion = semver(options.currentVersion);
561
+ const minimumPath = executable(options.minimumPath);
562
+ const currentPath = executable(options.currentPath);
563
+ if (minimumPath === currentPath) fail("fixtures-not-distinct");
564
+ const sandbox = mkdtempSync(join(tmpdir(), "cairn-pi-release-"));
565
+ try {
566
+ const catalogs = await buildCatalogs(sandbox);
567
+ const minimum = await runExecutable(minimumPath, "0.84.1", catalogs, join(sandbox, "minimum"));
568
+ const current = await runExecutable(currentPath, currentVersion, catalogs, join(sandbox, "current"));
569
+ output({
570
+ schema_version: 1,
571
+ status: "PASS",
572
+ mode: options.requiredRelease ? "required-release" : "available-fixtures",
573
+ versions_equal: currentVersion === "0.84.1",
574
+ minimum,
575
+ current,
576
+ });
577
+ } finally {
578
+ rmSync(sandbox, { recursive: true, force: true });
579
+ }
580
+ }
581
+
582
+ try {
583
+ await main();
584
+ } catch (error) {
585
+ const reason = error instanceof GateError ? error.code : "acceptance-runner-failed";
586
+ output({ schema_version: 1, status: "FAIL", reason });
587
+ process.exitCode = 1;
588
+ }