@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.
Files changed (78) hide show
  1. package/CHANGELOG.md +46 -1
  2. package/README.md +54 -25
  3. package/bin/cairn +6 -165
  4. package/bin/cairn-container +8 -0
  5. package/docs/building-an-overlay.md +41 -4
  6. package/docs/containers.md +10 -0
  7. package/docs/harness-compatibility.md +30 -2
  8. package/docs/learning/CURRICULUM-MAP.md +9 -3
  9. package/docs/learning/FEATURE-GUIDE.md +9 -3
  10. package/docs/learning/README.md +2 -0
  11. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  12. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  13. package/docs/learning/lessons/L02-installation.md +11 -2
  14. package/docs/learning/lessons/L03-first-project.md +1 -1
  15. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  16. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  17. package/docs/learning/lessons/L21-context-packs.md +1 -1
  18. package/docs/learning/lessons/L22-native-windows.md +83 -0
  19. package/docs/learning/lessons/L23-guided-setup.md +138 -0
  20. package/docs/learning/tracks/operator.md +8 -3
  21. package/docs/learning/tracks/quickstart.md +7 -3
  22. package/docs/native-windows.md +122 -0
  23. package/docs/operating.md +119 -18
  24. package/docs/privacy-and-data-flow.md +34 -1
  25. package/docs/storage.md +14 -2
  26. package/examples/eval/bundled-fake.json +1 -1
  27. package/mcp-memory-server/dist/artifact-store-windows-worker.js +74 -0
  28. package/mcp-memory-server/dist/artifact-store.js +196 -55
  29. package/mcp-memory-server/dist/capability-cli.js +2 -1
  30. package/mcp-memory-server/dist/capability-config.js +4 -3
  31. package/mcp-memory-server/dist/capability-harness.js +34 -7
  32. package/mcp-memory-server/dist/context-pack.js +84 -25
  33. package/mcp-memory-server/dist/eval-cli.js +6 -5
  34. package/mcp-memory-server/dist/eval-plan.js +35 -13
  35. package/mcp-memory-server/dist/eval-process.js +11 -4
  36. package/mcp-memory-server/dist/eval-report.js +7 -5
  37. package/mcp-memory-server/dist/graph-cli.js +7 -1
  38. package/mcp-memory-server/dist/index.js +19 -20
  39. package/mcp-memory-server/dist/mcp-tool-profile.js +6 -4
  40. package/mcp-memory-server/dist/note-store.js +12 -3
  41. package/mcp-memory-server/dist/pi-mcp-bridge.js +185 -0
  42. package/mcp-memory-server/dist/platform-security.js +102 -0
  43. package/mcp-memory-server/dist/skill-store.js +27 -12
  44. package/mcp-memory-server/package.json +2 -1
  45. package/package.json +5 -4
  46. package/pi/extensions/cairnkeep-memory.ts +61 -0
  47. package/schemas/cairnkeep-setup-policy.schema.json +55 -0
  48. package/schemas/cairnkeep-setup.schema.json +36 -0
  49. package/scripts/cairn-cli.mjs +119 -0
  50. package/scripts/cairn-container-cli.mjs +121 -0
  51. package/scripts/cairn-hook.mjs +199 -0
  52. package/scripts/completion.sh +12 -3
  53. package/scripts/doctor.sh +57 -0
  54. package/scripts/fix-permissions.mjs +12 -0
  55. package/scripts/memory-wiki-audit.mjs +95 -0
  56. package/scripts/phase26-test-manifest.mjs +26 -0
  57. package/scripts/run-repository-tests.mjs +76 -0
  58. package/scripts/setup-core.mjs +310 -0
  59. package/scripts/setup-reconcile.mjs +331 -0
  60. package/scripts/setup.mjs +306 -0
  61. package/scripts/sync-pi-assets.sh +19 -4
  62. package/scripts/test-completion.sh +39 -0
  63. package/scripts/test-doctor.sh +66 -0
  64. package/scripts/test-learning-docs.sh +52 -3
  65. package/scripts/test-package-install.sh +64 -0
  66. package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
  67. package/scripts/test-phase18-capability-lifecycle.sh +1 -1
  68. package/scripts/test-pi-lifecycle.mjs +190 -0
  69. package/scripts/test-setup-compatibility.mjs +186 -0
  70. package/scripts/test-setup-output.sh +131 -0
  71. package/scripts/test-setup-overlay.sh +113 -0
  72. package/scripts/test-setup-preflight.mjs +281 -0
  73. package/scripts/test-setup-reconcile.mjs +257 -0
  74. package/scripts/test-uninstall.sh +50 -0
  75. package/scripts/test-windows-native.mjs +136 -0
  76. package/scripts/uninstall.sh +69 -6
  77. package/scripts/verify-pi-mcp-bridge.mjs +588 -0
  78. package/scripts/windows-platform.mjs +725 -0
@@ -1,9 +1,12 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
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
- renameSync(rewritePath, dbPath);
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
- const agent = await openArtifactStore(projectRoot, false);
511
- if (!agent)
512
- throw new Error("Artifact not found.");
513
- let retainedRows;
514
- let result;
515
- try {
516
- await assertCompatibleSchema(agent, false);
517
- const artifact = await findByIdentifier(agent, identifier);
518
- if (!options.dryRun) {
519
- await inImmediateTransaction(agent, async () => {
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
- if (retainedRows)
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
- const agent = await openArtifactStore(projectRoot, false);
544
- if (!agent) {
545
- return { schema_version: 1, dry_run: Boolean(options.dryRun), removed: [], remaining_artifacts: 0, logical_bytes: 0 };
546
- }
547
- let retainedRows;
548
- let result;
549
- try {
550
- await assertCompatibleSchema(agent, false);
551
- const resolved = {
552
- dryRun: options.dryRun ?? false,
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
- if (retainedRows)
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() || (info.mode & 0o077) !== 0;
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 { chmod, lstat, mkdir, open, rename, rm, } from "node:fs/promises";
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
- await chmod(temporary, 0o600);
251
+ hardenPrivatePath(temporary);
251
252
  await rename(temporary, path);
252
- await chmod(path, 0o600);
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 { chmod, lstat, mkdir, open, readdir, realpath, rename, rm, stat, } from "node:fs/promises";
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
- await chmod(directory, 0o700);
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
- await chmod(temporary, 0o600);
190
+ hardenPrivatePath(temporary);
189
191
  await rename(temporary, path);
190
- await chmod(path, 0o600);
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 || (info.mode & 0o077) !== 0)
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
- await rm(path, { force: true });
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() || (info.mode & 0o077) !== 0)
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 || (process.platform !== "win32" && (info.mode & 0o077) !== 0)) {
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
- await rename(temporary, path);
197
- await chmod(path, 0o600);
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
- await chmod(directory, 0o500);
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
- await execFileAsync("git", ["-C", checkout, "checkout", "--quiet", "--detach", commit], { timeout: 60_000, maxBuffer: 1024 * 1024 });
254
- await rm(join(checkout, ".git"), { recursive: true, force: true });
255
- return { root: checkout, cleanup: checkout, source: { kind: "git", url: gitSource, ref, commit } };
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
- if (!["EEXIST", "ENOTEMPTY"].includes(error.code ?? "") || !existsSync(destination))
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 chmod(temporary, 0o700).catch(() => undefined);
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: false };
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
- || (process.platform !== "win32" && (info.mode & 0o077) !== 0))
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
- || (process.platform !== "win32" && (info.mode & 0o077) !== 0)) {
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
- const unlock = async (directory) => {
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;