@cairnkeep/cli 2.8.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 (89) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/README.md +82 -27
  3. package/bin/cairn +6 -153
  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/context-packs.md +94 -0
  8. package/docs/harness-compatibility.md +37 -3
  9. package/docs/learning/CURRICULUM-MAP.md +16 -5
  10. package/docs/learning/FEATURE-GUIDE.md +15 -4
  11. package/docs/learning/README.md +8 -2
  12. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  13. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  14. package/docs/learning/lessons/L02-installation.md +11 -2
  15. package/docs/learning/lessons/L03-first-project.md +1 -1
  16. package/docs/learning/lessons/L19-validated-skill-improvement.md +52 -21
  17. package/docs/learning/lessons/L20-mcp-tool-profiles.md +67 -0
  18. package/docs/learning/lessons/L21-context-packs.md +79 -0
  19. package/docs/learning/lessons/L22-native-windows.md +83 -0
  20. package/docs/learning/lessons/L23-guided-setup.md +138 -0
  21. package/docs/learning/tracks/operator.md +10 -3
  22. package/docs/learning/tracks/quickstart.md +7 -3
  23. package/docs/mcp-tool-profiles.md +43 -0
  24. package/docs/native-windows.md +122 -0
  25. package/docs/operating.md +157 -18
  26. package/docs/privacy-and-data-flow.md +51 -1
  27. package/docs/storage.md +29 -0
  28. package/examples/eval/bundled-fake.json +1 -1
  29. package/mcp-memory-server/dist/artifact-store-windows-worker.js +74 -0
  30. package/mcp-memory-server/dist/artifact-store.js +196 -55
  31. package/mcp-memory-server/dist/capability-cli.js +2 -1
  32. package/mcp-memory-server/dist/capability-config.js +4 -3
  33. package/mcp-memory-server/dist/capability-harness.js +34 -7
  34. package/mcp-memory-server/dist/context-pack-cli.js +185 -0
  35. package/mcp-memory-server/dist/context-pack.js +889 -0
  36. package/mcp-memory-server/dist/eval-cli.js +6 -5
  37. package/mcp-memory-server/dist/eval-plan.js +35 -13
  38. package/mcp-memory-server/dist/eval-process.js +11 -4
  39. package/mcp-memory-server/dist/eval-report.js +8 -5
  40. package/mcp-memory-server/dist/eval-runner.js +2 -0
  41. package/mcp-memory-server/dist/eval-schema.js +1 -0
  42. package/mcp-memory-server/dist/graph-cli.js +7 -1
  43. package/mcp-memory-server/dist/index.js +87 -37
  44. package/mcp-memory-server/dist/mcp-tool-catalog.js +47 -0
  45. package/mcp-memory-server/dist/mcp-tool-cli.js +85 -0
  46. package/mcp-memory-server/dist/mcp-tool-profile.js +164 -0
  47. package/mcp-memory-server/dist/note-store.js +12 -3
  48. package/mcp-memory-server/dist/pi-mcp-bridge.js +185 -0
  49. package/mcp-memory-server/dist/platform-security.js +102 -0
  50. package/mcp-memory-server/dist/skill-store.js +27 -12
  51. package/mcp-memory-server/package.json +4 -1
  52. package/package.json +5 -4
  53. package/pi/extensions/cairnkeep-memory.ts +61 -0
  54. package/schemas/cairnkeep-setup-policy.schema.json +55 -0
  55. package/schemas/cairnkeep-setup.schema.json +36 -0
  56. package/schemas/context-pack.schema.json +33 -0
  57. package/schemas/eval-report.schema.json +1 -0
  58. package/scripts/cairn-cli.mjs +119 -0
  59. package/scripts/cairn-container-cli.mjs +121 -0
  60. package/scripts/cairn-hook.mjs +199 -0
  61. package/scripts/completion.sh +33 -5
  62. package/scripts/doctor.sh +96 -0
  63. package/scripts/fix-permissions.mjs +12 -0
  64. package/scripts/memory-wiki-audit.mjs +95 -0
  65. package/scripts/phase26-test-manifest.mjs +26 -0
  66. package/scripts/run-repository-tests.mjs +76 -0
  67. package/scripts/setup-core.mjs +310 -0
  68. package/scripts/setup-reconcile.mjs +331 -0
  69. package/scripts/setup.mjs +306 -0
  70. package/scripts/sync-pi-assets.sh +19 -4
  71. package/scripts/test-cli-dispatch.sh +6 -0
  72. package/scripts/test-completion.sh +44 -0
  73. package/scripts/test-doctor.sh +66 -0
  74. package/scripts/test-learning-docs.sh +53 -3
  75. package/scripts/test-package-install.sh +81 -0
  76. package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
  77. package/scripts/test-phase18-capability-lifecycle.sh +1 -1
  78. package/scripts/test-pi-lifecycle.mjs +190 -0
  79. package/scripts/test-setup-compatibility.mjs +186 -0
  80. package/scripts/test-setup-output.sh +131 -0
  81. package/scripts/test-setup-overlay.sh +113 -0
  82. package/scripts/test-setup-preflight.mjs +281 -0
  83. package/scripts/test-setup-reconcile.mjs +257 -0
  84. package/scripts/test-uninstall.sh +71 -0
  85. package/scripts/test-windows-native.mjs +136 -0
  86. package/scripts/uninstall.sh +95 -7
  87. package/scripts/verify-pi-mcp-bridge.mjs +588 -0
  88. package/scripts/windows-platform.mjs +725 -0
  89. package/templates/env.example.template +11 -0
@@ -0,0 +1,85 @@
1
+ import { MCP_TOOL_CATALOG, MCP_TOOL_NAMES } from "./mcp-tool-catalog.js";
2
+ import { resetMcpToolProfile, resolveMcpToolProfile, setMcpToolProfile } from "./mcp-tool-profile.js";
3
+ function usage() {
4
+ process.stderr.write("Usage: cairn mcp-tools list [--json] | status [--project PATH] [--json] | set full|read-only [--project PATH] | set custom --tool NAME... [--project PATH] | reset [--project PATH]\n");
5
+ process.exit(2);
6
+ }
7
+ function option(args, name) {
8
+ const index = args.indexOf(name);
9
+ if (index < 0)
10
+ return undefined;
11
+ const value = args[index + 1];
12
+ if (!value || value.startsWith("--"))
13
+ usage();
14
+ args.splice(index, 2);
15
+ return value;
16
+ }
17
+ function flags(args, name) {
18
+ const values = [];
19
+ for (let index = 0; index < args.length;) {
20
+ if (args[index] !== name) {
21
+ index += 1;
22
+ continue;
23
+ }
24
+ args.splice(index, 1);
25
+ let consumed = 0;
26
+ while (index < args.length && !args[index].startsWith("--")) {
27
+ values.push(args[index]);
28
+ args.splice(index, 1);
29
+ consumed += 1;
30
+ }
31
+ if (consumed === 0)
32
+ usage();
33
+ }
34
+ return values;
35
+ }
36
+ function output(value, json, human) {
37
+ process.stdout.write(`${json ? JSON.stringify(value) : human}\n`);
38
+ }
39
+ async function main() {
40
+ const args = process.argv.slice(2);
41
+ const command = args.shift();
42
+ const jsonIndex = args.indexOf("--json");
43
+ const json = jsonIndex >= 0;
44
+ if (json)
45
+ args.splice(jsonIndex, 1);
46
+ const projectRoot = option(args, "--project") ?? process.cwd();
47
+ if (command === "list") {
48
+ if (args.length)
49
+ usage();
50
+ const tools = MCP_TOOL_NAMES.map((name) => ({ name, title: MCP_TOOL_CATALOG[name].title, annotations: MCP_TOOL_CATALOG[name].annotations }));
51
+ output({ schema_version: 1, tools }, json, tools.map(({ name, annotations }) => `${name}\t${annotations.readOnlyHint ? "read-only" : "mutation"}`).join("\n"));
52
+ return;
53
+ }
54
+ if (command === "status") {
55
+ if (args.length)
56
+ usage();
57
+ const status = resolveMcpToolProfile({ projectRoot });
58
+ output(status, json, `Profile: ${status.mode}\nSource: ${status.source}\nDigest: ${status.profile_digest}\nTools: ${status.allowed_tools.join(", ")}`);
59
+ return;
60
+ }
61
+ if (command === "set") {
62
+ const mode = args.shift();
63
+ if (!mode || !["full", "read-only", "custom"].includes(mode))
64
+ usage();
65
+ const allowedTools = flags(args, "--tool");
66
+ if (args.length || (mode === "custom" && allowedTools.length === 0) || (mode !== "custom" && allowedTools.length > 0))
67
+ usage();
68
+ const status = await setMcpToolProfile({ projectRoot, mode, allowedTools });
69
+ output(status, json, `MCP tool profile set to ${mode} (${status.profile_digest}).`);
70
+ return;
71
+ }
72
+ if (command === "reset") {
73
+ if (args.length)
74
+ usage();
75
+ await resetMcpToolProfile(projectRoot);
76
+ const status = resolveMcpToolProfile({ projectRoot });
77
+ output(status, json, `Project MCP tool profile reset; effective profile is ${status.mode} from ${status.source}.`);
78
+ return;
79
+ }
80
+ usage();
81
+ }
82
+ main().catch((error) => {
83
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
84
+ process.exitCode = 1;
85
+ });
@@ -0,0 +1,164 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { closeSync, constants, existsSync, lstatSync, openSync, readFileSync, realpathSync, rmSync } from "node:fs";
3
+ import { chmod, mkdir, open, rm } from "node:fs/promises";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import { MCP_TOOL_CATALOG, MCP_TOOL_NAMES, isCairnToolName } from "./mcp-tool-catalog.js";
6
+ import { atomicReplace, hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
7
+ const MAX_CONFIG_BYTES = 64 * 1024;
8
+ function paths(projectRoot) {
9
+ const root = resolve(projectRoot);
10
+ const directory = join(root, ".ai");
11
+ const config = join(directory, "mcp-tools.json");
12
+ if (dirname(directory) !== root || dirname(config) !== directory)
13
+ throw new Error("MCP tool profile path is unsafe.");
14
+ return { root, directory, config };
15
+ }
16
+ function exactAllowed(mode, requested) {
17
+ if (mode === "full")
18
+ return [...MCP_TOOL_NAMES];
19
+ if (mode === "read-only") {
20
+ return MCP_TOOL_NAMES.filter((name) => MCP_TOOL_CATALOG[name].annotations.readOnlyHint);
21
+ }
22
+ const unknown = requested.filter((name) => !isCairnToolName(name));
23
+ if (unknown.length)
24
+ throw new Error(`Unknown MCP tool name${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}`);
25
+ if (requested.length === 0)
26
+ throw new Error("A custom MCP tool profile requires at least one allowed tool.");
27
+ const selected = new Set(requested);
28
+ return MCP_TOOL_NAMES.filter((name) => selected.has(name));
29
+ }
30
+ function canonicalProfile(mode, allowed) {
31
+ return { schema_version: 1, mode, allowed_tools: exactAllowed(mode, allowed) };
32
+ }
33
+ function storedProfile(mode, allowed) {
34
+ const effective = exactAllowed(mode, allowed);
35
+ return { schema_version: 1, mode, allowed_tools: mode === "custom" ? effective : [] };
36
+ }
37
+ export function mcpToolProfileDigest(config) {
38
+ return createHash("sha256").update(JSON.stringify(config), "utf8").digest("hex");
39
+ }
40
+ function parseConfig(value) {
41
+ if (!value || typeof value !== "object" || Array.isArray(value))
42
+ throw new Error("Invalid MCP tool profile configuration.");
43
+ const row = value;
44
+ if (Object.keys(row).some((key) => !["schema_version", "mode", "allowed_tools"].includes(key))
45
+ || row.schema_version !== 1
46
+ || !["full", "read-only", "custom"].includes(String(row.mode))
47
+ || !Array.isArray(row.allowed_tools)
48
+ || row.allowed_tools.some((name) => typeof name !== "string")) {
49
+ throw new Error("Invalid MCP tool profile configuration.");
50
+ }
51
+ const mode = row.mode;
52
+ const requested = row.allowed_tools;
53
+ const canonical = storedProfile(mode, requested);
54
+ if (mode !== "custom" && requested.length > 0)
55
+ throw new Error("Only custom MCP tool profiles may contain allowed_tools.");
56
+ if (mode === "custom" && new Set(requested).size !== requested.length)
57
+ throw new Error("Duplicate MCP tool names are not allowed.");
58
+ return canonical;
59
+ }
60
+ function readProjectConfig(projectRoot) {
61
+ const { directory, config } = paths(projectRoot);
62
+ if (!existsSync(config))
63
+ return undefined;
64
+ const dirInfo = lstatSync(directory);
65
+ const info = lstatSync(config);
66
+ if (!dirInfo.isDirectory() || dirInfo.isSymbolicLink() || !info.isFile() || info.isSymbolicLink()
67
+ || info.size > MAX_CONFIG_BYTES || !privatePathIsSafe(config)) {
68
+ throw new Error("MCP tool profile configuration is unsafe.");
69
+ }
70
+ const fd = requireNoFollow(config);
71
+ try {
72
+ const bytes = readFileSync(fd);
73
+ if (bytes.byteLength > MAX_CONFIG_BYTES)
74
+ throw new Error("MCP tool profile configuration is too large.");
75
+ return parseConfig(JSON.parse(bytes.toString("utf8")));
76
+ }
77
+ finally {
78
+ // readFileSync does not own numeric descriptors.
79
+ closeSync(fd);
80
+ }
81
+ }
82
+ function requireNoFollow(path) {
83
+ return openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
84
+ }
85
+ function environmentConfig(env) {
86
+ const rawMode = env.CAIRN_MCP_TOOL_PROFILE?.trim();
87
+ if (!rawMode)
88
+ return undefined;
89
+ if (!["full", "read-only", "custom"].includes(rawMode))
90
+ throw new Error("CAIRN_MCP_TOOL_PROFILE must be full, read-only, or custom.");
91
+ const allowed = (env.CAIRN_MCP_ALLOWED_TOOLS ?? "").split(",").map((name) => name.trim()).filter(Boolean);
92
+ if (rawMode !== "custom" && allowed.length)
93
+ throw new Error("CAIRN_MCP_ALLOWED_TOOLS is valid only with the custom profile.");
94
+ return storedProfile(rawMode, allowed);
95
+ }
96
+ export function resolveMcpToolProfile(options = {}) {
97
+ const projectRoot = options.projectRoot ?? process.cwd();
98
+ const env = options.env ?? process.env;
99
+ const { root, config } = paths(projectRoot);
100
+ const environment = environmentConfig(env);
101
+ const project = environment ? undefined : readProjectConfig(root);
102
+ const source = environment ? "environment" : project ? "project" : "default";
103
+ const selected = environment ?? project ?? storedProfile("full", []);
104
+ const effective = canonicalProfile(selected.mode, selected.allowed_tools);
105
+ return { ...effective, source, profile_digest: mcpToolProfileDigest(effective), project_root: root, config_path: config, issues: [] };
106
+ }
107
+ async function atomicWrite(path, value) {
108
+ if (existsSync(dirname(path))) {
109
+ const existingDirectory = lstatSync(dirname(path));
110
+ if (!existingDirectory.isDirectory() || existingDirectory.isSymbolicLink())
111
+ throw new Error("MCP tool profile directory is unsafe.");
112
+ }
113
+ else {
114
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
115
+ }
116
+ const directoryInfo = lstatSync(dirname(path));
117
+ if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink())
118
+ throw new Error("MCP tool profile directory is unsafe.");
119
+ if (existsSync(path)) {
120
+ const existing = lstatSync(path);
121
+ if (!existing.isFile() || existing.isSymbolicLink())
122
+ throw new Error("MCP tool profile configuration is unsafe.");
123
+ }
124
+ const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
125
+ let handle;
126
+ try {
127
+ handle = await open(temporary, "wx", 0o600);
128
+ await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
129
+ await handle.sync();
130
+ await handle.close();
131
+ handle = undefined;
132
+ await chmod(temporary, 0o600);
133
+ hardenPrivatePath(temporary);
134
+ await atomicReplace(temporary, path);
135
+ hardenPrivatePath(path);
136
+ }
137
+ finally {
138
+ if (handle)
139
+ await handle.close().catch(() => undefined);
140
+ await rm(temporary, { force: true });
141
+ }
142
+ }
143
+ export async function setMcpToolProfile(options) {
144
+ const { root, config } = paths(options.projectRoot ?? process.cwd());
145
+ const value = storedProfile(options.mode, options.allowedTools ?? []);
146
+ await atomicWrite(config, value);
147
+ return resolveMcpToolProfile({ projectRoot: root, env: {} });
148
+ }
149
+ export async function resetMcpToolProfile(projectRoot = process.cwd()) {
150
+ const { config } = paths(projectRoot);
151
+ if (!existsSync(config))
152
+ return;
153
+ const info = lstatSync(config);
154
+ if (!info.isFile() || info.isSymbolicLink())
155
+ throw new Error("MCP tool profile configuration is unsafe.");
156
+ rmSync(config);
157
+ }
158
+ export function profileAllowsTool(status, name) {
159
+ return status.allowed_tools.includes(name);
160
+ }
161
+ export function canonicalProjectRoot(projectRoot) {
162
+ const resolved = resolve(projectRoot);
163
+ return existsSync(resolved) ? realpathSync(resolved) : resolved;
164
+ }
@@ -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
+ }