@cairnkeep/cli 2.22.2 → 2.22.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -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 +8 -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 +19 -1
- 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 +25 -34
- 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-setup-diagnosis.mjs +97 -0
- package/scripts/test-windows-native.mjs +5 -0
- package/scripts/verify-omp-capture.mjs +1 -0
- package/scripts/windows-platform.mjs +54 -22
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, readFileSync, writeFileSync, chmodSync, rmSync, symlinkSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { hardenPrivatePath } from "../mcp-memory-server/dist/platform-security.js";
|
|
6
|
+
|
|
7
|
+
const source = readFileSync(new URL("../containers/healthcheck.mjs", import.meta.url), "utf8");
|
|
8
|
+
const containerfile = readFileSync(new URL("../Containerfile", import.meta.url), "utf8");
|
|
9
|
+
const quadlet = readFileSync(new URL("../containers/quadlet/cairnkeep.container", import.meta.url), "utf8");
|
|
10
|
+
assert.match(quadlet, /^Secret=cairnkeep-http-token,type=mount,target=http-token,uid=10001,gid=10001,mode=0400$/m,
|
|
11
|
+
"the managed container secret must satisfy the probe's private-file contract");
|
|
12
|
+
assert.match(containerfile, /COPY --chmod=644 containers\/healthcheck\.mjs \/opt\/cairnkeep\/containers\/healthcheck\.mjs/, "probe imports must resolve within the packaged server");
|
|
13
|
+
assert.match(containerfile, /ln -s \/opt\/cairnkeep\/containers\/healthcheck\.mjs \/usr\/local\/lib\/cairnkeep\/container-healthcheck\.mjs/, "keep the established container probe path");
|
|
14
|
+
assert.match(source, /parseHttpPort/, "health destination must use the server's numeric port parser");
|
|
15
|
+
assert.match(source, /resolveHttpToken/, "health tokens must use the server's bounded resolver");
|
|
16
|
+
const { containerHealthcheck } = await import("../containers/healthcheck.mjs");
|
|
17
|
+
const root = mkdtempSync(join(tmpdir(), "cairn-health-security-"));
|
|
18
|
+
const originalFetch = globalThis.fetch;
|
|
19
|
+
const token = "synthetic-health-token-0123456789";
|
|
20
|
+
const requests = [];
|
|
21
|
+
try {
|
|
22
|
+
globalThis.fetch = async (url, options) => {
|
|
23
|
+
requests.push({ url: String(url), options });
|
|
24
|
+
return Response.json({ result: {} });
|
|
25
|
+
};
|
|
26
|
+
assert.equal(await containerHealthcheck({}), 0);
|
|
27
|
+
for (const port of ["7801@external.example.test", "1/path", "-1", "65536", " 7801 "]) {
|
|
28
|
+
assert.equal(await containerHealthcheck({ MCP_HTTP_PORT: port, CAIRN_MEMORY_HTTP_TOKEN: token }), 1);
|
|
29
|
+
}
|
|
30
|
+
assert.equal(requests.length, 0, "disabled or invalid configuration must not fetch");
|
|
31
|
+
assert.equal(await containerHealthcheck({ MCP_HTTP_PORT: "7801", CAIRN_MEMORY_HTTP_TOKEN: token }), 0);
|
|
32
|
+
assert.equal(requests[0].url, "http://127.0.0.1:7801/mcp");
|
|
33
|
+
assert.equal(requests[0].options.redirect, "manual");
|
|
34
|
+
assert.equal(requests[0].options.headers.Authorization, `Bearer ${token}`);
|
|
35
|
+
assert.equal(JSON.parse(requests[0].options.body).method, "initialize");
|
|
36
|
+
const tokenFile = join(root, "token");
|
|
37
|
+
writeFileSync(tokenFile, token + "\n", { mode: 0o600 });
|
|
38
|
+
hardenPrivatePath(tokenFile);
|
|
39
|
+
assert.equal(await containerHealthcheck({ MCP_HTTP_PORT: "7801", CAIRN_MEMORY_HTTP_TOKEN_FILE: tokenFile }), 0);
|
|
40
|
+
if (process.platform !== "win32") {
|
|
41
|
+
chmodSync(tokenFile, 0o644);
|
|
42
|
+
assert.equal(await containerHealthcheck({ MCP_HTTP_PORT: "7801", CAIRN_MEMORY_HTTP_TOKEN_FILE: tokenFile }), 1);
|
|
43
|
+
chmodSync(tokenFile, 0o600);
|
|
44
|
+
const link = join(root, "linked-token");
|
|
45
|
+
symlinkSync(tokenFile, link);
|
|
46
|
+
assert.equal(await containerHealthcheck({ MCP_HTTP_PORT: "7801", CAIRN_MEMORY_HTTP_TOKEN_FILE: link }), 1);
|
|
47
|
+
}
|
|
48
|
+
writeFileSync(tokenFile, "x".repeat(64 * 1024 + 1));
|
|
49
|
+
assert.equal(await containerHealthcheck({ MCP_HTTP_PORT: "7801", CAIRN_MEMORY_HTTP_TOKEN_FILE: tokenFile }), 1);
|
|
50
|
+
globalThis.fetch = async () => new Response(null, { status: 302, headers: { Location: "https://external.example.test" } });
|
|
51
|
+
assert.equal(await containerHealthcheck({ MCP_HTTP_PORT: "7801", CAIRN_MEMORY_HTTP_TOKEN: token }), 1);
|
|
52
|
+
} finally {
|
|
53
|
+
globalThis.fetch = originalFetch;
|
|
54
|
+
rmSync(root, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
console.log("PASS: container health checks remain loopback-only with bounded, safe credentials and no redirects");
|
|
@@ -110,7 +110,14 @@ done
|
|
|
110
110
|
exit 1
|
|
111
111
|
}
|
|
112
112
|
cat "$tmp/write.out"
|
|
113
|
-
|
|
113
|
+
# Exec probes see the original container environment, not the entrypoint's
|
|
114
|
+
# loaded secret. The deliberately public bind fixture must be rejected.
|
|
115
|
+
if "$ENGINE" exec "$name" node /usr/local/lib/cairnkeep/container-healthcheck.mjs; then
|
|
116
|
+
echo "FAIL: healthcheck accepted a non-private token file" >&2
|
|
117
|
+
exit 1
|
|
118
|
+
fi
|
|
119
|
+
# Exercise the same legacy entrypoint against a private, container-owned file.
|
|
120
|
+
"$ENGINE" exec "$name" sh -c 'umask 077; cat /run/secrets/http-token > /tmp/health-token; CAIRN_MEMORY_HTTP_TOKEN_FILE=/tmp/health-token node /usr/local/lib/cairnkeep/container-healthcheck.mjs; status=$?; rm -f /tmp/health-token; exit "$status"'
|
|
114
121
|
"$ENGINE" rm -f "$name" >/dev/null
|
|
115
122
|
|
|
116
123
|
# Replacement boot with the same volume must recover the canary.
|
|
@@ -192,5 +192,13 @@ for phrase in \
|
|
|
192
192
|
}
|
|
193
193
|
done
|
|
194
194
|
grep -qF '`cairn security` | L28' "$coverage"
|
|
195
|
+
for file in "$l28" "$video28" docs/security-assurance.md; do
|
|
196
|
+
for phrase in 'smoke-note-signatures.mjs' 'smoke-runtime-file-reads.mjs' 'smoke-bounded-input-reads.mjs' 'smoke-workspace-file-security.mjs' 'smoke-outbound-security.mjs' 'test-runtime-file-security.mjs' 'test-omp-staged-security.mjs' 'test-container-health.mjs' 'coverage-guided'; do
|
|
197
|
+
grep -qF -- "$phrase" "$file" || {
|
|
198
|
+
echo "Security learning document missing runtime regression boundary: $file ($phrase)" >&2
|
|
199
|
+
exit 1
|
|
200
|
+
}
|
|
201
|
+
done
|
|
202
|
+
done
|
|
195
203
|
|
|
196
204
|
echo "PASS: public learning path structure, readiness, links, and version alignment"
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { syncBuiltinESMExports } from "node:module";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
|
|
8
|
+
const repository = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
|
+
const root = fs.mkdtempSync(join(tmpdir(), "cairn-omp-staged-security-"));
|
|
10
|
+
const originalRead = fs.readFileSync;
|
|
11
|
+
const originalDescriptorRead = fs.readSync;
|
|
12
|
+
const originalOpen = fs.openSync;
|
|
13
|
+
const failures = [];
|
|
14
|
+
function restore() {
|
|
15
|
+
fs.readFileSync = originalRead;
|
|
16
|
+
fs.readSync = originalDescriptorRead;
|
|
17
|
+
fs.openSync = originalOpen;
|
|
18
|
+
syncBuiltinESMExports();
|
|
19
|
+
}
|
|
20
|
+
function mock() {
|
|
21
|
+
const handlers = new Map();
|
|
22
|
+
const commands = new Map();
|
|
23
|
+
const messages = [];
|
|
24
|
+
return {
|
|
25
|
+
handlers, commands, messages,
|
|
26
|
+
pi: {
|
|
27
|
+
on(event, handler) { handlers.set(event, handler); },
|
|
28
|
+
registerCommand(name, command) { commands.set(name, command); },
|
|
29
|
+
getAllTools() { return []; },
|
|
30
|
+
sendMessage(payload) { messages.push(payload); },
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function context(project, ui) {
|
|
35
|
+
return { cwd: project, agent: { kind: "main" }, hasUI: true, ui: { notify(text) { ui.push(text); } } };
|
|
36
|
+
}
|
|
37
|
+
async function control(name, operation) {
|
|
38
|
+
try { await operation(); }
|
|
39
|
+
catch (error) { failures.push(`${name}: ${error.message}`); }
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
fs.writeFileSync(join(root, "package.json"), '{"type":"module"}');
|
|
43
|
+
const extension = join(root, "capture.ts");
|
|
44
|
+
fs.writeFileSync(extension, fs.readFileSync(join(repository, "omp", "extensions", "cairnkeep-capture.ts"), "utf8").replaceAll("@@INFRA_ROOT@@", repository.replaceAll("\\", "/")));
|
|
45
|
+
const { default: factory } = await import(pathToFileURL(extension).href);
|
|
46
|
+
const project = join(root, "project");
|
|
47
|
+
const staging = join(project, ".planning", "memory-staging");
|
|
48
|
+
fs.mkdirSync(staging, { recursive: true });
|
|
49
|
+
const staged = join(staging, "fixture.json");
|
|
50
|
+
const payload = '{"candidates":[{"value":"reviewed fixture"}]}';
|
|
51
|
+
fs.writeFileSync(staged, payload);
|
|
52
|
+
let instance = mock();
|
|
53
|
+
factory(instance.pi);
|
|
54
|
+
let ui = [];
|
|
55
|
+
await instance.handlers.get("session_start")({}, context(project, ui));
|
|
56
|
+
assert.equal(instance.messages.length, 1);
|
|
57
|
+
assert.match(ui[0], /1 memory candidate file/);
|
|
58
|
+
assert.match(instance.messages[0].content, /memory_write/);
|
|
59
|
+
|
|
60
|
+
const outside = join(root, "outside.json");
|
|
61
|
+
fs.writeFileSync(outside, '{"candidates":[{"value":"private-external-sentinel"}]}');
|
|
62
|
+
if (process.platform !== "win32") {
|
|
63
|
+
fs.symlinkSync(outside, join(staging, "linked.json"));
|
|
64
|
+
await control("OMP staged symlink exclusion", async () => {
|
|
65
|
+
instance = mock(); factory(instance.pi); ui = [];
|
|
66
|
+
await instance.handlers.get("session_start")({}, context(project, ui));
|
|
67
|
+
assert.match(ui[0], /1 memory candidate file/);
|
|
68
|
+
ui = [];
|
|
69
|
+
await instance.commands.get("cairn-staged").handler("", context(project, ui));
|
|
70
|
+
assert.equal(ui.join("\n").includes("private-external-sentinel"), false);
|
|
71
|
+
});
|
|
72
|
+
fs.unlinkSync(join(staging, "linked.json"));
|
|
73
|
+
}
|
|
74
|
+
await control("OMP staged growth", async () => {
|
|
75
|
+
const descriptors = new Set();
|
|
76
|
+
let grew = false;
|
|
77
|
+
let unbounded = false;
|
|
78
|
+
const change = () => {
|
|
79
|
+
if (!grew) { grew = true; fs.appendFileSync(staged, " ".repeat(64 * 1024)); }
|
|
80
|
+
};
|
|
81
|
+
fs.openSync = (path, ...args) => {
|
|
82
|
+
const fd = originalOpen(path, ...args);
|
|
83
|
+
if (path === staged) descriptors.add(fd);
|
|
84
|
+
return fd;
|
|
85
|
+
};
|
|
86
|
+
fs.readSync = (fd, ...args) => { if (descriptors.has(fd)) change(); return originalDescriptorRead(fd, ...args); };
|
|
87
|
+
fs.readFileSync = (path, ...args) => {
|
|
88
|
+
if (path === staged || descriptors.has(path)) { unbounded = true; change(); }
|
|
89
|
+
return originalRead(path, ...args);
|
|
90
|
+
};
|
|
91
|
+
syncBuiltinESMExports();
|
|
92
|
+
instance = mock(); factory(instance.pi); ui = [];
|
|
93
|
+
try {
|
|
94
|
+
await instance.handlers.get("session_start")({}, context(project, ui));
|
|
95
|
+
assert.equal(grew, true);
|
|
96
|
+
assert.equal(unbounded, false);
|
|
97
|
+
assert.equal(instance.messages.length, 0, "changed staged content must not become wakeup evidence");
|
|
98
|
+
} finally { restore(); }
|
|
99
|
+
});
|
|
100
|
+
fs.writeFileSync(staged, payload);
|
|
101
|
+
fs.writeFileSync(join(staging, "oversized.json"), '{"candidates":[{"value":"oversized-sentinel"}]}' + " ".repeat(8192));
|
|
102
|
+
ui = []; instance = mock(); factory(instance.pi);
|
|
103
|
+
await instance.commands.get("cairn-staged").handler("", context(project, ui));
|
|
104
|
+
await control("OMP listing bound", () => { assert.equal(ui.join("\n").includes("oversized-sentinel"), false); });
|
|
105
|
+
assert.deepEqual(failures, [], "staged inspection must be bounded, contained and fail open without trusting unsafe content");
|
|
106
|
+
} finally {
|
|
107
|
+
restore();
|
|
108
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
109
|
+
}
|
|
110
|
+
console.log("PASS: OMP staged inspection rejects symlinks, growth and oversized content without blocking the session");
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { syncBuiltinESMExports } from "node:module";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { gzipSync } from "node:zlib";
|
|
8
|
+
|
|
9
|
+
import { readSetupPolicy, buildSetupPlan } from "./setup-core.mjs";
|
|
10
|
+
import { reconcileSetupPlan } from "./setup-reconcile.mjs";
|
|
11
|
+
import { diagnoseSetup } from "./setup.mjs";
|
|
12
|
+
import { reconcilePlaybookInstructions } from "./playbook-instructions.mjs";
|
|
13
|
+
import { createTar, runWindowsCommand } from "./windows-platform.mjs";
|
|
14
|
+
import { readStableFile, readStableText } from "./lib/stable-file.mjs";
|
|
15
|
+
|
|
16
|
+
const root = fs.mkdtempSync(join(tmpdir(), "cairn-cli-file-security-"));
|
|
17
|
+
const failures = [];
|
|
18
|
+
const original = {
|
|
19
|
+
lstat: fs.lstatSync, open: fs.openSync, close: fs.closeSync,
|
|
20
|
+
read: fs.readSync, readFile: fs.readFileSync,
|
|
21
|
+
rename: fs.renameSync, write: fs.writeFileSync,
|
|
22
|
+
};
|
|
23
|
+
let swap = 0;
|
|
24
|
+
function restore() {
|
|
25
|
+
fs.lstatSync = original.lstat;
|
|
26
|
+
fs.openSync = original.open;
|
|
27
|
+
fs.closeSync = original.close;
|
|
28
|
+
fs.readSync = original.read;
|
|
29
|
+
fs.readFileSync = original.readFile;
|
|
30
|
+
fs.renameSync = original.rename;
|
|
31
|
+
fs.writeFileSync = original.write;
|
|
32
|
+
syncBuiltinESMExports();
|
|
33
|
+
}
|
|
34
|
+
function write(path, value, mode = 0o600) {
|
|
35
|
+
fs.mkdirSync(dirname(path), { recursive: true });
|
|
36
|
+
fs.writeFileSync(path, value, { mode });
|
|
37
|
+
}
|
|
38
|
+
async function control(name, operation) {
|
|
39
|
+
try { await operation(); }
|
|
40
|
+
catch (error) { failures.push(`${name}: ${error.message}`); }
|
|
41
|
+
}
|
|
42
|
+
async function substitute(path, occurrence, replacement, operation) {
|
|
43
|
+
const candidate = join(root, "swaps", `${++swap}.candidate`);
|
|
44
|
+
const inspected = join(root, "swaps", `${swap}.inspected`);
|
|
45
|
+
write(candidate, replacement, fs.statSync(path).mode & 0o777);
|
|
46
|
+
let calls = 0;
|
|
47
|
+
let injected = false;
|
|
48
|
+
fs.lstatSync = (target, ...args) => {
|
|
49
|
+
const info = original.lstat(target, ...args);
|
|
50
|
+
if (target === path && ++calls === occurrence) {
|
|
51
|
+
injected = true;
|
|
52
|
+
fs.renameSync(path, inspected);
|
|
53
|
+
fs.renameSync(candidate, path);
|
|
54
|
+
}
|
|
55
|
+
return info;
|
|
56
|
+
};
|
|
57
|
+
syncBuiltinESMExports();
|
|
58
|
+
try { await operation(); }
|
|
59
|
+
finally { restore(); assert.equal(injected, true, "the actual reader must encounter replacement"); }
|
|
60
|
+
}
|
|
61
|
+
async function grow(path, operation) {
|
|
62
|
+
const inspectedSize = fs.statSync(path).size;
|
|
63
|
+
const descriptors = new Set();
|
|
64
|
+
let injected = false;
|
|
65
|
+
let unbounded = false;
|
|
66
|
+
const allocations = [];
|
|
67
|
+
function change() {
|
|
68
|
+
if (!injected) { injected = true; fs.appendFileSync(path, " ".repeat(2 * 1024 * 1024)); }
|
|
69
|
+
}
|
|
70
|
+
fs.openSync = (target, ...args) => {
|
|
71
|
+
const fd = original.open(target, ...args);
|
|
72
|
+
if (target === path) descriptors.add(fd);
|
|
73
|
+
return fd;
|
|
74
|
+
};
|
|
75
|
+
fs.closeSync = (fd) => { descriptors.delete(fd); return original.close(fd); };
|
|
76
|
+
fs.readSync = (fd, buffer, ...args) => {
|
|
77
|
+
if (descriptors.has(fd)) { change(); allocations.push(buffer.byteLength); }
|
|
78
|
+
return original.read(fd, buffer, ...args);
|
|
79
|
+
};
|
|
80
|
+
fs.readFileSync = (target, ...args) => {
|
|
81
|
+
if (target === path || descriptors.has(target)) { unbounded = true; change(); }
|
|
82
|
+
return original.readFile(target, ...args);
|
|
83
|
+
};
|
|
84
|
+
syncBuiltinESMExports();
|
|
85
|
+
try { await operation(); }
|
|
86
|
+
finally {
|
|
87
|
+
restore();
|
|
88
|
+
assert.equal(injected, true, "the actual reader must encounter growth");
|
|
89
|
+
assert.equal(unbounded, false, "CLI readers must not use an unbounded readFile result");
|
|
90
|
+
assert.ok(allocations.every((size) => size <= inspectedSize + 1));
|
|
91
|
+
assert.equal(descriptors.size, 0, "failed reads must close descriptors");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const missingProject = join(root, "not-created-yet");
|
|
97
|
+
assert.equal(reconcilePlaybookInstructions(missingProject, { check: true }).status, "would-created");
|
|
98
|
+
assert.equal(fs.existsSync(missingProject), false, "instruction preflight must not create a new project");
|
|
99
|
+
assert.throws(() => reconcilePlaybookInstructions(missingProject), /ENOENT/);
|
|
100
|
+
const helperFixture = join(root, "helper.txt");
|
|
101
|
+
write(helperFixture, "bounded", 0o644);
|
|
102
|
+
assert.equal(readStableText(helperFixture, { label: "Fixture", maxBytes: 7 }).text, "bounded");
|
|
103
|
+
fs.readSync = (descriptor, buffer, offset, length, position) => original.read(descriptor, buffer, offset, Math.min(2, length), position);
|
|
104
|
+
syncBuiltinESMExports();
|
|
105
|
+
try { assert.equal(readStableText(helperFixture, { label: "Fixture", maxBytes: 7 }).text, "bounded", "partial reads must assemble complete content"); }
|
|
106
|
+
finally { restore(); }
|
|
107
|
+
let readAttempts = 0;
|
|
108
|
+
fs.readSync = (...args) => { readAttempts += 1; return original.read(...args); };
|
|
109
|
+
syncBuiltinESMExports();
|
|
110
|
+
try {
|
|
111
|
+
assert.throws(() => readStableFile(helperFixture, { label: "Fixture", maxBytes: 6 }), /unsafe/);
|
|
112
|
+
if (process.platform !== "win32") assert.throws(() => readStableFile("/dev/null", { label: "Fixture", maxBytes: 7 }), /unsafe/);
|
|
113
|
+
assert.equal(readAttempts, 0, "oversized and special files must be rejected before content reads");
|
|
114
|
+
} finally { restore(); }
|
|
115
|
+
write(helperFixture, Buffer.from([0xff]), 0o644);
|
|
116
|
+
assert.throws(() => readStableText(helperFixture, { label: "Fixture", maxBytes: 7 }), /UTF-8/);
|
|
117
|
+
if (process.platform !== "win32") assert.throws(() => readStableFile(helperFixture, { label: "Fixture", maxBytes: 7, privateMode: 0o600 }), /unsafe/);
|
|
118
|
+
|
|
119
|
+
const policyPath = join(root, "policy.json");
|
|
120
|
+
write(policyPath, '{"schema_version":1,"defaults":{},"constraints":{}}', 0o644);
|
|
121
|
+
assert.equal(readSetupPolicy(policyPath).schema_version, 1);
|
|
122
|
+
await control("setup policy growth", () => grow(policyPath, () => {
|
|
123
|
+
assert.throws(() => readSetupPolicy(policyPath), /unsafe|changed|limit/i);
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
const project = join(root, "project");
|
|
127
|
+
fs.mkdirSync(project);
|
|
128
|
+
const plan = buildSetupPlan({
|
|
129
|
+
target: project,
|
|
130
|
+
preflight: { targetState: "empty", gitExecutable: "available", repository: "work-tree" },
|
|
131
|
+
choices: { git: "existing", harnesses: ["claude"], memory: "local", confirmed: true },
|
|
132
|
+
});
|
|
133
|
+
const created = await reconcileSetupPlan(plan);
|
|
134
|
+
assert.equal(diagnoseSetup(project).status, "complete");
|
|
135
|
+
const rerun = await reconcileSetupPlan(plan, { previousState: created.state });
|
|
136
|
+
assert.deepEqual(rerun.counts, { created: 0, updated: 0, unchanged: plan.assets.length, skipped: 0 },
|
|
137
|
+
"native mode representation must not discard generated-file ownership");
|
|
138
|
+
const statePath = join(project, ".ai", "cairnkeep.json");
|
|
139
|
+
const stateBytes = fs.readFileSync(statePath);
|
|
140
|
+
await control("setup diagnosis state growth", () => grow(statePath, () => {
|
|
141
|
+
assert.equal(diagnoseSetup(project).status, "incomplete");
|
|
142
|
+
}));
|
|
143
|
+
write(statePath, stateBytes);
|
|
144
|
+
const owned = join(project, ".ai", "start-claude.sh");
|
|
145
|
+
const ownedBytes = fs.readFileSync(owned);
|
|
146
|
+
write(owned, "# Caller-owned launcher.\n", 0o755);
|
|
147
|
+
await control("setup diagnosis owned replacement", () => substitute(owned, 1, ownedBytes, () => {
|
|
148
|
+
assert.equal(diagnoseSetup(project).status, "incomplete", "replacement must not restore recorded ownership");
|
|
149
|
+
}));
|
|
150
|
+
write(owned, "# Caller-owned launcher.\n", 0o755);
|
|
151
|
+
await control("setup reconciliation ownership replacement", () => substitute(owned, 2, ownedBytes, async () => {
|
|
152
|
+
await assert.rejects(() => reconcileSetupPlan(plan, { previousState: created.state }), /unsafe|changed/i);
|
|
153
|
+
}));
|
|
154
|
+
write(owned, ownedBytes, 0o755);
|
|
155
|
+
await control("setup mutation ownership recheck", async () => {
|
|
156
|
+
const update = { ...plan, assets: [ { ...plan.assets.find((asset) => asset.path === ".ai/start-claude.sh"), bytes: Buffer.from("# Generated update.\n") } ] };
|
|
157
|
+
let observed;
|
|
158
|
+
let inspected = false;
|
|
159
|
+
let injected = false;
|
|
160
|
+
fs.openSync = (path, ...args) => {
|
|
161
|
+
const descriptor = original.open(path, ...args);
|
|
162
|
+
if (path === owned) observed = descriptor;
|
|
163
|
+
return descriptor;
|
|
164
|
+
};
|
|
165
|
+
fs.closeSync = (descriptor) => {
|
|
166
|
+
if (descriptor === observed) inspected = true;
|
|
167
|
+
return original.close(descriptor);
|
|
168
|
+
};
|
|
169
|
+
fs.lstatSync = (path, ...args) => {
|
|
170
|
+
if (inspected && !injected && path === dirname(owned)) {
|
|
171
|
+
injected = true;
|
|
172
|
+
original.write(owned, "# Concurrent caller rule.\n");
|
|
173
|
+
}
|
|
174
|
+
return original.lstat(path, ...args);
|
|
175
|
+
};
|
|
176
|
+
syncBuiltinESMExports();
|
|
177
|
+
try {
|
|
178
|
+
await assert.rejects(() => reconcileSetupPlan(update, { previousState: created.state }), /changed|unsafe/i);
|
|
179
|
+
assert.equal(injected, true);
|
|
180
|
+
assert.equal(fs.readFileSync(owned, "utf8"), "# Concurrent caller rule.\n");
|
|
181
|
+
} finally { restore(); write(owned, ownedBytes, 0o755); }
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const agents = join(project, "AGENTS.md");
|
|
185
|
+
write(agents, "# User rules\n\nRetain this line.\n", 0o640);
|
|
186
|
+
assert.equal(reconcilePlaybookInstructions(project, { check: true }).status, "would-updated");
|
|
187
|
+
await control("playbook instructions growth", () => grow(agents, () => {
|
|
188
|
+
assert.throws(() => reconcilePlaybookInstructions(project, { check: true }), /unsafe|changed|bounded/i);
|
|
189
|
+
}));
|
|
190
|
+
write(agents, "# User rules\n\nRetain this line.\n", 0o640);
|
|
191
|
+
fs.chmodSync(agents, 0o640);
|
|
192
|
+
assert.equal(reconcilePlaybookInstructions(project).status, "updated");
|
|
193
|
+
assert.match(fs.readFileSync(agents, "utf8"), /Retain this line/);
|
|
194
|
+
if (process.platform !== "win32") assert.equal(fs.statSync(agents).mode & 0o777, 0o640);
|
|
195
|
+
assert.equal(reconcilePlaybookInstructions(project).status, "unchanged");
|
|
196
|
+
await control("instruction mutation ownership recheck", () => {
|
|
197
|
+
write(agents, "# Prior instructions\n", 0o640);
|
|
198
|
+
let injected = false;
|
|
199
|
+
fs.writeFileSync = (path, ...args) => {
|
|
200
|
+
if (!injected && typeof path === "string" && basename(path).startsWith(".AGENTS.md.")) {
|
|
201
|
+
injected = true;
|
|
202
|
+
original.write(agents, "# Concurrent caller rule.\n");
|
|
203
|
+
}
|
|
204
|
+
return original.write(path, ...args);
|
|
205
|
+
};
|
|
206
|
+
fs.openSync = (path, ...args) => {
|
|
207
|
+
if (!injected && typeof path === "string" && basename(path).startsWith(".AGENTS.md.")) {
|
|
208
|
+
injected = true;
|
|
209
|
+
original.write(agents, "# Concurrent caller rule.\n");
|
|
210
|
+
}
|
|
211
|
+
return original.open(path, ...args);
|
|
212
|
+
};
|
|
213
|
+
syncBuiltinESMExports();
|
|
214
|
+
try {
|
|
215
|
+
assert.throws(() => reconcilePlaybookInstructions(project), /changed|unsafe/i);
|
|
216
|
+
assert.equal(injected, true);
|
|
217
|
+
assert.equal(fs.readFileSync(agents, "utf8"), "# Concurrent caller rule.\n");
|
|
218
|
+
assert.equal(fs.readdirSync(project).some((name) => name.startsWith(".AGENTS.md.")), false);
|
|
219
|
+
} finally { restore(); }
|
|
220
|
+
});
|
|
221
|
+
const unapproved = join(root, "unapproved");
|
|
222
|
+
fs.mkdirSync(unapproved);
|
|
223
|
+
write(join(unapproved, "AGENTS.md"), "# Private rule\n", 0o640);
|
|
224
|
+
if (process.platform !== "win32") {
|
|
225
|
+
const linkedProject = join(root, "linked-project");
|
|
226
|
+
fs.symlinkSync(unapproved, linkedProject, "dir");
|
|
227
|
+
await control("playbook project symlink", () => {
|
|
228
|
+
assert.throws(() => reconcilePlaybookInstructions(linkedProject), /unsafe|symlink|directory/i);
|
|
229
|
+
assert.equal(fs.readFileSync(join(unapproved, "AGENTS.md"), "utf8"), "# Private rule\n");
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const archive = join(root, "memory.tgz");
|
|
234
|
+
const archiveBytes = gzipSync(createTar([["project.db", Buffer.from("New database fixture.")]]));
|
|
235
|
+
write(archive, archiveBytes);
|
|
236
|
+
const memory = join(root, "memory");
|
|
237
|
+
const database = join(memory, "project.db");
|
|
238
|
+
write(database, "Prior database fixture.");
|
|
239
|
+
const repository = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
240
|
+
const previousBase = process.env.CAIRN_AGENTFS_BASE_DIR;
|
|
241
|
+
process.env.CAIRN_AGENTFS_BASE_DIR = memory;
|
|
242
|
+
try {
|
|
243
|
+
await control("Windows archive growth", () => grow(archive, async () => {
|
|
244
|
+
await assert.rejects(() => runWindowsCommand({ command: "memory", args: ["import", archive], root: repository }), /unsafe|changed/i);
|
|
245
|
+
}));
|
|
246
|
+
write(archive, archiveBytes);
|
|
247
|
+
fs.renameSync = (source, destination) => {
|
|
248
|
+
if (destination === database) throw Object.assign(new Error("Synthetic denied replacement."), { code: "EPERM" });
|
|
249
|
+
return original.rename(source, destination);
|
|
250
|
+
};
|
|
251
|
+
syncBuiltinESMExports();
|
|
252
|
+
await control("Windows denied replacement retention", async () => {
|
|
253
|
+
try {
|
|
254
|
+
await assert.rejects(() => runWindowsCommand({ command: "memory", args: ["import", archive], root: repository }), /denied/i);
|
|
255
|
+
assert.equal(fs.readFileSync(database, "utf8"), "Prior database fixture.", "replacement failure must retain the live database");
|
|
256
|
+
assert.deepEqual(fs.readdirSync(memory).filter((name) => name.includes(".tmp-")), [], "failed replacement must clean its own temporary files");
|
|
257
|
+
} finally { restore(); }
|
|
258
|
+
});
|
|
259
|
+
write(database, "Prior database fixture.");
|
|
260
|
+
let denied = 0;
|
|
261
|
+
fs.renameSync = (source, destination) => {
|
|
262
|
+
if (destination === database && denied++ < 2) {
|
|
263
|
+
assert.equal(fs.readFileSync(database, "utf8"), "Prior database fixture.");
|
|
264
|
+
throw Object.assign(new Error("Synthetic transient sharing violation."), { code: "EBUSY" });
|
|
265
|
+
}
|
|
266
|
+
return original.rename(source, destination);
|
|
267
|
+
};
|
|
268
|
+
syncBuiltinESMExports();
|
|
269
|
+
try {
|
|
270
|
+
await runWindowsCommand({ command: "memory", args: ["import", archive], root: repository });
|
|
271
|
+
assert.equal(denied, 3);
|
|
272
|
+
assert.equal(fs.readFileSync(database, "utf8"), "New database fixture.");
|
|
273
|
+
} finally { restore(); }
|
|
274
|
+
write(database, "Prior database fixture.");
|
|
275
|
+
if (process.platform !== "win32") {
|
|
276
|
+
const outside = join(root, "outside-target");
|
|
277
|
+
write(outside, "Outside sentinel.");
|
|
278
|
+
let injected = false;
|
|
279
|
+
let collidingTemporary;
|
|
280
|
+
function substituteTemporary(path) {
|
|
281
|
+
if (!injected && typeof path === "string" && dirname(path) === memory
|
|
282
|
+
&& basename(path).startsWith("project.db.tmp-")) {
|
|
283
|
+
injected = true;
|
|
284
|
+
collidingTemporary = path;
|
|
285
|
+
fs.symlinkSync(outside, path);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
fs.openSync = (path, ...args) => { substituteTemporary(path); return original.open(path, ...args); };
|
|
289
|
+
fs.writeFileSync = (path, ...args) => { substituteTemporary(path); return original.write(path, ...args); };
|
|
290
|
+
syncBuiltinESMExports();
|
|
291
|
+
await control("Windows exclusive temporary publication", async () => {
|
|
292
|
+
try {
|
|
293
|
+
await assert.rejects(() => runWindowsCommand({ command: "memory", args: ["import", archive], root: repository }), /exist|unsafe/i);
|
|
294
|
+
assert.equal(injected, true);
|
|
295
|
+
assert.equal(fs.readFileSync(outside, "utf8"), "Outside sentinel.", "temporary symlink must not receive private content");
|
|
296
|
+
assert.equal(fs.readFileSync(database, "utf8"), "Prior database fixture.");
|
|
297
|
+
assert.equal(fs.lstatSync(collidingTemporary).isSymbolicLink(), true, "a collision must not delete an unowned temporary name");
|
|
298
|
+
} finally { restore(); }
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
} finally {
|
|
302
|
+
if (previousBase === undefined) delete process.env.CAIRN_AGENTFS_BASE_DIR;
|
|
303
|
+
else process.env.CAIRN_AGENTFS_BASE_DIR = previousBase;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
assert.deepEqual(failures, [], "CLI file boundaries must fail closed without changing ownership");
|
|
307
|
+
} finally {
|
|
308
|
+
restore();
|
|
309
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
310
|
+
}
|
|
311
|
+
console.log("PASS: bounded setup/instruction/archive reads and denied/exclusive Windows publication");
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { buildSetupPlan } from "./setup-core.mjs";
|
|
7
|
+
import { reconcileSetupPlan } from "./setup-reconcile.mjs";
|
|
8
|
+
import { diagnoseSetup } from "./setup.mjs";
|
|
9
|
+
|
|
10
|
+
const sandbox = mkdtempSync(join(tmpdir(), "cairn-setup-diagnosis-"));
|
|
11
|
+
try {
|
|
12
|
+
const project = join(sandbox, "project");
|
|
13
|
+
mkdirSync(project);
|
|
14
|
+
const plan = buildSetupPlan({
|
|
15
|
+
target: project,
|
|
16
|
+
preflight: { targetState: "empty", gitExecutable: "available", repository: "work-tree" },
|
|
17
|
+
choices: { git: "existing", harnesses: ["claude", "opencode", "omp"], memory: "local", confirmed: true },
|
|
18
|
+
});
|
|
19
|
+
const customPaths = [".ai/env.example", ".ai/start-claude.sh", ".ai/start-opencode.sh", ".planning/graphs/policy.md"];
|
|
20
|
+
for (const path of customPaths) {
|
|
21
|
+
const file = join(project, path);
|
|
22
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
23
|
+
writeFileSync(file, path.endsWith(".sh") ? "#!/bin/sh\n# Project-owned launcher\nexit 0\n" : "Project-owned configuration\n");
|
|
24
|
+
chmodSync(file, path.endsWith(".sh") ? 0o755 : 0o640);
|
|
25
|
+
}
|
|
26
|
+
const before = new Map(customPaths.map(path => [path, { bytes: readFileSync(join(project, path)), mode: statSync(join(project, path)).mode, mtime: statSync(join(project, path)).mtimeMs }]));
|
|
27
|
+
const result = await reconcileSetupPlan(plan);
|
|
28
|
+
assert.equal(result.counts.skipped, customPaths.length);
|
|
29
|
+
for (const path of customPaths) assert.equal(Object.hasOwn(result.state.assets, path), false, "custom file became setup-owned");
|
|
30
|
+
const statePath = join(project, ".ai/cairnkeep.json");
|
|
31
|
+
const stateBytes = readFileSync(statePath);
|
|
32
|
+
assert.equal(diagnoseSetup(project).status, "complete", "safe preserved custom assets must not make setup incomplete");
|
|
33
|
+
for (const [path, saved] of before) {
|
|
34
|
+
assert.deepEqual(readFileSync(join(project, path)), saved.bytes);
|
|
35
|
+
assert.equal(statSync(join(project, path)).mode, saved.mode);
|
|
36
|
+
assert.equal(statSync(join(project, path)).mtimeMs, saved.mtime);
|
|
37
|
+
}
|
|
38
|
+
assert.deepEqual(readFileSync(statePath), stateBytes, "diagnosis rewrote ownership state");
|
|
39
|
+
|
|
40
|
+
for (const path of customPaths) {
|
|
41
|
+
const file = join(project, path), saved = `${file}.saved`;
|
|
42
|
+
renameSync(file, saved);
|
|
43
|
+
assert.equal(diagnoseSetup(project).status, "incomplete", "missing unowned asset accepted");
|
|
44
|
+
mkdirSync(file);
|
|
45
|
+
assert.equal(diagnoseSetup(project).status, "incomplete", "directory asset accepted");
|
|
46
|
+
rmSync(file, { recursive: true });
|
|
47
|
+
if (process.platform !== "win32") {
|
|
48
|
+
symlinkSync(saved, file);
|
|
49
|
+
assert.equal(diagnoseSetup(project).status, "incomplete", "symlink asset accepted");
|
|
50
|
+
rmSync(file);
|
|
51
|
+
}
|
|
52
|
+
renameSync(saved, file);
|
|
53
|
+
}
|
|
54
|
+
if (process.platform !== "win32") {
|
|
55
|
+
const launcher = join(project, ".ai/start-claude.sh");
|
|
56
|
+
chmodSync(launcher, 0o644);
|
|
57
|
+
assert.equal(diagnoseSetup(project).status, "incomplete", "non-executable custom launcher accepted");
|
|
58
|
+
chmodSync(launcher, 0o755);
|
|
59
|
+
const directory = join(project, ".planning/graphs"), saved = `${directory}.saved`;
|
|
60
|
+
renameSync(directory, saved);
|
|
61
|
+
symlinkSync(saved, directory, "dir");
|
|
62
|
+
assert.equal(diagnoseSetup(project).status, "incomplete", "symlinked asset ancestor accepted");
|
|
63
|
+
rmSync(directory);
|
|
64
|
+
renameSync(saved, directory);
|
|
65
|
+
}
|
|
66
|
+
const owned = join(project, ".ai/start-omp.sh"), ownedBytes = readFileSync(owned);
|
|
67
|
+
writeFileSync(owned, "owned drift\n");
|
|
68
|
+
assert.equal(diagnoseSetup(project).status, "incomplete", "owned digest drift accepted");
|
|
69
|
+
writeFileSync(owned, ownedBytes);
|
|
70
|
+
assert.equal(diagnoseSetup(project).status, "complete");
|
|
71
|
+
|
|
72
|
+
// An ownership map can legitimately be empty when every asset was preserved.
|
|
73
|
+
const unowned = { ...result.state, assets: {} };
|
|
74
|
+
writeFileSync(statePath, JSON.stringify(unowned) + "\n");
|
|
75
|
+
chmodSync(statePath, 0o600);
|
|
76
|
+
assert.equal(diagnoseSetup(project).status, "complete", "all-custom setup falsely requires owned files");
|
|
77
|
+
assert.deepEqual(JSON.parse(readFileSync(statePath)), unowned);
|
|
78
|
+
assert.equal(existsSync(join(project, ".ai/start-omp.sh")), true);
|
|
79
|
+
|
|
80
|
+
const codexProject = join(sandbox, "codex");
|
|
81
|
+
mkdirSync(codexProject);
|
|
82
|
+
const codexPlan = buildSetupPlan({
|
|
83
|
+
target: codexProject,
|
|
84
|
+
preflight: { targetState: "empty", gitExecutable: "available", repository: "work-tree" },
|
|
85
|
+
choices: { git: "existing", harnesses: ["codex"], memory: "local", confirmed: true },
|
|
86
|
+
});
|
|
87
|
+
const codexResult = await reconcileSetupPlan(codexPlan);
|
|
88
|
+
const codexState = join(codexProject, ".ai/cairnkeep.json");
|
|
89
|
+
writeFileSync(codexState, JSON.stringify({ ...codexResult.state, assets: {} }) + "\n");
|
|
90
|
+
chmodSync(codexState, 0o600);
|
|
91
|
+
assert.equal(diagnoseSetup(codexProject).status, "complete");
|
|
92
|
+
writeFileSync(join(codexProject, ".codex/config.toml"), "# Caller-owned but no memory server configured\n");
|
|
93
|
+
assert.equal(diagnoseSetup(codexProject).status, "incomplete", "empty ownership map bypassed Codex MCP validation");
|
|
94
|
+
console.log("PASS: setup diagnosis preserves custom ownership and rejects missing, unsafe, non-executable and drifted assets");
|
|
95
|
+
} finally {
|
|
96
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
97
|
+
}
|
|
@@ -19,6 +19,11 @@ import { runNativeContainer } from "./cairn-container-cli.mjs";
|
|
|
19
19
|
import { HARNESS_IDS } from "./harness-registry.mjs";
|
|
20
20
|
|
|
21
21
|
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
22
|
+
// Runs on real Windows and POSIX, independently of shell-only doctor checks.
|
|
23
|
+
await import("./test-setup-diagnosis.mjs");
|
|
24
|
+
await import("./test-runtime-file-security.mjs");
|
|
25
|
+
await import("./test-container-health.mjs");
|
|
26
|
+
await import("./test-omp-staged-security.mjs");
|
|
22
27
|
const sandbox = mkdtempSync(join(tmpdir(), "cairn-windows-native-"));
|
|
23
28
|
const originalBase = process.env.CAIRN_AGENTFS_BASE_DIR;
|
|
24
29
|
const originalPackBase = process.env.CAIRN_PACK_BASE_DIR;
|
|
@@ -79,6 +79,7 @@ function writeFakeInfra(sandbox) {
|
|
|
79
79
|
writeFileSync(stub, STUB_SERVER);
|
|
80
80
|
chmodSync(stub, 0o755);
|
|
81
81
|
copyFileSync(stageWrapper, join(fakeRoot, "scripts", "lib", "omp-capture-stage.mjs"));
|
|
82
|
+
copyFileSync(join(root, "scripts", "lib", "stable-file.mjs"), join(fakeRoot, "scripts", "lib", "stable-file.mjs"));
|
|
82
83
|
return fakeRoot;
|
|
83
84
|
}
|
|
84
85
|
|