@cairnkeep/cli 2.9.0 → 2.11.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 (78) hide show
  1. package/CHANGELOG.md +46 -1
  2. package/README.md +54 -25
  3. package/bin/cairn +6 -165
  4. package/bin/cairn-container +8 -0
  5. package/docs/building-an-overlay.md +41 -4
  6. package/docs/containers.md +10 -0
  7. package/docs/harness-compatibility.md +30 -2
  8. package/docs/learning/CURRICULUM-MAP.md +9 -3
  9. package/docs/learning/FEATURE-GUIDE.md +9 -3
  10. package/docs/learning/README.md +2 -0
  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 +11 -2
  14. package/docs/learning/lessons/L03-first-project.md +1 -1
  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 +138 -0
  20. package/docs/learning/tracks/operator.md +8 -3
  21. package/docs/learning/tracks/quickstart.md +7 -3
  22. package/docs/native-windows.md +122 -0
  23. package/docs/operating.md +119 -18
  24. package/docs/privacy-and-data-flow.md +34 -1
  25. package/docs/storage.md +14 -2
  26. package/examples/eval/bundled-fake.json +1 -1
  27. package/mcp-memory-server/dist/artifact-store-windows-worker.js +74 -0
  28. package/mcp-memory-server/dist/artifact-store.js +196 -55
  29. package/mcp-memory-server/dist/capability-cli.js +2 -1
  30. package/mcp-memory-server/dist/capability-config.js +4 -3
  31. package/mcp-memory-server/dist/capability-harness.js +34 -7
  32. package/mcp-memory-server/dist/context-pack.js +84 -25
  33. package/mcp-memory-server/dist/eval-cli.js +6 -5
  34. package/mcp-memory-server/dist/eval-plan.js +35 -13
  35. package/mcp-memory-server/dist/eval-process.js +11 -4
  36. package/mcp-memory-server/dist/eval-report.js +7 -5
  37. package/mcp-memory-server/dist/graph-cli.js +7 -1
  38. package/mcp-memory-server/dist/index.js +19 -20
  39. package/mcp-memory-server/dist/mcp-tool-profile.js +6 -4
  40. package/mcp-memory-server/dist/note-store.js +12 -3
  41. package/mcp-memory-server/dist/pi-mcp-bridge.js +185 -0
  42. package/mcp-memory-server/dist/platform-security.js +102 -0
  43. package/mcp-memory-server/dist/skill-store.js +27 -12
  44. package/mcp-memory-server/package.json +2 -1
  45. package/package.json +5 -4
  46. package/pi/extensions/cairnkeep-memory.ts +61 -0
  47. package/schemas/cairnkeep-setup-policy.schema.json +55 -0
  48. package/schemas/cairnkeep-setup.schema.json +36 -0
  49. package/scripts/cairn-cli.mjs +119 -0
  50. package/scripts/cairn-container-cli.mjs +121 -0
  51. package/scripts/cairn-hook.mjs +199 -0
  52. package/scripts/completion.sh +12 -3
  53. package/scripts/doctor.sh +57 -0
  54. package/scripts/fix-permissions.mjs +12 -0
  55. package/scripts/memory-wiki-audit.mjs +95 -0
  56. package/scripts/phase26-test-manifest.mjs +26 -0
  57. package/scripts/run-repository-tests.mjs +76 -0
  58. package/scripts/setup-core.mjs +310 -0
  59. package/scripts/setup-reconcile.mjs +331 -0
  60. package/scripts/setup.mjs +306 -0
  61. package/scripts/sync-pi-assets.sh +19 -4
  62. package/scripts/test-completion.sh +39 -0
  63. package/scripts/test-doctor.sh +66 -0
  64. package/scripts/test-learning-docs.sh +52 -3
  65. package/scripts/test-package-install.sh +64 -0
  66. package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
  67. package/scripts/test-phase18-capability-lifecycle.sh +1 -1
  68. package/scripts/test-pi-lifecycle.mjs +190 -0
  69. package/scripts/test-setup-compatibility.mjs +186 -0
  70. package/scripts/test-setup-output.sh +131 -0
  71. package/scripts/test-setup-overlay.sh +113 -0
  72. package/scripts/test-setup-preflight.mjs +281 -0
  73. package/scripts/test-setup-reconcile.mjs +257 -0
  74. package/scripts/test-uninstall.sh +50 -0
  75. package/scripts/test-windows-native.mjs +136 -0
  76. package/scripts/uninstall.sh +69 -6
  77. package/scripts/verify-pi-mcp-bridge.mjs +588 -0
  78. package/scripts/windows-platform.mjs +725 -0
@@ -7,6 +7,7 @@ import { isEvalEnabled, loadEvalPlan } from "./eval-plan.js";
7
7
  import { diagnoseEvalReport, readEvalReport, renderEvalReport, } from "./eval-report.js";
8
8
  import { buildAblationArms, runCapabilityAblation, runTwoPassExperiment, } from "./eval-runner.js";
9
9
  import { canonicalJson, EVAL_SCHEMA_VERSION } from "./eval-schema.js";
10
+ import { privatePathIsSafe } from "./platform-security.js";
10
11
  process.stdout.on("error", (error) => {
11
12
  if (error.code === "EPIPE")
12
13
  process.exit(0);
@@ -126,7 +127,7 @@ async function evalRoot() {
126
127
  return null;
127
128
  throw error;
128
129
  }
129
- if (info.isSymbolicLink() || !info.isDirectory() || (info.mode & 0o077) !== 0) {
130
+ if (info.isSymbolicLink() || !info.isDirectory() || !privatePathIsSafe(root)) {
130
131
  throw new Error("unsafe_eval_root");
131
132
  }
132
133
  const resolvedRoot = await realpath(root);
@@ -185,7 +186,7 @@ async function diagnoseProjectReports(projectRoot) {
185
186
  }
186
187
  if (info.isSymbolicLink() || !info.isDirectory() || await realpath(directory) !== directory)
187
188
  return "unsafe";
188
- if (directory === root && (info.mode & 0o077) !== 0)
189
+ if (directory === root && !privatePathIsSafe(directory))
189
190
  return "unsafe";
190
191
  }
191
192
  const entries = await readdir(root, { withFileTypes: true });
@@ -201,7 +202,7 @@ async function diagnoseProjectReports(projectRoot) {
201
202
  if (!isContained(root, experimentPath) || experimentPath === root)
202
203
  return "unsafe";
203
204
  const experimentInfo = await lstat(experimentPath);
204
- if (experimentInfo.isSymbolicLink() || !experimentInfo.isDirectory() || (experimentInfo.mode & 0o077) !== 0
205
+ if (experimentInfo.isSymbolicLink() || !experimentInfo.isDirectory() || !privatePathIsSafe(experimentPath)
205
206
  || await realpath(experimentPath) !== experimentPath) {
206
207
  return "unsafe";
207
208
  }
@@ -247,7 +248,7 @@ async function safeReportStore(experimentId) {
247
248
  throw new Error("eval_report_not_found");
248
249
  throw error;
249
250
  }
250
- if (directory.isSymbolicLink() || !directory.isDirectory() || (directory.mode & 0o077) !== 0
251
+ if (directory.isSymbolicLink() || !directory.isDirectory() || !privatePathIsSafe(experimentPath)
251
252
  || await realpath(experimentPath) !== experimentPath) {
252
253
  throw new Error("unsafe_experiment_path");
253
254
  }
@@ -261,7 +262,7 @@ async function safeReportStore(experimentId) {
261
262
  throw new Error("eval_report_not_found");
262
263
  throw error;
263
264
  }
264
- if (reportInfo.isSymbolicLink() || !reportInfo.isFile() || (reportInfo.mode & 0o077) !== 0
265
+ if (reportInfo.isSymbolicLink() || !reportInfo.isFile() || !privatePathIsSafe(reportPath)
265
266
  || reportInfo.size > DEFAULT_REPORT_BYTES) {
266
267
  throw new Error("unsafe_eval_report");
267
268
  }
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { constants } from "node:fs";
3
3
  import { accessSync, closeSync, existsSync, fstatSync, lstatSync, openSync, readFileSync, realpathSync, } from "node:fs";
4
- import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { basename, delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { CAPABILITY_IDS } from "./capability-schema.js";
7
7
  import { EVAL_SCHEMA_VERSION, canonicalDigest, canonicalJson, evalAdapterConfigSchema, evalTaskSetSchema, } from "./eval-schema.js";
@@ -16,6 +16,19 @@ function isContained(root, candidate) {
16
16
  const rel = relative(root, candidate);
17
17
  return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
18
18
  }
19
+ function isExistingPathContained(root, candidate) {
20
+ const rootInfo = lstatSync(root);
21
+ let current = realpathSync(candidate);
22
+ for (;;) {
23
+ const currentInfo = lstatSync(current);
24
+ if (currentInfo.dev === rootInfo.dev && currentInfo.ino === rootInfo.ino)
25
+ return true;
26
+ const parent = dirname(current);
27
+ if (parent === current)
28
+ return false;
29
+ current = parent;
30
+ }
31
+ }
19
32
  function assertRealDirectory(path, label) {
20
33
  const info = lstatSync(path);
21
34
  if (info.isSymbolicLink() || !info.isDirectory())
@@ -68,12 +81,16 @@ function resolveExactCommit(repository, revision) {
68
81
  return resolved;
69
82
  }
70
83
  function parseCommittedTaskSet(taskSetPath) {
71
- const manifestRepository = assertRealDirectory(git(dirname(taskSetPath), ["rev-parse", "--show-toplevel"], "Resolve task-set repository"), "Task-set repository");
72
- if (!isContained(manifestRepository, taskSetPath))
84
+ const manifestDirectory = dirname(taskSetPath);
85
+ const manifestRepository = assertRealDirectory(git(manifestDirectory, ["rev-parse", "--show-toplevel"], "Resolve task-set repository"), "Task-set repository");
86
+ if (!isExistingPathContained(manifestRepository, taskSetPath)) {
73
87
  throw new Error("The task-set manifest must be contained by its Git repository.");
74
- const manifestPath = relative(manifestRepository, taskSetPath).split(sep).join("/");
75
- if (!manifestPath || manifestPath.startsWith("../"))
88
+ }
89
+ const manifestPrefix = git(manifestDirectory, ["rev-parse", "--show-prefix"], "Resolve task-set repository prefix");
90
+ const manifestPath = `${manifestPrefix}${basename(taskSetPath)}`.split("\\").join("/");
91
+ if (!manifestPath || manifestPath.startsWith("../") || isAbsolute(manifestPath)) {
76
92
  throw new Error("The task-set manifest path is invalid.");
93
+ }
77
94
  git(manifestRepository, ["ls-files", "--error-unmatch", "--", manifestPath], "Require a tracked evaluation task set");
78
95
  const dirty = git(manifestRepository, ["status", "--porcelain", "--untracked-files=all", "--", manifestPath], "Check evaluation task set status");
79
96
  if (dirty !== "")
@@ -111,15 +128,20 @@ function resolvePathProgram(program, base) {
111
128
  function resolveProgram(program, base) {
112
129
  if (isAbsolute(program) || program.includes("/") || program.includes("\\"))
113
130
  return resolvePathProgram(program, base);
131
+ const names = process.platform === "win32" && extname(program) === ""
132
+ ? [program, ...[".EXE", ".COM"].map((extension) => `${program}${extension}`)]
133
+ : [program];
114
134
  for (const directory of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) {
115
- const candidate = join(directory, program);
116
- try {
117
- accessSync(candidate, constants.X_OK);
118
- if (lstatSync(candidate).isFile() || lstatSync(candidate).isSymbolicLink())
119
- return realpathSync(candidate);
120
- }
121
- catch {
122
- // Continue through PATH without executing the candidate.
135
+ for (const name of names) {
136
+ const candidate = join(directory, name);
137
+ try {
138
+ accessSync(candidate, constants.X_OK);
139
+ if (lstatSync(candidate).isFile() || lstatSync(candidate).isSymbolicLink())
140
+ return realpathSync(candidate);
141
+ }
142
+ catch {
143
+ // Continue through PATH without executing the candidate.
144
+ }
123
145
  }
124
146
  }
125
147
  throw new Error(`Evaluation program was not found on PATH: ${program}`);
@@ -1,4 +1,4 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawn, spawnSync } from "node:child_process";
2
2
  import { evalAdapterRequestSchema, evalAdapterResultSchema, } from "./eval-schema.js";
3
3
  export const EVAL_PROCESS_ERROR_CODES = [
4
4
  "spawn_error",
@@ -33,7 +33,7 @@ export class EvalProcessError extends Error {
33
33
  const DEFAULT_KILL_GRACE_MS = 1_000;
34
34
  const MAX_TIMER_MS = 2_147_483_647;
35
35
  function terminationScope() {
36
- return process.platform === "win32" ? "child" : "process-group";
36
+ return process.platform === "win32" ? "process-tree" : "process-group";
37
37
  }
38
38
  function assertOptions(options) {
39
39
  if (!Number.isSafeInteger(options.timeout_ms) || options.timeout_ms <= 0 || options.timeout_ms > MAX_TIMER_MS) {
@@ -59,7 +59,9 @@ function signalChild(child, signal) {
59
59
  process.kill(-child.pid, signal);
60
60
  return true;
61
61
  }
62
- return child.kill(signal);
62
+ const args = ["/PID", String(child.pid), "/T", ...(signal === "SIGKILL" ? ["/F"] : [])];
63
+ const result = spawnSync("taskkill.exe", args, { stdio: "ignore", windowsHide: true });
64
+ return result.status === 0 || child.kill(signal);
63
65
  }
64
66
  catch {
65
67
  try {
@@ -90,7 +92,12 @@ async function runCommandInternal(options) {
90
92
  let exitSignal = null;
91
93
  let graceTimer;
92
94
  let closeTimer;
93
- const child = spawn(options.command.program, options.command.args, {
95
+ // A shebang does not make a JavaScript file directly executable on
96
+ // Windows. Adapter contracts may point at JS programs, so launch
97
+ // those through the current runtime while preserving the declared
98
+ // command for hashing and provenance.
99
+ const javascriptProgram = /\.(?:cjs|mjs|js)$/i.test(options.command.program);
100
+ const child = spawn(javascriptProgram ? process.execPath : options.command.program, [...(javascriptProgram ? [options.command.program] : []), ...options.command.args], {
94
101
  cwd: options.cwd,
95
102
  env: options.env,
96
103
  shell: false,
@@ -1,9 +1,10 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
- import { chmod, lstat, mkdir, open, readdir, realpath, rename, rm, } from "node:fs/promises";
3
+ import { lstat, mkdir, open, readdir, realpath, rename, rm, } from "node:fs/promises";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { canonicalDigest, canonicalJson, evalAggregateSchema, evalReportSchema, } from "./eval-schema.js";
6
6
  import { computeDifferenceInDifferences, computePairedEstimate, } from "./eval-statistics.js";
7
+ import { hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
7
8
  const DEFAULT_MAX_REPORT_BYTES = 16 * 1024 * 1024;
8
9
  const MAX_ALLOWED_REPORT_BYTES = 64 * 1024 * 1024;
9
10
  const DEFAULT_MAX_EXPERIMENTS = 10_000;
@@ -36,7 +37,7 @@ async function ensureSafeDirectory(path, privateMode = false) {
36
37
  if (info.isSymbolicLink() || !info.isDirectory())
37
38
  throw new Error("unsafe_report_directory");
38
39
  if (privateMode)
39
- await chmod(path, 0o700);
40
+ hardenPrivatePath(path);
40
41
  return;
41
42
  }
42
43
  catch (error) {
@@ -48,7 +49,7 @@ async function ensureSafeDirectory(path, privateMode = false) {
48
49
  throw new Error("unsafe_report_directory");
49
50
  await ensureSafeDirectory(parent, false);
50
51
  await mkdir(path, { mode: 0o700 });
51
- await chmod(path, 0o700);
52
+ hardenPrivatePath(path);
52
53
  }
53
54
  function assertLimits(maxReportBytes, maxExperiments) {
54
55
  if (!Number.isSafeInteger(maxReportBytes) || maxReportBytes < 1 || maxReportBytes > MAX_ALLOWED_REPORT_BYTES) {
@@ -206,8 +207,9 @@ export async function checkpointEvalReport(store, report, options = {}) {
206
207
  await handle.close();
207
208
  handle = undefined;
208
209
  injectFault(options, "after_close");
210
+ hardenPrivatePath(temporary);
209
211
  await rename(temporary, store.report_path);
210
- await chmod(store.report_path, 0o600);
212
+ hardenPrivatePath(store.report_path);
211
213
  injectFault(options, "after_rename");
212
214
  }
213
215
  finally {
@@ -239,7 +241,7 @@ export async function diagnoseEvalReport(store) {
239
241
  });
240
242
  if (!info)
241
243
  return { state: "absent", code: "report_absent" };
242
- if (info.isSymbolicLink() || !info.isFile() || (info.mode & 0o077) !== 0) {
244
+ if (info.isSymbolicLink() || !info.isFile() || !privatePathIsSafe(store.report_path)) {
243
245
  return { state: "unsafe", code: "report_unsafe" };
244
246
  }
245
247
  const report = await readEvalReport(store);
@@ -90,7 +90,13 @@ function graphifyEnvironment() {
90
90
  return environment;
91
91
  }
92
92
  function executeGraphify(projectRoot, args, timeout) {
93
- const result = spawnSync("graphify", args, {
93
+ const configuredProgram = process.env.CAIRN_GRAPHIFY_BINARY?.trim();
94
+ const graphifyProgram = configuredProgram || "graphify";
95
+ const javascriptProgram = /\.(?:cjs|mjs|js)$/i.test(graphifyProgram);
96
+ const result = spawnSync(javascriptProgram ? process.execPath : graphifyProgram, [
97
+ ...(javascriptProgram ? [graphifyProgram] : []),
98
+ ...args,
99
+ ], {
94
100
  cwd: projectRoot,
95
101
  encoding: "utf8",
96
102
  shell: false,
@@ -761,7 +761,18 @@ async function runContextExplore(args) {
761
761
  }
762
762
  // --- Execution tier: return { ok: false, ... } (runtime problems, D-04) ---
763
763
  if (!evidence) {
764
- const result = await runCommand(binaryPath, ["explore", "--query", query, "--repo-root", repoRoot], (timeoutSeconds ?? 120) * 1000, { ...process.env, NO_COLOR: "1" });
764
+ // Native Windows cannot execute a JavaScript file directly from a
765
+ // shebang. Treat JS helpers as Node programs while keeping compiled
766
+ // token_miser binaries byte-for-byte compatible on every platform.
767
+ const javascriptHelper = /\.(?:cjs|mjs|js)$/i.test(binaryPath);
768
+ const result = await runCommand(javascriptHelper ? process.execPath : binaryPath, [
769
+ ...(javascriptHelper ? [binaryPath] : []),
770
+ "explore",
771
+ "--query",
772
+ query,
773
+ "--repo-root",
774
+ repoRoot,
775
+ ], (timeoutSeconds ?? 120) * 1000, { ...process.env, NO_COLOR: "1" });
765
776
  if (result.timedOut || result.exitCode !== 0) {
766
777
  const payload = {
767
778
  ok: false,
@@ -1718,30 +1729,18 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
1718
1729
  }
1719
1730
  // Resolve containment before any database is opened or directory is created.
1720
1731
  const scopePath = resolveScopePath(envelope.scope, scopeOptions);
1721
- const existing = await openScope(envelope.scope, false, scopeOptions);
1722
- let plan;
1723
- try {
1724
- plan = await planMemoryImport(existing, envelope);
1725
- }
1726
- finally {
1727
- await existing?.close();
1728
- }
1729
- if (envelope.dry_run) {
1730
- const payload = await commitMemoryImport(null, plan);
1731
- return {
1732
- content: [{ type: "text", text: asToolText(payload) }],
1733
- structuredContent: payload,
1734
- };
1735
- }
1736
1732
  const payload = await serializeScopeMutation(scopePath, async () => {
1737
- const agent = await openScope(envelope.scope, true, scopeOptions);
1738
- if (!agent)
1739
- throw new Error(`Unable to open scope ${envelope.scope}.`);
1733
+ const agent = await openScope(envelope.scope, !envelope.dry_run, scopeOptions);
1740
1734
  try {
1735
+ const plan = await planMemoryImport(agent, envelope);
1736
+ if (envelope.dry_run)
1737
+ return commitMemoryImport(null, plan);
1738
+ if (!agent)
1739
+ throw new Error(`Unable to open scope ${envelope.scope}.`);
1741
1740
  return await commitMemoryImport(agent, plan);
1742
1741
  }
1743
1742
  finally {
1744
- await agent.close();
1743
+ await agent?.close();
1745
1744
  }
1746
1745
  });
1747
1746
  return {
@@ -1,8 +1,9 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { closeSync, constants, existsSync, lstatSync, openSync, readFileSync, realpathSync, rmSync } from "node:fs";
3
- import { chmod, mkdir, open, rename, rm } from "node:fs/promises";
3
+ import { chmod, mkdir, open, rm } from "node:fs/promises";
4
4
  import { basename, dirname, join, resolve } from "node:path";
5
5
  import { MCP_TOOL_CATALOG, MCP_TOOL_NAMES, isCairnToolName } from "./mcp-tool-catalog.js";
6
+ import { atomicReplace, hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
6
7
  const MAX_CONFIG_BYTES = 64 * 1024;
7
8
  function paths(projectRoot) {
8
9
  const root = resolve(projectRoot);
@@ -63,7 +64,7 @@ function readProjectConfig(projectRoot) {
63
64
  const dirInfo = lstatSync(directory);
64
65
  const info = lstatSync(config);
65
66
  if (!dirInfo.isDirectory() || dirInfo.isSymbolicLink() || !info.isFile() || info.isSymbolicLink()
66
- || info.size > MAX_CONFIG_BYTES || (process.platform !== "win32" && (info.mode & 0o077) !== 0)) {
67
+ || info.size > MAX_CONFIG_BYTES || !privatePathIsSafe(config)) {
67
68
  throw new Error("MCP tool profile configuration is unsafe.");
68
69
  }
69
70
  const fd = requireNoFollow(config);
@@ -129,8 +130,9 @@ async function atomicWrite(path, value) {
129
130
  await handle.close();
130
131
  handle = undefined;
131
132
  await chmod(temporary, 0o600);
132
- await rename(temporary, path);
133
- await chmod(path, 0o600);
133
+ hardenPrivatePath(temporary);
134
+ await atomicReplace(temporary, path);
135
+ hardenPrivatePath(path);
134
136
  }
135
137
  finally {
136
138
  if (handle)
@@ -5,6 +5,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:pat
5
5
  import { buildFailureSignature } from "./failure-signature.js";
6
6
  import { NOTE_SCHEMA_VERSION, noteNodeSchema, } from "./note-schema.js";
7
7
  import { memoryImportEnvelopeSchema, nodePathSchema, } from "./node-schema.js";
8
+ import { atomicReplace } from "./platform-security.js";
8
9
  const MANAGED_START = "<!-- cairnkeep:managed:v1:start -->";
9
10
  const MANAGED_END = "<!-- cairnkeep:managed:v1:end -->";
10
11
  const MAX_PROCESSED_SESSIONS_PER_PROJECT = 4096;
@@ -685,7 +686,8 @@ export function planCreateAddressedNote(options) {
685
686
  return { schema_version: 1, operation: "create", notes_root: target.layout.notes_root, project_id: target.layout.project_id, changes: manifestChanges(target.layout.notes_root, manifest, [{ path: target.absolute_path, bytes: renderNote(record) }]), result: { ok: true, scope: "project", key: options.key, created: true, snapshot_key: null, node } };
686
687
  }
687
688
  function historyPath(notesRoot, addressSpace, key) {
688
- return join(notesRoot, ".cairnkeep", "history", addressSpace, ...key.split("/"), `${new Date().toISOString()}-${randomBytes(6).toString("hex")}.json`);
689
+ const portableTimestamp = new Date().toISOString().replaceAll(":", "-");
690
+ return join(notesRoot, ".cairnkeep", "history", addressSpace, ...key.split("/"), `${portableTimestamp}-${randomBytes(6).toString("hex")}.json`);
689
691
  }
690
692
  export function planSupersedeAddressedNote(options) {
691
693
  const target = resolveNoteTarget(options);
@@ -725,7 +727,9 @@ function pendingTransactionDirectories(notesRoot) {
725
727
  return readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(root, entry.name)).sort();
726
728
  }
727
729
  function syncFile(path) {
728
- const descriptor = openSync(path, "r");
730
+ // FlushFileBuffers requires a write-capable Windows handle. POSIX accepts
731
+ // the narrower read handle used by the historical implementation.
732
+ const descriptor = openSync(path, process.platform === "win32" ? "r+" : "r");
729
733
  try {
730
734
  fsyncSync(descriptor);
731
735
  }
@@ -734,6 +738,11 @@ function syncFile(path) {
734
738
  }
735
739
  }
736
740
  function syncDirectory(path) {
741
+ // Windows does not permit opening a directory as a file descriptor, so
742
+ // Node cannot fsync it. File contents are still fsynced before the atomic
743
+ // rename; POSIX additionally persists the containing directory entry.
744
+ if (process.platform === "win32")
745
+ return;
737
746
  const descriptor = openSync(path, "r");
738
747
  try {
739
748
  fsyncSync(descriptor);
@@ -839,7 +848,7 @@ export async function commitJournaledNoteMutation(plan) {
839
848
  for (const change of journal.changes) {
840
849
  mkdirSync(dirname(change.path), { recursive: true });
841
850
  if (change.staged)
842
- renameSync(change.staged, change.path);
851
+ await atomicReplace(change.staged, change.path);
843
852
  else
844
853
  rmSync(change.path, { force: true });
845
854
  if (existsSync(change.path))
@@ -0,0 +1,185 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ const DEFAULT_STARTUP_TIMEOUT_MS = 10_000;
4
+ const DEFAULT_CALL_TIMEOUT_MS = 30_000;
5
+ const DEFAULT_STDERR_LIMIT_BYTES = 16 * 1024;
6
+ const DEFAULT_RESULT_LIMIT_BYTES = 4 * 1024 * 1024;
7
+ const MAX_CATALOG_PAGES = 1_000;
8
+ function positiveInteger(value, fallback, name) {
9
+ const selected = value ?? fallback;
10
+ if (!Number.isSafeInteger(selected) || selected <= 0)
11
+ throw new Error(`${name} must be a positive integer.`);
12
+ return selected;
13
+ }
14
+ function childEnvironment(source) {
15
+ const env = {};
16
+ for (const [name, value] of Object.entries(source)) {
17
+ if (value !== undefined && name !== "MCP_HTTP_PORT")
18
+ env[name] = value;
19
+ }
20
+ return env;
21
+ }
22
+ function byteLength(value) {
23
+ try {
24
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
25
+ }
26
+ catch {
27
+ throw new Error("MCP result could not be measured safely.");
28
+ }
29
+ }
30
+ function boundedMessage(error, stderr) {
31
+ const base = error instanceof Error ? error.message : String(error);
32
+ const tail = stderr.trim();
33
+ return tail ? `${base} (server stderr: ${tail})` : base;
34
+ }
35
+ function transportBufferSize(resultLimitBytes) {
36
+ return Math.min(Math.max(resultLimitBytes + 64 * 1024, resultLimitBytes * 4), 64 * 1024 * 1024);
37
+ }
38
+ function failureText(result) {
39
+ const text = result.content
40
+ .filter((item) => item.type === "text")
41
+ .map((item) => item.text.trim())
42
+ .filter(Boolean)
43
+ .join("; ");
44
+ return text || "The MCP server reported a tool failure.";
45
+ }
46
+ function piContent(result) {
47
+ return result.content.map((item) => {
48
+ if (item.type === "text")
49
+ return { ...item };
50
+ if (item.type === "image")
51
+ return { ...item };
52
+ throw new Error(`Unsupported MCP result content type: ${item.type}.`);
53
+ });
54
+ }
55
+ export async function connectCairnPiBridge(options = {}) {
56
+ const startupTimeoutMs = positiveInteger(options.startupTimeoutMs, DEFAULT_STARTUP_TIMEOUT_MS, "startupTimeoutMs");
57
+ const callTimeoutMs = positiveInteger(options.callTimeoutMs, DEFAULT_CALL_TIMEOUT_MS, "callTimeoutMs");
58
+ const stderrLimitBytes = positiveInteger(options.stderrLimitBytes, DEFAULT_STDERR_LIMIT_BYTES, "stderrLimitBytes");
59
+ const resultLimitBytes = positiveInteger(options.resultLimitBytes, DEFAULT_RESULT_LIMIT_BYTES, "resultLimitBytes");
60
+ const transport = new StdioClientTransport({
61
+ command: options.command ?? "cairn",
62
+ args: options.args ? [...options.args] : ["memory-server"],
63
+ cwd: options.cwd ?? process.cwd(),
64
+ env: childEnvironment(options.env ?? process.env),
65
+ stderr: "pipe",
66
+ maxBufferSize: transportBufferSize(resultLimitBytes),
67
+ });
68
+ const client = new Client({ name: "cairnkeep-pi-bridge", version: "1" }, { capabilities: {} });
69
+ let state = "idle";
70
+ let stderr = Buffer.alloc(0);
71
+ let transportFailure;
72
+ let catalog = [];
73
+ let closePromise;
74
+ transport.stderr?.on("data", (chunk) => {
75
+ const next = Buffer.concat([stderr, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
76
+ stderr = next.byteLength <= stderrLimitBytes ? next : next.subarray(next.byteLength - stderrLimitBytes);
77
+ });
78
+ const stderrTail = () => stderr.toString("utf8");
79
+ const requireReady = () => {
80
+ if (state !== "ready")
81
+ throw new Error(`Pi MCP bridge is ${state}; calls require ready state.`);
82
+ };
83
+ const discover = async () => {
84
+ const tools = [];
85
+ const names = new Set();
86
+ const cursors = new Set();
87
+ let cursor;
88
+ for (let pageNumber = 0; pageNumber < MAX_CATALOG_PAGES; pageNumber += 1) {
89
+ const page = await client.listTools(cursor ? { cursor } : undefined, { timeout: startupTimeoutMs });
90
+ if (byteLength(page) > resultLimitBytes)
91
+ throw new Error("MCP tool catalog exceeds the configured result size limit.");
92
+ for (const tool of page.tools) {
93
+ if (names.has(tool.name))
94
+ throw new Error(`MCP tool catalog contains duplicate name: ${tool.name}.`);
95
+ names.add(tool.name);
96
+ tools.push(tool);
97
+ }
98
+ if (!page.nextCursor)
99
+ return tools;
100
+ if (cursors.has(page.nextCursor))
101
+ throw new Error("MCP tool catalog pagination repeated a cursor.");
102
+ cursors.add(page.nextCursor);
103
+ cursor = page.nextCursor;
104
+ }
105
+ throw new Error("MCP tool catalog exceeded the pagination limit.");
106
+ };
107
+ const close = () => {
108
+ if (closePromise)
109
+ return closePromise;
110
+ state = "closing";
111
+ closePromise = (async () => {
112
+ try {
113
+ await client.close();
114
+ }
115
+ finally {
116
+ state = "closed";
117
+ catalog = [];
118
+ }
119
+ })();
120
+ return closePromise;
121
+ };
122
+ state = "starting";
123
+ try {
124
+ await client.connect(transport, { timeout: startupTimeoutMs, maxTotalTimeout: startupTimeoutMs });
125
+ const protocolErrorHandler = transport.onerror;
126
+ transport.onerror = (error) => {
127
+ transportFailure = error;
128
+ protocolErrorHandler?.(error);
129
+ };
130
+ const protocolCloseHandler = transport.onclose;
131
+ transport.onclose = () => {
132
+ protocolCloseHandler?.();
133
+ if (state === "starting" || state === "ready") {
134
+ state = "closed";
135
+ catalog = [];
136
+ }
137
+ };
138
+ const pid = transport.pid;
139
+ if (pid !== null)
140
+ options.onSpawn?.({ pid });
141
+ catalog = await discover();
142
+ state = "ready";
143
+ }
144
+ catch (error) {
145
+ await close().catch(() => undefined);
146
+ throw new Error(`Unable to start local Pi MCP bridge: ${boundedMessage(error, stderrTail())}`, { cause: error });
147
+ }
148
+ return {
149
+ get state() { return state; },
150
+ async listAllTools() {
151
+ requireReady();
152
+ return catalog.map((tool) => structuredClone(tool));
153
+ },
154
+ async call(tool, args, callOptions = {}) {
155
+ requireReady();
156
+ const discovered = catalog.find(({ name }) => name === tool.name);
157
+ if (!discovered)
158
+ throw new Error(`MCP tool was not discovered for this session: ${tool.name}.`);
159
+ try {
160
+ const result = await client.callTool({ name: discovered.name, arguments: args }, undefined, { signal: callOptions.signal, timeout: callTimeoutMs, maxTotalTimeout: callTimeoutMs });
161
+ if (byteLength(result) > resultLimitBytes)
162
+ throw new Error("MCP tool result exceeds the configured size limit.");
163
+ if (result.isError)
164
+ throw new Error(failureText(result));
165
+ const details = {
166
+ tool: discovered,
167
+ annotations: discovered.annotations,
168
+ outputSchema: discovered.outputSchema,
169
+ content: result.content,
170
+ structuredContent: result.structuredContent,
171
+ isError: false,
172
+ ...(result._meta ? { _meta: result._meta } : {}),
173
+ };
174
+ return { content: piContent(result), details };
175
+ }
176
+ catch (error) {
177
+ const failure = transportFailure && /closed/i.test(error instanceof Error ? error.message : String(error))
178
+ ? transportFailure
179
+ : error;
180
+ throw new Error(`Pi MCP call ${discovered.name} failed: ${boundedMessage(failure, stderrTail())}`, { cause: error });
181
+ }
182
+ },
183
+ close,
184
+ };
185
+ }
@@ -0,0 +1,102 @@
1
+ import { chmodSync, existsSync, lstatSync, rmSync } from "node:fs";
2
+ import { rename } from "node:fs/promises";
3
+ import { spawnSync } from "node:child_process";
4
+ import { randomUUID } from "node:crypto";
5
+ function currentWindowsSid() {
6
+ const result = spawnSync("whoami.exe", ["/user", "/fo", "csv", "/nh"], { encoding: "utf8", windowsHide: true });
7
+ if (result.status !== 0)
8
+ throw new Error("Unable to resolve the current Windows security identity.");
9
+ const match = result.stdout.match(/"(S-1-[0-9-]+)"/i);
10
+ if (!match)
11
+ throw new Error("Unable to resolve the current Windows security identity.");
12
+ return match[1];
13
+ }
14
+ export function hardenPrivatePath(path) {
15
+ if (process.platform !== "win32") {
16
+ chmodSync(path, lstatSync(path).isDirectory() ? 0o700 : 0o600);
17
+ return;
18
+ }
19
+ const sid = currentWindowsSid();
20
+ const args = [
21
+ path,
22
+ "/inheritance:r",
23
+ "/grant:r", `*${sid}:(F)`,
24
+ "/grant:r", "*S-1-5-18:(F)",
25
+ "/grant:r", "*S-1-5-32-544:(F)",
26
+ ];
27
+ const result = spawnSync("icacls.exe", args, { encoding: "utf8", windowsHide: true });
28
+ if (result.status !== 0)
29
+ throw new Error("Unable to restrict Windows ACLs for private Cairnkeep state.");
30
+ }
31
+ export function privatePathIsSafe(path) {
32
+ if (!existsSync(path))
33
+ return false;
34
+ const info = lstatSync(path);
35
+ if (info.isSymbolicLink())
36
+ return false;
37
+ if (process.platform !== "win32")
38
+ return (info.mode & 0o077) === 0;
39
+ const script = [
40
+ "$p=$env:CK_INTERNAL_PRIVATE_PATH",
41
+ "$me=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value",
42
+ "$ok=@($me,'S-1-5-18','S-1-5-32-544')",
43
+ "$acl=Get-Acl -LiteralPath $p",
44
+ "foreach($a in $acl.Access){",
45
+ "if($a.AccessControlType -eq 'Allow'){",
46
+ "try{$sid=$a.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value}catch{exit 1}",
47
+ "if($ok -notcontains $sid){exit 1}",
48
+ "}",
49
+ "}",
50
+ "exit 0",
51
+ ].join(";");
52
+ const result = spawnSync("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], {
53
+ encoding: "utf8",
54
+ windowsHide: true,
55
+ env: { ...process.env, CK_INTERNAL_PRIVATE_PATH: path },
56
+ });
57
+ return result.status === 0;
58
+ }
59
+ function delay(milliseconds) {
60
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
61
+ }
62
+ export async function atomicReplace(source, destination) {
63
+ if (process.platform === "win32") {
64
+ let lastError = "Windows atomic replacement failed.";
65
+ for (let attempt = 0; attempt < 12; attempt += 1) {
66
+ if (!existsSync(destination)) {
67
+ try {
68
+ await rename(source, destination);
69
+ return;
70
+ }
71
+ catch (error) {
72
+ lastError = error instanceof Error ? error.message : String(error);
73
+ }
74
+ }
75
+ else {
76
+ const backup = `${destination}.replace-backup-${randomUUID()}`;
77
+ const result = spawnSync("powershell.exe", [
78
+ "-NoLogo", "-NoProfile", "-NonInteractive", "-Command",
79
+ "[IO.File]::Replace($env:CK_INTERNAL_ATOMIC_SOURCE,$env:CK_INTERNAL_ATOMIC_DESTINATION,$env:CK_INTERNAL_ATOMIC_BACKUP,$true)",
80
+ ], {
81
+ encoding: "utf8",
82
+ windowsHide: true,
83
+ env: {
84
+ ...process.env,
85
+ CK_INTERNAL_ATOMIC_SOURCE: source,
86
+ CK_INTERNAL_ATOMIC_DESTINATION: destination,
87
+ CK_INTERNAL_ATOMIC_BACKUP: backup,
88
+ },
89
+ });
90
+ if (result.status === 0) {
91
+ rmSync(backup, { force: true });
92
+ return;
93
+ }
94
+ rmSync(backup, { force: true });
95
+ lastError = result.stderr.trim() || result.stdout.trim() || lastError;
96
+ }
97
+ await delay(25 * (attempt + 1));
98
+ }
99
+ throw new Error(lastError);
100
+ }
101
+ await rename(source, destination);
102
+ }