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