@cairnkeep/cli 2.9.0 → 2.12.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 (86) hide show
  1. package/CHANGELOG.md +66 -1
  2. package/README.md +83 -29
  3. package/bin/cairn +6 -165
  4. package/bin/cairn-container +8 -0
  5. package/docs/building-an-overlay.md +42 -5
  6. package/docs/containers.md +10 -0
  7. package/docs/harness-compatibility.md +64 -3
  8. package/docs/learning/CURRICULUM-MAP.md +13 -6
  9. package/docs/learning/FEATURE-GUIDE.md +14 -3
  10. package/docs/learning/README.md +5 -2
  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 +24 -8
  14. package/docs/learning/lessons/L03-first-project.md +34 -15
  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 +142 -0
  20. package/docs/learning/tracks/operator.md +8 -3
  21. package/docs/learning/tracks/quickstart.md +9 -4
  22. package/docs/native-windows.md +136 -0
  23. package/docs/operating.md +142 -20
  24. package/docs/privacy-and-data-flow.md +41 -1
  25. package/docs/quickstart.md +86 -0
  26. package/docs/storage.md +14 -2
  27. package/examples/eval/bundled-fake.json +1 -1
  28. package/mcp-memory-server/dist/artifact-store-windows-worker.js +74 -0
  29. package/mcp-memory-server/dist/artifact-store.js +196 -55
  30. package/mcp-memory-server/dist/capability-cli.js +2 -1
  31. package/mcp-memory-server/dist/capability-config.js +4 -3
  32. package/mcp-memory-server/dist/capability-harness.js +34 -7
  33. package/mcp-memory-server/dist/context-pack.js +84 -25
  34. package/mcp-memory-server/dist/eval-cli.js +6 -5
  35. package/mcp-memory-server/dist/eval-plan.js +35 -13
  36. package/mcp-memory-server/dist/eval-process.js +11 -4
  37. package/mcp-memory-server/dist/eval-report.js +7 -5
  38. package/mcp-memory-server/dist/graph-cli.js +7 -1
  39. package/mcp-memory-server/dist/index.js +19 -20
  40. package/mcp-memory-server/dist/mcp-tool-profile.js +6 -4
  41. package/mcp-memory-server/dist/note-store.js +12 -3
  42. package/mcp-memory-server/dist/pi-mcp-bridge.js +185 -0
  43. package/mcp-memory-server/dist/platform-security.js +102 -0
  44. package/mcp-memory-server/dist/skill-store.js +27 -12
  45. package/mcp-memory-server/package.json +2 -1
  46. package/package.json +6 -4
  47. package/pi/extensions/cairnkeep-memory.ts +61 -0
  48. package/schemas/cairnkeep-setup-policy.schema.json +55 -0
  49. package/schemas/cairnkeep-setup.schema.json +36 -0
  50. package/scripts/bootstrap.sh +7 -15
  51. package/scripts/cairn-cli.mjs +119 -0
  52. package/scripts/cairn-container-cli.mjs +121 -0
  53. package/scripts/cairn-hook.mjs +199 -0
  54. package/scripts/completion.sh +17 -6
  55. package/scripts/doctor.sh +58 -0
  56. package/scripts/fix-permissions.mjs +12 -0
  57. package/scripts/harness-registry.mjs +126 -0
  58. package/scripts/memory-wiki-audit.mjs +95 -0
  59. package/scripts/phase26-test-manifest.mjs +26 -0
  60. package/scripts/run-repository-tests.mjs +76 -0
  61. package/scripts/setup-core.mjs +312 -0
  62. package/scripts/setup-reconcile.mjs +332 -0
  63. package/scripts/setup.mjs +342 -0
  64. package/scripts/sync-pi-assets.sh +19 -4
  65. package/scripts/test-bootstrap-untracked.sh +2 -1
  66. package/scripts/test-completion.sh +41 -0
  67. package/scripts/test-doctor.sh +66 -0
  68. package/scripts/test-harness-registry.sh +94 -0
  69. package/scripts/test-learning-docs.sh +52 -3
  70. package/scripts/test-package-install.sh +69 -0
  71. package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
  72. package/scripts/test-phase18-capability-lifecycle.sh +1 -1
  73. package/scripts/test-phase19-eval-lifecycle.sh +12 -2
  74. package/scripts/test-pi-lifecycle.mjs +190 -0
  75. package/scripts/test-setup-compatibility.mjs +187 -0
  76. package/scripts/test-setup-output.sh +131 -0
  77. package/scripts/test-setup-overlay.sh +113 -0
  78. package/scripts/test-setup-preflight.mjs +281 -0
  79. package/scripts/test-setup-reconcile.mjs +257 -0
  80. package/scripts/test-uninstall.sh +50 -0
  81. package/scripts/test-windows-native.mjs +159 -0
  82. package/scripts/uninstall.sh +73 -6
  83. package/scripts/verify-pi-mcp-bridge.mjs +588 -0
  84. package/scripts/windows-platform.mjs +733 -0
  85. package/templates/codex-config.toml.template +5 -0
  86. package/templates/start-codex.sh.template +30 -0
@@ -0,0 +1,86 @@
1
+ # Cairnkeep quickstart
2
+
3
+ This is the shortest supported route from an empty directory or existing Git
4
+ repository to durable, local agent memory. The default transport is stdio and
5
+ the default store stays on this computer.
6
+
7
+ ## Codex CLI
8
+
9
+ Install Cairnkeep, then let guided setup initialize an empty project and write
10
+ Codex's project-scoped MCP entry:
11
+
12
+ ```bash
13
+ npm install --global @cairnkeep/cli
14
+ cairn setup /path/to/project --git init --harness codex --memory local --yes
15
+ cd /path/to/project
16
+ cairn doctor
17
+ ./.ai/start-codex.sh
18
+ ```
19
+
20
+ Use `--git existing` instead when the target is already a Git work tree. On
21
+ native Windows, launch with `.\.ai\start-codex.cmd`.
22
+
23
+ Before the first session, review `.codex/config.toml` and accept Codex's project
24
+ trust prompt. The generated entry runs `cairn memory-server` locally. Setup
25
+ does not edit the user-wide Codex configuration and does not enable networking,
26
+ telemetry, embeddings, session capture, or context packs.
27
+
28
+ If `.codex/config.toml` already exists and differs, setup reports it as
29
+ `skipped` and preserves it byte-for-byte. Merge this table into the existing
30
+ file, then run `cairn doctor`:
31
+
32
+ ```toml
33
+ [mcp_servers.cairn-memory]
34
+ command = "cairn"
35
+ args = ["memory-server"]
36
+ ```
37
+
38
+ ## Claude Code
39
+
40
+ Claude Code has the full Cairnkeep operating layer, so it needs one explicit
41
+ machine-level sync in addition to project setup:
42
+
43
+ ```bash
44
+ npm install --global @cairnkeep/cli
45
+ claude mcp add cairn-memory -s user -- cairn memory-server
46
+ cairn sync --apply
47
+ cairn setup /path/to/project --git init --harness claude --memory local --yes
48
+ cd /path/to/project
49
+ cairn doctor
50
+ ./.ai/start-claude.sh
51
+ ```
52
+
53
+ Setup reports machine-level commands but never executes them. This keeps
54
+ project scaffolding separate from harness-wide changes.
55
+
56
+ ## First memory cycle
57
+
58
+ In a harness with Cairnkeep commands, remember one durable fact and recall it:
59
+
60
+ ```text
61
+ > /remember Use transactional migrations for schema changes
62
+ > /recall transactional migrations
63
+ ```
64
+
65
+ In Codex or another MCP client, ask the client to call `memory_write`, then
66
+ `memory_search`. Close the session, start a new one, and repeat the search. The
67
+ result should survive the restart.
68
+
69
+ ## What setup owns
70
+
71
+ `cairn setup` creates only the selected harness assets plus the common `.ai`,
72
+ `.planning`, and `.agentfs` scaffold. It initializes Git only with explicit
73
+ `--git init`, preserves unrelated files, does not overwrite modified files it
74
+ does not own, and records managed digests in private `.ai/cairnkeep.json`.
75
+
76
+ Useful follow-ups:
77
+
78
+ ```bash
79
+ cairn setup . --git existing --harness codex --memory local --yes # reconcile
80
+ cairn doctor # diagnose
81
+ cairn uninstall --dry-run . # preview removal
82
+ ```
83
+
84
+ For all harnesses, remote HTTP, storage, profiles, context packs, overlays, and
85
+ recovery details, continue with [Operating Cairnkeep](operating.md) or the
86
+ [learning path](learning/README.md).
package/docs/storage.md CHANGED
@@ -31,6 +31,17 @@ For the local launchers, the server working directory is normally the project
31
31
  root. Bootstrap installs `.agentfs/.gitignore` so private project memory is not
32
32
  accidentally committed. SQLite may also create `-wal` and `-shm` sidecar files.
33
33
 
34
+ Private-file protection is platform-equivalent rather than mode-identical.
35
+ Linux and macOS use `0600` files and `0700` directories. Native Windows removes
36
+ inherited ACLs and grants access only to the current SID, Local System, and the
37
+ built-in Administrators group. `cairn doctor` fails closed when a managed MCP
38
+ profile or context-pack pointer has broader access. Context-pack objects also
39
+ receive a read-only attribute after their content digest is verified.
40
+
41
+ Where this document names a generated `revert.sh`, native Windows produces the
42
+ equivalent backup manifest and `revert.ps1`; it restores exact bytes and the
43
+ Windows ACL contract rather than emulating POSIX mode bits.
44
+
34
45
  `CAIRN_LLM_API_URL` and `CAIRN_MEMORY_EMBEDDING_URL` select optional model
35
46
  services used to process/search memory. They do not change where the SQLite
36
47
  databases are stored. Git-provider and routing configuration do not change
@@ -502,8 +513,9 @@ bearer_token_env_var = "CAIRN_MEMORY_HTTP_TOKEN"
502
513
  Context-pack objects are content-addressed, immutable directories. Installation
503
514
  copies verified content through a temporary directory and atomically publishes
504
515
  the digest; a failed or concurrent install never changes project enablement.
505
- Project pointers are separate atomic mode-`0600` files keyed by canonical local
506
- project identity or a validated remote project ID. Updates retain old objects
516
+ Project pointers are separate atomic private files (`0600` on POSIX, restricted
517
+ ACLs on Windows) keyed by canonical local project identity or a validated
518
+ remote project ID. Updates retain old objects
507
519
  and switch only the named project after exact-digest confirmation.
508
520
 
509
521
  `cairn doctor` verifies objects, pointers, and interrupted-install remnants.
@@ -1 +1 @@
1
- {"identifier":"cairn-offline-fake-v1","package_version":"2.9.0","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
1
+ {"identifier":"cairn-offline-fake-v1","package_version":"2.12.0","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
@@ -0,0 +1,74 @@
1
+ import { AgentFS } from "agentfs-sdk";
2
+ import { deleteArtifactForWindowsWorker, doctorArtifactStore, getArtifactDbPath, listArtifacts, pruneArtifactsForWindowsWorker, putArtifact, readArtifact, readLatestCompaction, recordUnsupportedCompactionAdapter, } from "./artifact-store.js";
3
+ async function readRequest() {
4
+ const chunks = [];
5
+ for await (const chunk of process.stdin)
6
+ chunks.push(Buffer.from(chunk));
7
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
8
+ if (!parsed || typeof parsed.operation !== "string" || typeof parsed.projectRoot !== "string") {
9
+ throw new Error("Invalid Windows artifact worker request.");
10
+ }
11
+ return parsed;
12
+ }
13
+ function resolvedOptions(options = {}) {
14
+ const { now, ...rest } = options;
15
+ return { ...rest, ...(now ? { now: new Date(now) } : {}) };
16
+ }
17
+ async function rawOperation(request) {
18
+ const agent = await AgentFS.open({ id: "artifacts", path: getArtifactDbPath(request.projectRoot) });
19
+ try {
20
+ if (request.action === "list")
21
+ return await agent.kv.list(request.prefix ?? "");
22
+ if (request.action === "get")
23
+ return await agent.kv.get(request.key ?? "");
24
+ if (request.action === "set") {
25
+ await agent.kv.set(request.key ?? "", request.value);
26
+ return null;
27
+ }
28
+ if (request.action === "delete") {
29
+ await agent.kv.delete(request.key ?? "");
30
+ return null;
31
+ }
32
+ throw new Error("Unsupported raw Windows artifact operation.");
33
+ }
34
+ finally {
35
+ await agent.close();
36
+ }
37
+ }
38
+ async function dispatch(request) {
39
+ const limits = request.limits;
40
+ if (request.operation === "put" && limits) {
41
+ return { result: await putArtifact(request.projectRoot, request.candidate, limits, resolvedOptions(request.options)) };
42
+ }
43
+ if (request.operation === "read" && request.identifier) {
44
+ return { result: await readArtifact(request.identifier, request.projectRoot) };
45
+ }
46
+ if (request.operation === "list") {
47
+ return { result: await listArtifacts(request.projectRoot, request.filters) };
48
+ }
49
+ if (request.operation === "latest") {
50
+ return { result: await readLatestCompaction(request.projectRoot, request.sessionRef) };
51
+ }
52
+ if (request.operation === "delete" && request.identifier) {
53
+ return deleteArtifactForWindowsWorker(request.identifier, request.projectRoot, request.options);
54
+ }
55
+ if (request.operation === "prune" && limits) {
56
+ return pruneArtifactsForWindowsWorker(request.projectRoot, limits, resolvedOptions(request.options));
57
+ }
58
+ if (request.operation === "record-unsupported") {
59
+ await recordUnsupportedCompactionAdapter(request.projectRoot, request.diagnostic);
60
+ return { result: null };
61
+ }
62
+ if (request.operation === "doctor" && limits) {
63
+ return { result: await doctorArtifactStore(request.projectRoot, request.repair, limits) };
64
+ }
65
+ if (request.operation === "raw")
66
+ return { result: await rawOperation(request) };
67
+ throw new Error("Unsupported Windows artifact worker operation.");
68
+ }
69
+ dispatch(await readRequest())
70
+ .then((response) => process.stdout.write(JSON.stringify(response)))
71
+ .catch((error) => {
72
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
73
+ process.exitCode = 1;
74
+ });
@@ -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))