@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.
- package/CHANGELOG.md +46 -1
- package/README.md +54 -25
- package/bin/cairn +6 -165
- package/bin/cairn-container +8 -0
- package/docs/building-an-overlay.md +41 -4
- package/docs/containers.md +10 -0
- package/docs/harness-compatibility.md +30 -2
- package/docs/learning/CURRICULUM-MAP.md +9 -3
- package/docs/learning/FEATURE-GUIDE.md +9 -3
- package/docs/learning/README.md +2 -0
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +11 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
- package/docs/learning/lessons/L21-context-packs.md +1 -1
- package/docs/learning/lessons/L22-native-windows.md +83 -0
- package/docs/learning/lessons/L23-guided-setup.md +138 -0
- package/docs/learning/tracks/operator.md +8 -3
- package/docs/learning/tracks/quickstart.md +7 -3
- package/docs/native-windows.md +122 -0
- package/docs/operating.md +119 -18
- package/docs/privacy-and-data-flow.md +34 -1
- package/docs/storage.md +14 -2
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/artifact-store-windows-worker.js +74 -0
- package/mcp-memory-server/dist/artifact-store.js +196 -55
- package/mcp-memory-server/dist/capability-cli.js +2 -1
- package/mcp-memory-server/dist/capability-config.js +4 -3
- package/mcp-memory-server/dist/capability-harness.js +34 -7
- package/mcp-memory-server/dist/context-pack.js +84 -25
- package/mcp-memory-server/dist/eval-cli.js +6 -5
- package/mcp-memory-server/dist/eval-plan.js +35 -13
- package/mcp-memory-server/dist/eval-process.js +11 -4
- package/mcp-memory-server/dist/eval-report.js +7 -5
- package/mcp-memory-server/dist/graph-cli.js +7 -1
- package/mcp-memory-server/dist/index.js +19 -20
- package/mcp-memory-server/dist/mcp-tool-profile.js +6 -4
- package/mcp-memory-server/dist/note-store.js +12 -3
- package/mcp-memory-server/dist/pi-mcp-bridge.js +185 -0
- package/mcp-memory-server/dist/platform-security.js +102 -0
- package/mcp-memory-server/dist/skill-store.js +27 -12
- package/mcp-memory-server/package.json +2 -1
- package/package.json +5 -4
- package/pi/extensions/cairnkeep-memory.ts +61 -0
- package/schemas/cairnkeep-setup-policy.schema.json +55 -0
- package/schemas/cairnkeep-setup.schema.json +36 -0
- package/scripts/cairn-cli.mjs +119 -0
- package/scripts/cairn-container-cli.mjs +121 -0
- package/scripts/cairn-hook.mjs +199 -0
- package/scripts/completion.sh +12 -3
- package/scripts/doctor.sh +57 -0
- package/scripts/fix-permissions.mjs +12 -0
- package/scripts/memory-wiki-audit.mjs +95 -0
- package/scripts/phase26-test-manifest.mjs +26 -0
- package/scripts/run-repository-tests.mjs +76 -0
- package/scripts/setup-core.mjs +310 -0
- package/scripts/setup-reconcile.mjs +331 -0
- package/scripts/setup.mjs +306 -0
- package/scripts/sync-pi-assets.sh +19 -4
- package/scripts/test-completion.sh +39 -0
- package/scripts/test-doctor.sh +66 -0
- package/scripts/test-learning-docs.sh +52 -3
- package/scripts/test-package-install.sh +64 -0
- package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
- package/scripts/test-phase18-capability-lifecycle.sh +1 -1
- package/scripts/test-pi-lifecycle.mjs +190 -0
- package/scripts/test-setup-compatibility.mjs +186 -0
- package/scripts/test-setup-output.sh +131 -0
- package/scripts/test-setup-overlay.sh +113 -0
- package/scripts/test-setup-preflight.mjs +281 -0
- package/scripts/test-setup-reconcile.mjs +257 -0
- package/scripts/test-uninstall.sh +50 -0
- package/scripts/test-windows-native.mjs +136 -0
- package/scripts/uninstall.sh +69 -6
- package/scripts/verify-pi-mcp-bridge.mjs +588 -0
- 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() || (
|
|
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 && (
|
|
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() || (
|
|
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() || (
|
|
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() || (
|
|
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
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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" ? "
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() || (
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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 || (
|
|
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
|
-
|
|
133
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|