@cairnkeep/cli 2.22.2 → 2.22.3
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 +11 -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 +2 -2
- package/docs/operating.md +9 -1
- package/examples/eval/bundled-fake.json +1 -1
- package/package.json +1 -1
- package/scripts/setup.mjs +18 -3
- package/scripts/test-setup-diagnosis.mjs +97 -0
- package/scripts/test-windows-native.mjs +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ All notable user-facing changes are documented here. This project follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [2.22.3] - 2026-10-07
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Diagnose preserved custom setup files separately from generated-file
|
|
13
|
+
ownership. A complete scaffold no longer fails merely because setup skipped
|
|
14
|
+
caller-owned files or its ownership map is empty. Keep missing/unsafe asset,
|
|
15
|
+
launcher-executable and recorded digest/mode checks; never adopt, rewrite or
|
|
16
|
+
remove custom files during diagnosis. Add cross-platform regression controls
|
|
17
|
+
and clarify operating and guided-setup learning documentation.
|
|
18
|
+
|
|
8
19
|
## [2.22.2] - 2026-10-07
|
|
9
20
|
|
|
10
21
|
### Security
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
**Status:** Ready
|
|
4
4
|
**Track:** Quickstart
|
|
5
5
|
**Time:** 15 minutes
|
|
6
|
-
**Tested with:** Cairnkeep 2.22.
|
|
6
|
+
**Tested with:** Cairnkeep 2.22.3
|
|
7
7
|
|
|
8
8
|
## Outcome
|
|
9
9
|
|
|
@@ -26,7 +26,7 @@ ephemeral; the named course volume is persistent.
|
|
|
26
26
|
1. Pull the public image:
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
podman pull ghcr.io/cairnkeep/cairnkeep:2.22.
|
|
29
|
+
podman pull ghcr.io/cairnkeep/cairnkeep:2.22.3
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
2. Start the stdio server with a read-only root filesystem and no Linux
|
|
@@ -38,7 +38,7 @@ ephemeral; the named course volume is persistent.
|
|
|
38
38
|
--read-only --cap-drop=all --security-opt=no-new-privileges \
|
|
39
39
|
--tmpfs=/tmp:rw,noexec,nosuid,size=64m,mode=1777 \
|
|
40
40
|
--volume cairnkeep-course-data:/data:Z,U \
|
|
41
|
-
ghcr.io/cairnkeep/cairnkeep:2.22.
|
|
41
|
+
ghcr.io/cairnkeep/cairnkeep:2.22.3 stdio
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
3. The process waits quietly for MCP input. Press `Ctrl-C` after confirming it
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
**Status:** Ready
|
|
4
4
|
**Track:** Quickstart
|
|
5
5
|
**Time:** 25 minutes
|
|
6
|
-
**Tested with:** Cairnkeep 2.22.
|
|
6
|
+
**Tested with:** Cairnkeep 2.22.3 and Node.js 22 or newer
|
|
7
7
|
|
|
8
8
|
## Outcome
|
|
9
9
|
|
|
@@ -47,7 +47,7 @@ sync. Codex project trust remains an explicit operator decision.
|
|
|
47
47
|
2. Install Cairnkeep:
|
|
48
48
|
|
|
49
49
|
```bash
|
|
50
|
-
npm install --global @cairnkeep/cli@2.22.
|
|
50
|
+
npm install --global @cairnkeep/cli@2.22.3
|
|
51
51
|
cairn --version
|
|
52
52
|
```
|
|
53
53
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
**Status:** Ready
|
|
4
4
|
**Track:** Operator
|
|
5
5
|
**Time:** 30 minutes
|
|
6
|
-
**Tested with:** Cairnkeep 2.22.
|
|
6
|
+
**Tested with:** Cairnkeep 2.22.3, Windows x64, and Node.js 22 or newer
|
|
7
7
|
|
|
8
8
|
## Outcome
|
|
9
9
|
|
|
@@ -16,7 +16,7 @@ perform a reversible uninstall without relying on WSL or Git Bash.
|
|
|
16
16
|
From PowerShell:
|
|
17
17
|
|
|
18
18
|
```powershell
|
|
19
|
-
npm install --global @cairnkeep/cli@2.22.
|
|
19
|
+
npm install --global @cairnkeep/cli@2.22.3
|
|
20
20
|
cairn version
|
|
21
21
|
|
|
22
22
|
$Lab = Join-Path $env:TEMP 'Cairnkeep Windows Lab'
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
**Status:** Ready
|
|
4
4
|
**Track:** Quickstart and Operator
|
|
5
5
|
**Time:** 35 minutes
|
|
6
|
-
**Tested with:** Cairnkeep 2.22.
|
|
6
|
+
**Tested with:** Cairnkeep 2.22.3; Pi 0.84.1; Node.js 22, 24, and 26
|
|
7
7
|
|
|
8
8
|
## Outcome
|
|
9
9
|
|
|
@@ -54,6 +54,13 @@ records the selected Pi harness in private `.ai/cairnkeep.json`, and reports
|
|
|
54
54
|
created, updated, unchanged, and skipped assets. Repeating the same `cairn
|
|
55
55
|
setup` command should report the managed files as unchanged.
|
|
56
56
|
|
|
57
|
+
When an existing project has customized launchers or scaffold files, setup
|
|
58
|
+
preserves and skips them. Its private asset map tracks ownership, not a full
|
|
59
|
+
inventory. Doctor accepts safely present custom files without adopting them,
|
|
60
|
+
but still rejects missing files, links, non-executable POSIX launchers and
|
|
61
|
+
changes to recorded owned assets. Do not replace a custom file just to satisfy
|
|
62
|
+
a generated digest. Diagnosis does not change file contents or ownership.
|
|
63
|
+
|
|
57
64
|
Project setup never changes the Pi installation. Apply and check machine assets
|
|
58
65
|
explicitly, then diagnose the project again:
|
|
59
66
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# L28 - Verify Cairnkeep's security posture
|
|
2
2
|
|
|
3
3
|
**Status:** Ready
|
|
4
|
-
**Tested with:** Cairnkeep 2.22.
|
|
4
|
+
**Tested with:** Cairnkeep 2.22.3 and Node.js 22 or newer
|
|
5
5
|
|
|
6
6
|
## Outcome
|
|
7
7
|
|
|
@@ -11,7 +11,7 @@ and choose least authority for a network-facing MCP client.
|
|
|
11
11
|
|
|
12
12
|
## Prerequisites
|
|
13
13
|
|
|
14
|
-
- Install Cairnkeep 2.22.
|
|
14
|
+
- Install Cairnkeep 2.22.3 or use a disposable source checkout.
|
|
15
15
|
- Complete [L20 - Least-authority MCP tool profiles](L20-mcp-tool-profiles.md).
|
|
16
16
|
- Use synthetic values only; never paste a real bearer token into a recording,
|
|
17
17
|
terminal transcript, or issue.
|
package/docs/operating.md
CHANGED
|
@@ -94,6 +94,12 @@ returns the same schema-v1 result. The private mode-`0600`
|
|
|
94
94
|
selected harnesses, and digests/modes/template identifiers for setup-owned
|
|
95
95
|
assets. It contains no credentials, endpoints, or absolute paths.
|
|
96
96
|
|
|
97
|
+
The asset map records ownership, not every file in the project. Existing custom
|
|
98
|
+
files that setup skips remain caller-owned. Doctor checks that every required
|
|
99
|
+
selected asset is a regular file under non-symlinked scaffold directories and
|
|
100
|
+
that POSIX launchers are executable, without adopting or rewriting custom files.
|
|
101
|
+
An empty ownership map is valid when all required assets were already present.
|
|
102
|
+
|
|
97
103
|
Setup never installs or refreshes machine-level harness assets. Its
|
|
98
104
|
`machine_sync.automatic` field is always false, and the human output labels each
|
|
99
105
|
reported command as not run automatically. The JSON result's
|
|
@@ -1072,7 +1078,9 @@ Unsupported schema, SQLite failure, invalid full records, or digest corruption
|
|
|
1072
1078
|
fails untouched with guidance to preserve the database before manual recovery.
|
|
1073
1079
|
|
|
1074
1080
|
When `.ai/cairnkeep.json` exists, doctor also validates its private state,
|
|
1075
|
-
selected assets, digests
|
|
1081
|
+
selected assets, and the digests and modes of recorded setup-owned files.
|
|
1082
|
+
Preserved caller-owned files need safe presence, not a generated-file digest;
|
|
1083
|
+
doctor never claims ownership of them. It reports `limited` for explicit
|
|
1076
1084
|
`--git none`, fails an incomplete or drifted setup with the recorded
|
|
1077
1085
|
deterministic `cairn setup` recovery command, and checks the Pi machine
|
|
1078
1086
|
extension when Pi was selected. `cairn sync-pi --apply` repairs that
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"identifier":"cairn-offline-fake-v1","package_version":"2.22.
|
|
1
|
+
{"identifier":"cairn-offline-fake-v1","package_version":"2.22.3","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
|
package/package.json
CHANGED
package/scripts/setup.mjs
CHANGED
|
@@ -344,6 +344,21 @@ function safeStateAsset(path, record) {
|
|
|
344
344
|
&& /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(record.template);
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
+
function safeRequiredAsset(project, path, launcher) {
|
|
348
|
+
// Presence is not ownership: setup intentionally skips pre-existing custom
|
|
349
|
+
// files. Validate the selected asset without adopting or reading its content.
|
|
350
|
+
const parts = path.split("/");
|
|
351
|
+
let destination = project;
|
|
352
|
+
for (const part of parts.slice(0, -1)) {
|
|
353
|
+
destination = join(destination, part);
|
|
354
|
+
const parent = lstatSync(destination);
|
|
355
|
+
if (!parent.isDirectory() || parent.isSymbolicLink()) return false;
|
|
356
|
+
}
|
|
357
|
+
const info = lstatSync(join(destination, parts.at(-1)));
|
|
358
|
+
return info.isFile() && !info.isSymbolicLink()
|
|
359
|
+
&& (!launcher || process.platform === "win32" || (info.mode & 0o100) !== 0);
|
|
360
|
+
}
|
|
361
|
+
|
|
347
362
|
function readPrivateState(path) {
|
|
348
363
|
const info = lstatSync(path);
|
|
349
364
|
if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) throw new Error("unsafe");
|
|
@@ -435,8 +450,7 @@ export function diagnoseSetup(target = ".") {
|
|
|
435
450
|
|| state.harnesses.some((name) => !HARNESSES.includes(name))
|
|
436
451
|
|| !state.assets
|
|
437
452
|
|| typeof state.assets !== "object"
|
|
438
|
-
|| Array.isArray(state.assets)
|
|
439
|
-
|| Object.keys(state.assets).length === 0) return incompleteDiagnosis();
|
|
453
|
+
|| Array.isArray(state.assets)) return incompleteDiagnosis();
|
|
440
454
|
|
|
441
455
|
const recovery = `cairn setup . --git ${state.git} --harness ${state.harnesses.join(",")} --memory ${state.memory} --yes`;
|
|
442
456
|
for (const [path, record] of Object.entries(state.assets)) {
|
|
@@ -449,7 +463,8 @@ export function diagnoseSetup(target = ".") {
|
|
|
449
463
|
if (digest !== record.digest || (process.platform !== "win32" && (info.mode & 0o777) !== record.mode)) return incompleteDiagnosis(recovery);
|
|
450
464
|
}
|
|
451
465
|
const requiredAssets = [...COMMON_SETUP_ASSETS, ...requiredHarnessAssetPaths(state.harnesses, state.memory)];
|
|
452
|
-
|
|
466
|
+
const launchers = new Set(state.harnesses.map((id) => HARNESS_REGISTRY.find((harness) => harness.id === id).launcher.path));
|
|
467
|
+
if (requiredAssets.some((path) => !safeRequiredAsset(project, path, launchers.has(path)))) return incompleteDiagnosis(recovery);
|
|
453
468
|
if (state.memory === "local" && state.harnesses.includes("codex")) {
|
|
454
469
|
if (!hasCodexMemoryConfig(project)) return incompleteDiagnosis(recovery);
|
|
455
470
|
if (hasCodexRemoteMemoryConflict(project)) {
|
|
@@ -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,8 @@ 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");
|
|
22
24
|
const sandbox = mkdtempSync(join(tmpdir(), "cairn-windows-native-"));
|
|
23
25
|
const originalBase = process.env.CAIRN_AGENTFS_BASE_DIR;
|
|
24
26
|
const originalPackBase = process.env.CAIRN_PACK_BASE_DIR;
|