@cairnkeep/cli 2.22.2 → 2.22.4

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 (59) hide show
  1. package/CHANGELOG.md +67 -1
  2. package/containers/healthcheck.mjs +27 -33
  3. package/containers/quadlet/cairnkeep.container +1 -1
  4. package/docs/containers.md +12 -0
  5. package/docs/domain-knowledge.md +15 -0
  6. package/docs/learning/CURRICULUM-MAP.md +1 -1
  7. package/docs/learning/FEATURE-GUIDE.md +1 -1
  8. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  9. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  10. package/docs/learning/lessons/L02-installation.md +2 -2
  11. package/docs/learning/lessons/L03-first-project.md +1 -1
  12. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  13. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  14. package/docs/learning/lessons/L21-context-packs.md +1 -1
  15. package/docs/learning/lessons/L22-native-windows.md +2 -2
  16. package/docs/learning/lessons/L23-guided-setup.md +8 -1
  17. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  18. package/docs/learning/lessons/L25-playbooks.md +1 -1
  19. package/docs/learning/lessons/L26-context-intelligence.md +1 -1
  20. package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
  21. package/docs/learning/lessons/L28-security-assurance.md +52 -2
  22. package/docs/learning/video-scripts/V28-security-assurance.md +45 -0
  23. package/docs/native-windows.md +10 -0
  24. package/docs/operating.md +19 -1
  25. package/docs/privacy-and-data-flow.md +6 -0
  26. package/docs/releasing.md +3 -2
  27. package/docs/security-assurance.md +67 -0
  28. package/examples/eval/bundled-fake.json +1 -1
  29. package/mcp-memory-server/dist/capability-registry.js +2 -15
  30. package/mcp-memory-server/dist/context-pack-retrieval.js +6 -6
  31. package/mcp-memory-server/dist/domain-retrieval-provider.js +100 -45
  32. package/mcp-memory-server/dist/eval-plan.js +15 -22
  33. package/mcp-memory-server/dist/eval-runner.js +17 -10
  34. package/mcp-memory-server/dist/eval-workspace.js +82 -23
  35. package/mcp-memory-server/dist/failure-signature.js +29 -8
  36. package/mcp-memory-server/dist/graph-cli.js +25 -10
  37. package/mcp-memory-server/dist/okf.js +4 -9
  38. package/mcp-memory-server/dist/playbook-receipt.js +3 -17
  39. package/mcp-memory-server/dist/skill-store.js +10 -26
  40. package/mcp-memory-server/dist/stable-file.js +85 -0
  41. package/mcp-memory-server/dist/work-evidence-store.js +7 -5
  42. package/omp/extensions/cairnkeep-capture.ts +29 -17
  43. package/package.json +1 -1
  44. package/scripts/lib/opencode-v2-plugin-harness.mjs +20 -3
  45. package/scripts/lib/stable-file.mjs +75 -0
  46. package/scripts/playbook-instructions.mjs +42 -20
  47. package/scripts/setup-core.mjs +5 -36
  48. package/scripts/setup-reconcile.mjs +32 -32
  49. package/scripts/setup.mjs +25 -34
  50. package/scripts/test-ci-sharding.sh +5 -0
  51. package/scripts/test-container-health.mjs +56 -0
  52. package/scripts/test-container.sh +8 -1
  53. package/scripts/test-learning-docs.sh +8 -0
  54. package/scripts/test-omp-staged-security.mjs +110 -0
  55. package/scripts/test-runtime-file-security.mjs +311 -0
  56. package/scripts/test-setup-diagnosis.mjs +97 -0
  57. package/scripts/test-windows-native.mjs +5 -0
  58. package/scripts/verify-omp-capture.mjs +1 -0
  59. package/scripts/windows-platform.mjs +54 -22
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
2
2
  import { spawn } from "node:child_process";
3
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { existsSync, lstatSync, mkdirSync, readdirSync } from "node:fs";
4
+ import { createRequire } from "node:module";
4
5
  import { join } from "node:path";
5
6
 
6
7
  // omp port of the claude/hooks memory-capture + memory-wakeup pair (D-03/D-04,
@@ -25,6 +26,7 @@ import { join } from "node:path";
25
26
  // Fail-open everywhere: every guard is a cheap check and every path is
26
27
  // try/caught so a throw can never reach the extension runner.
27
28
  const CAIRN_ROOT = "@@INFRA_ROOT@@";
29
+ const { readStableText } = createRequire(import.meta.url)(join(CAIRN_ROOT, "scripts", "lib", "stable-file.mjs"));
28
30
  const SERVER_ENTRY = join(CAIRN_ROOT, "mcp-memory-server", "dist", "index.js");
29
31
  const STAGE_ENTRY = join(CAIRN_ROOT, "scripts", "lib", "omp-capture-stage.mjs");
30
32
 
@@ -87,21 +89,36 @@ type StagedCandidate = {
87
89
 
88
90
  // Bounded staged-candidate scan: the STAGED_MAX_FILES most recent *.json
89
91
  // files, each read capped, malformed JSON skipped (fail-open).
90
- function stagedCandidates(repo: string): StagedCandidate[] {
92
+ function stagedFiles(repo: string): string[] {
93
+ const planning = join(repo, ".planning");
91
94
  const stagingDir = join(repo, ".planning", "memory-staging");
92
- if (!existsSync(stagingDir)) return [];
93
- const files = readdirSync(stagingDir)
94
- .filter((name) => name.endsWith(".json"))
95
- .map((name) => ({ name, mtime: statSync(join(stagingDir, name)).mtimeMs }))
96
- .sort((a, b) => b.mtime - a.mtime)
95
+ try {
96
+ for (const path of [planning, stagingDir]) {
97
+ const info = lstatSync(path);
98
+ if (!info.isDirectory() || info.isSymbolicLink()) return [];
99
+ }
100
+ } catch { return []; }
101
+ return readdirSync(stagingDir)
102
+ .filter((name) => /^[A-Za-z0-9][A-Za-z0-9._-]*\.json$/.test(name))
103
+ .flatMap((name) => {
104
+ try {
105
+ const info = lstatSync(join(stagingDir, name));
106
+ return info.isFile() && !info.isSymbolicLink() && info.size <= STAGED_FILE_READ_CAP
107
+ ? [{ name, mtime: info.mtimeMs }] : [];
108
+ } catch { return []; }
109
+ })
110
+ .sort((a, b) => b.mtime - a.mtime || a.name.localeCompare(b.name, "en"))
97
111
  .slice(0, STAGED_MAX_FILES)
98
112
  .map(({ name }) => name);
113
+ }
114
+
115
+ function stagedCandidates(repo: string): StagedCandidate[] {
116
+ const stagingDir = join(repo, ".planning", "memory-staging");
99
117
  const staged: StagedCandidate[] = [];
100
- for (const file of files) {
118
+ for (const file of stagedFiles(repo)) {
101
119
  try {
102
120
  const path = join(stagingDir, file);
103
- if (statSync(path).size > STAGED_FILE_READ_CAP) continue;
104
- const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
121
+ const parsed: unknown = JSON.parse(readStableText(path, { label: "Staged candidate", maxBytes: STAGED_FILE_READ_CAP }).text);
105
122
  const count = Array.isArray((parsed as { candidates?: unknown })?.candidates)
106
123
  ? ((parsed as { candidates: unknown[] }).candidates.length)
107
124
  : 0;
@@ -197,12 +214,7 @@ export default function cairnCaptureExtension(pi: ExtensionAPI): void {
197
214
  if (ctx.hasUI) ctx.ui.notify("No staged memory candidates (.planning/memory-staging is absent).", "info");
198
215
  return;
199
216
  }
200
- const files = readdirSync(stagingDir)
201
- .filter((name) => name.endsWith(".json"))
202
- .map((name) => ({ name, mtime: statSync(join(stagingDir, name)).mtimeMs }))
203
- .sort((a, b) => b.mtime - a.mtime)
204
- .slice(0, STAGED_MAX_FILES)
205
- .map(({ name }) => name);
217
+ const files = stagedFiles(ctx.cwd);
206
218
  if (files.length === 0) {
207
219
  if (ctx.hasUI) ctx.ui.notify("No staged memory candidates.", "info");
208
220
  return;
@@ -210,7 +222,7 @@ export default function cairnCaptureExtension(pi: ExtensionAPI): void {
210
222
  const sections = files.map((file) => {
211
223
  let body: string;
212
224
  try {
213
- body = readFileSync(join(stagingDir, file), "utf8").slice(0, STAGED_FILE_READ_CAP);
225
+ body = readStableText(join(stagingDir, file), { label: "Staged candidate", maxBytes: STAGED_FILE_READ_CAP }).text;
214
226
  } catch {
215
227
  body = "(unreadable)";
216
228
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnkeep/cli",
3
- "version": "2.22.2",
3
+ "version": "2.22.4",
4
4
  "description": "A durable, harness-agnostic memory + context layer for coding agents and MCP clients.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -76,6 +76,9 @@ await beforeHook({ ...editEvent, tool: "read", input: { filePath: join(repo, "ot
76
76
 
77
77
  const now = Date.now();
78
78
  let subscribed = false;
79
+ let streamFinished = false;
80
+ let finishStream;
81
+ const streamCompletion = new Promise((resolve) => { finishStream = resolve; });
79
82
  const captureContext = {
80
83
  location: { directory: repo },
81
84
  event: {
@@ -84,6 +87,11 @@ const captureContext = {
84
87
  assert.equal(signal instanceof AbortSignal, true);
85
88
  yield { type: "session.compacted", data: { sessionID: "v2-session" } };
86
89
  yield { type: "session.idle", data: { sessionID: "v2-session" } };
90
+ // File creation is not capture completion. Keep that distinction
91
+ // deterministic even when the child writer is unusually fast.
92
+ await new Promise((resolve) => setTimeout(resolve, 100));
93
+ streamFinished = true;
94
+ finishStream();
87
95
  },
88
96
  },
89
97
  session: {
@@ -125,10 +133,19 @@ const captureContext = {
125
133
  const cleanup = await capture.default.setup(captureContext);
126
134
  assert.equal(typeof cleanup, "function");
127
135
  const db = join(repo, ".agentfs", "trajectory.db");
128
- for (let attempt = 0; attempt < 100 && !existsSync(db); attempt += 1) {
129
- await new Promise((resolve) => setTimeout(resolve, 25));
136
+ let completionTimer;
137
+ try {
138
+ await Promise.race([
139
+ streamCompletion,
140
+ new Promise((_resolve, reject) => {
141
+ completionTimer = setTimeout(() => reject(new Error("V2 fixture capture did not complete within eight seconds")), 8000);
142
+ }),
143
+ ]);
144
+ } finally {
145
+ clearTimeout(completionTimer);
146
+ cleanup();
130
147
  }
131
- cleanup();
148
+ assert.equal(streamFinished, true, "fixture readers must wait for completed capture, not database creation");
132
149
  assert.equal(subscribed, true, "V2 capture must subscribe to the public event stream");
133
150
  assert.equal(existsSync(db), true, "V2 idle event must create a trajectory record");
134
151
 
@@ -0,0 +1,75 @@
1
+ import fs from "node:fs";
2
+ import { dirname } from "node:path";
3
+
4
+ function sameFile(left, right) {
5
+ return right.isFile() && !right.isSymbolicLink()
6
+ && left.dev === right.dev && left.ino === right.ino && left.size === right.size
7
+ && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs
8
+ && left.uid === right.uid && left.gid === right.gid && left.mode === right.mode;
9
+ }
10
+
11
+ // Recheck an already descriptor-inspected mutation target. This is a
12
+ // point-in-time guard, not an operating-system compare-and-swap primitive.
13
+ export function assertFileUnchanged(path, expected, label) {
14
+ try {
15
+ const named = fs.lstatSync(path);
16
+ if (expected && sameFile(expected, named)) return;
17
+ } catch (error) {
18
+ if (!expected && error?.code === "ENOENT") return;
19
+ }
20
+ throw new Error(`${label} changed or is unsafe before publication.`);
21
+ }
22
+
23
+ // Bootstrap utilities must work before the TypeScript server has been built.
24
+ // Keep the descriptor-first contract aligned with src/stable-file.ts.
25
+ export function readStableFile(path, { label, maxBytes, privateMode, nonExecutable = false }) {
26
+ let descriptor;
27
+ try {
28
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error();
29
+ descriptor = fs.openSync(path, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)
30
+ | (fs.constants.O_NOCTTY ?? 0) | (process.platform === "win32" ? 0 : fs.constants.O_NONBLOCK ?? 0));
31
+ const opened = fs.fstatSync(descriptor);
32
+ if (!opened.isFile() || opened.size > maxBytes) throw new Error();
33
+ if (process.platform !== "win32") {
34
+ if (nonExecutable && (opened.mode & 0o111) !== 0) throw new Error(`${label} has an unsafe executable mode.`);
35
+ if (privateMode !== undefined && ((opened.mode & 0o777) !== privateMode
36
+ || (typeof process.getuid === "function" && opened.uid !== process.getuid() && opened.uid !== 0))) throw new Error();
37
+ }
38
+ const parent = fs.lstatSync(dirname(path));
39
+ const named = fs.lstatSync(path);
40
+ if (named.isSymbolicLink() || parent.isSymbolicLink()) throw new Error(`${label} crosses a symlink.`);
41
+ if (!parent.isDirectory() || !sameFile(opened, named)) throw new Error();
42
+ const buffer = Buffer.allocUnsafe(opened.size + 1);
43
+ let offset = 0;
44
+ while (offset < buffer.byteLength) {
45
+ const count = fs.readSync(descriptor, buffer, offset, buffer.byteLength - offset, null);
46
+ if (count === 0) break;
47
+ offset += count;
48
+ }
49
+ const after = fs.fstatSync(descriptor);
50
+ const parentAfter = fs.lstatSync(dirname(path));
51
+ if (!sameFile(opened, after) || offset !== opened.size
52
+ || !parentAfter.isDirectory() || parentAfter.isSymbolicLink()
53
+ || parent.dev !== parentAfter.dev || parent.ino !== parentAfter.ino
54
+ || !sameFile(opened, fs.lstatSync(path))) throw new Error();
55
+ return { bytes: buffer.subarray(0, offset), stat: opened };
56
+ } catch (error) {
57
+ if (error instanceof Error && [`${label} crosses a symlink.`, `${label} has an unsafe executable mode.`].includes(error.message)) throw error;
58
+ if (error?.code === "ELOOP") throw new Error(`${label} crosses a symlink.`);
59
+ throw new Error(`${label} is unsafe, changed or unreadable.`);
60
+ } finally {
61
+ if (descriptor !== undefined) fs.closeSync(descriptor);
62
+ }
63
+ }
64
+
65
+ export function readStableText(path, options) {
66
+ const { bytes, stat } = readStableFile(path, options);
67
+ try { return { text: new TextDecoder("utf-8", { fatal: true }).decode(bytes), stat }; }
68
+ catch { throw new Error(`${options.label} contains invalid UTF-8.`); }
69
+ }
70
+
71
+ export function readStableJson(path, options) {
72
+ const { text } = readStableText(path, options);
73
+ try { return JSON.parse(text); }
74
+ catch { throw new Error(`${options.label} contains invalid JSON.`); }
75
+ }
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { randomBytes } from "node:crypto";
3
- import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { closeSync, existsSync, fchmodSync, fsyncSync, linkSync, lstatSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, constants } from "node:fs";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { readStableText, assertFileUnchanged } from "./lib/stable-file.mjs";
6
7
 
7
8
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8
9
  const TEMPLATE = join(ROOT, "templates", "playbook-agent-instructions.md.template");
@@ -11,17 +12,21 @@ const END = "<!-- cairnkeep:playbook:v1:end -->";
11
12
  const MAX_BYTES = 1024 * 1024;
12
13
 
13
14
  function safeRead(path) {
14
- const info = lstatSync(path);
15
- if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_BYTES) throw new Error("AGENTS.md is not a safe bounded regular file.");
16
- let descriptor;
17
- try {
18
- descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
19
- const opened = fstatSync(descriptor);
20
- if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino || opened.size > MAX_BYTES) throw new Error("AGENTS.md changed during validation.");
21
- return { text: readFileSync(descriptor, "utf8"), mode: info.mode & 0o777 };
22
- } finally {
23
- if (descriptor !== undefined) closeSync(descriptor);
15
+ const { text, stat } = readStableText(path, { label: "AGENTS.md", maxBytes: MAX_BYTES });
16
+ return { text, mode: stat.mode & 0o777, stat };
17
+ }
18
+
19
+ function safeRoot(projectRoot, allowMissing = false) {
20
+ const root = resolve(projectRoot);
21
+ let info;
22
+ try { info = lstatSync(root); }
23
+ catch (error) {
24
+ // Planning a new project must remain read-only before setup creates it.
25
+ if (allowMissing && error?.code === "ENOENT") return root;
26
+ throw error;
24
27
  }
28
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("Playbook project directory is unsafe.");
29
+ return root;
25
30
  }
26
31
 
27
32
  function count(text, marker) {
@@ -50,38 +55,55 @@ function removeBlock(current) {
50
55
  return `${current.slice(0, start)}${current.slice(end + END.length)}`.replace(/\n{3,}/g, "\n\n").trimEnd();
51
56
  }
52
57
 
53
- function atomicWrite(path, text, mode) {
58
+ function atomicWrite(path, text, mode, expected = null) {
59
+ const parent = lstatSync(dirname(path));
60
+ if (!parent.isDirectory() || parent.isSymbolicLink()) throw new Error("Instruction publication parent is unsafe.");
54
61
  const temporary = join(dirname(path), `.AGENTS.md.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
62
+ let descriptor;
63
+ let ownsTemporary = false;
55
64
  try {
56
- writeFileSync(temporary, text, { encoding: "utf8", mode, flag: "wx" });
57
- renameSync(temporary, path);
65
+ descriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), mode);
66
+ ownsTemporary = true;
67
+ writeFileSync(descriptor, text, { encoding: "utf8" });
68
+ fchmodSync(descriptor, mode);
69
+ fsyncSync(descriptor);
70
+ closeSync(descriptor);
71
+ descriptor = undefined;
72
+ const currentParent = lstatSync(dirname(path));
73
+ if (!currentParent.isDirectory() || currentParent.isSymbolicLink()
74
+ || parent.dev !== currentParent.dev || parent.ino !== currentParent.ino) throw new Error("Instruction publication parent changed.");
75
+ if (readStableText(temporary, { label: "Instruction publication", maxBytes: MAX_BYTES }).text !== text) throw new Error("Instruction publication changed.");
76
+ assertFileUnchanged(path, expected, "AGENTS.md");
77
+ if (expected) renameSync(temporary, path);
78
+ else linkSync(temporary, path);
58
79
  } finally {
59
- rmSync(temporary, { force: true });
80
+ if (descriptor !== undefined) closeSync(descriptor);
81
+ if (ownsTemporary) rmSync(temporary, { force: true });
60
82
  }
61
83
  }
62
84
 
63
85
  export function reconcilePlaybookInstructions(projectRoot = process.cwd(), options = {}) {
64
- const root = resolve(projectRoot);
86
+ const root = safeRoot(projectRoot, Boolean(options.check));
65
87
  const path = join(root, "AGENTS.md");
66
88
  if (dirname(path) !== root) throw new Error("AGENTS.md path escapes the project root.");
67
89
  const block = readFileSync(TEMPLATE, "utf8");
68
90
  const existing = existsSync(path) ? safeRead(path) : { text: "", mode: 0o644 };
69
91
  const desired = render(existing.text, block);
70
92
  const status = !existsSync(path) ? "created" : desired === existing.text ? "unchanged" : "updated";
71
- if (!options.check && status !== "unchanged") atomicWrite(path, desired, existing.mode || 0o644);
93
+ if (!options.check && status !== "unchanged") atomicWrite(path, desired, existing.mode || 0o644, existing.stat ?? null);
72
94
  return Object.freeze({ schema_version: 1, path, status: options.check && status !== "unchanged" ? `would-${status}` : status });
73
95
  }
74
96
 
75
97
  export function removePlaybookInstructions(projectRoot = process.cwd(), options = {}) {
76
- const root = resolve(projectRoot);
98
+ const root = safeRoot(projectRoot);
77
99
  const path = join(root, "AGENTS.md");
78
100
  if (!existsSync(path)) return Object.freeze({ schema_version: 1, path, status: "unchanged" });
79
101
  const existing = safeRead(path);
80
102
  const desired = removeBlock(existing.text);
81
103
  const status = desired === existing.text ? "unchanged" : "removed";
82
104
  if (!options.check && status === "removed") {
83
- if (desired.trim()) atomicWrite(path, `${desired}\n`, existing.mode || 0o644);
84
- else rmSync(path);
105
+ if (desired.trim()) atomicWrite(path, `${desired}\n`, existing.mode || 0o644, existing.stat);
106
+ else { assertFileUnchanged(path, existing.stat, "AGENTS.md"); rmSync(path); }
85
107
  }
86
108
  return Object.freeze({ schema_version: 1, path, status: options.check && status === "removed" ? "would-remove" : status });
87
109
  }
@@ -1,9 +1,10 @@
1
- import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readdirSync, realpathSync } from "node:fs";
1
+ import { constants, existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
2
2
  import { accessSync } from "node:fs";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { dirname, join, parse, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { HARNESS_IDS, harnessProjectAssets } from "./harness-registry.mjs";
7
+ import { readStableJson } from "./lib/stable-file.mjs";
7
8
 
8
9
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
9
10
  const TEMPLATE_ROOT = join(ROOT, "templates");
@@ -111,45 +112,13 @@ function parsePolicy(value) {
111
112
  return deepFreeze(policy);
112
113
  }
113
114
 
114
- export function readSetupPolicy(path, options = {}) {
115
- const lstat = options.lstat ?? lstatSync;
116
- const read = options.readFile ?? readFileSync;
117
- let info;
118
- try {
119
- info = lstat(path);
120
- } catch (error) {
121
- throw operational("unsafe-policy", `Setup policy could not be read: ${error instanceof Error ? error.message : String(error)}.`);
122
- }
123
- if (!info.isFile() || info.isSymbolicLink()) throw operational("unsafe-policy", "Setup policy must be a regular file, not a symbolic link or device.");
124
- if (info.size > POLICY_LIMIT_BYTES) throw operational("unsafe-policy", "Setup policy exceeds the size limit.");
125
- if (process.platform !== "win32" && (info.mode & 0o111) !== 0) throw operational("unsafe-policy", "Setup policy must not be executable.");
126
- let descriptor;
127
- let bytes;
115
+ export function readSetupPolicy(path) {
128
116
  try {
129
- if (options.readFile) bytes = read(path);
130
- else {
131
- descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
132
- const opened = fstatSync(descriptor);
133
- if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino) throw operational("unsafe-policy", "Setup policy changed during validation.");
134
- if (opened.size > POLICY_LIMIT_BYTES || (process.platform !== "win32" && (opened.mode & 0o111) !== 0)) {
135
- throw operational("unsafe-policy", "Setup policy type, size, or executable mode is unsafe.");
136
- }
137
- bytes = read(descriptor);
138
- }
117
+ return parsePolicy(readStableJson(path, { label: "Setup policy", maxBytes: POLICY_LIMIT_BYTES, nonExecutable: true }));
139
118
  } catch (error) {
140
119
  if (error instanceof SetupInputError) throw error;
141
- throw operational("unsafe-policy", `Setup policy could not be opened safely: ${error instanceof Error ? error.message : String(error)}.`);
142
- } finally {
143
- if (descriptor !== undefined) closeSync(descriptor);
144
- }
145
- if (bytes.byteLength > POLICY_LIMIT_BYTES) throw operational("unsafe-policy", "Setup policy exceeds the size limit.");
146
- let parsed;
147
- try {
148
- parsed = JSON.parse(bytes.toString("utf8"));
149
- } catch {
150
- throw operational("unsafe-policy", "Invalid setup policy JSON syntax.");
120
+ throw operational("unsafe-policy", error instanceof Error ? error.message : "Setup policy is unsafe or unreadable.");
151
121
  }
152
- return parsePolicy(parsed);
153
122
  }
154
123
 
155
124
  function parseHarnesses(raw) {
@@ -1,22 +1,19 @@
1
1
  import { createHash, randomBytes, randomUUID } from "node:crypto";
2
2
  import {
3
3
  chmodSync,
4
- closeSync,
5
- constants,
6
4
  existsSync,
7
- fstatSync,
8
5
  lstatSync,
9
6
  mkdirSync,
10
- openSync,
11
- readFileSync,
12
7
  rmSync,
13
- statSync,
14
8
  writeFileSync,
15
9
  } from "node:fs";
16
10
  import { open, rename } from "node:fs/promises";
17
11
  import { spawnSync } from "node:child_process";
18
12
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
19
13
  import { HARNESS_IDS } from "./harness-registry.mjs";
14
+ import { readStableFile, readStableJson, assertFileUnchanged } from "./lib/stable-file.mjs";
15
+
16
+ const MAX_SETUP_BYTES = 1024 * 1024;
20
17
 
21
18
  export const SETUP_STATE_SCHEMA_VERSION = 1;
22
19
 
@@ -218,41 +215,37 @@ function hardenPrivatePath(path) {
218
215
  if (result.status !== 0) throw new Error("Unable to restrict private setup state.");
219
216
  }
220
217
 
221
- async function atomicWrite(path, bytes, mode, atomicReplace = defaultAtomicReplace, privateState = false) {
218
+ async function atomicWrite(path, bytes, mode, atomicReplace = defaultAtomicReplace, privateState = false, expected = null) {
219
+ const parent = lstatSync(dirname(path));
220
+ if (!parent.isDirectory() || parent.isSymbolicLink()) throw new Error("Setup publication parent is unsafe.");
222
221
  const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
223
222
  let handle;
223
+ let ownsTemporary = false;
224
224
  try {
225
225
  handle = await open(temporary, "wx", mode);
226
+ ownsTemporary = true;
226
227
  await handle.writeFile(bytes);
228
+ await handle.chmod(mode);
227
229
  await handle.sync();
228
230
  await handle.close();
229
231
  handle = undefined;
230
- chmodSync(temporary, mode);
231
232
  if (privateState) hardenPrivatePath(temporary);
233
+ const currentParent = lstatSync(dirname(path));
234
+ if (!currentParent.isDirectory() || currentParent.isSymbolicLink()
235
+ || parent.dev !== currentParent.dev || parent.ino !== currentParent.ino) throw new Error("Setup publication parent changed.");
236
+ if (!readStableFile(temporary, { label: "Setup publication", maxBytes: MAX_SETUP_BYTES }).bytes.equals(bytes)) throw new Error("Setup publication changed.");
237
+ assertFileUnchanged(path, expected, "Setup target");
232
238
  await atomicReplace(temporary, path);
233
- chmodSync(path, mode);
234
239
  if (privateState) hardenPrivatePath(path);
235
240
  } finally {
236
241
  if (handle) await handle.close().catch(() => undefined);
237
- rmSync(temporary, { force: true });
242
+ if (ownsTemporary) rmSync(temporary, { force: true });
238
243
  }
239
244
  }
240
245
 
241
246
  function readPriorState(statePath) {
242
247
  if (!existsSync(statePath)) return null;
243
- const info = lstatSync(statePath);
244
- if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) throw new Error("Existing setup state is unsafe.");
245
- let descriptor;
246
- try {
247
- descriptor = openSync(statePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
248
- const opened = fstatSync(descriptor);
249
- if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino || opened.size > 1024 * 1024) {
250
- throw new Error("Existing setup state changed during validation.");
251
- }
252
- return normalizeState(JSON.parse(readFileSync(descriptor, "utf8")));
253
- } finally {
254
- if (descriptor !== undefined) closeSync(descriptor);
255
- }
248
+ return normalizeState(readStableJson(statePath, { label: "Existing setup state", maxBytes: MAX_SETUP_BYTES }));
256
249
  }
257
250
 
258
251
  export async function writeSetupStateAtomic(path, state, options = {}) {
@@ -265,8 +258,13 @@ export async function writeSetupStateAtomic(path, state, options = {}) {
265
258
  safeMkdirChain(target, directory);
266
259
  hardenPrivatePath(directory);
267
260
  const bytes = Buffer.from(`${JSON.stringify(normalized, null, 2)}\n`, "utf8");
268
- if (existsSync(path) && readFileSync(path).equals(bytes) && (statSync(path).mode & 0o777) === 0o600) return normalized;
269
- await atomicWrite(path, bytes, 0o600, options.atomicReplace ?? defaultAtomicReplace, true);
261
+ let expected = null;
262
+ if (existsSync(path)) {
263
+ const current = readStableFile(path, { label: "Existing setup state", maxBytes: MAX_SETUP_BYTES });
264
+ if (current.bytes.equals(bytes) && (current.stat.mode & 0o777) === 0o600) return normalized;
265
+ expected = current.stat;
266
+ }
267
+ await atomicWrite(path, bytes, 0o600, options.atomicReplace ?? defaultAtomicReplace, true, expected);
270
268
  return normalized;
271
269
  }
272
270
 
@@ -292,20 +290,22 @@ export async function reconcileSetupPlan(plan, options = {}) {
292
290
 
293
291
  const decisions = selected.map((asset) => {
294
292
  if (!existsSync(asset.destination)) return { ...asset, status: "created" };
295
- const currentInfo = lstatSync(asset.destination);
296
- if (!currentInfo.isFile() || currentInfo.isSymbolicLink()) throw new Error(`Unsafe setup asset destination: ${asset.path}.`);
297
- const currentDigest = hashSetupAsset(readFileSync(asset.destination));
298
- const currentMode = currentInfo.mode & 0o777;
299
- if (currentDigest === asset.digest && currentMode === asset.mode) return { ...asset, status: "unchanged" };
293
+ const current = readStableFile(asset.destination, { label: "Setup asset", maxBytes: MAX_SETUP_BYTES });
294
+ const currentDigest = hashSetupAsset(current.bytes);
295
+ const currentMode = current.stat.mode & 0o777;
296
+ // Windows mode bits do not represent POSIX executability. Match diagnosis:
297
+ // retain digest ownership there, while writers enforce private state ACLs.
298
+ const modeMatches = (mode) => process.platform === "win32" || currentMode === mode;
299
+ if (currentDigest === asset.digest && modeMatches(asset.mode)) return { ...asset, status: "unchanged" };
300
300
  const prior = previousState?.assets?.[asset.path];
301
- if (prior && prior.digest === currentDigest && prior.mode === currentMode) return { ...asset, status: "updated" };
301
+ if (prior && prior.digest === currentDigest && modeMatches(prior.mode)) return { ...asset, status: "updated", expected: current.stat };
302
302
  return { ...asset, status: "skipped" };
303
303
  });
304
304
 
305
305
  for (const decision of decisions) {
306
306
  if (decision.status !== "created" && decision.status !== "updated") continue;
307
307
  safeMkdirChain(target, dirname(decision.destination));
308
- await atomicWrite(decision.destination, decision.bytes, decision.mode, options.atomicReplace ?? defaultAtomicReplace, decision.mode === 0o600);
308
+ await atomicWrite(decision.destination, decision.bytes, decision.mode, options.atomicReplace ?? defaultAtomicReplace, decision.mode === 0o600, decision.expected ?? null);
309
309
  }
310
310
 
311
311
  const assets = {};
package/scripts/setup.mjs CHANGED
@@ -1,13 +1,8 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import {
3
- closeSync,
4
- constants,
5
3
  existsSync,
6
- fstatSync,
7
4
  lstatSync,
8
5
  mkdirSync,
9
- openSync,
10
- readFileSync,
11
6
  } from "node:fs";
12
7
  import { createHash } from "node:crypto";
13
8
  import { homedir } from "node:os";
@@ -25,6 +20,7 @@ import { reconcileSetupPlan } from "./setup-reconcile.mjs";
25
20
  import { HARNESS_IDS, HARNESS_REGISTRY, machineSyncCommand, machineSyncCommands, requiredHarnessAssetPaths } from "./harness-registry.mjs";
26
21
  import { reconcilePlaybookInstructions } from "./playbook-instructions.mjs";
27
22
  import { selectManyPrompt, selectOnePrompt, supportsTerminalPrompts } from "./terminal-prompts.mjs";
23
+ import { readStableFile, readStableJson, readStableText } from "./lib/stable-file.mjs";
28
24
 
29
25
  const HARNESSES = HARNESS_IDS;
30
26
  const GIT_MODES = Object.freeze(["init", "existing", "none"]);
@@ -344,34 +340,30 @@ function safeStateAsset(path, record) {
344
340
  && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(record.template);
345
341
  }
346
342
 
347
- function readPrivateState(path) {
348
- const info = lstatSync(path);
349
- if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) throw new Error("unsafe");
350
- if (process.platform !== "win32" && (info.mode & 0o777) !== 0o600) throw new Error("unsafe");
351
- let descriptor;
352
- try {
353
- descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
354
- const opened = fstatSync(descriptor);
355
- if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino || opened.size > 1024 * 1024) throw new Error("unsafe");
356
- return JSON.parse(readFileSync(descriptor, "utf8"));
357
- } finally {
358
- if (descriptor !== undefined) closeSync(descriptor);
343
+ function safeRequiredAsset(project, path, launcher) {
344
+ // Presence is not ownership: setup intentionally skips pre-existing custom
345
+ // files. Validate the selected asset without adopting or reading its content.
346
+ const parts = path.split("/");
347
+ let destination = project;
348
+ for (const part of parts.slice(0, -1)) {
349
+ destination = join(destination, part);
350
+ const parent = lstatSync(destination);
351
+ if (!parent.isDirectory() || parent.isSymbolicLink()) return false;
359
352
  }
353
+ const info = lstatSync(join(destination, parts.at(-1)));
354
+ return info.isFile() && !info.isSymbolicLink()
355
+ && (!launcher || process.platform === "win32" || (info.mode & 0o100) !== 0);
356
+ }
357
+
358
+ function readPrivateState(path) {
359
+ return readStableJson(path, { label: "Setup state", maxBytes: 1024 * 1024, privateMode: 0o600 });
360
360
  }
361
361
 
362
362
  function readTomlConfig(path) {
363
363
  if (!existsSync(path)) return null;
364
- const info = lstatSync(path);
365
- if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) return null;
366
- let descriptor;
367
364
  try {
368
- descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
369
- const opened = fstatSync(descriptor);
370
- if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino || opened.size > 1024 * 1024) return null;
371
- return readFileSync(descriptor, "utf8");
372
- } finally {
373
- if (descriptor !== undefined) closeSync(descriptor);
374
- }
365
+ return readStableText(path, { label: "Harness configuration", maxBytes: 1024 * 1024 }).text;
366
+ } catch { return null; }
375
367
  }
376
368
 
377
369
  function codexMemorySection(text, id) {
@@ -435,21 +427,20 @@ export function diagnoseSetup(target = ".") {
435
427
  || state.harnesses.some((name) => !HARNESSES.includes(name))
436
428
  || !state.assets
437
429
  || typeof state.assets !== "object"
438
- || Array.isArray(state.assets)
439
- || Object.keys(state.assets).length === 0) return incompleteDiagnosis();
430
+ || Array.isArray(state.assets)) return incompleteDiagnosis();
440
431
 
441
432
  const recovery = `cairn setup . --git ${state.git} --harness ${state.harnesses.join(",")} --memory ${state.memory} --yes`;
442
433
  for (const [path, record] of Object.entries(state.assets)) {
443
434
  if (!safeStateAsset(path, record)) return incompleteDiagnosis(recovery);
444
435
  const destination = join(project, ...path.split("/"));
445
436
  if (!existsSync(destination)) return incompleteDiagnosis(recovery);
446
- const info = lstatSync(destination);
447
- if (!info.isFile() || info.isSymbolicLink()) return incompleteDiagnosis(recovery);
448
- const digest = createHash("sha256").update(readFileSync(destination)).digest("hex");
449
- if (digest !== record.digest || (process.platform !== "win32" && (info.mode & 0o777) !== record.mode)) return incompleteDiagnosis(recovery);
437
+ const current = readStableFile(destination, { label: "Setup asset", maxBytes: 1024 * 1024 });
438
+ const digest = createHash("sha256").update(current.bytes).digest("hex");
439
+ if (digest !== record.digest || (process.platform !== "win32" && (current.stat.mode & 0o777) !== record.mode)) return incompleteDiagnosis(recovery);
450
440
  }
451
441
  const requiredAssets = [...COMMON_SETUP_ASSETS, ...requiredHarnessAssetPaths(state.harnesses, state.memory)];
452
- if (requiredAssets.some((path) => !Object.hasOwn(state.assets, path))) return incompleteDiagnosis(recovery);
442
+ const launchers = new Set(state.harnesses.map((id) => HARNESS_REGISTRY.find((harness) => harness.id === id).launcher.path));
443
+ if (requiredAssets.some((path) => !safeRequiredAsset(project, path, launchers.has(path)))) return incompleteDiagnosis(recovery);
453
444
  if (state.memory === "local" && state.harnesses.includes("codex")) {
454
445
  if (!hasCodexMemoryConfig(project)) return incompleteDiagnosis(recovery);
455
446
  if (hasCodexRemoteMemoryConflict(project)) {
@@ -29,5 +29,10 @@ for (const shard of ["1/3", "2/3", "3/3"]) assert.ok(workflow.includes(`shard: "
29
29
  assert.match(workflow, /run-smoke-suite\.mjs --shard \$\{\{ matrix\.shard \}\}/);
30
30
  assert.doesNotMatch(workflow.match(/ native-windows:[\s\S]*?\n repository:/)?.[0] ?? "",
31
31
  /npm --prefix mcp-memory-server test/);
32
+ const native = workflow.match(/ native-windows:[\s\S]*?\n repository:/)?.[0] ?? "";
33
+ const focused = native.indexOf("run: node mcp-memory-server/scripts/smoke-bounded-input-reads.mjs");
34
+ assert.ok(focused > native.indexOf("npm --prefix mcp-memory-server run build"));
35
+ assert.ok(focused < native.indexOf("- name: Native Windows CLI contracts"));
36
+ assert.ok(focused < native.indexOf("- name: Sharded memory-server contracts"));
32
37
  console.log(`PASS: ${smokeChecks.length} memory-server contracts split once across balanced Windows shards (${weights.join(", ")})`);
33
38
  NODE