@cairnkeep/cli 2.9.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -1
- package/README.md +54 -25
- package/bin/cairn +6 -165
- package/bin/cairn-container +8 -0
- package/docs/building-an-overlay.md +41 -4
- package/docs/containers.md +10 -0
- package/docs/harness-compatibility.md +30 -2
- package/docs/learning/CURRICULUM-MAP.md +9 -3
- package/docs/learning/FEATURE-GUIDE.md +9 -3
- package/docs/learning/README.md +2 -0
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +11 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
- package/docs/learning/lessons/L21-context-packs.md +1 -1
- package/docs/learning/lessons/L22-native-windows.md +83 -0
- package/docs/learning/lessons/L23-guided-setup.md +138 -0
- package/docs/learning/tracks/operator.md +8 -3
- package/docs/learning/tracks/quickstart.md +7 -3
- package/docs/native-windows.md +122 -0
- package/docs/operating.md +119 -18
- package/docs/privacy-and-data-flow.md +34 -1
- package/docs/storage.md +14 -2
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/artifact-store-windows-worker.js +74 -0
- package/mcp-memory-server/dist/artifact-store.js +196 -55
- package/mcp-memory-server/dist/capability-cli.js +2 -1
- package/mcp-memory-server/dist/capability-config.js +4 -3
- package/mcp-memory-server/dist/capability-harness.js +34 -7
- package/mcp-memory-server/dist/context-pack.js +84 -25
- package/mcp-memory-server/dist/eval-cli.js +6 -5
- package/mcp-memory-server/dist/eval-plan.js +35 -13
- package/mcp-memory-server/dist/eval-process.js +11 -4
- package/mcp-memory-server/dist/eval-report.js +7 -5
- package/mcp-memory-server/dist/graph-cli.js +7 -1
- package/mcp-memory-server/dist/index.js +19 -20
- package/mcp-memory-server/dist/mcp-tool-profile.js +6 -4
- package/mcp-memory-server/dist/note-store.js +12 -3
- package/mcp-memory-server/dist/pi-mcp-bridge.js +185 -0
- package/mcp-memory-server/dist/platform-security.js +102 -0
- package/mcp-memory-server/dist/skill-store.js +27 -12
- package/mcp-memory-server/package.json +2 -1
- package/package.json +5 -4
- package/pi/extensions/cairnkeep-memory.ts +61 -0
- package/schemas/cairnkeep-setup-policy.schema.json +55 -0
- package/schemas/cairnkeep-setup.schema.json +36 -0
- package/scripts/cairn-cli.mjs +119 -0
- package/scripts/cairn-container-cli.mjs +121 -0
- package/scripts/cairn-hook.mjs +199 -0
- package/scripts/completion.sh +12 -3
- package/scripts/doctor.sh +57 -0
- package/scripts/fix-permissions.mjs +12 -0
- package/scripts/memory-wiki-audit.mjs +95 -0
- package/scripts/phase26-test-manifest.mjs +26 -0
- package/scripts/run-repository-tests.mjs +76 -0
- package/scripts/setup-core.mjs +310 -0
- package/scripts/setup-reconcile.mjs +331 -0
- package/scripts/setup.mjs +306 -0
- package/scripts/sync-pi-assets.sh +19 -4
- package/scripts/test-completion.sh +39 -0
- package/scripts/test-doctor.sh +66 -0
- package/scripts/test-learning-docs.sh +52 -3
- package/scripts/test-package-install.sh +64 -0
- package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
- package/scripts/test-phase18-capability-lifecycle.sh +1 -1
- package/scripts/test-pi-lifecycle.mjs +190 -0
- package/scripts/test-setup-compatibility.mjs +186 -0
- package/scripts/test-setup-output.sh +131 -0
- package/scripts/test-setup-overlay.sh +113 -0
- package/scripts/test-setup-preflight.mjs +281 -0
- package/scripts/test-setup-reconcile.mjs +257 -0
- package/scripts/test-uninstall.sh +50 -0
- package/scripts/test-windows-native.mjs +136 -0
- package/scripts/uninstall.sh +69 -6
- package/scripts/verify-pi-mcp-bridge.mjs +588 -0
- package/scripts/windows-platform.mjs +725 -0
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
3
4
|
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
4
6
|
import { AgentFS } from "agentfs-sdk";
|
|
5
7
|
import { ARTIFACT_SCHEMA_VERSION, artifactEnvelopeSchema, artifactWriteInputSchema, canonicalBytes, canonicalJson, getArtifactLimits, sha256Hex, truncateUtf8, } from "./artifact-schema.js";
|
|
6
8
|
import { redactLocalValue } from "./trajectory-redaction.js";
|
|
9
|
+
import { atomicReplace } from "./platform-security.js";
|
|
7
10
|
const META_KEY = "artifact/meta/schema-version";
|
|
8
11
|
const FULL_PREFIX = "artifact/full/";
|
|
9
12
|
const INDEX_PREFIX = "artifact/index/";
|
|
@@ -14,6 +17,40 @@ const SEQUENCE_PREFIX = "compaction/sequence/";
|
|
|
14
17
|
const DIAGNOSTIC_PREFIX = "artifact/meta/unsupported-adapter/";
|
|
15
18
|
const MAX_DIAGNOSTICS = 32;
|
|
16
19
|
const mutationQueues = new Map();
|
|
20
|
+
const artifactWorkerPath = fileURLToPath(new URL("./artifact-store-windows-worker.js", import.meta.url));
|
|
21
|
+
function useWindowsArtifactWorker() {
|
|
22
|
+
return (process.platform === "win32" || process.env.CAIRN_TEST_WINDOWS_ARTIFACT_WORKER === "1")
|
|
23
|
+
&& resolve(process.argv[1] ?? "") !== resolve(artifactWorkerPath);
|
|
24
|
+
}
|
|
25
|
+
async function runArtifactWorker(request) {
|
|
26
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
27
|
+
const child = spawn(process.execPath, [artifactWorkerPath], {
|
|
28
|
+
env: process.env,
|
|
29
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
30
|
+
windowsHide: true,
|
|
31
|
+
});
|
|
32
|
+
let stdout = "";
|
|
33
|
+
let stderr = "";
|
|
34
|
+
child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
|
|
35
|
+
child.stderr.on("data", (chunk) => {
|
|
36
|
+
stderr = `${stderr}${chunk.toString("utf8")}`.slice(-1024 * 1024);
|
|
37
|
+
});
|
|
38
|
+
child.once("error", rejectPromise);
|
|
39
|
+
child.once("close", (code) => {
|
|
40
|
+
if (code !== 0) {
|
|
41
|
+
rejectPromise(new Error(stderr.trim() || "Windows artifact worker failed."));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
resolvePromise(JSON.parse(stdout));
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
rejectPromise(error);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
child.stdin.end(JSON.stringify(request));
|
|
52
|
+
});
|
|
53
|
+
}
|
|
17
54
|
export function getArtifactDbPath(projectRoot = process.cwd()) {
|
|
18
55
|
return resolve(projectRoot, ".agentfs", "artifacts.db");
|
|
19
56
|
}
|
|
@@ -273,7 +310,7 @@ async function rebuildPointers(agent) {
|
|
|
273
310
|
if (latest)
|
|
274
311
|
await agent.kv.set(PROJECT_LATEST_KEY, latest.artifact_id);
|
|
275
312
|
}
|
|
276
|
-
async function rewriteArtifactDatabase(projectRoot, rows) {
|
|
313
|
+
async function rewriteArtifactDatabase(projectRoot, rows, deferReplacement = false) {
|
|
277
314
|
const dbPath = getArtifactDbPath(projectRoot);
|
|
278
315
|
const rewritePath = `${dbPath}.rewrite-${randomUUID()}`;
|
|
279
316
|
const rewrite = await AgentFS.open({ id: "artifacts", path: rewritePath });
|
|
@@ -288,11 +325,33 @@ async function rewriteArtifactDatabase(projectRoot, rows) {
|
|
|
288
325
|
await rewrite.close();
|
|
289
326
|
}
|
|
290
327
|
chmodSync(rewritePath, 0o600);
|
|
328
|
+
if (deferReplacement)
|
|
329
|
+
return rewritePath;
|
|
291
330
|
for (const suffix of ["-wal", "-shm"]) {
|
|
292
331
|
rmSync(`${rewritePath}${suffix}`, { force: true });
|
|
293
332
|
rmSync(`${dbPath}${suffix}`, { force: true });
|
|
294
333
|
}
|
|
295
|
-
|
|
334
|
+
await atomicReplace(rewritePath, dbPath);
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
async function installArtifactWorkerRewrite(projectRoot, rewritePath) {
|
|
338
|
+
if (!rewritePath)
|
|
339
|
+
throw new Error("Windows artifact worker did not produce a replacement database.");
|
|
340
|
+
const dbPath = getArtifactDbPath(projectRoot);
|
|
341
|
+
if (!rewritePath.startsWith(`${dbPath}.rewrite-`) || dirname(rewritePath) !== dirname(dbPath)) {
|
|
342
|
+
throw new Error("Windows artifact worker returned an invalid replacement path.");
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
346
|
+
rmSync(`${rewritePath}${suffix}`, { force: true });
|
|
347
|
+
rmSync(`${dbPath}${suffix}`, { force: true });
|
|
348
|
+
}
|
|
349
|
+
await atomicReplace(rewritePath, dbPath);
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
for (const suffix of ["", "-wal", "-shm"])
|
|
353
|
+
rmSync(`${rewritePath}${suffix}`, { force: true });
|
|
354
|
+
}
|
|
296
355
|
}
|
|
297
356
|
function planPrune(artifacts, limits, options) {
|
|
298
357
|
const sorted = sortedOldest(artifacts);
|
|
@@ -369,6 +428,15 @@ async function pruneInTransaction(agent, limits, options) {
|
|
|
369
428
|
export async function putArtifact(projectRoot, candidate, limits = getArtifactLimits(), options = {}) {
|
|
370
429
|
const prepared = prepareInput(projectRoot, candidate, limits);
|
|
371
430
|
return withMutationLock(projectRoot, async () => {
|
|
431
|
+
if (useWindowsArtifactWorker()) {
|
|
432
|
+
return (await runArtifactWorker({
|
|
433
|
+
operation: "put",
|
|
434
|
+
projectRoot: resolve(projectRoot),
|
|
435
|
+
candidate,
|
|
436
|
+
limits,
|
|
437
|
+
options,
|
|
438
|
+
})).result;
|
|
439
|
+
}
|
|
372
440
|
const agent = await openArtifactStore(projectRoot, true);
|
|
373
441
|
if (!agent)
|
|
374
442
|
throw new Error("Unable to open the local artifact store.");
|
|
@@ -443,6 +511,13 @@ export async function putCompactionArtifact(projectRoot, candidate, limits = get
|
|
|
443
511
|
return putArtifact(projectRoot, parsed, limits, options);
|
|
444
512
|
}
|
|
445
513
|
export async function readArtifact(identifier, projectRoot = process.cwd()) {
|
|
514
|
+
if (useWindowsArtifactWorker()) {
|
|
515
|
+
return (await runArtifactWorker({
|
|
516
|
+
operation: "read",
|
|
517
|
+
identifier,
|
|
518
|
+
projectRoot: resolve(projectRoot),
|
|
519
|
+
})).result;
|
|
520
|
+
}
|
|
446
521
|
const agent = await openArtifactStore(projectRoot, false);
|
|
447
522
|
if (!agent)
|
|
448
523
|
throw new Error("Artifact not found.");
|
|
@@ -455,6 +530,13 @@ export async function readArtifact(identifier, projectRoot = process.cwd()) {
|
|
|
455
530
|
}
|
|
456
531
|
}
|
|
457
532
|
export async function listArtifacts(projectRoot = process.cwd(), filters = {}) {
|
|
533
|
+
if (useWindowsArtifactWorker()) {
|
|
534
|
+
return (await runArtifactWorker({
|
|
535
|
+
operation: "list",
|
|
536
|
+
projectRoot: resolve(projectRoot),
|
|
537
|
+
filters,
|
|
538
|
+
})).result;
|
|
539
|
+
}
|
|
458
540
|
const agent = await openArtifactStore(projectRoot, false);
|
|
459
541
|
if (!agent)
|
|
460
542
|
return { schema_version: ARTIFACT_SCHEMA_VERSION, artifacts: [], logical_bytes: 0 };
|
|
@@ -486,6 +568,13 @@ export async function listArtifacts(projectRoot = process.cwd(), filters = {}) {
|
|
|
486
568
|
}
|
|
487
569
|
}
|
|
488
570
|
export async function readLatestCompaction(projectRoot = process.cwd(), sessionRef) {
|
|
571
|
+
if (useWindowsArtifactWorker()) {
|
|
572
|
+
return (await runArtifactWorker({
|
|
573
|
+
operation: "latest",
|
|
574
|
+
projectRoot: resolve(projectRoot),
|
|
575
|
+
sessionRef,
|
|
576
|
+
})).result;
|
|
577
|
+
}
|
|
489
578
|
const agent = await openArtifactStore(projectRoot, false);
|
|
490
579
|
if (!agent)
|
|
491
580
|
return null;
|
|
@@ -505,70 +594,114 @@ export async function readLatestCompaction(projectRoot = process.cwd(), sessionR
|
|
|
505
594
|
}
|
|
506
595
|
}
|
|
507
596
|
export const selectRecoveryArtifact = readLatestCompaction;
|
|
597
|
+
async function deleteArtifactLocally(identifier, projectRoot, options, deferReplacement) {
|
|
598
|
+
const agent = await openArtifactStore(projectRoot, false);
|
|
599
|
+
if (!agent)
|
|
600
|
+
throw new Error("Artifact not found.");
|
|
601
|
+
let retainedRows;
|
|
602
|
+
let result;
|
|
603
|
+
try {
|
|
604
|
+
await assertCompatibleSchema(agent, false);
|
|
605
|
+
const artifact = await findByIdentifier(agent, identifier);
|
|
606
|
+
if (!options.dryRun) {
|
|
607
|
+
await inImmediateTransaction(agent, async () => {
|
|
608
|
+
await deleteArtifactRows(agent, artifact);
|
|
609
|
+
await rebuildPointers(agent);
|
|
610
|
+
});
|
|
611
|
+
await agent.getDatabase().exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
612
|
+
retainedRows = await agent.kv.list("");
|
|
613
|
+
}
|
|
614
|
+
result = {
|
|
615
|
+
schema_version: ARTIFACT_SCHEMA_VERSION,
|
|
616
|
+
artifact_id: artifact.artifact_id,
|
|
617
|
+
deleted: !options.dryRun,
|
|
618
|
+
dry_run: Boolean(options.dryRun),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
finally {
|
|
622
|
+
await agent.close();
|
|
623
|
+
}
|
|
624
|
+
const rewritePath = retainedRows
|
|
625
|
+
? await rewriteArtifactDatabase(projectRoot, retainedRows, deferReplacement)
|
|
626
|
+
: undefined;
|
|
627
|
+
return { result, rewritePath };
|
|
628
|
+
}
|
|
629
|
+
export async function deleteArtifactForWindowsWorker(identifier, projectRoot, options = {}) {
|
|
630
|
+
return deleteArtifactLocally(identifier, projectRoot, options, !options.dryRun);
|
|
631
|
+
}
|
|
508
632
|
export async function deleteArtifact(identifier, projectRoot = process.cwd(), options = {}) {
|
|
509
633
|
return withMutationLock(projectRoot, async () => {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
await deleteArtifactRows(agent, artifact);
|
|
521
|
-
await rebuildPointers(agent);
|
|
522
|
-
});
|
|
523
|
-
await agent.getDatabase().exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
524
|
-
retainedRows = await agent.kv.list("");
|
|
525
|
-
}
|
|
526
|
-
result = {
|
|
527
|
-
schema_version: ARTIFACT_SCHEMA_VERSION,
|
|
528
|
-
artifact_id: artifact.artifact_id,
|
|
529
|
-
deleted: !options.dryRun,
|
|
530
|
-
dry_run: Boolean(options.dryRun),
|
|
531
|
-
};
|
|
532
|
-
}
|
|
533
|
-
finally {
|
|
534
|
-
await agent.close();
|
|
634
|
+
if (useWindowsArtifactWorker()) {
|
|
635
|
+
const response = await runArtifactWorker({
|
|
636
|
+
operation: "delete",
|
|
637
|
+
identifier,
|
|
638
|
+
projectRoot: resolve(projectRoot),
|
|
639
|
+
options,
|
|
640
|
+
});
|
|
641
|
+
if (!options.dryRun)
|
|
642
|
+
await installArtifactWorkerRewrite(projectRoot, response.rewritePath);
|
|
643
|
+
return response.result;
|
|
535
644
|
}
|
|
536
|
-
|
|
537
|
-
await rewriteArtifactDatabase(projectRoot, retainedRows);
|
|
538
|
-
return result;
|
|
645
|
+
return (await deleteArtifactLocally(identifier, projectRoot, options, false)).result;
|
|
539
646
|
});
|
|
540
647
|
}
|
|
648
|
+
async function pruneArtifactsLocally(projectRoot, limits, options, deferReplacement) {
|
|
649
|
+
const agent = await openArtifactStore(projectRoot, false);
|
|
650
|
+
if (!agent) {
|
|
651
|
+
return { result: { schema_version: 1, dry_run: Boolean(options.dryRun), removed: [], remaining_artifacts: 0, logical_bytes: 0 } };
|
|
652
|
+
}
|
|
653
|
+
let retainedRows;
|
|
654
|
+
let result;
|
|
655
|
+
try {
|
|
656
|
+
await assertCompatibleSchema(agent, false);
|
|
657
|
+
const resolved = {
|
|
658
|
+
dryRun: options.dryRun ?? false,
|
|
659
|
+
includeProtected: options.includeProtected ?? false,
|
|
660
|
+
now: options.now ?? new Date(),
|
|
661
|
+
};
|
|
662
|
+
result = await inImmediateTransaction(agent, () => pruneInTransaction(agent, limits, resolved));
|
|
663
|
+
if (!resolved.dryRun && result.removed.length > 0) {
|
|
664
|
+
await agent.getDatabase().exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
665
|
+
retainedRows = await agent.kv.list("");
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
finally {
|
|
669
|
+
await agent.close();
|
|
670
|
+
}
|
|
671
|
+
const rewritePath = retainedRows
|
|
672
|
+
? await rewriteArtifactDatabase(projectRoot, retainedRows, deferReplacement)
|
|
673
|
+
: undefined;
|
|
674
|
+
return { result, ...(rewritePath ? { rewritePath } : {}) };
|
|
675
|
+
}
|
|
676
|
+
export async function pruneArtifactsForWindowsWorker(projectRoot, limits = getArtifactLimits(), options = {}) {
|
|
677
|
+
return pruneArtifactsLocally(projectRoot, limits, options, !options.dryRun);
|
|
678
|
+
}
|
|
541
679
|
export async function pruneArtifacts(projectRoot, limits = getArtifactLimits(), options = {}) {
|
|
542
680
|
return withMutationLock(projectRoot, async () => {
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
includeProtected: options.includeProtected ?? false,
|
|
554
|
-
now: options.now ?? new Date(),
|
|
555
|
-
};
|
|
556
|
-
result = await inImmediateTransaction(agent, () => pruneInTransaction(agent, limits, resolved));
|
|
557
|
-
if (!resolved.dryRun && result.removed.length > 0) {
|
|
558
|
-
await agent.getDatabase().exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
559
|
-
retainedRows = await agent.kv.list("");
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
finally {
|
|
563
|
-
await agent.close();
|
|
681
|
+
if (useWindowsArtifactWorker()) {
|
|
682
|
+
const response = await runArtifactWorker({
|
|
683
|
+
operation: "prune",
|
|
684
|
+
projectRoot: resolve(projectRoot),
|
|
685
|
+
limits,
|
|
686
|
+
options,
|
|
687
|
+
});
|
|
688
|
+
if (!options.dryRun && response.rewritePath)
|
|
689
|
+
await installArtifactWorkerRewrite(projectRoot, response.rewritePath);
|
|
690
|
+
return response.result;
|
|
564
691
|
}
|
|
565
|
-
|
|
566
|
-
await rewriteArtifactDatabase(projectRoot, retainedRows);
|
|
567
|
-
return result;
|
|
692
|
+
return (await pruneArtifactsLocally(projectRoot, limits, options, false)).result;
|
|
568
693
|
});
|
|
569
694
|
}
|
|
570
695
|
export async function recordUnsupportedCompactionAdapter(projectRoot, diagnostic = {}) {
|
|
571
696
|
await withMutationLock(projectRoot, async () => {
|
|
697
|
+
if (useWindowsArtifactWorker()) {
|
|
698
|
+
await runArtifactWorker({
|
|
699
|
+
operation: "record-unsupported",
|
|
700
|
+
projectRoot: resolve(projectRoot),
|
|
701
|
+
diagnostic,
|
|
702
|
+
});
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
572
705
|
const agent = await openArtifactStore(projectRoot, true);
|
|
573
706
|
if (!agent)
|
|
574
707
|
return;
|
|
@@ -727,6 +860,14 @@ function inspectRetentionState(artifacts, limits) {
|
|
|
727
860
|
return issues;
|
|
728
861
|
}
|
|
729
862
|
export async function doctorArtifactStore(projectRoot = process.cwd(), repair = false, limits = getArtifactLimits()) {
|
|
863
|
+
if (useWindowsArtifactWorker()) {
|
|
864
|
+
return (await runArtifactWorker({
|
|
865
|
+
operation: "doctor",
|
|
866
|
+
projectRoot: resolve(projectRoot),
|
|
867
|
+
repair,
|
|
868
|
+
limits,
|
|
869
|
+
})).result;
|
|
870
|
+
}
|
|
730
871
|
const dbPath = getArtifactDbPath(projectRoot);
|
|
731
872
|
if (!existsSync(dbPath)) {
|
|
732
873
|
return { schema_version: 1, exists: false, ok: true, repaired: false, integrity: "not_present", valid_artifacts: 0, indexed_artifacts: 0, issues: [] };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { lstat } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { privatePathIsSafe } from "./platform-security.js";
|
|
4
5
|
import { capabilityAdapterClassificationSchema, finishOperatingCapability, operatingCapabilityBypassResultSchema, operatingCapabilityHandleSchema, startOperatingCapability, } from "./capability-adapter.js";
|
|
5
6
|
import { readCapabilityConfig, resetCapabilityLogging, resetCapabilityOverride, resolveCapabilityStatus, setCapabilityLogging, setCapabilityOverride, } from "./capability-config.js";
|
|
6
7
|
import { CAPABILITY_CALLBACK_SCHEMA_VERSION, doctorCapabilityRecords, } from "./capability-store.js";
|
|
@@ -226,7 +227,7 @@ async function configDoctor() {
|
|
|
226
227
|
try {
|
|
227
228
|
const info = await lstat(path);
|
|
228
229
|
exists = true;
|
|
229
|
-
unsafePermissions = info.isSymbolicLink() || !info.isFile() || (
|
|
230
|
+
unsafePermissions = info.isSymbolicLink() || !info.isFile() || !privatePathIsSafe(path);
|
|
230
231
|
}
|
|
231
232
|
catch (error) {
|
|
232
233
|
if (error.code !== "ENOENT")
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
|
-
import {
|
|
3
|
+
import { lstat, mkdir, open, rename, rm, } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { hardenPrivatePath } from "./platform-security.js";
|
|
5
6
|
import { CAPABILITY_REGISTRY } from "./capability-registry.js";
|
|
6
7
|
import { CAPABILITY_IDS, CAPABILITY_SCHEMA_VERSION, capabilityIdSchema, capabilityManagedConfigSchema, capabilityStatusSchema, } from "./capability-schema.js";
|
|
7
8
|
const MAX_CONFIG_BYTES = 64 * 1024;
|
|
@@ -247,9 +248,9 @@ async function atomicWriteConfig(path, config) {
|
|
|
247
248
|
await handle.sync();
|
|
248
249
|
await handle.close();
|
|
249
250
|
handle = undefined;
|
|
250
|
-
|
|
251
|
+
hardenPrivatePath(temporary);
|
|
251
252
|
await rename(temporary, path);
|
|
252
|
-
|
|
253
|
+
hardenPrivatePath(path);
|
|
253
254
|
}
|
|
254
255
|
finally {
|
|
255
256
|
if (handle)
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import {
|
|
4
|
+
import { lstat, mkdir, open, readdir, realpath, rename, rm, stat, } from "node:fs/promises";
|
|
5
5
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { isCapabilityContractEnabled, resolveCapabilityStatus, } from "./capability-config.js";
|
|
8
8
|
import { operatingCapabilityHandleSchema, } from "./capability-adapter.js";
|
|
9
9
|
import { CAPABILITY_CALLBACK_SCHEMA_VERSION, isOperatingCapabilitySettled, issueOperatingCapability, settleOperatingCapability, } from "./capability-store.js";
|
|
10
10
|
import { isTrajectoryCaptureEnabled } from "./trajectory-schema.js";
|
|
11
|
+
import { hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
|
|
11
12
|
const LEASE_DIRECTORY_NAME = "capability-leases-v1";
|
|
12
13
|
const LEASE_SCHEMA_VERSION = 1;
|
|
13
14
|
const MAX_LEASE_BYTES = 4_096;
|
|
@@ -124,12 +125,13 @@ async function ensureLeaseDirectory(options = {}) {
|
|
|
124
125
|
const rootInfo = await lstat(root);
|
|
125
126
|
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink())
|
|
126
127
|
throw new Error("Harness state root is unsafe.");
|
|
128
|
+
hardenPrivatePath(root);
|
|
127
129
|
const directory = getHarnessCapabilityLeaseDirectory(options);
|
|
128
130
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
129
131
|
const info = await lstat(directory);
|
|
130
132
|
if (!info.isDirectory() || info.isSymbolicLink())
|
|
131
133
|
throw new Error("Harness lease directory is unsafe.");
|
|
132
|
-
|
|
134
|
+
hardenPrivatePath(directory);
|
|
133
135
|
return directory;
|
|
134
136
|
}
|
|
135
137
|
async function canonicalProjectBinding(rawRoot) {
|
|
@@ -185,9 +187,9 @@ async function writeLease(lease, options = {}) {
|
|
|
185
187
|
await handle.sync();
|
|
186
188
|
await handle.close();
|
|
187
189
|
handle = undefined;
|
|
188
|
-
|
|
190
|
+
hardenPrivatePath(temporary);
|
|
189
191
|
await rename(temporary, path);
|
|
190
|
-
|
|
192
|
+
hardenPrivatePath(path);
|
|
191
193
|
}
|
|
192
194
|
finally {
|
|
193
195
|
if (handle)
|
|
@@ -200,7 +202,7 @@ async function readLease(path) {
|
|
|
200
202
|
try {
|
|
201
203
|
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
202
204
|
const info = await handle.stat();
|
|
203
|
-
if (!info.isFile() || info.size > MAX_LEASE_BYTES || (
|
|
205
|
+
if (!info.isFile() || info.size > MAX_LEASE_BYTES || !privatePathIsSafe(path))
|
|
204
206
|
return undefined;
|
|
205
207
|
const bytes = await handle.readFile();
|
|
206
208
|
if (bytes.byteLength > MAX_LEASE_BYTES)
|
|
@@ -216,7 +218,32 @@ async function readLease(path) {
|
|
|
216
218
|
}
|
|
217
219
|
}
|
|
218
220
|
async function removeLease(path) {
|
|
219
|
-
|
|
221
|
+
let lastError;
|
|
222
|
+
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
223
|
+
try {
|
|
224
|
+
await rm(path, { force: true });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
const code = error.code;
|
|
229
|
+
if (code !== "EPERM" && code !== "EBUSY")
|
|
230
|
+
throw error;
|
|
231
|
+
lastError = error;
|
|
232
|
+
if (process.platform === "win32" && attempt === 0) {
|
|
233
|
+
try {
|
|
234
|
+
const info = await lstat(path);
|
|
235
|
+
if (info.isFile() && !info.isSymbolicLink())
|
|
236
|
+
hardenPrivatePath(path);
|
|
237
|
+
}
|
|
238
|
+
catch (aclError) {
|
|
239
|
+
if (aclError.code === "ENOENT")
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 25 * (attempt + 1)));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
throw lastError;
|
|
220
247
|
}
|
|
221
248
|
function capabilityState(snapshot, id) {
|
|
222
249
|
const state = snapshot.capabilities.find((row) => row.id === id);
|
|
@@ -280,7 +307,7 @@ async function matchingLeases(input, options = {}) {
|
|
|
280
307
|
let entries;
|
|
281
308
|
try {
|
|
282
309
|
const info = await lstat(directory);
|
|
283
|
-
if (!info.isDirectory() || info.isSymbolicLink() || (
|
|
310
|
+
if (!info.isDirectory() || info.isSymbolicLink() || !privatePathIsSafe(directory))
|
|
284
311
|
return [];
|
|
285
312
|
entries = (await readdir(directory, { withFileTypes: true }))
|
|
286
313
|
.sort((left, right) => left.name.localeCompare(right.name))
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
3
|
import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
4
|
-
import { chmod, cp, mkdir, mkdtemp, open, readdir, readFile, realpath, rename, rm, stat, } from "node:fs/promises";
|
|
4
|
+
import { chmod, cp, mkdir, mkdtemp, open, readdir, readFile, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
|
|
5
5
|
import { homedir, tmpdir } from "node:os";
|
|
6
6
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { EmbeddingCache, cosineSimilarity, embedTexts, getEmbeddingConfig, hashText } from "./embeddings.js";
|
|
10
|
+
import { atomicReplace, hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
|
|
10
11
|
const execFileAsync = promisify(execFile);
|
|
11
12
|
export const CONTEXT_PACK_MANIFEST = "context-pack.json";
|
|
12
13
|
export const CONTEXT_PACK_MAX_FILE_BYTES = 1024 * 1024;
|
|
@@ -73,7 +74,7 @@ function readPackSource(digest) {
|
|
|
73
74
|
const directoryInfo = lstatSync(directory);
|
|
74
75
|
const info = lstatSync(path);
|
|
75
76
|
if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink() || !info.isFile() || info.isSymbolicLink()
|
|
76
|
-
|| info.size > 64 * 1024 || (
|
|
77
|
+
|| info.size > 64 * 1024 || !privatePathIsSafe(path)) {
|
|
77
78
|
throw new Error(`Context pack source record is unsafe: ${digest}`);
|
|
78
79
|
}
|
|
79
80
|
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -193,8 +194,9 @@ async function atomicJson(path, value) {
|
|
|
193
194
|
await handle.close();
|
|
194
195
|
handle = undefined;
|
|
195
196
|
await chmod(temporary, 0o600);
|
|
196
|
-
|
|
197
|
-
await
|
|
197
|
+
hardenPrivatePath(temporary);
|
|
198
|
+
await atomicReplace(temporary, path);
|
|
199
|
+
hardenPrivatePath(path);
|
|
198
200
|
}
|
|
199
201
|
finally {
|
|
200
202
|
if (handle)
|
|
@@ -210,13 +212,35 @@ async function makeImmutable(root) {
|
|
|
210
212
|
const path = join(directory, entry.name);
|
|
211
213
|
if (entry.isDirectory())
|
|
212
214
|
await walk(path);
|
|
215
|
+
else if (process.platform === "win32") {
|
|
216
|
+
hardenPrivatePath(path);
|
|
217
|
+
await execFileAsync("attrib.exe", ["+R", path]);
|
|
218
|
+
}
|
|
213
219
|
else
|
|
214
220
|
await chmod(path, 0o400);
|
|
215
221
|
}
|
|
216
222
|
};
|
|
217
223
|
await walk(root);
|
|
218
|
-
for (const directory of directories.reverse())
|
|
219
|
-
|
|
224
|
+
for (const directory of directories.reverse()) {
|
|
225
|
+
if (process.platform === "win32")
|
|
226
|
+
hardenPrivatePath(directory);
|
|
227
|
+
else
|
|
228
|
+
await chmod(directory, 0o500);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async function makeWritable(root) {
|
|
232
|
+
if (!existsSync(root))
|
|
233
|
+
return;
|
|
234
|
+
await chmod(root, 0o700);
|
|
235
|
+
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
236
|
+
const path = join(root, entry.name);
|
|
237
|
+
if (entry.isDirectory())
|
|
238
|
+
await makeWritable(path);
|
|
239
|
+
else if (process.platform === "win32")
|
|
240
|
+
await execFileAsync("attrib.exe", ["-R", path]);
|
|
241
|
+
else
|
|
242
|
+
await chmod(path, 0o600);
|
|
243
|
+
}
|
|
220
244
|
}
|
|
221
245
|
function rejectCredentialUrl(source) {
|
|
222
246
|
try {
|
|
@@ -232,6 +256,45 @@ function rejectCredentialUrl(source) {
|
|
|
232
256
|
throw new Error("Git source URLs may not contain credentials.");
|
|
233
257
|
}
|
|
234
258
|
}
|
|
259
|
+
function execFileBuffer(program, args, options) {
|
|
260
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
261
|
+
execFile(program, args, { ...options, encoding: null, windowsHide: true }, (error, stdout) => {
|
|
262
|
+
if (error)
|
|
263
|
+
rejectPromise(error);
|
|
264
|
+
else
|
|
265
|
+
resolvePromise(Buffer.from(stdout));
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
async function materializeGitTree(repository, destination, commit) {
|
|
270
|
+
const listing = await execFileBuffer("git", ["-C", repository, "ls-tree", "-r", "-z", commit], { timeout: 30_000, maxBuffer: 4 * 1024 * 1024 });
|
|
271
|
+
const records = new TextDecoder("utf-8", { fatal: true }).decode(listing).split("\0").filter(Boolean);
|
|
272
|
+
if (records.length > CONTEXT_PACK_MAX_ENTRIES + 1)
|
|
273
|
+
throw new Error("Git context pack exceeds the entry limit.");
|
|
274
|
+
let totalBytes = 0;
|
|
275
|
+
for (const record of records) {
|
|
276
|
+
const match = /^(100644|100755|120000) blob ([a-f0-9]{40}|[a-f0-9]{64})\t([\s\S]+)$/.exec(record);
|
|
277
|
+
if (!match || match[1] === "120000")
|
|
278
|
+
throw new Error("Git context packs may contain only regular files.");
|
|
279
|
+
const path = normalizePackPath(match[3]);
|
|
280
|
+
const { stdout: rawSize } = await execFileAsync("git", ["-C", repository, "cat-file", "-s", match[2]], { encoding: "utf8", timeout: 30_000, maxBuffer: 1024 });
|
|
281
|
+
const size = Number(rawSize.trim());
|
|
282
|
+
if (!Number.isSafeInteger(size) || size < 0 || size > CONTEXT_PACK_MAX_FILE_BYTES) {
|
|
283
|
+
throw new Error(`Git context pack file is unsafe or too large: ${path}`);
|
|
284
|
+
}
|
|
285
|
+
totalBytes += size;
|
|
286
|
+
if (totalBytes > CONTEXT_PACK_MAX_TOTAL_BYTES)
|
|
287
|
+
throw new Error("Git context pack exceeds the total size limit.");
|
|
288
|
+
const target = resolve(destination, ...path.split("/"));
|
|
289
|
+
if (!contained(destination, target))
|
|
290
|
+
throw new Error(`Unsafe Git context pack path: ${path}`);
|
|
291
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
292
|
+
const bytes = await execFileBuffer("git", ["-C", repository, "cat-file", "blob", match[2]], { timeout: 30_000, maxBuffer: CONTEXT_PACK_MAX_FILE_BYTES + 1 });
|
|
293
|
+
if (bytes.byteLength !== size)
|
|
294
|
+
throw new Error(`Git context pack blob changed while reading: ${path}`);
|
|
295
|
+
await writeFile(target, bytes, { mode: match[1] === "100755" ? 0o700 : 0o600 });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
235
298
|
async function materializeSource(source, ref) {
|
|
236
299
|
if (!ref) {
|
|
237
300
|
const requested = resolve(source);
|
|
@@ -250,9 +313,10 @@ async function materializeSource(source, ref) {
|
|
|
250
313
|
const commit = stdout.trim();
|
|
251
314
|
if (!GIT_COMMIT.test(commit))
|
|
252
315
|
throw new Error("Git did not resolve the requested ref to a full commit.");
|
|
253
|
-
|
|
254
|
-
await
|
|
255
|
-
|
|
316
|
+
const root = join(checkout, "pack");
|
|
317
|
+
await mkdir(root, { mode: 0o700 });
|
|
318
|
+
await materializeGitTree(checkout, root, commit);
|
|
319
|
+
return { root, cleanup: checkout, source: { kind: "git", url: gitSource, ref, commit } };
|
|
256
320
|
}
|
|
257
321
|
catch (error) {
|
|
258
322
|
await rm(checkout, { recursive: true, force: true });
|
|
@@ -275,6 +339,7 @@ export async function installContextPack(sourcePath, options = {}) {
|
|
|
275
339
|
const objects = dirname(destination);
|
|
276
340
|
await mkdir(objects, { recursive: true, mode: 0o700 });
|
|
277
341
|
const temporary = join(objects, `.${pack.digest}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
342
|
+
let published = true;
|
|
278
343
|
try {
|
|
279
344
|
await cp(pack.root, temporary, { recursive: true, errorOnExist: true, force: false });
|
|
280
345
|
const copied = await validateContextPack(temporary);
|
|
@@ -285,19 +350,23 @@ export async function installContextPack(sourcePath, options = {}) {
|
|
|
285
350
|
await rename(temporary, destination);
|
|
286
351
|
}
|
|
287
352
|
catch (error) {
|
|
288
|
-
|
|
353
|
+
const code = error.code ?? "";
|
|
354
|
+
const converged = existsSync(destination)
|
|
355
|
+
&& (["EEXIST", "ENOTEMPTY"].includes(code) || (process.platform === "win32" && code === "EPERM"));
|
|
356
|
+
if (!converged)
|
|
289
357
|
throw error;
|
|
290
358
|
const concurrent = await validateContextPack(destination);
|
|
291
359
|
if (concurrent.digest !== pack.digest)
|
|
292
360
|
throw new Error("Concurrent context pack installation produced a conflicting object.");
|
|
361
|
+
published = false;
|
|
293
362
|
}
|
|
294
363
|
}
|
|
295
364
|
finally {
|
|
296
|
-
await
|
|
365
|
+
await makeWritable(temporary).catch(() => undefined);
|
|
297
366
|
await rm(temporary, { recursive: true, force: true }).catch(() => undefined);
|
|
298
367
|
}
|
|
299
368
|
await atomicJson(join(packBaseDir(), "sources", `${pack.digest}.json`), { schema_version: 1, source: materialized.source });
|
|
300
|
-
return { pack: await validateContextPack(destination), source: materialized.source, existing:
|
|
369
|
+
return { pack: await validateContextPack(destination), source: materialized.source, existing: !published };
|
|
301
370
|
}
|
|
302
371
|
finally {
|
|
303
372
|
if (materialized.cleanup)
|
|
@@ -396,7 +465,7 @@ export function readProjectPointer(options) {
|
|
|
396
465
|
return emptyPointer(identity.id);
|
|
397
466
|
const info = lstatSync(identity.path);
|
|
398
467
|
if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024
|
|
399
|
-
|| (
|
|
468
|
+
|| !privatePathIsSafe(identity.path))
|
|
400
469
|
throw new Error("Context pack project pointer is unsafe.");
|
|
401
470
|
return parseProjectPointer(JSON.parse(readFileSync(identity.path, "utf8")), identity.id);
|
|
402
471
|
}
|
|
@@ -470,7 +539,7 @@ async function allPointers() {
|
|
|
470
539
|
const path = join(directory, entry.name);
|
|
471
540
|
const info = lstatSync(path);
|
|
472
541
|
if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024
|
|
473
|
-
|| (
|
|
542
|
+
|| !privatePathIsSafe(path)) {
|
|
474
543
|
throw new Error(`Context pack project pointer is unsafe: ${path}`);
|
|
475
544
|
}
|
|
476
545
|
const raw = JSON.parse(await readFile(path, "utf8"));
|
|
@@ -489,17 +558,7 @@ export async function removeContextPack(selector) {
|
|
|
489
558
|
throw new Error("Context pack is enabled by a project and cannot be removed.");
|
|
490
559
|
}
|
|
491
560
|
const root = objectRoot(pack.digest);
|
|
492
|
-
|
|
493
|
-
await chmod(directory, 0o700);
|
|
494
|
-
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
495
|
-
const path = join(directory, entry.name);
|
|
496
|
-
if (entry.isDirectory())
|
|
497
|
-
await unlock(path);
|
|
498
|
-
else
|
|
499
|
-
await chmod(path, 0o600);
|
|
500
|
-
}
|
|
501
|
-
};
|
|
502
|
-
await unlock(root);
|
|
561
|
+
await makeWritable(root);
|
|
503
562
|
await rm(root, { recursive: true, force: true });
|
|
504
563
|
await rm(join(packBaseDir(), "sources", `${pack.digest}.json`), { force: true });
|
|
505
564
|
return pack.digest;
|